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:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -4,19 +4,13 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { usePortContext } from '@/providers/port-provider';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
import { apiFetch } from '@/lib/api/client';
import { PageHeader } from '@/components/shared/page-header';
import { ActivityFeed } from './activity-feed';
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
import { DateRangePicker } from './date-range-picker';
import { PipelineFunnelChart } from './pipeline-funnel-chart';
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { LeadSourceChart } from './lead-source-chart';
import { MyRemindersRail } from './my-reminders-rail';
import { WebsiteGlanceTile } from './website-glance-tile';
import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail';
import type { DashboardWidget } from './widget-registry';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
@@ -43,8 +37,10 @@ function rangeLabel(range: DateRange): string {
interface MeData {
data?: {
firstName?: string | null;
displayName?: string | null;
profile?: {
firstName?: string | null;
displayName?: string | null;
};
};
}
@@ -58,8 +54,14 @@ function timeOfDayGreeting(): string {
export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d');
const { currentPort } = usePortContext();
const portName = currentPort?.name ?? 'this port';
const { visibleWidgets } = useDashboardWidgets();
// Bucket once so the JSX stays readable. Registry order is preserved
// inside each bucket, so reordering the registry reorders the render.
const charts = visibleWidgets.filter((w) => w.group === 'chart');
const rails = visibleWidgets.filter((w) => w.group === 'rail');
const feed = visibleWidgets.filter((w) => w.group === 'feed');
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
// useTablePreferences elsewhere — usually a cache hit, so no extra
@@ -70,7 +72,7 @@ export function DashboardShell() {
queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }),
staleTime: 5 * 60_000,
});
const firstName = me.data?.data?.firstName?.trim();
const firstName = me.data?.data?.profile?.firstName?.trim();
// Time-aware greeting line, falls back to a generic "Welcome back" when
// we don't know the user's first name yet (e.g. profile not filled out).
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
@@ -96,51 +98,103 @@ export function DashboardShell() {
return (
<div className="space-y-6">
{/* Mobile-only greeting strip. The shared PageHeader is hidden
below `sm` (its title is normally duplicated by the topbar),
so we render the welcome message inline here for mobile —
keeps the personalized touch from desktop without polluting
the topbar (which stays "Dashboard" for wayfinding). */}
<div className="sm:hidden">
<p className="text-xs font-semibold uppercase tracking-wide text-brand">Dashboard</p>
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
</div>
<PageHeader
title={greeting}
eyebrow="Dashboard"
description={`Live snapshot of ${portName} activity`}
kpiLine={<span>{rangeLabel(range)}</span>}
// The date-range subtitle only means something when at least
// one widget is on the page to consume the range; if everything
// is hidden it just reads as an orphaned line.
kpiLine={visibleWidgets.length > 0 ? <span>{rangeLabel(range)}</span> : undefined}
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={setRange} />
<CustomizeWidgetsMenu />
</div>
}
/>
{/* `items-start` is critical: without it, the right-column aside is
{/* Charts + rails sit side-by-side at xl+. Each side is an auto-fit
grid, so hiding a card causes the remaining ones to widen.
`items-start` is critical: without it, the right-column aside is
stretched to match the chart column's row height, which forces
MyRemindersRail (or any other child with `h-full`) to push later
children out of the aside's box and into the rows below where
ActivityFeed renders. */}
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
<WidgetErrorBoundary>
<PipelineFunnelChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<OccupancyTimelineChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<RevenueBreakdownChart range={range} />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<LeadSourceChart range={range} />
</WidgetErrorBoundary>
children out of the aside's box. */}
{/* Charts + rails. Layout adapts to which regions have content so
we never leave a 320px stripe of dead space when only one side
is populated:
both → main 1fr column + 320px rail (the original layout)
charts only → single full-width auto-fit chart grid
rails only → rails widen into an auto-fit grid (no fixed 320)
neither → nothing renders
The chart grid uses `minmax(360px, 1fr)` so a lone chart fills
the row; the rails-only grid uses a slightly tighter `280px`
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
instead of stretching to 600px+ each. */}
{charts.length > 0 && rails.length > 0 ? (
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
<aside className="min-w-0 space-y-4">
{rails.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</aside>
</div>
<aside className="min-w-0 space-y-4">
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
isn't configured for this port. */}
<WidgetErrorBoundary>
<WebsiteGlanceTile />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<MyRemindersRail />
</WidgetErrorBoundary>
<WidgetErrorBoundary>
<AlertRail />
</WidgetErrorBoundary>
</aside>
</div>
) : charts.length > 0 ? (
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
{charts.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
) : rails.length > 0 ? (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
{rails.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
) : null}
<ActivityFeed />
{feed.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
</div>
);
}
/**
* Placeholder shown when the rep has hidden every widget. Without this,
* the dashboard collapses to just the gradient header strip and looks
* like a broken page — this hints at the "Customize" button to bring
* widgets back.
*/
function EmptyDashboardHint() {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">No widgets on your dashboard yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Click <span className="font-medium text-foreground">Customize</span> above to pick which
analytics cards appear here.
</p>
</div>
);
}
function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) {
return <WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>;
}