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

@@ -0,0 +1,27 @@
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
/**
* Auto-opens a list page's create sheet when the URL carries `?create=1`,
* then strips the param so a refresh / back-nav doesn't re-open it. Used
* by the topbar's "+ New" dropdown — each menu item navigates to the
* relevant list page with `?create=1` so the user lands on the right
* scoped view AND gets the create sheet popped in one click.
*/
export function useCreateFromUrl(onOpen: () => void): void {
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
if (searchParams.get('create') !== '1') return;
onOpen();
const params = new URLSearchParams(searchParams.toString());
params.delete('create');
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
// typedRoutes can't statically validate a same-route replace; cast is safe.
router.replace(newUrl as never);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
import type { WidgetIntegration } from '@/components/dashboard/widget-registry';
/**
* Returns availability for each external integration a dashboard widget
* might depend on. Lets the widget picker hide options whose underlying
* service isn't wired up — so reps don't enable widgets that'd render
* nothing.
*
* Add a new integration by:
* 1. Extending `WidgetIntegration` in the registry.
* 2. Probing the service here (cheap query is fine — the hook already
* ships its own network call so adding another doesn't change the
* cost model).
* 3. Returning the boolean in the map.
*
* `loading: true` is treated as "available" so widgets don't flash off
* during initial hydration.
*/
export function useDashboardIntegrations(): {
loading: boolean;
available: Record<WidgetIntegration, boolean>;
} {
const umami = useUmamiActive('today');
// Same probe the sidebar uses — `notConfigured: true` is the explicit
// signal the server returns when the integration isn't wired up.
const umamiAvailable = umami.isLoading
? true
: (umami.data as { notConfigured?: boolean } | undefined)?.notConfigured !== true;
// Documenso has no dashboard widgets yet — wire a real probe when the
// first one lands. Assuming available for now keeps the map honest if
// a Documenso widget is added before this hook is updated.
const documensoAvailable = true;
return {
loading: umami.isLoading,
available: {
umami: umamiAvailable,
documenso: documensoAvailable,
},
};
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { DASHBOARD_WIDGETS, type DashboardWidget } from '@/components/dashboard/widget-registry';
import { useDashboardIntegrations } from '@/hooks/use-dashboard-integrations';
interface PreferencesResponse {
data?: {
dashboardWidgets?: Record<string, boolean>;
// Other fields exist (timezone, locale, …) but we don't need them
// here — the typed access is intentionally narrow.
};
}
/**
* Returns the dashboard widget list filtered by the user's visibility
* preferences and exposes a toggle. Single source of truth for "what's
* showing on the dashboard right now" — used by both `DashboardShell`
* and the settings UI.
*
* Stored shape: `preferences.dashboardWidgets: { [widgetId]: boolean }`.
* Missing keys fall back to the registry's `defaultVisible`, so a newly
* added widget surfaces for everyone without a migration.
*/
export function useDashboardWidgets() {
const queryClient = useQueryClient();
const integrations = useDashboardIntegrations();
const { data, isLoading } = useQuery<PreferencesResponse>({
queryKey: ['me', 'preferences', 'dashboard-widgets'],
queryFn: () => apiFetch<PreferencesResponse>('/api/v1/users/me/preferences'),
staleTime: 60_000,
});
// The registry is the universe of declared widgets. `availableWidgets`
// is the universe filtered down to what actually CAN render right now
// (i.e. its required integration is connected). The picker iterates
// this list, and the visible-widgets render path filters off the same
// list so flipping on a widget whose service isn't wired up does
// nothing silently — the toggle simply isn't shown.
const availableWidgets: DashboardWidget[] = useMemo(
() =>
DASHBOARD_WIDGETS.filter(
(w) => !w.requires || integrations.available[w.requires],
),
[integrations],
);
const visibility: Record<string, boolean> = useMemo(() => {
const stored = data?.data?.dashboardWidgets ?? {};
const merged: Record<string, boolean> = {};
for (const w of availableWidgets) {
merged[w.id] = stored[w.id] ?? w.defaultVisible;
}
return merged;
}, [data, availableWidgets]);
const visibleWidgets: DashboardWidget[] = useMemo(
() => availableWidgets.filter((w) => visibility[w.id]),
[availableWidgets, visibility],
);
/**
* Persists a single widget's visibility. Optimistically updates the
* cache so the dashboard reflows instantly; the server PATCH races in
* the background. On failure the cache invalidates and re-reads the
* authoritative value.
*/
const mutation = useMutation({
mutationFn: async (next: Record<string, boolean>) =>
apiFetch<PreferencesResponse>('/api/v1/users/me/preferences', {
method: 'PATCH',
body: { dashboardWidgets: next },
}),
onMutate: async (next) => {
await queryClient.cancelQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] });
const previous = queryClient.getQueryData<PreferencesResponse>([
'me',
'preferences',
'dashboard-widgets',
]);
queryClient.setQueryData<PreferencesResponse>(
['me', 'preferences', 'dashboard-widgets'],
(old) => ({
data: { ...(old?.data ?? {}), dashboardWidgets: next },
}),
);
return { previous };
},
onError: (_err, _next, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(['me', 'preferences', 'dashboard-widgets'], ctx.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] });
},
});
function setVisible(id: string, visible: boolean) {
mutation.mutate({ ...visibility, [id]: visible });
}
function setAll(visible: boolean) {
const next: Record<string, boolean> = {};
for (const w of availableWidgets) next[w.id] = visible;
mutation.mutate(next);
}
/**
* Restores each widget's visibility to its registry `defaultVisible`.
* Different from `setAll(true)` — it keeps the "off by default" widgets
* (KPI tiles, Berth Status donut, Source Conversion, Hot Deals) off so
* reps end up with the original out-of-the-box dashboard. Scoped to
* `availableWidgets` so disconnected integrations don't sneak in.
*/
function resetToDefaults() {
const next: Record<string, boolean> = {};
for (const w of availableWidgets) next[w.id] = w.defaultVisible;
mutation.mutate(next);
}
return {
isLoading,
/**
* Widgets that can render right now (registry minus those whose
* required integration isn't connected). Use this for the picker
* AND for the dashboard render — both surfaces stay in sync.
*/
allWidgets: availableWidgets,
/** Visible widgets, in registry order. */
visibleWidgets,
/** Map of widgetId → visible. Use for switch state binding. */
visibility,
setVisible,
setAll,
resetToDefaults,
isSaving: mutation.isPending,
};
}

