feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { Activity, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -45,18 +45,51 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
// already conveyed by the pill below, so the stripe is dock-keyed.
|
||||
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
|
||||
|
||||
// Dimensions string
|
||||
let dimText: string | null = null;
|
||||
if (berth.lengthM || berth.widthM) {
|
||||
const l = berth.lengthM ?? '?';
|
||||
const w = berth.widthM ?? '?';
|
||||
dimText = `${l}m × ${w}m`;
|
||||
// Dimensions string — Length × Width × Draft (each segment is optional).
|
||||
// The avatar already conveys the mooring number, so this becomes the
|
||||
// primary "what is this berth" line.
|
||||
const dimParts: string[] = [];
|
||||
if (berth.lengthM) dimParts.push(`${berth.lengthM}m`);
|
||||
if (berth.widthM) dimParts.push(`${berth.widthM}m`);
|
||||
if (berth.draftM) dimParts.push(`${berth.draftM}m draft`);
|
||||
const dimText = dimParts.length > 0 ? dimParts.join(' × ') : null;
|
||||
|
||||
// Recommended boat size — the most rep-actionable signal in a glance
|
||||
// ("can my client's yacht park here?"). Tenure was previously here but
|
||||
// dropped: tenure is set per EOI/contract, not per berth, so showing
|
||||
// it as a berth property was misleading.
|
||||
let boatCapacityText: string | null = null;
|
||||
if (berth.nominalBoatSizeM) {
|
||||
boatCapacityText = `Fits up to ${berth.nominalBoatSizeM}m`;
|
||||
} else if (berth.nominalBoatSize) {
|
||||
boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`;
|
||||
}
|
||||
|
||||
// Water depth — operational; matters for deep-keel yachts.
|
||||
let waterDepthText: string | null = null;
|
||||
if (berth.waterDepthM) {
|
||||
const prefix = berth.waterDepthIsMinimum ? '≥ ' : '';
|
||||
waterDepthText = `${prefix}${berth.waterDepthM}m deep`;
|
||||
}
|
||||
|
||||
// Power label: combine capacity + voltage when both present.
|
||||
let powerText: string | null = null;
|
||||
if (berth.powerCapacity && berth.voltage) {
|
||||
powerText = `${berth.powerCapacity}A / ${berth.voltage}V`;
|
||||
} else if (berth.powerCapacity) {
|
||||
powerText = `${berth.powerCapacity}A`;
|
||||
} else if (berth.voltage) {
|
||||
powerText = `${berth.voltage}V`;
|
||||
}
|
||||
|
||||
// Secondary meta: boat-capacity · water-depth · price · power. All
|
||||
// optional; order favours the highest-utility scan signals first.
|
||||
const metaParts: string[] = [];
|
||||
if (dimText) metaParts.push(dimText);
|
||||
if (boatCapacityText) metaParts.push(boatCapacityText);
|
||||
if (waterDepthText) metaParts.push(waterDepthText);
|
||||
if (berth.price)
|
||||
metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }));
|
||||
if (powerText) metaParts.push(powerText);
|
||||
|
||||
const tags = berth.tags ?? [];
|
||||
|
||||
@@ -101,26 +134,27 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} />
|
||||
<div className="flex items-center gap-3">
|
||||
{/* The mooring number IS the avatar — recognisable at a glance
|
||||
(A1, B12, …) and eliminates the duplicate berth-number heading
|
||||
that previously sat to the right of an anchor icon. */}
|
||||
<ListCardAvatar
|
||||
initials={berth.mooringNumber}
|
||||
className="text-base font-bold tracking-tight"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title row + spacer for actions button */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{berth.mooringNumber}
|
||||
</h3>
|
||||
{/* Primary line: dimensions (L × W × Draft). The avatar
|
||||
already carries the area letter, so this slot becomes the
|
||||
"what fits here" answer. Falls back gracefully when
|
||||
dimensions aren't recorded yet. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{dimText ?? <span className="font-normal text-muted-foreground">No dimensions</span>}
|
||||
</p>
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Area subtitle */}
|
||||
{berth.area ? (
|
||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
<span className="truncate">{berth.area}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Dimensions · Price meta line */}
|
||||
{/* Meta line: tenure · price · power. All optional. */}
|
||||
{metaParts.length > 0 ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||
{metaParts.map((part, i) => (
|
||||
@@ -132,8 +166,8 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Status pill */}
|
||||
<div className="mt-1.5">
|
||||
{/* Status pill + tags */}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
|
||||
@@ -142,21 +176,15 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{tags.length > 2 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
+{tags.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{tags.length > 2 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
+{tags.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListCard>
|
||||
|
||||
Reference in New Issue
Block a user