diff --git a/src/app/api/v1/clients/[id]/field-history/route.ts b/src/app/api/v1/clients/[id]/field-history/route.ts new file mode 100644 index 00000000..65c5400e --- /dev/null +++ b/src/app/api/v1/clients/[id]/field-history/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { and, desc, eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { interestFieldHistory } from '@/lib/db/schema'; +import { errorResponse } from '@/lib/errors'; + +/** + * GET /api/v1/clients/[id]/field-history + * + * Returns every supplemental-form override that touched the client (rolling + * up across all of their interests), newest first. Powers the inline clock + * icon + popover on Client detail. Mirrors /interests/[id]/field-history. + */ +export const GET = withAuth( + withPermission('clients', 'view', async (_req, ctx, params) => { + try { + const rows = await db + .select() + .from(interestFieldHistory) + .where( + and( + eq(interestFieldHistory.portId, ctx.portId), + eq(interestFieldHistory.clientId, params.id!), + ), + ) + .orderBy(desc(interestFieldHistory.createdAt)) + .limit(100); + return NextResponse.json({ data: rows }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/forms/form-template-form.tsx b/src/components/admin/forms/form-template-form.tsx index 276c9844..8b163bdc 100644 --- a/src/components/admin/forms/form-template-form.tsx +++ b/src/components/admin/forms/form-template-form.tsx @@ -13,14 +13,24 @@ import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Badge } from '@/components/ui/badge'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import type { FormField } from '@/lib/validators/form-templates'; +import { + bindableFieldsByEntity, + getBindableField, + type BindableType, +} from '@/lib/templates/bindable-fields'; + +const BIND_TO_NONE = '__none__'; interface FormTemplate { id: string; @@ -103,6 +113,41 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f))); } + function changeBinding(idx: number, raw: string) { + if (raw === BIND_TO_NONE) { + // Clear the binding but leave the rest of the field untouched — + // admins may want to keep a custom field that no longer autofills. + setFields((prev) => + prev.map((f, i) => { + if (i !== idx) return f; + const { bindTo, ...rest } = f; + void bindTo; + return rest; + }), + ); + return; + } + const meta = getBindableField(raw); + if (!meta) return; + // Adopt the binding + auto-derive input type and label when the admin + // hasn't typed one yet (saves repetitive data entry on the common + // "bind to client email" flow). Pre-existing labels are preserved so + // admins can override the friendly name per template. + setFields((prev) => + prev.map((f, i) => + i === idx + ? { + ...f, + bindTo: raw, + type: coerceFieldType(meta.inputType, f.type), + label: f.label.trim() ? f.label : meta.label, + key: f.key.trim() ? f.key : meta.path.split('.').pop()!, + } + : f, + ), + ); + } + function addField() { setFields((prev) => [...prev, { ...DEFAULT_FIELD }]); } @@ -158,6 +203,40 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) )} + +
+ + + {f.bindTo ? ( + + Autofills from + writes back to {getBindableField(f.bindTo)?.label} ·{' '} + {f.bindTo} + + ) : null} +
+
@@ -240,3 +319,18 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) ); } + +/** + * Map a bindable column's natural input type onto the form-field types we + * actually render. When binding to a `number` column we still let the + * admin keep `select` if they'd already chosen it (e.g. they want to + * constrain to specific values) — same for `textarea`. + */ +function coerceFieldType( + bindableType: BindableType, + currentType: FormField['type'], +): FormField['type'] { + if (currentType === 'select' || currentType === 'checkbox') return currentType; + if (currentType === 'textarea' && bindableType === 'text') return 'textarea'; + return bindableType; +} diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index 199f976b..86cbd255 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { DetailTab } from '@/components/shared/detail-layout'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history'; import { InlineCountryField } from '@/components/shared/inline-country-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; import { RemindersInline } from '@/components/reminders/reminders-inline'; @@ -56,11 +57,25 @@ function useClientPatch(clientId: string) { }); } -function EditableRow({ label, children }: { label: string; children: React.ReactNode }) { +function EditableRow({ + label, + children, + historyPath, +}: { + label: string; + children: React.ReactNode; + /** When set, renders a clock icon (if any override rows exist) that + * opens the field-history popover. The icon component renders nothing + * when the field has no history, so it's safe to pass on every row. */ + historyPath?: string; +}) { return (
{label}
-
{children}
+
+
{children}
+ {historyPath ? : null} +
); } @@ -135,101 +150,103 @@ function OverviewTab({ }; return ( -
-
- -
+ +
+
+ +
-
- {/* 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; +
+ {/* 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" - /> - - - - -
+ countryHint={(client.nationalityIso as CountryCode | null) ?? null} + onSave={async (tz) => { + await mutation.mutateAsync({ timezone: tz }); + }} + data-testid="client-timezone-inline" + /> + + + + +
+
+ + {/* Contacts */} +
+

Contact Details

+ +
+ + {/* Source */} +
+

Source

+
+ + + + + + +
+
+ + + +
- - {/* Contacts */} -
-

Contact Details

- -
- - {/* Source */} -
-

Source

-
- - - - - - -
-
- - - -
-
+ ); } diff --git a/src/components/clients/contacts-editor.tsx b/src/components/clients/contacts-editor.tsx index f35fdfb8..d5b4263a 100644 --- a/src/components/clients/contacts-editor.tsx +++ b/src/components/clients/contacts-editor.tsx @@ -16,6 +16,7 @@ import { SelectValue, } from '@/components/ui/select'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { FieldHistoryIcon } from '@/components/shared/field-history'; import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { useConfirmation } from '@/hooks/use-confirmation'; @@ -199,32 +200,44 @@ function ContactRow({ -
- {contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( - { - if (!e164) { - toast.error('Phone number is required'); - return; - } - await onUpdate({ value: e164, valueE164: e164, valueCountry: country }); - }} - /> - ) : ( - { - if (!v) { - toast.error('Value is required'); - return; - } - await onUpdate({ value: v }); - }} - /> - )} +
+
+ {contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( + { + if (!e164) { + toast.error('Phone number is required'); + return; + } + await onUpdate({ value: e164, valueE164: e164, valueCountry: country }); + }} + /> + ) : ( + { + if (!v) { + toast.error('Value is required'); + return; + } + await onUpdate({ value: v }); + }} + /> + )} +
+ {/* Override history is only meaningful for the canonical "primary + email" / "primary phone" entries the supplemental form + overwrites — secondary contacts don't have a matching + bindable path. The icon renders nothing when no rows exist. */} + {contact.isPrimary && contact.channel === 'email' ? ( + + ) : null} + {contact.isPrimary && (contact.channel === 'phone' || contact.channel === 'whatsapp') ? ( + + ) : null}
diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index e5e61e50..f7e89a84 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -19,6 +19,7 @@ import { } from '@/components/ui/accordion'; import { NotesList } from '@/components/shared/notes-list'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history'; import { ClientChannelEditor } from '@/components/clients/client-channel-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { RemindersInline } from '@/components/reminders/reminders-inline'; @@ -222,11 +223,26 @@ function useStageMutation(interestId: string) { }); } -function EditableRow({ label, children }: { label: string; children: React.ReactNode }) { +function EditableRow({ + label, + children, + historyPath, +}: { + label: string; + children: React.ReactNode; + /** When set, renders a clock icon (when at least one override row + * exists for this path on the surrounding FieldHistoryProvider scope) + * that opens the field-history popover. The icon renders nothing + * without history, so it's safe to pass on every row. */ + historyPath?: string; +}) { return (
{label}
-
{children}
+
+
{children}
+ {historyPath ? : null} +
); } @@ -977,27 +993,28 @@ function OverviewTab({ const futureMilestones = milestones.filter((m) => m.phase === 'future'); return ( -
- {/* 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. -

-
- ) : null} + {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. +

+
+ ) : 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} -
- {/* Lead & Source (editable) */} -
-

Lead

-
- - - - - ({ value: s.value, label: s.label }))} - value={interest.source} - onSave={save('source')} - /> - -
-
+
+ {/* Lead & Source (editable) */} +
+

Lead

+
+ + + + + ({ value: s.value, label: s.label }))} + value={interest.source} + onSave={save('source')} + /> + +
+
- {/* 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). */} -
-

