-
-
-
-
- {
- // Auto-default the timezone to the country's primary
- // zone when none is set yet — saves the rep a click
- // and matches what a marina actually wants for first
- // contact (London for GB, NYC for US, etc.). Only
- // fires when timezone is empty so we never clobber a
- // value the rep deliberately picked.
- const patch: { nationalityIso: string | null; timezone?: string | null } = {
- nationalityIso: iso,
- };
- if (iso && !client.timezone) {
- const defaultTz = primaryTimezoneFor(iso as CountryCode);
- if (defaultTz) patch.timezone = defaultTz;
+
+ {/* Personal Info */}
+
+
Personal Information
+
+
+
+
+
+ {
+ // Auto-default the timezone to the country's primary
+ // zone when none is set yet — saves the rep a click
+ // and matches what a marina actually wants for first
+ // contact (London for GB, NYC for US, etc.). Only
+ // fires when timezone is empty so we never clobber a
+ // value the rep deliberately picked.
+ const patch: { nationalityIso: string | null; timezone?: string | null } = {
+ nationalityIso: iso,
+ };
+ if (iso && !client.timezone) {
+ const defaultTz = primaryTimezoneFor(iso as CountryCode);
+ if (defaultTz) patch.timezone = defaultTz;
+ }
+ await mutation.mutateAsync(patch);
+ }}
+ data-testid="client-country-inline"
+ />
+
+
+
-
-
- {
- await mutation.mutateAsync({ timezone: tz });
- }}
- data-testid="client-timezone-inline"
- />
-
-
-
-
-
- {/* Skip-ahead nudge - informational only; fires when the deal jumped
+
+
+ {/* Skip-ahead nudge - informational only; fires when the deal jumped
past a milestone without stamping the matching date. */}
-
+
- {/* Conflict callout - fires when a linked berth is sold or already
+ {/* Conflict callout - fires when a linked berth is sold or already
under offer to another active deal. Doesn't block the rep; just
surfaces the situation so they treat the deal as a backup. */}
-
+
- {/* Qualification checklist - surfaces the port's per-port criteria so
+ {/* Qualification checklist - surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */}
-
+
- {/* Payments - bank-issued invoices live elsewhere; this is the
+ {/* Payments - bank-issued invoices live elsewhere; this is the
internal audit record of money received against the deal. The
running deposit total here drives the auto-advance into the
deposit_paid stage server-side. Hidden before the reservation
@@ -1005,138 +1022,138 @@ function OverviewTab({
noise - the next-milestone card carries the actionable copy
instead. Render order: deprioritized below the milestone strip
so the rep's eye lands on the active step first. */}
- {/* Pre-reservation: the dedicated "Next step" guidance card was
+ {/* Pre-reservation: the dedicated "Next step" guidance card was
removed in favour of a brighter NEXT STEP pill on the active
MilestoneSection below (it already owns the workflow actions -
two surfaces was redundant). Nurturing keeps a slim helper
since no milestone is naturally "current" while a deal is
paused. */}
- {interest.pipelineStage === 'nurturing' ? (
-
-
Deal is on nurture
-
- Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
- them back to Qualified.
-
+ Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
+ them back to Qualified.
+
+
+ ) : null}
- {/* Sales-process milestones - phase-aware so the user only sees
+ {/* Sales-process milestones - phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future
milestones are hidden behind a toggle so reps can still
skip-ahead when reality calls for it (an override-confirm
gates the actual stage move). */}
- {pastMilestones.length > 0 && (
-
-
- Past
-
-
- {pastMilestones.map((m) => (
-
-
-
-
- {m.title}
- ·
- {m.pastSummary}
-
-
-
- {/* Reuse the same MilestoneSection layout used for the
+ {pastMilestones.length > 0 && (
+
+
+ Past
+
+
+ {pastMilestones.map((m) => (
+
+
+
+
+ {m.title}
+ ·
+ {m.pastSummary}
+
+
+
+ {/* Reuse the same MilestoneSection layout used for the
current milestone — the steps list, sub-status badge,
and any inline doc actions all render the same way.
`isActive={false}` keeps the NEXT-STEP pill off. */}
-
-
-
+
+
+
+ ))}
+
+
+ )}
+
+ {currentMilestones.length > 0 && (
+
+ {currentMilestones.map((m) => (
+
))}
-
-
- )}
+
+ )}
- {currentMilestones.length > 0 && (
-
- {currentMilestones.map((m) => (
-
- ))}
-
- )}
+ {futureMilestones.length > 0 && (
+
+ )}
- {futureMilestones.length > 0 && (
-
- )}
-
- {/* Payments section relocated below milestones (was above): the
+ {/* Payments section relocated below milestones (was above): the
deposit-tracking surface is reference/history, not the rep's
primary focus once they're at Reservation+. The active
milestone above carries the actionable copy. */}
- {showPaymentsSection ? (
-
- ) : null}
+ {showPaymentsSection ? (
+
+ ) : null}
-
- {/* Contact - client's primary email + phone (from the linked client
+ {/* Contact - client's primary email + phone (from the linked client
record) AND the first/last-contact activity dates from the
contact log. Phone is rendered via libphonenumber-js's
international formatter so `+33633219796` reads as
@@ -1144,255 +1161,260 @@ function OverviewTab({
Both email + phone are click-to-edit: the PATCH flows to the
underlying client_contacts row (resolved via the
`*ContactId` fields surfaced by the interest read). */}
-
- {/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired`
+ {/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired`
still drive the auto-follow-up worker (`processFollowUpReminders`),
but the Overview surface for them is hidden: the REMINDERS
section below shows the full reminders table and the bell-in-
header surfaces active counts. Removing the duplicate read-only
panel cleans Overview without affecting the backend job. */}
- {/* Most-recent threaded note teaser. Saves a click into the Notes
+ {/* Most-recent threaded note teaser. Saves a click into the Notes
tab when the rep just wants to peek at "what was discussed last."
Always rendered now that the redundant `interests.notes` blob is
gone - falls back to an empty-state prompt so reps still have an
obvious entry point to the Notes tab from Overview. */}
-
+ {/* Stage pill = the deal's current stage. Source-of-truth
interpretation: the note is about the deal as it
stands today; reading it on Overview, "current stage"
answers the implicit "where in the deal is this?". A
historical "stage-at-note-time" lookup would need an
audit_logs read per teaser render — over-engineered for
a context hint. */}
-
- {stageLabel(interest.pipelineStage)}
-
-
- {formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
- addSuffix: true,
- })}
- {interest.recentNote.authorId
- ? ` · ${
- interest.recentNote.authorId === 'system'
- ? 'system'
- : (interest.recentNote.authorName ?? 'Unknown')
- }`
- : ''}
-
-
-
- {/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
+ {/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
what's already linked before browsing more options. Each row exposes
per-berth role-flag toggles and the EOI bypass control (only visible
once the parent interest's primary EOI is signed). */}
- {/* Won-status wrap-up checklist - only renders when this interest's
+ {/* Won-status wrap-up checklist - only renders when this interest's
outcome is `won`. Surfaces upload slots for the manual paperwork
that didn't flow through the EOI->Contract chain automatically. */}
-
+
- {/* Pre-EOI supplemental info request. Sends the client a one-time
+ {/* Pre-EOI supplemental info request. Sends the client a one-time
public form pre-filled with what's on file so they can confirm /
correct details before the EOI is drafted. Hides itself once
the EOI is signed. */}
-
+
-
+
- {/* Berth recommender (plan §5.3) - always-mounted card driven by the
+ {/* Berth recommender (plan §5.3) - always-mounted card driven by the
interest's desired dimensions. Renders an inline guidance message
when dimensions aren't set yet. */}
-
- {confirmDialog}
- {/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
+
+ {confirmDialog}
+ {/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
footer button can launch the dialog without leaving the tab. Same
dialog component the dedicated EOI tab uses - single source of
truth for the editing/confirmation flow. */}
-
-
+
+
+
);
}
diff --git a/src/components/shared/field-history.tsx b/src/components/shared/field-history.tsx
new file mode 100644
index 00000000..fcf27242
--- /dev/null
+++ b/src/components/shared/field-history.tsx
@@ -0,0 +1,170 @@
+'use client';
+
+/**
+ * Inline field-history surface for detail pages.
+ *
+ * The pattern:
+ *
+ * 1. Wrap the detail page (or just the section containing editable
+ * fields) in . The
+ * provider fires a single GET for every recorded override, keyed
+ * by entity, and exposes a Map via context.
+ *
+ * 2. Pass `historyPath` to any InlineEditableField that maps to a
+ * bindable path (see src/lib/templates/bindable-fields.ts). The
+ * field renders a small clock icon next to the value when at least
+ * one history row exists for that path. Click → popover with the
+ * reverse-chronological diff list.
+ *
+ * Fields without `historyPath`, or fields whose path has zero history
+ * rows, render nothing extra — the surface is purely additive.
+ */
+
+import { createContext, useContext, useMemo, type ReactNode } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Clock } from 'lucide-react';
+import { formatDistanceToNow } from 'date-fns';
+
+import { apiFetch } from '@/lib/api/client';
+import { Button } from '@/components/ui/button';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { getBindableField } from '@/lib/templates/bindable-fields';
+import { cn } from '@/lib/utils';
+
+export interface FieldHistoryScope {
+ type: 'interest' | 'client';
+ id: string;
+}
+
+export interface FieldHistoryRow {
+ id: string;
+ fieldPath: string;
+ oldValue: unknown;
+ newValue: unknown;
+ source: string;
+ createdAt: string;
+}
+
+interface ContextValue {
+ byPath: Map;
+ isLoading: boolean;
+}
+
+const FieldHistoryContext = createContext({
+ byPath: new Map(),
+ isLoading: false,
+});
+
+interface ProviderProps {
+ scope: FieldHistoryScope | null;
+ children: ReactNode;
+}
+
+export function FieldHistoryProvider({ scope, children }: ProviderProps) {
+ const { data, isLoading } = useQuery({
+ enabled: scope !== null,
+ queryKey: ['field-history', scope?.type, scope?.id],
+ queryFn: async () => {
+ if (!scope) return [] as FieldHistoryRow[];
+ const url =
+ scope.type === 'interest'
+ ? `/api/v1/interests/${scope.id}/field-history`
+ : `/api/v1/clients/${scope.id}/field-history`;
+ const res = await apiFetch<{ data: FieldHistoryRow[] }>(url);
+ return res.data;
+ },
+ // Field history is small + read-mostly. 30s is plenty fresh for the
+ // common case where the rep edits a field and wants to see the
+ // history reflect immediately — react-query refetches on focus.
+ staleTime: 30_000,
+ });
+
+ const byPath = useMemo(() => {
+ const map = new Map();
+ for (const row of data ?? []) {
+ const arr = map.get(row.fieldPath) ?? [];
+ arr.push(row);
+ map.set(row.fieldPath, arr);
+ }
+ return map;
+ }, [data]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+interface IconProps {
+ fieldPath: string;
+ className?: string;
+}
+
+/**
+ * Renders nothing when the field has no history. When at least one
+ * override row exists, renders a small clock button that opens a
+ * popover with the diff timeline.
+ */
+export function FieldHistoryIcon({ fieldPath, className }: IconProps) {
+ const { byPath } = useContext(FieldHistoryContext);
+ const rows = byPath.get(fieldPath);
+ if (!rows || rows.length === 0) return null;
+ const meta = getBindableField(fieldPath);
+ const label = meta?.label ?? fieldPath;
+
+ return (
+
+
+
+
+
+