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:
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user