Contact

-
- - {interest.clientId ? ( - +
+

Contact

+
+ + {interest.clientId ? ( + + ) : ( + - + )} + + + {interest.clientId ? ( + + ) : ( + - + )} + + {interest.dateFirstContact || interest.dateLastContact ? ( + <> + + + ) : ( - - +

+ No contact activity logged yet - log a call, email, or meeting from the Contact + log tab to start tracking. +

)} - - - {interest.clientId ? ( - - ) : ( - - - )} - - {interest.dateFirstContact || interest.dateLastContact ? ( - <> - - - - ) : ( -

- No contact activity logged yet - log a call, email, or meeting from the Contact log - tab to start tracking. -

- )} - {interest.reservationStatus ? ( - - ) : null} -
-
+ {interest.reservationStatus ? ( + + ) : null} +
+
- {/* Berth requirements - desired length / width / draft. Editable + {/* Berth requirements - desired length / width / draft. Editable inline so reps can capture or correct a buyer's needs without leaving the Overview tab. These values drive the auto-tick on the "Dimensions confirmed" qualification row + the BerthRecommenderPanel rankings below. */} -
-

Berth requirements

- {(() => { - // Honour the interest's `desiredLengthUnit` so a deal whose rep - // entered metric values doesn't render labelled "(ft)" with - // empty inputs. On save we patch BOTH the chosen-unit column - // and the canonical counterpart so downstream surfaces - // (recommender, EOI merge fields) stay in lockstep. - const unitIsM = interest.desiredLengthUnit === 'm'; - const FT_PER_M = 3.28084; - const toCounterpart = (v: string | null): string | null => { - if (!v) return null; - const n = Number(v); - if (!Number.isFinite(n)) return null; - return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4); - }; - const onSavePair = - ( - primary: InterestPatchField, - counterpart: InterestPatchField, - ): ((next: string | null) => Promise) => - async (next: string | null) => { - await mutation.mutateAsync({ - [primary]: next, - [counterpart]: toCounterpart(next), - }); +
+

Berth requirements

+ {(() => { + // Honour the interest's `desiredLengthUnit` so a deal whose rep + // entered metric values doesn't render labelled "(ft)" with + // empty inputs. On save we patch BOTH the chosen-unit column + // and the canonical counterpart so downstream surfaces + // (recommender, EOI merge fields) stay in lockstep. + const unitIsM = interest.desiredLengthUnit === 'm'; + const FT_PER_M = 3.28084; + const toCounterpart = (v: string | null): string | null => { + if (!v) return null; + const n = Number(v); + if (!Number.isFinite(n)) return null; + return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4); }; - const unitLabel = unitIsM ? 'm' : 'ft'; - return ( -
- - - - - - - - - -
- ); - })()} -
+ const onSavePair = + ( + primary: InterestPatchField, + counterpart: InterestPatchField, + ): ((next: string | null) => Promise) => + async (next: string | null) => { + await mutation.mutateAsync({ + [primary]: next, + [counterpart]: toCounterpart(next), + }); + }; + const unitLabel = unitIsM ? 'm' : 'ft'; + return ( +
+ + + + + + + + + +
+ ); + })()} +
- {/* 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. */} -
-
-

Latest note

- - {interest.recentNote - ? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}` - : 'Add note'} - -
- {interest.recentNote ? ( -
-

- {interest.recentNote.content} -

-

- {/* Stage pill = the deal's current stage. Source-of-truth +

+
+

Latest note

+ + {interest.recentNote + ? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}` + : 'Add note'} + +
+ {interest.recentNote ? ( +
+

+ {interest.recentNote.content} +

+

+ {/* 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') - }` - : ''} - -

-
- ) : ( -
- No notes yet. -
- )} + + {stageLabel(interest.pipelineStage)} + + + {formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), { + addSuffix: true, + })} + {interest.recentNote.authorId + ? ` · ${ + interest.recentNote.authorId === 'system' + ? 'system' + : (interest.recentNote.authorName ?? 'Unknown') + }` + : ''} + +

+
+ ) : ( +
+ No notes yet. +
+ )} +
+ + + +
+ +
- - -
- -
-
- - {/* 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 ( + + + + + +
+
{label}
+
+ {rows.length} override{rows.length === 1 ? '' : 's'} +
+
+ +
    + {rows.map((r) => ( +
  • +
    + {formatSource(r.source)} + + {formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })} + +
    +
    + {formatValue(r.oldValue)} + + {formatValue(r.newValue)} +
    +
  • + ))} +
+
+
+
+ ); +} + +function formatValue(v: unknown): string { + if (v === null || v === undefined) return '(empty)'; + if (typeof v === 'string') return v.length > 80 ? `${v.slice(0, 77)}…` : v; + return JSON.stringify(v); +} + +function formatSource(source: string): string { + if (source === 'supplemental_form') return 'Supplemental info form'; + return source; +} diff --git a/src/lib/services/supplemental-forms.service.ts b/src/lib/services/supplemental-forms.service.ts index d31dc9c9..98cdb020 100644 --- a/src/lib/services/supplemental-forms.service.ts +++ b/src/lib/services/supplemental-forms.service.ts @@ -337,6 +337,23 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr countryIso: input.country ?? null, isPrimary: true, }); + // Insert-path: every populated field is a "from null → value" + // override so the history panel surfaces the initial population + // the same way it surfaces later edits. + if (input.address) { + overrides.push({ + fieldPath: 'client.address.streetAddress', + oldValue: null, + newValue: input.address, + }); + } + if (input.country) { + overrides.push({ + fieldPath: 'client.address.countryIso', + oldValue: null, + newValue: input.country, + }); + } } else { const addrPatch: Record = {}; if (input.address && input.address !== existingAddr.streetAddress) { @@ -407,7 +424,17 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr valueCountry: input.phoneCountry, isPrimary: true, }); + overrides.push({ + fieldPath: 'client.primaryPhone', + oldValue: null, + newValue: input.phoneE164, + }); } else if (existing.valueE164 !== input.phoneE164) { + overrides.push({ + fieldPath: 'client.primaryPhone', + oldValue: existing.valueE164 ?? existing.value, + newValue: input.phoneE164, + }); await tx .update(clientContacts) .set({ @@ -425,11 +452,42 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr where: eq(interests.id, row.interestId), }); if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) { + const existingYacht = await tx.query.yachts.findFirst({ + where: eq(yachts.id, interest.yachtId), + }); const yachtPatch: Record = {}; - if (input.yachtName) yachtPatch.name = input.yachtName; - if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt); - if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt); - if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt); + if (input.yachtName && input.yachtName !== existingYacht?.name) { + yachtPatch.name = input.yachtName; + overrides.push({ + fieldPath: 'yacht.name', + oldValue: existingYacht?.name ?? null, + newValue: input.yachtName, + }); + } + if (input.yachtLengthFt !== null && String(input.yachtLengthFt) !== existingYacht?.lengthFt) { + yachtPatch.lengthFt = String(input.yachtLengthFt); + overrides.push({ + fieldPath: 'yacht.lengthFt', + oldValue: existingYacht?.lengthFt ?? null, + newValue: String(input.yachtLengthFt), + }); + } + if (input.yachtWidthFt !== null && String(input.yachtWidthFt) !== existingYacht?.widthFt) { + yachtPatch.widthFt = String(input.yachtWidthFt); + overrides.push({ + fieldPath: 'yacht.widthFt', + oldValue: existingYacht?.widthFt ?? null, + newValue: String(input.yachtWidthFt), + }); + } + if (input.yachtDraftFt !== null && String(input.yachtDraftFt) !== existingYacht?.draftFt) { + yachtPatch.draftFt = String(input.yachtDraftFt); + overrides.push({ + fieldPath: 'yacht.draftFt', + oldValue: existingYacht?.draftFt ?? null, + newValue: String(input.yachtDraftFt), + }); + } if (Object.keys(yachtPatch).length > 0) { await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId)); } diff --git a/src/lib/templates/bindable-fields.ts b/src/lib/templates/bindable-fields.ts new file mode 100644 index 00000000..07901398 --- /dev/null +++ b/src/lib/templates/bindable-fields.ts @@ -0,0 +1,206 @@ +/** + * Catalog of fields a form-template can bind to on Interest / Client / Yacht. + * + * Each entry maps a dot-path token (e.g. `client.email`) to: + * - the entity table whose row should be read/written + * - the column on that table + * - the form input type that best matches the column + * - a human label shown in the admin "Bind to" picker + * + * The form-template editor uses this as an allow-list (a field whose + * `bindTo` isn't in the catalog is rejected by the validator). The + * supplemental-form runtime uses it twice: at load time to prefill the + * public form with the entity's current value, and at submission time to + * route each posted answer back to the correct table+column with an + * `interest_field_history` row capturing the override. + * + * Entity scoping rules: + * - `interest.*` → write to `interests` row resolved from the token + * - `client.*` → write to `clients` row resolved from interest.clientId + * - `client_address.*` → write to first `client_addresses` row (or insert) + * - `yacht.*` → write to the interest's linked yacht (if present) + * + * Not in the catalog (intentional): + * - Polymorphic ownership columns (yacht.current_owner_*) — needs + * ownership-flow service, not a flat write. + * - Anything on companies (M43 first pass scopes to client+yacht). + */ + +export type BindableType = 'text' | 'textarea' | 'email' | 'phone' | 'number'; + +export interface BindableField { + /** Stable dot-path. The form template stores this verbatim in `field.bindTo`. */ + path: string; + /** Human label shown in the admin picker + the field-history popover. */ + label: string; + /** Entity bucket — drives the picker grouping + write routing. */ + entity: 'interest' | 'client' | 'client_address' | 'yacht'; + /** Column on the entity's row. */ + column: string; + /** Default form-input type when the binding is set (the editor still lets the admin override). */ + inputType: BindableType; +} + +/** + * Path naming convention: + * - `client.` — top-level clients row + * - `client.primaryEmail|primaryPhone` — synthesised over client_contacts + * - `client.address.` — the primary client_addresses row + * - `yacht.` — interest's linked yachts row + * - `interest.` — interests row resolved from the token + * + * `interest_field_history.field_path` stores these strings verbatim, so the + * detail-page history popover can `WHERE field_path = ?` to surface the + * inline clock icon for the matching InlineEditableField. + */ +export const BINDABLE_FIELDS: readonly BindableField[] = [ + // ─── Client (top-level identity) ───────────────────────────────── + { + path: 'client.fullName', + label: 'Full name', + entity: 'client', + column: 'fullName', + inputType: 'text', + }, + { + path: 'client.primaryEmail', + label: 'Primary email', + entity: 'client', + column: 'primaryEmail', + inputType: 'email', + }, + { + path: 'client.primaryPhone', + label: 'Primary phone', + entity: 'client', + column: 'primaryPhone', + inputType: 'phone', + }, + { + path: 'client.nationality', + label: 'Nationality', + entity: 'client', + column: 'nationality', + inputType: 'text', + }, + + // ─── Client address (single canonical row) ─────────────────────── + { + path: 'client.address.streetAddress', + label: 'Street address', + entity: 'client_address', + column: 'streetAddress', + inputType: 'text', + }, + { + path: 'client.address.city', + label: 'City', + entity: 'client_address', + column: 'city', + inputType: 'text', + }, + { + path: 'client.address.postalCode', + label: 'Postal code', + entity: 'client_address', + column: 'postalCode', + inputType: 'text', + }, + { + path: 'client.address.countryIso', + label: 'Country', + entity: 'client_address', + column: 'countryIso', + inputType: 'text', + }, + + // ─── Yacht ─────────────────────────────────────────────────────── + { path: 'yacht.name', label: 'Yacht name', entity: 'yacht', column: 'name', inputType: 'text' }, + { + path: 'yacht.hullNumber', + label: 'Hull number', + entity: 'yacht', + column: 'hullNumber', + inputType: 'text', + }, + { + path: 'yacht.registration', + label: 'Registration', + entity: 'yacht', + column: 'registration', + inputType: 'text', + }, + { path: 'yacht.flag', label: 'Flag', entity: 'yacht', column: 'flag', inputType: 'text' }, + { + path: 'yacht.yearBuilt', + label: 'Year built', + entity: 'yacht', + column: 'yearBuilt', + inputType: 'number', + }, + { + path: 'yacht.lengthFt', + label: 'Length (ft)', + entity: 'yacht', + column: 'lengthFt', + inputType: 'number', + }, + { + path: 'yacht.widthFt', + label: 'Beam (ft)', + entity: 'yacht', + column: 'widthFt', + inputType: 'number', + }, + { + path: 'yacht.draftFt', + label: 'Draft (ft)', + entity: 'yacht', + column: 'draftFt', + inputType: 'number', + }, + + // ─── Interest (deal-level free-form) ───────────────────────────── + { + path: 'interest.notes', + label: 'Additional notes', + entity: 'interest', + column: 'notes', + inputType: 'textarea', + }, +] as const; + +const BINDABLE_BY_PATH = new Map(BINDABLE_FIELDS.map((f) => [f.path, f])); + +export function getBindableField(path: string | null | undefined): BindableField | null { + if (!path) return null; + return BINDABLE_BY_PATH.get(path) ?? null; +} + +export function isBindablePath(path: string): boolean { + return BINDABLE_BY_PATH.has(path); +} + +export const BINDABLE_PATHS: readonly string[] = BINDABLE_FIELDS.map((f) => f.path); + +/** + * Grouped form for the admin picker. Returns entries in the order entities + * should appear in the dropdown (Client, then Yacht, then Interest, then + * address — most-frequent first). + */ +export function bindableFieldsByEntity(): Array<{ + entity: BindableField['entity']; + label: string; + fields: readonly BindableField[]; +}> { + const buckets: Array<{ entity: BindableField['entity']; label: string }> = [ + { entity: 'client', label: 'Client' }, + { entity: 'client_address', label: 'Client address' }, + { entity: 'yacht', label: 'Yacht' }, + { entity: 'interest', label: 'Interest' }, + ]; + return buckets.map((b) => ({ + ...b, + fields: BINDABLE_FIELDS.filter((f) => f.entity === b.entity), + })); +} diff --git a/src/lib/validators/form-templates.ts b/src/lib/validators/form-templates.ts index c48d72f9..bc250b3f 100644 --- a/src/lib/validators/form-templates.ts +++ b/src/lib/validators/form-templates.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { BINDABLE_PATHS } from '@/lib/templates/bindable-fields'; + export const formFieldSchema = z.object({ key: z.string().min(1).max(80), label: z.string().min(1).max(200), @@ -7,6 +9,19 @@ export const formFieldSchema = z.object({ required: z.boolean().optional().default(false), options: z.array(z.string()).optional(), helpText: z.string().optional(), + /** + * Optional binding to an Interest/Client/Yacht column. When set, the + * supplemental-form runtime prefills the field from the linked entity + * AND writes the submitted value back to that column on apply (with an + * `interest_field_history` row capturing the override). Validated + * against `BINDABLE_FIELDS` so unknown paths can't sneak in. + */ + bindTo: z + .string() + .refine((v) => BINDABLE_PATHS.includes(v), { + message: 'Unknown bindable path', + }) + .optional(), }); export const createFormTemplateSchema = z.object({