View File

@@ -25,12 +25,24 @@ export type BucketType =
| 'navigation'
| 'notes';
/**
* Provenance hint for a result row that surfaced via graph expansion
* rather than a direct match against the query. Rendered as a "via X"
* subtitle by the result-row UI.
*/
export interface RelatedVia {
type: 'berth' | 'interest' | 'client' | 'yacht' | 'company';
id: string;
label: string;
}
export interface ClientResult {
id: string;
fullName: string;
matchedContact: string | null;
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface ResidentialClientResult {
id: string;
@@ -46,6 +58,7 @@ export interface YachtResult {
hullNumber: string | null;
registration: string | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface CompanyResult {
id: string;
@@ -54,6 +67,7 @@ export interface CompanyResult {
taxId: string | null;
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface InterestResult {
id: string;
@@ -61,6 +75,7 @@ export interface InterestResult {
berthMooringNumber: string | null;
pipelineStage: string;
outcome: string | null;
relatedVia?: RelatedVia | null;
}
export interface ResidentialInterestResult {
id: string;
@@ -73,6 +88,7 @@ export interface BerthResult {
area: string | null;
status: string;
linkedInterestCount: number;
relatedVia?: RelatedVia | null;
}
export interface InvoiceResult {
id: string;

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { trackEntityView } from '@/hooks/use-search';
@@ -10,11 +11,11 @@ import { trackEntityView } from '@/hooks/use-search';
* the call when `id` is falsy (e.g. during a transitional render before
* the data has loaded).
*
* Uses a JSON-stringified deps array so re-renders with the same
* (type, id) don't re-fire the network call. The fire-and-forget
* tracking endpoint debounces server-side too (Redis ZADD upserts the
* same member with a fresh score), but skipping the redundant fetch
* keeps the network panel tidy.
* After the track POST resolves, invalidates the recently-viewed query
* so the search dropdown re-fetches the freshly-updated list. Without
* this, the overlay's query cache (mounted once at the layout root)
* stays frozen on whatever it had at first paint — typically empty —
* and the user never sees the entity they just opened.
*/
export function useTrackEntityView(
type:
@@ -30,8 +31,12 @@ export function useTrackEntityView(
| 'document',
id: string | null | undefined,
): void {
const queryClient = useQueryClient();
useEffect(() => {
if (!id) return;
void trackEntityView(type, id);
}, [type, id]);
void trackEntityView(type, id).then(() => {
queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
});
}, [type, id, queryClient]);
}