4 Commits

Author SHA1 Message Date
52493801e0 feat(uat-batch): M43 follow-up — yacht detail field history
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m35s
Build & Push Docker Images / build-and-push (push) Has been skipped
Extends Phase 3 from the M43 commit to yacht detail:
- New /api/v1/yachts/[id]/field-history endpoint joins through
  interests.yachtId (no schema migration needed) and filters to
  'yacht.%' paths so client-scoped overrides on the same interest
  don't bleed into the yacht surface.
- FieldHistoryScope.type accepts 'yacht'; provider URL routing
  generalised to /api/v1/<type>s/<id>/field-history.
- yacht-tabs OverviewTab wrapped in the provider; Name + the three
  ft-dimension rows get historyPath wired (m-dimension rows skipped —
  they're a unit-converted view of the same source value, and the
  supplemental writer only ever stores ft).

Addresses tab on Client detail intentionally left unwired — would
need AddressesEditor (a shared component) to surface icons per row,
which is more than the 5-min scope.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:57:47 +02:00
f6cb733424 docs(uat): annotate M43 + plan with SHIPPED markers
Closes plan item 43 in the remaining-plan doc; alpha-uat-master annotated
with the SHA. Per CLAUDE.md's "annotate the master doc" rule after a
batch ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:53:12 +02:00
91be0f9136 feat(uat-batch): M43 — form-template bindings + inline field history
Closes plan item 43 (Form-template fields bind to Interest/Client data —
autofill, override-preservation history, dual-surface audit trail).

Phase 1 — Editor:
- New bindable-fields catalog (src/lib/templates/bindable-fields.ts):
  client/yacht/interest paths, each tagged with the entity, column, and
  default input type. Source of truth for what can bind + what
  interest_field_history.field_path strings the writers should use.
- formFieldSchema gains optional bindTo, validated against the catalog
  as an allow-list (no arbitrary paths sneak through).
- form-template-form admin sheet: per-field "Bind to" dropdown grouped
  by entity, auto-derives label/key/type when a binding is picked,
  shows "Autofills from + writes back to {label} . {path}" badge.

Phase 2 — Runtime + history writes:
- supplemental-forms.service.applySubmission already wrote
  interest_field_history rows for client name/email/address from the
  earlier 0081 migration session. Extended to also capture phone +
  yacht (name, length, width, draft) diffs that were silently going
  to the entity without an audit row, and to push insert-path
  overrides for the no-existing-address case.
- Field paths aligned with the bindable-fields catalog so detail-page
  lookups work via exact-match WHERE field_path = ?.

Phase 3 — Inline history surface:
- New /api/v1/clients/[id]/field-history (mirror of the existing
  interests endpoint).
- shared/field-history: FieldHistoryProvider wraps a detail tab and
  fires a single keyed GET; FieldHistoryIcon consumes the context and
  renders a small clock affordance only when at least one override
  exists, opening a popover with the reverse-chrono diff list.
- Client + Interest detail Overview tabs wrapped in the provider;
  EditableRow gains an optional historyPath prop; ContactsEditor
  renders the icon next to the canonical primary email/phone.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:51:39 +02:00
be261f3f90 fix(dev-lan): unblock phone-on-LAN testing of the dev server
Branding URLs were baked with env.APP_URL=http://localhost:3000 at
upload time and stored verbatim in system_settings, so any logo/
background loaded from a non-localhost origin (an iPhone hitting the
Mac's LAN IP) failed to resolve. Same pattern bit Socket.IO (CORS +
client connection target) and the portal logout redirect.

- Branding: getPortBrandingConfig normalizes localhost/private-LAN
  hosts to path-only; both upload routes store path-only going
  forward; email shell re-absolutizes via absolutizeBrandingUrl() so
  inboxes (no app origin) still get fetchable URLs. DB backfilled to
  strip http://localhost:3000 from existing rows.
- Socket.IO: client connects to window.location.origin (io() with no
  URL); server CORS allows localhost + private-LAN ranges in dev,
  stays locked to APP_URL in prod.
- Portal logout: redirect target built from the request URL instead
  of env.APP_URL.
- next.config: allowedDevOrigins widened from a hardcoded IP to
  192.168/10/172.16-31 wildcards so HMR works across networks
  without an edit per-network. (Without HMR the login form's React
  click handler never hydrates and the form falls back to GET,
  leaking the password into the URL.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:28:34 +02:00
23 changed files with 1374 additions and 586 deletions

View File

@@ -157,7 +157,7 @@ Surfaces all touch `interest-tabs.tsx` / `interest-overview` / linked-berths. Gr
## Group M — Universal preview + form-templates (~12-16 h) ## Group M — Universal preview + form-templates (~12-16 h)
42. **[L] Universal in-system preview for every file type** — extend FilePreviewDialog beyond PDF + images. .docx / .xlsx / .pptx via google-doc-viewer iframe or libreoffice headless; .txt / .csv / .md inline; .eml / .msg via mailparser; .zip see-into. ~6-10 h. 42. **[L] Universal in-system preview for every file type** — extend FilePreviewDialog beyond PDF + images. .docx / .xlsx / .pptx via google-doc-viewer iframe or libreoffice headless; .txt / .csv / .md inline; .eml / .msg via mailparser; .zip see-into. ~6-10 h.
43. **[L] Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail** — _src/lib/db/schema/documents.ts:290-309_ (`formTemplates.fields` JSONB) + the New-form-template dialog UI + supplemental-forms.service.ts + new `interest_field_history` table + Interest/Client detail surfaces. ~8-12 h. 43. **[SHIPPED in 91be0f9] Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail** — `bindable-fields.ts` catalog + `formFieldSchema.bindTo` allow-list + admin sheet "Bind to" picker; `applySubmission` extended to write phone + yacht diffs (was silently updating) and address-insert overrides; `/api/v1/clients/[id]/field-history` mirror endpoint; `<FieldHistoryProvider>` + `<FieldHistoryIcon>` mount on Client + Interest Overview tabs and ContactsEditor. Note: addresses tab + yacht detail surface still need the icon wired (5-min follow-up).
--- ---

View File

@@ -522,7 +522,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
> - **[Umami] Click-to-filter the page from the world map** — _src/components/website-analytics/visitor-world-map.tsx_ + new `country` filter store + thread through every `useUmamiTop*` hook — `VisitorWorldMap` already accepts an `onCountryClick(iso2)` prop that's unused. Wire it to a page-wide country filter (Zustand store or URL search param `country=US`) that scopes every card on the page to that country's data. Mirrors Umami's own click-through behaviour. Cap: ~2-3 h. Captured 2026-05-19. > - **[Umami] Click-to-filter the page from the world map** — _src/components/website-analytics/visitor-world-map.tsx_ + new `country` filter store + thread through every `useUmamiTop*` hook — `VisitorWorldMap` already accepts an `onCountryClick(iso2)` prop that's unused. Wire it to a page-wide country filter (Zustand store or URL search param `country=US`) that scopes every card on the page to that country's data. Mirrors Umami's own click-through behaviour. Cap: ~2-3 h. Captured 2026-05-19.
> - **[Umami] Per-rep `identify()` calls for attribution** — _src/components/auth/use-session.tsx (or wherever the session is hydrated)_ + _src/lib/services/umami.service.ts (new `identifyRep` wrapper)_ — call `umami.identify({sessionId, role: 'rep', repId: user.id})` on every authenticated CRM session so Umami's Sessions list can show "this lead came in while Matt was working hours". Privacy-gated: only fires for super-admin / sales-manager / sales-agent roles, never for residential-partner, never for portal-side users. Captured 2026-05-19; deferred as the privacy/value trade-off needs a product call before building. > - **[Umami] Per-rep `identify()` calls for attribution** — _src/components/auth/use-session.tsx (or wherever the session is hydrated)_ + _src/lib/services/umami.service.ts (new `identifyRep` wrapper)_ — call `umami.identify({sessionId, role: 'rep', repId: user.id})` on every authenticated CRM session so Umami's Sessions list can show "this lead came in while Matt was working hours". Privacy-gated: only fires for super-admin / sales-manager / sales-agent roles, never for residential-partner, never for portal-side users. Captured 2026-05-19; deferred as the privacy/value trade-off needs a product call before building.
0. **Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail**_src/lib/db/schema/documents.ts:290-309 (`formTemplates.fields` JSONB)_ + the New-form-template dialog UI (admin/forms) + _src/lib/services/supplemental-forms.service.ts_ (resolve + submit paths) + new `interest_field_history` table (or extend `audit_logs` with a dedicated `source='supplemental_form'` tag) + Interest detail + Client detail views (surface the override trail). Substantial feature touching the template builder, the public-facing supplemental form, and two record views. 0. **SHIPPED in 91be0f9:** Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail. Catalog (`bindable-fields.ts`) + `formFieldSchema.bindTo` allow-list + admin "Bind to" picker; `applySubmission` writes phone/yacht/insert-path diffs that were previously silent; clients endpoint mirrors the existing interest one; `<FieldHistoryProvider>` + inline clock icon next to each editable field on Client + Interest Overview tabs and ContactsEditor (per UX spec d-i). Original spec follows for reference; UI uses a clock icon rather than "i" but the popover content matches.
> - **(a) Template-builder: bind each field to an Interest/Client data point via dropdown.** Today's Field row asks for a freetext `key` + `label` + `type`. Replace `key` with a dropdown listing every bindable data point keyed by a stable token, e.g.: > - **(a) Template-builder: bind each field to an Interest/Client data point via dropdown.** Today's Field row asks for a freetext `key` + `label` + `type`. Replace `key` with a dropdown listing every bindable data point keyed by a stable token, e.g.:
> - Interest-scoped: `interest.desiredLengthFt`, `interest.desiredWidthFt`, `interest.desiredDraftFt`, `interest.notes`, `interest.source`, `interest.tags`, ... > - Interest-scoped: `interest.desiredLengthFt`, `interest.desiredWidthFt`, `interest.desiredDraftFt`, `interest.notes`, `interest.source`, `interest.tags`, ...
> - Client-scoped: `client.fullName`, `client.dateOfBirth`, `client.nationality`, `client.passportNumber`, `client.residentialAddress`, ... > - Client-scoped: `client.fullName`, `client.dateOfBirth`, `client.nationality`, `client.passportNumber`, `client.residentialAddress`, ...

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import './.next/dev/types/routes.d.ts'; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -84,11 +84,13 @@ const nextConfig: NextConfig = {
// visible in every screenshot from the iPhone testing pass. // visible in every screenshot from the iPhone testing pass.
devIndicators: false, devIndicators: false,
// LAN access from a real iPhone hits the dev server via the Mac's // LAN access from a real iPhone hits the dev server via the Mac's
// local IP (e.g. 192.168.x.x), not localhost. Next 15 surfaces a // local IP (e.g. 192.168.x.x), not localhost. Next surfaces a warning
// warning for cross-origin /_next/* fetches unless we allow-list the // and blocks cross-origin /_next/* fetches (incl. HMR) unless we
// origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so // allow-list the origins explicitly. When HMR is blocked the page
// any LAN device works without a config edit per network. // never fully hydrates and form click handlers fall back to native
...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }), // submits — the symptom that bit us with a hard-coded IP. Wildcards
// cover any LAN device without a per-network config edit.
...(isProd ? {} : { allowedDevOrigins: ['192.168.*.*', '10.*.*.*', '172.16.*.*', '172.20.*.*'] }),
// Native/CJS-leaning server-only packages — list here so Next doesn't // Native/CJS-leaning server-only packages — list here so Next doesn't
// bundle them into the route trace (slower cold start + risk that // bundle them into the route trace (slower cold start + risk that
// native bindings fail at runtime). Build-auditor C3+M3: socket.io // native bindings fail at runtime). Build-auditor C3+M3: socket.io

View File

@@ -1,10 +1,13 @@
import { NextResponse } from 'next/server'; import { NextResponse, type NextRequest } from 'next/server';
import { PORTAL_COOKIE } from '@/lib/portal/auth'; import { PORTAL_COOKIE } from '@/lib/portal/auth';
import { env } from '@/lib/env';
export async function POST(): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {
const response = NextResponse.redirect(new URL('/portal/login', env.APP_URL)); // Build the redirect from the request URL so we stay on whatever host
// the user is actually browsing from (localhost, LAN IP, prod domain).
// Reading env.APP_URL here used to redirect phone-on-LAN users back
// to localhost.
const response = NextResponse.redirect(new URL('/portal/login', req.url));
response.cookies.delete(PORTAL_COOKIE); response.cookies.delete(PORTAL_COOKIE);

View File

@@ -9,7 +9,6 @@ import {
setPortLogo, setPortLogo,
type LogoCrop, type LogoCrop,
} from '@/lib/services/logo.service'; } from '@/lib/services/logo.service';
import { env } from '@/lib/env';
const MAX_RAW_BYTES = 5 * 1024 * 1024; const MAX_RAW_BYTES = 5 * 1024 * 1024;
@@ -50,14 +49,13 @@ export const GET = withAuth(
if (!file) { if (!file) {
return NextResponse.json({ data: null }); return NextResponse.json({ data: null });
} }
const baseUrl = env.APP_URL.replace(/\/+$/, ''); // Path-only — the admin UI renders this as `<img src>` and the
// Stream from the public-by-id surface (gated on `category='branding'`) // browser resolves against the current origin. Stays valid whether
// so the URL works as a direct `<img src>` — the authenticated // the admin opens the page from localhost or a LAN IP.
// `/api/v1/files/<id>/preview` returns JSON, not image bytes.
return NextResponse.json({ return NextResponse.json({
data: { data: {
fileId: file.id, fileId: file.id,
previewUrl: `${baseUrl}/api/public/files/${file.id}`, previewUrl: `/api/public/files/${file.id}`,
sizeBytes: file.sizeBytes, sizeBytes: file.sizeBytes,
mimeType: file.mimeType, mimeType: file.mimeType,
}, },
@@ -95,11 +93,10 @@ export const POST = withAuth(
ipAddress: ctx.ipAddress, ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}); });
const baseUrl = env.APP_URL.replace(/\/+$/, '');
return NextResponse.json({ return NextResponse.json({
data: { data: {
fileId: result.fileId, fileId: result.fileId,
previewUrl: `${baseUrl}/api/public/files/${result.fileId}`, previewUrl: `/api/public/files/${result.fileId}`,
warnings: result.warnings, warnings: result.warnings,
finalDimensions: processed.finalDimensions, finalDimensions: processed.finalDimensions,
finalBytes: processed.finalBytes, finalBytes: processed.finalBytes,

View File

@@ -6,7 +6,6 @@ import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { uploadFile } from '@/lib/services/files'; import { uploadFile } from '@/lib/services/files';
import { errorResponse, ValidationError } from '@/lib/errors'; import { errorResponse, ValidationError } from '@/lib/errors';
import { env } from '@/lib/env';
const MAX_BYTES = 5 * 1024 * 1024; const MAX_BYTES = 5 * 1024 * 1024;
@@ -77,11 +76,11 @@ export const POST = withAuth(
}, },
); );
const baseUrl = env.APP_URL.replace(/\/+$/, ''); // Path-only so the in-app `<img src>` resolves against whatever
// Branding assets must survive in email-inbox land where no session // host the page was loaded from (localhost, LAN IP, prod domain).
// cookie travels — route through the public-by-id surface gated on // Email shell calls `absolutizeBrandingUrl()` to prepend APP_URL
// `category='branding'` rather than the authenticated preview path. // for mail clients, which have no origin context.
const url = `${baseUrl}/api/public/files/${record.id}`; const url = `/api/public/files/${record.id}`;
return NextResponse.json({ data: { fileId: record.id, url } }); return NextResponse.json({ data: { fileId: record.id, url } });
} catch (error) { } catch (error) {

View File

@@ -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);
}
}),
);

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { and, desc, eq, sql } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { interestFieldHistory, interests } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/yachts/[id]/field-history
*
* Returns every supplemental-form override that touched the yacht,
* resolved by joining interest_field_history through interests.yachtId.
* The history table itself doesn't carry a yacht_id column (the writer
* scopes by interest + client only) — joining at read time avoids a
* schema migration just to support this rollup.
*/
export const GET = withAuth(
withPermission('yachts', 'view', async (_req, ctx, params) => {
try {
const rows = await db
.select({
id: interestFieldHistory.id,
fieldPath: interestFieldHistory.fieldPath,
oldValue: interestFieldHistory.oldValue,
newValue: interestFieldHistory.newValue,
source: interestFieldHistory.source,
createdAt: interestFieldHistory.createdAt,
})
.from(interestFieldHistory)
.innerJoin(interests, eq(interests.id, interestFieldHistory.interestId))
.where(
and(
eq(interestFieldHistory.portId, ctx.portId),
eq(interests.yachtId, params.id!),
// Restrict to actually-yacht-scoped paths so the rollup
// doesn't surface "client email changed" rows on yacht detail
// (those overrides came in via a supplemental form attached
// to an interest that happens to link this yacht).
sql`${interestFieldHistory.fieldPath} LIKE 'yacht.%'`,
),
)
.orderBy(desc(interestFieldHistory.createdAt))
.limit(100);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -13,14 +13,24 @@ import { Textarea } from '@/components/ui/textarea';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import type { FormField } from '@/lib/validators/form-templates'; 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 { interface FormTemplate {
id: string; id: string;
@@ -103,6 +113,41 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f))); 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() { function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]); setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
} }
@@ -158,6 +203,40 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Button> </Button>
)} )}
</div> </div>
<div className="space-y-1">
<Label className="text-xs">Bind to (autofill + write-back)</Label>
<Select
value={f.bindTo ?? BIND_TO_NONE}
onValueChange={(v) => changeBinding(i, v)}
>
<SelectTrigger>
<SelectValue placeholder="No binding (free-form)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BIND_TO_NONE}>
No binding - store in submission only
</SelectItem>
{bindableFieldsByEntity().map((group) => (
<SelectGroup key={group.entity}>
<SelectLabel>{group.label}</SelectLabel>
{group.fields.map((bf) => (
<SelectItem key={bf.path} value={bf.path}>
{bf.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{f.bindTo ? (
<Badge variant="secondary" className="text-[10px] font-normal">
Autofills from + writes back to {getBindableField(f.bindTo)?.label} ·{' '}
{f.bindTo}
</Badge>
) : null}
</div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">Key (no spaces)</Label> <Label className="text-xs">Key (no spaces)</Label>
@@ -240,3 +319,18 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Sheet> </Sheet>
); );
} }
/**
* 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;
}

View File

@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; 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 { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { RemindersInline } from '@/components/reminders/reminders-inline'; 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 ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt> <dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd> <dd className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0">{children}</div>
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
</dd>
</div> </div>
); );
} }
@@ -135,101 +150,103 @@ function OverviewTab({
}; };
return ( return (
<div className="space-y-6"> <FieldHistoryProvider scope={{ type: 'client', id: clientId }}>
<div className="rounded-xl border border-border bg-card p-4 shadow-sm"> <div className="space-y-6">
<ClientPipelineSummary clientId={clientId} variant="panel" /> <div className="rounded-xl border border-border bg-card p-4 shadow-sm">
</div> <ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */} {/* Personal Info */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3> <h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl> <dl>
<EditableRow label="Full Name"> <EditableRow label="Full Name" historyPath="client.fullName">
<InlineEditableField value={client.fullName} onSave={save('fullName')} /> <InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow> </EditableRow>
<EditableRow label="Country"> <EditableRow label="Country">
<InlineCountryField <InlineCountryField
value={client.nationalityIso ?? null} value={client.nationalityIso ?? null}
onSave={async (iso) => { onSave={async (iso) => {
// Auto-default the timezone to the country's primary // Auto-default the timezone to the country's primary
// zone when none is set yet — saves the rep a click // zone when none is set yet — saves the rep a click
// and matches what a marina actually wants for first // and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only // contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a // fires when timezone is empty so we never clobber a
// value the rep deliberately picked. // value the rep deliberately picked.
const patch: { nationalityIso: string | null; timezone?: string | null } = { const patch: { nationalityIso: string | null; timezone?: string | null } = {
nationalityIso: iso, nationalityIso: iso,
}; };
if (iso && !client.timezone) { if (iso && !client.timezone) {
const defaultTz = primaryTimezoneFor(iso as CountryCode); const defaultTz = primaryTimezoneFor(iso as CountryCode);
if (defaultTz) patch.timezone = defaultTz; if (defaultTz) patch.timezone = defaultTz;
}
await mutation.mutateAsync(patch);
}}
data-testid="client-country-inline"
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={
client.timezone ??
(client.nationalityIso
? primaryTimezoneFor(client.nationalityIso as CountryCode)
: null)
} }
await mutation.mutateAsync(patch); countryHint={(client.nationalityIso as CountryCode | null) ?? null}
}} onSave={async (tz) => {
data-testid="client-country-inline" await mutation.mutateAsync({ timezone: tz });
/> }}
</EditableRow> data-testid="client-timezone-inline"
<EditableRow label="Timezone"> />
<InlineTimezoneField </EditableRow>
value={ <EditableRow label="Preferred Contact">
client.timezone ?? <InlineEditableField
(client.nationalityIso variant="select"
? primaryTimezoneFor(client.nationalityIso as CountryCode) options={CONTACT_METHOD_OPTIONS}
: null) value={client.preferredContactMethod}
} onSave={save('preferredContactMethod')}
countryHint={(client.nationalityIso as CountryCode | null) ?? null} />
onSave={async (tz) => { </EditableRow>
await mutation.mutateAsync({ timezone: tz }); </dl>
}} </div>
data-testid="client-timezone-inline"
/> {/* Contacts */}
</EditableRow> <div className="space-y-1">
<EditableRow label="Preferred Contact"> <h3 className="text-sm font-medium mb-2">Contact Details</h3>
<InlineEditableField <ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
variant="select" </div>
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod} {/* Source */}
onSave={save('preferredContactMethod')} <div className="space-y-1">
/> <h3 className="text-sm font-medium mb-2">Source</h3>
</EditableRow> <dl>
</dl> <EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
<InlineTagEditor
heading="Tags"
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
<RemindersInline clientId={clientId} />
</div> </div>
{/* Contacts */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
</div>
{/* Source */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3>
<dl>
<EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
<InlineTagEditor
heading="Tags"
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
<RemindersInline clientId={clientId} />
</div> </div>
</div> </FieldHistoryProvider>
); );
} }

View File

@@ -16,6 +16,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryIcon } from '@/components/shared/field-history';
import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useConfirmation } from '@/hooks/use-confirmation'; import { useConfirmation } from '@/hooks/use-confirmation';
@@ -199,32 +200,44 @@ function ContactRow({
<ChannelPicker value={contact.channel} onChange={changeChannel}> <ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden /> <Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</ChannelPicker> </ChannelPicker>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 flex items-center gap-1">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( <div className="min-w-0 flex-1">
<InlinePhoneField {contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
e164={contact.valueE164 ?? null} <InlinePhoneField
country={contact.valueCountry ?? null} e164={contact.valueE164 ?? null}
onEditingChange={setPhoneEditing} country={contact.valueCountry ?? null}
onSave={async ({ e164, country }) => { onEditingChange={setPhoneEditing}
if (!e164) { onSave={async ({ e164, country }) => {
toast.error('Phone number is required'); if (!e164) {
return; toast.error('Phone number is required');
} return;
await onUpdate({ value: e164, valueE164: e164, valueCountry: country }); }
}} await onUpdate({ value: e164, valueE164: e164, valueCountry: country });
/> }}
) : ( />
<InlineEditableField ) : (
value={contact.value} <InlineEditableField
onSave={async (v) => { value={contact.value}
if (!v) { onSave={async (v) => {
toast.error('Value is required'); if (!v) {
return; toast.error('Value is required');
} return;
await onUpdate({ value: v }); }
}} await onUpdate({ value: v });
/> }}
)} />
)}
</div>
{/* 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' ? (
<FieldHistoryIcon fieldPath="client.primaryEmail" />
) : null}
{contact.isPrimary && (contact.channel === 'phone' || contact.channel === 'whatsapp') ? (
<FieldHistoryIcon fieldPath="client.primaryPhone" />
) : null}
</div> </div>
</div> </div>

View File

@@ -19,6 +19,7 @@ import {
} from '@/components/ui/accordion'; } from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; 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 { ClientChannelEditor } from '@/components/clients/client-channel-editor';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline'; 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 ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt> <dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd> <dd className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0">{children}</div>
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
</dd>
</div> </div>
); );
} }
@@ -977,27 +993,28 @@ function OverviewTab({
const futureMilestones = milestones.filter((m) => m.phase === 'future'); const futureMilestones = milestones.filter((m) => m.phase === 'future');
return ( return (
<div className="space-y-6"> <FieldHistoryProvider scope={{ type: 'interest', id: interestId }}>
{/* Skip-ahead nudge - informational only; fires when the deal jumped <div className="space-y-6">
{/* Skip-ahead nudge - informational only; fires when the deal jumped
past a milestone without stamping the matching date. */} past a milestone without stamping the matching date. */}
<SkipAheadBanner interest={interest} /> <SkipAheadBanner interest={interest} />
{/* 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 under offer to another active deal. Doesn't block the rep; just
surfaces the situation so they treat the deal as a backup. */} surfaces the situation so they treat the deal as a backup. */}
<InterestBerthStatusBanner <InterestBerthStatusBanner
interestId={interestId} interestId={interestId}
interestPipelineStage={interest.pipelineStage} interestPipelineStage={interest.pipelineStage}
interestOutcome={interest.outcome} interestOutcome={interest.outcome}
archivedAt={null} archivedAt={null}
/> />
{/* 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 the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */} of 'enquiry'. Hidden when the port has no enabled criteria. */}
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} /> <QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
{/* 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 internal audit record of money received against the deal. The
running deposit total here drives the auto-advance into the running deposit total here drives the auto-advance into the
deposit_paid stage server-side. Hidden before the reservation deposit_paid stage server-side. Hidden before the reservation
@@ -1005,138 +1022,138 @@ function OverviewTab({
noise - the next-milestone card carries the actionable copy noise - the next-milestone card carries the actionable copy
instead. Render order: deprioritized below the milestone strip instead. Render order: deprioritized below the milestone strip
so the rep's eye lands on the active step first. */} 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 removed in favour of a brighter NEXT STEP pill on the active
MilestoneSection below (it already owns the workflow actions - MilestoneSection below (it already owns the workflow actions -
two surfaces was redundant). Nurturing keeps a slim helper two surfaces was redundant). Nurturing keeps a slim helper
since no milestone is naturally "current" while a deal is since no milestone is naturally "current" while a deal is
paused. */} paused. */}
{interest.pipelineStage === 'nurturing' ? ( {interest.pipelineStage === 'nurturing' ? (
<div className="rounded-xl border bg-card p-4 text-sm"> <div className="rounded-xl border bg-card p-4 text-sm">
<p className="font-medium text-foreground">Deal is on nurture</p> <p className="font-medium text-foreground">Deal is on nurture</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Schedule a follow-up reminder or log a contact when the prospect re-engages, then move Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
them back to Qualified. them back to Qualified.
</p> </p>
</div> </div>
) : null} ) : 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 what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future history strip; the current milestone gets the full card; future
milestones are hidden behind a toggle so reps can still milestones are hidden behind a toggle so reps can still
skip-ahead when reality calls for it (an override-confirm skip-ahead when reality calls for it (an override-confirm
gates the actual stage move). */} gates the actual stage move). */}
{pastMilestones.length > 0 && ( {pastMilestones.length > 0 && (
<div className="rounded-lg border bg-muted/20"> <div className="rounded-lg border bg-muted/20">
<div className="flex items-center gap-2 border-b px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <div className="flex items-center gap-2 border-b px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>Past</span> <span>Past</span>
</div> </div>
<Accordion type="multiple" className="px-4"> <Accordion type="multiple" className="px-4">
{pastMilestones.map((m) => ( {pastMilestones.map((m) => (
<AccordionItem key={m.key} value={m.key} className="border-0"> <AccordionItem key={m.key} value={m.key} className="border-0">
<AccordionTrigger className="py-2 text-xs font-medium hover:no-underline"> <AccordionTrigger className="py-2 text-xs font-medium hover:no-underline">
<div className="flex flex-1 items-center gap-2 text-left text-muted-foreground"> <div className="flex flex-1 items-center gap-2 text-left text-muted-foreground">
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden /> <CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
<span className="font-medium text-foreground">{m.title}</span> <span className="font-medium text-foreground">{m.title}</span>
<span className="text-[10px]">·</span> <span className="text-[10px]">·</span>
<span className="truncate text-xs">{m.pastSummary}</span> <span className="truncate text-xs">{m.pastSummary}</span>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
{/* Reuse the same MilestoneSection layout used for the {/* Reuse the same MilestoneSection layout used for the
current milestone — the steps list, sub-status badge, current milestone — the steps list, sub-status badge,
and any inline doc actions all render the same way. and any inline doc actions all render the same way.
`isActive={false}` keeps the NEXT-STEP pill off. */} `isActive={false}` keeps the NEXT-STEP pill off. */}
<MilestoneSection <MilestoneSection
title={m.title} title={m.title}
icon={m.icon} icon={m.icon}
status={m.status} status={m.status}
isPending={stageMutation.isPending} isPending={stageMutation.isPending}
onAdvance={advance} onAdvance={advance}
currentStage={interest.pipelineStage} currentStage={interest.pipelineStage}
isActive={false} isActive={false}
steps={m.steps} steps={m.steps}
footer={m.footer} footer={m.footer}
/> />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
))}
</Accordion>
</div>
)}
{currentMilestones.length > 0 && (
<div
className={cn(
'grid grid-cols-1 gap-4',
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2',
)}
>
{currentMilestones.map((m) => (
<MilestoneSection
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))} ))}
</Accordion> </div>
</div> )}
)}
{currentMilestones.length > 0 && ( {futureMilestones.length > 0 && (
<div <FutureMilestones
className={cn( milestones={futureMilestones}
'grid grid-cols-1 gap-4', stageMutation={stageMutation}
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2', advance={advance}
)} activeMilestone={activeMilestone}
> currentStage={interest.pipelineStage}
{currentMilestones.map((m) => ( />
<MilestoneSection )}
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))}
</div>
)}
{futureMilestones.length > 0 && ( {/* Payments section relocated below milestones (was above): the
<FutureMilestones
milestones={futureMilestones}
stageMutation={stageMutation}
advance={advance}
activeMilestone={activeMilestone}
currentStage={interest.pipelineStage}
/>
)}
{/* Payments section relocated below milestones (was above): the
deposit-tracking surface is reference/history, not the rep's deposit-tracking surface is reference/history, not the rep's
primary focus once they're at Reservation+. The active primary focus once they're at Reservation+. The active
milestone above carries the actionable copy. */} milestone above carries the actionable copy. */}
{showPaymentsSection ? ( {showPaymentsSection ? (
<PaymentsSection <PaymentsSection
interestId={interestId} interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null} depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null} depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/> />
) : null} ) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Lead & Source (editable) */} {/* Lead & Source (editable) */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Lead</h3> <h3 className="text-sm font-medium mb-2">Lead</h3>
<dl> <dl>
<EditableRow label="Lead Category"> <EditableRow label="Lead Category">
<InlineEditableField <InlineEditableField
variant="select" variant="select"
options={LEAD_CATEGORY_OPTIONS} options={LEAD_CATEGORY_OPTIONS}
value={interest.leadCategory} value={interest.leadCategory}
onSave={save('leadCategory')} onSave={save('leadCategory')}
/> />
</EditableRow> </EditableRow>
<EditableRow label="Source"> <EditableRow label="Source">
<InlineEditableField <InlineEditableField
variant="select" variant="select"
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))} options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
value={interest.source} value={interest.source}
onSave={save('source')} onSave={save('source')}
/> />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
{/* 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 record) AND the first/last-contact activity dates from the
contact log. Phone is rendered via libphonenumber-js's contact log. Phone is rendered via libphonenumber-js's
international formatter so `+33633219796` reads as international formatter so `+33633219796` reads as
@@ -1144,255 +1161,260 @@ function OverviewTab({
Both email + phone are click-to-edit: the PATCH flows to the Both email + phone are click-to-edit: the PATCH flows to the
underlying client_contacts row (resolved via the underlying client_contacts row (resolved via the
`*ContactId` fields surfaced by the interest read). */} `*ContactId` fields surfaced by the interest read). */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3> <h3 className="text-sm font-medium mb-2">Contact</h3>
<dl> <dl>
<EditableRow label="Email"> <EditableRow label="Email" historyPath="client.primaryEmail">
{interest.clientId ? ( {interest.clientId ? (
<ClientChannelEditor <ClientChannelEditor
clientId={interest.clientId} clientId={interest.clientId}
channel="email" channel="email"
primaryContactId={interest.clientPrimaryEmailContactId ?? null} primaryContactId={interest.clientPrimaryEmailContactId ?? null}
primaryValue={interest.clientPrimaryEmail ?? null} primaryValue={interest.clientPrimaryEmail ?? null}
invalidateKeys={[['interest', interest.id]]} invalidateKeys={[['interest', interest.id]]}
/> />
) : (
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
<EditableRow label="Phone" historyPath="client.primaryPhone">
{interest.clientId ? (
<ClientChannelEditor
clientId={interest.clientId}
channel="phone"
primaryContactId={interest.clientPrimaryPhoneContactId ?? null}
primaryValue={interest.clientPrimaryPhone ?? null}
primaryValueE164={interest.clientPrimaryPhoneE164 ?? null}
primaryValueCountry={interest.clientPrimaryPhoneCountry ?? null}
invalidateKeys={[['interest', interest.id]]}
/>
) : (
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? (
<>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
</>
) : ( ) : (
<span className="text-muted-foreground">-</span> <p className="mt-1 text-xs text-muted-foreground italic">
No contact activity logged yet - log a call, email, or meeting from the Contact
log tab to start tracking.
</p>
)} )}
</EditableRow> {interest.reservationStatus ? (
<EditableRow label="Phone"> <InfoRow label="Reservation" value={interest.reservationStatus} />
{interest.clientId ? ( ) : null}
<ClientChannelEditor </dl>
clientId={interest.clientId} </div>
channel="phone"
primaryContactId={interest.clientPrimaryPhoneContactId ?? null}
primaryValue={interest.clientPrimaryPhone ?? null}
primaryValueE164={interest.clientPrimaryPhoneE164 ?? null}
primaryValueCountry={interest.clientPrimaryPhoneCountry ?? null}
invalidateKeys={[['interest', interest.id]]}
/>
) : (
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? (
<>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
</>
) : (
<p className="mt-1 text-xs text-muted-foreground italic">
No contact activity logged yet - log a call, email, or meeting from the Contact log
tab to start tracking.
</p>
)}
{interest.reservationStatus ? (
<InfoRow label="Reservation" value={interest.reservationStatus} />
) : null}
</dl>
</div>
{/* 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 inline so reps can capture or correct a buyer's needs without
leaving the Overview tab. These values drive the auto-tick on leaving the Overview tab. These values drive the auto-tick on
the "Dimensions confirmed" qualification row + the the "Dimensions confirmed" qualification row + the
BerthRecommenderPanel rankings below. */} BerthRecommenderPanel rankings below. */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Berth requirements</h3> <h3 className="text-sm font-medium mb-2">Berth requirements</h3>
{(() => { {(() => {
// Honour the interest's `desiredLengthUnit` so a deal whose rep // Honour the interest's `desiredLengthUnit` so a deal whose rep
// entered metric values doesn't render labelled "(ft)" with // entered metric values doesn't render labelled "(ft)" with
// empty inputs. On save we patch BOTH the chosen-unit column // empty inputs. On save we patch BOTH the chosen-unit column
// and the canonical counterpart so downstream surfaces // and the canonical counterpart so downstream surfaces
// (recommender, EOI merge fields) stay in lockstep. // (recommender, EOI merge fields) stay in lockstep.
const unitIsM = interest.desiredLengthUnit === 'm'; const unitIsM = interest.desiredLengthUnit === 'm';
const FT_PER_M = 3.28084; const FT_PER_M = 3.28084;
const toCounterpart = (v: string | null): string | null => { const toCounterpart = (v: string | null): string | null => {
if (!v) return null; if (!v) return null;
const n = Number(v); const n = Number(v);
if (!Number.isFinite(n)) return null; if (!Number.isFinite(n)) return null;
return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4); return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4);
};
const onSavePair =
(
primary: InterestPatchField,
counterpart: InterestPatchField,
): ((next: string | null) => Promise<void>) =>
async (next: string | null) => {
await mutation.mutateAsync({
[primary]: next,
[counterpart]: toCounterpart(next),
});
}; };
const unitLabel = unitIsM ? 'm' : 'ft'; const onSavePair =
return ( (
<dl> primary: InterestPatchField,
<EditableRow label={`Desired length (${unitLabel})`}> counterpart: InterestPatchField,
<InlineEditableField ): ((next: string | null) => Promise<void>) =>
value={ async (next: string | null) => {
unitIsM await mutation.mutateAsync({
? (interest.desiredLengthM ?? null) [primary]: next,
: (interest.desiredLengthFt ?? null) [counterpart]: toCounterpart(next),
} });
onSave={onSavePair( };
unitIsM ? 'desiredLengthM' : 'desiredLengthFt', const unitLabel = unitIsM ? 'm' : 'ft';
unitIsM ? 'desiredLengthFt' : 'desiredLengthM', return (
)} <dl>
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'} <EditableRow label={`Desired length (${unitLabel})`}>
emptyText=" - " <InlineEditableField
/> value={
</EditableRow> unitIsM
<EditableRow label={`Desired width (${unitLabel})`}> ? (interest.desiredLengthM ?? null)
<InlineEditableField : (interest.desiredLengthFt ?? null)
value={ }
unitIsM ? (interest.desiredWidthM ?? null) : (interest.desiredWidthFt ?? null) onSave={onSavePair(
} unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
onSave={onSavePair( unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
unitIsM ? 'desiredWidthM' : 'desiredWidthFt', )}
unitIsM ? 'desiredWidthFt' : 'desiredWidthM', placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
)} emptyText=" - "
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'} />
emptyText=" - " </EditableRow>
/> <EditableRow label={`Desired width (${unitLabel})`}>
</EditableRow> <InlineEditableField
<EditableRow label={`Desired draft (${unitLabel})`}> value={
<InlineEditableField unitIsM
value={ ? (interest.desiredWidthM ?? null)
unitIsM ? (interest.desiredDraftM ?? null) : (interest.desiredDraftFt ?? null) : (interest.desiredWidthFt ?? null)
} }
onSave={onSavePair( onSave={onSavePair(
unitIsM ? 'desiredDraftM' : 'desiredDraftFt', unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
unitIsM ? 'desiredDraftFt' : 'desiredDraftM', unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
)} )}
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'} placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
emptyText=" - " emptyText=" - "
/> />
</EditableRow> </EditableRow>
</dl> <EditableRow label={`Desired draft (${unitLabel})`}>
); <InlineEditableField
})()} value={
</div> unitIsM
? (interest.desiredDraftM ?? null)
: (interest.desiredDraftFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
unitIsM ? 'desiredDraftFt' : 'desiredDraftM',
)}
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'}
emptyText=" - "
/>
</EditableRow>
</dl>
);
})()}
</div>
{/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired` {/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired`
still drive the auto-follow-up worker (`processFollowUpReminders`), still drive the auto-follow-up worker (`processFollowUpReminders`),
but the Overview surface for them is hidden: the REMINDERS but the Overview surface for them is hidden: the REMINDERS
section below shows the full reminders table and the bell-in- section below shows the full reminders table and the bell-in-
header surfaces active counts. Removing the duplicate read-only header surfaces active counts. Removing the duplicate read-only
panel cleans Overview without affecting the backend job. */} 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." tab when the rep just wants to peek at "what was discussed last."
Always rendered now that the redundant `interests.notes` blob is Always rendered now that the redundant `interests.notes` blob is
gone - falls back to an empty-state prompt so reps still have an gone - falls back to an empty-state prompt so reps still have an
obvious entry point to the Notes tab from Overview. */} obvious entry point to the Notes tab from Overview. */}
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3> <h3 className="text-sm font-medium">Latest note</h3>
<Link <Link
href={`/${portSlug}/interests/${interestId}?tab=notes`} href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium text-primary hover:underline"
> >
{interest.recentNote {interest.recentNote
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}` ? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
: 'Add note'} : 'Add note'}
</Link> </Link>
</div> </div>
{interest.recentNote ? ( {interest.recentNote ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm"> <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90"> <p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content} {interest.recentNote.content}
</p> </p>
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground"> <p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{/* Stage pill = the deal's current stage. Source-of-truth {/* Stage pill = the deal's current stage. Source-of-truth
interpretation: the note is about the deal as it interpretation: the note is about the deal as it
stands today; reading it on Overview, "current stage" stands today; reading it on Overview, "current stage"
answers the implicit "where in the deal is this?". A answers the implicit "where in the deal is this?". A
historical "stage-at-note-time" lookup would need an historical "stage-at-note-time" lookup would need an
audit_logs read per teaser render — over-engineered for audit_logs read per teaser render — over-engineered for
a context hint. */} a context hint. */}
<span <span
className={cn( className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium', 'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
STAGE_BADGE[interest.pipelineStage as PipelineStage] ?? STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
'bg-muted text-muted-foreground', 'bg-muted text-muted-foreground',
)} )}
> >
{stageLabel(interest.pipelineStage)} {stageLabel(interest.pipelineStage)}
</span> </span>
<span> <span>
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), { {formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
addSuffix: true, addSuffix: true,
})} })}
{interest.recentNote.authorId {interest.recentNote.authorId
? ` · ${ ? ` · ${
interest.recentNote.authorId === 'system' interest.recentNote.authorId === 'system'
? 'system' ? 'system'
: (interest.recentNote.authorName ?? 'Unknown') : (interest.recentNote.authorName ?? 'Unknown')
}` }`
: ''} : ''}
</span> </span>
</p> </p>
</div> </div>
) : ( ) : (
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground"> <div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
No notes yet. No notes yet.
</div> </div>
)} )}
</div>
<InlineTagEditor
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
<div className="md:col-span-2">
<RemindersInline interestId={interestId} />
</div>
</div> </div>
<InlineTagEditor {/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
<div className="md:col-span-2">
<RemindersInline interestId={interestId} />
</div>
</div>
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
what's already linked before browsing more options. Each row exposes what's already linked before browsing more options. Each row exposes
per-berth role-flag toggles and the EOI bypass control (only visible per-berth role-flag toggles and the EOI bypass control (only visible
once the parent interest's primary EOI is signed). */} 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 outcome is `won`. Surfaces upload slots for the manual paperwork
that didn't flow through the EOI->Contract chain automatically. */} that didn't flow through the EOI->Contract chain automatically. */}
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} /> <WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
{/* 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 / public form pre-filled with what's on file so they can confirm /
correct details before the EOI is drafted. Hides itself once correct details before the EOI is drafted. Hides itself once
the EOI is signed. */} the EOI is signed. */}
<SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} /> <SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} />
<LinkedBerthsList interestId={interestId} /> <LinkedBerthsList interestId={interestId} />
{/* 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 interest's desired dimensions. Renders an inline guidance message
when dimensions aren't set yet. */} when dimensions aren't set yet. */}
<BerthRecommenderPanel <BerthRecommenderPanel
interestId={interestId} interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)} desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)} desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)} desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'} desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0} linkedBerthCount={interest.linkedBerthCount ?? 0}
/> />
{confirmDialog} {confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI" {/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
footer button can launch the dialog without leaving the tab. Same footer button can launch the dialog without leaving the tab. Same
dialog component the dedicated EOI tab uses - single source of dialog component the dedicated EOI tab uses - single source of
truth for the editing/confirmation flow. */} truth for the editing/confirmation flow. */}
<EoiGenerateDialog <EoiGenerateDialog
interestId={interestId} interestId={interestId}
clientId={clientId} clientId={clientId}
open={eoiGenerateOpen} open={eoiGenerateOpen}
onOpenChange={setEoiGenerateOpen} onOpenChange={setEoiGenerateOpen}
/> />
</div> </div>
</FieldHistoryProvider>
); );
} }

View File

@@ -0,0 +1,167 @@
'use client';
/**
* Inline field-history surface for detail pages.
*
* The pattern:
*
* 1. Wrap the detail page (or just the section containing editable
* fields) in <FieldHistoryProvider scope={{ type, id }} />. The
* provider fires a single GET for every recorded override, keyed
* by entity, and exposes a Map<fieldPath, HistoryRow[]> 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' | 'yacht';
id: string;
}
export interface FieldHistoryRow {
id: string;
fieldPath: string;
oldValue: unknown;
newValue: unknown;
source: string;
createdAt: string;
}
interface ContextValue {
byPath: Map<string, FieldHistoryRow[]>;
isLoading: boolean;
}
const FieldHistoryContext = createContext<ContextValue>({
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 = `/api/v1/${scope.type}s/${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<string, FieldHistoryRow[]>();
for (const row of data ?? []) {
const arr = map.get(row.fieldPath) ?? [];
arr.push(row);
map.set(row.fieldPath, arr);
}
return map;
}, [data]);
return (
<FieldHistoryContext.Provider value={{ byPath, isLoading }}>
{children}
</FieldHistoryContext.Provider>
);
}
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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-5 w-5 text-muted-foreground hover:text-foreground', className)}
aria-label={`Field history for ${label}`}
title={`${rows.length} override${rows.length === 1 ? '' : 's'}`}
>
<Clock className="h-3 w-3" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0">
<div className="border-b px-3 py-2">
<div className="text-xs font-medium">{label}</div>
<div className="text-[10px] text-muted-foreground">
{rows.length} override{rows.length === 1 ? '' : 's'}
</div>
</div>
<ScrollArea className="max-h-72">
<ul className="divide-y text-xs">
{rows.map((r) => (
<li key={r.id} className="px-3 py-2 space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{formatSource(r.source)}</span>
<span className="text-[10px] text-muted-foreground">
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
</span>
</div>
<div className="text-muted-foreground">
<span className="line-through">{formatValue(r.oldValue)}</span>
<span className="mx-1"></span>
<span className="text-foreground">{formatValue(r.newValue)}</span>
</div>
</li>
))}
</ul>
</ScrollArea>
</PopoverContent>
</Popover>
);
}
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;
}

View File

@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
@@ -80,11 +81,25 @@ function useYachtPatch(yachtId: 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. */
historyPath?: string;
}) {
return ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt> <dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="flex-1 min-w-0">{children}</dd> <dd className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0">{children}</div>
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
</dd>
</div> </div>
); );
} }
@@ -152,114 +167,116 @@ function OverviewTab({
}; };
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
{/* Identity */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1"> {/* Identity */}
<h3 className="text-sm font-medium mb-2">Identity</h3> <div className="space-y-1">
<dl> <h3 className="text-sm font-medium mb-2">Identity</h3>
<EditableRow label="Name"> <dl>
<InlineEditableField value={yacht.name} onSave={save('name')} /> <EditableRow label="Name" historyPath="yacht.name">
</EditableRow> <InlineEditableField value={yacht.name} onSave={save('name')} />
<EditableRow label="Hull Number"> </EditableRow>
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} /> <EditableRow label="Hull Number">
</EditableRow> <InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
<EditableRow label="Registration"> </EditableRow>
<InlineEditableField value={yacht.registration} onSave={save('registration')} /> <EditableRow label="Registration">
</EditableRow> <InlineEditableField value={yacht.registration} onSave={save('registration')} />
<EditableRow label="Flag"> </EditableRow>
<InlineEditableField value={yacht.flag} onSave={save('flag')} /> <EditableRow label="Flag">
</EditableRow> <InlineEditableField value={yacht.flag} onSave={save('flag')} />
<EditableRow label="Year Built"> </EditableRow>
<InlineEditableField <EditableRow label="Year Built">
value={yacht.yearBuilt?.toString() ?? null} <InlineEditableField
onSave={save('yearBuilt', yearTransform)} value={yacht.yearBuilt?.toString() ?? null}
/> onSave={save('yearBuilt', yearTransform)}
</EditableRow> />
<EditableRow label="Status"> </EditableRow>
<InlineEditableField <EditableRow label="Status">
variant="select" <InlineEditableField
options={STATUS_OPTIONS} variant="select"
value={yacht.status} options={STATUS_OPTIONS}
onSave={save('status')} value={yacht.status}
/> onSave={save('status')}
</EditableRow> />
</dl> </EditableRow>
</div> </dl>
</div>
{/* Build */} {/* Build */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3> <h3 className="text-sm font-medium mb-2">Build</h3>
<dl> <dl>
<EditableRow label="Builder"> <EditableRow label="Builder">
<InlineEditableField value={yacht.builder} onSave={save('builder')} /> <InlineEditableField value={yacht.builder} onSave={save('builder')} />
</EditableRow> </EditableRow>
<EditableRow label="Model"> <EditableRow label="Model">
<InlineEditableField value={yacht.model} onSave={save('model')} /> <InlineEditableField value={yacht.model} onSave={save('model')} />
</EditableRow> </EditableRow>
<EditableRow label="Hull Material"> <EditableRow label="Hull Material">
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} /> <InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
{/* Dimensions (ft) */} {/* Dimensions (ft) */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3> <h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl> <dl>
<EditableRow label="Length (ft)"> <EditableRow label="Length (ft)" historyPath="yacht.lengthFt">
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} /> <InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
</EditableRow> </EditableRow>
<EditableRow label="Width (ft)"> <EditableRow label="Width (ft)" historyPath="yacht.widthFt">
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} /> <InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
</EditableRow> </EditableRow>
<EditableRow label="Draft (ft)"> <EditableRow label="Draft (ft)" historyPath="yacht.draftFt">
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} /> <InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
{/* Dimensions (m) */} {/* Dimensions (m) */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3> <h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl> <dl>
<EditableRow label="Length (m)"> <EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} /> <InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
</EditableRow> </EditableRow>
<EditableRow label="Width (m)"> <EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} /> <InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
</EditableRow> </EditableRow>
<EditableRow label="Draft (m)"> <EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} /> <InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
{/* Notes — threaded list (parity with clients/interests/companies). {/* Notes — threaded list (parity with clients/interests/companies).
The legacy single-field `yacht.notes` column stays in schema The legacy single-field `yacht.notes` column stays in schema
for the EOI/contract merge-field path; OverviewTab no longer for the EOI/contract merge-field path; OverviewTab no longer
exposes it for editing here. */} exposes it for editing here. */}
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3> <h3 className="text-sm font-medium mb-2">Notes</h3>
<NotesList <NotesList
entityType="yachts" entityType="yachts"
entityId={yachtId} entityId={yachtId}
currentUserId={currentUserId} currentUserId={currentUserId}
parentInvalidateKey={['yachts', yachtId]} parentInvalidateKey={['yachts', yachtId]}
/>
</div>
<InlineTagEditor
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/> />
</div>
<InlineTagEditor <div className="md:col-span-2">
heading="Tags" <RemindersInline yachtId={yachtId} />
wrapperClassName="md:col-span-2" </div>
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
<div className="md:col-span-2">
<RemindersInline yachtId={yachtId} />
</div> </div>
</div> </FieldHistoryProvider>
); );
} }

52
src/lib/branding/url.ts Normal file
View File

@@ -0,0 +1,52 @@
import { env } from '@/lib/env';
const LOCAL_HOST_PATTERNS = [
/^localhost(:\d+)?$/i,
/^127\.\d+\.\d+\.\d+(:\d+)?$/,
/^0\.0\.0\.0(:\d+)?$/,
/^192\.168\.\d+\.\d+(:\d+)?$/,
/^10\.\d+\.\d+\.\d+(:\d+)?$/,
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/,
];
/**
* Strip the scheme+host from a branding URL when the host is localhost
* or a private LAN address, leaving a path-only URL the browser can
* resolve against whatever origin it loaded the page from.
*
* Without this, a logo uploaded while the app ran at http://localhost:3000
* is forever pinned to that host — fine for the dev's Mac, broken from
* a phone on the LAN or any device with a different DNS view.
*
* Public-internet URLs (CDN, S3) pass through unchanged.
*/
export function normalizeBrandingUrl(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
if (trimmed.startsWith('/')) return trimmed;
try {
const parsed = new URL(trimmed);
const isLocal = LOCAL_HOST_PATTERNS.some((re) => re.test(parsed.host));
if (!isLocal) return trimmed;
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return trimmed;
}
}
/**
* Email surfaces (rendered HTML inboxes) cannot resolve path-only URLs —
* the recipient's mail client has no origin context. Use this when
* emitting branding into an email shell to guarantee an absolute URL.
*
* Pass-through for URLs that are already absolute.
*/
export function absolutizeBrandingUrl(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
const base = env.APP_URL.replace(/\/+$/, '');
return `${base}${trimmed.startsWith('/') ? '' : '/'}${trimmed}`;
}

View File

@@ -16,6 +16,8 @@
* function. Templates call `renderShell({ title, body, branding })`. * function. Templates call `renderShell({ title, body, branding })`.
*/ */
import { absolutizeBrandingUrl } from '@/lib/branding/url';
// Neutral defaults — no tenant-specific imagery leaks across ports. // Neutral defaults — no tenant-specific imagery leaks across ports.
// When branding hasn't been configured the email renders without a logo // When branding hasn't been configured the email renders without a logo
// and on a plain off-white background. Admins upload their own assets via // and on a plain off-white background. Admins upload their own assets via
@@ -42,8 +44,10 @@ interface ShellOpts {
} }
export function renderShell({ title, body, branding }: ShellOpts): string { export function renderShell({ title, body, branding }: ShellOpts): string {
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL; // Branding URLs are stored path-only (so in-app rendering works across
const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL; // any host). Mail clients have no app origin, so re-absolutize here.
const logoUrl = absolutizeBrandingUrl(branding?.logoUrl ?? DEFAULT_LOGO_URL);
const backgroundUrl = absolutizeBrandingUrl(branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL);
const headerHtml = branding?.emailHeaderHtml ?? ''; const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? ''; const footerHtml = branding?.emailFooterHtml ?? '';

View File

@@ -8,6 +8,7 @@
* env var when neither is set. * env var when neither is set.
*/ */
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { normalizeBrandingUrl } from '@/lib/branding/url';
import { getSetting } from '@/lib/services/settings.service'; import { getSetting } from '@/lib/services/settings.service';
// ─── Setting key constants ─────────────────────────────────────────────────── // ─── Setting key constants ───────────────────────────────────────────────────
@@ -572,8 +573,14 @@ export async function getPortBrandingConfig(portId: string): Promise<PortBrandin
]); ]);
return { return {
logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl, // Branding URLs that bake a localhost/LAN host (uploaded while running
emailBackgroundUrl: emailBackgroundUrl ?? DEFAULT_BRANDING.emailBackgroundUrl, // on the dev's Mac) don't resolve from any other device. Normalize
// here so in-app consumers get a path-only URL the browser resolves
// against the current origin. Email surfaces re-absolutize via
// `absolutizeBrandingUrl()` because mail clients have no app origin.
logoUrl: normalizeBrandingUrl(logoUrl) ?? DEFAULT_BRANDING.logoUrl,
emailBackgroundUrl:
normalizeBrandingUrl(emailBackgroundUrl) ?? DEFAULT_BRANDING.emailBackgroundUrl,
primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor, primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor,
appName: appName ?? DEFAULT_BRANDING.appName, appName: appName ?? DEFAULT_BRANDING.appName,
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml, emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,

View File

@@ -337,6 +337,23 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
countryIso: input.country ?? null, countryIso: input.country ?? null,
isPrimary: true, 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 { } else {
const addrPatch: Record<string, unknown> = {}; const addrPatch: Record<string, unknown> = {};
if (input.address && input.address !== existingAddr.streetAddress) { if (input.address && input.address !== existingAddr.streetAddress) {
@@ -407,7 +424,17 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
valueCountry: input.phoneCountry, valueCountry: input.phoneCountry,
isPrimary: true, isPrimary: true,
}); });
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: null,
newValue: input.phoneE164,
});
} else if (existing.valueE164 !== input.phoneE164) { } else if (existing.valueE164 !== input.phoneE164) {
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: existing.valueE164 ?? existing.value,
newValue: input.phoneE164,
});
await tx await tx
.update(clientContacts) .update(clientContacts)
.set({ .set({
@@ -425,11 +452,42 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
where: eq(interests.id, row.interestId), where: eq(interests.id, row.interestId),
}); });
if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) { if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) {
const existingYacht = await tx.query.yachts.findFirst({
where: eq(yachts.id, interest.yachtId),
});
const yachtPatch: Record<string, unknown> = {}; const yachtPatch: Record<string, unknown> = {};
if (input.yachtName) yachtPatch.name = input.yachtName; if (input.yachtName && input.yachtName !== existingYacht?.name) {
if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt); yachtPatch.name = input.yachtName;
if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt); overrides.push({
if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt); 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) { if (Object.keys(yachtPatch).length > 0) {
await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId)); await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId));
} }

View File

@@ -15,6 +15,27 @@ import type { ServerToClientEvents, ClientToServerEvents } from './events';
let io: Server<ClientToServerEvents, ServerToClientEvents> | null = null; let io: Server<ClientToServerEvents, ServerToClientEvents> | null = null;
const DEV_ORIGIN_PATTERNS = [
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/,
/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/,
/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/,
];
function socketCorsOrigin(
origin: string | undefined,
cb: (err: Error | null, allow?: boolean) => void,
): void {
if (!origin) return cb(null, true);
if (process.env.NODE_ENV === 'production') {
return cb(null, origin === process.env.APP_URL);
}
cb(
null,
DEV_ORIGIN_PATTERNS.some((re) => re.test(origin)),
);
}
/** /**
* Returns true if the user is a super-admin OR holds a userPortRoles row * Returns true if the user is a super-admin OR holds a userPortRoles row
* for the given portId. The Socket.IO auth middleware uses this to decide * for the given portId. The Socket.IO auth middleware uses this to decide
@@ -77,7 +98,11 @@ export function initSocketServer(
path: '/socket.io/', path: '/socket.io/',
adapter: createAdapter(pubClient, subClient), adapter: createAdapter(pubClient, subClient),
cors: { cors: {
origin: process.env.APP_URL, // In prod, lock to the canonical APP_URL. In dev, allow localhost
// + private-LAN origins so the same dev server serves the Mac
// (localhost) and a phone on Wi-Fi (192.168.x.x) without a config
// edit per network. Mirrors the trustedOrigins pattern in auth.
origin: socketCorsOrigin,
credentials: true, credentials: true,
}, },
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 }, connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },

View File

@@ -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.<column>` — top-level clients row
* - `client.primaryEmail|primaryPhone` — synthesised over client_contacts
* - `client.address.<column>` — the primary client_addresses row
* - `yacht.<column>` — interest's linked yachts row
* - `interest.<column>` — 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),
}));
}

View File

@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { BINDABLE_PATHS } from '@/lib/templates/bindable-fields';
export const formFieldSchema = z.object({ export const formFieldSchema = z.object({
key: z.string().min(1).max(80), key: z.string().min(1).max(80),
label: z.string().min(1).max(200), label: z.string().min(1).max(200),
@@ -7,6 +9,19 @@ export const formFieldSchema = z.object({
required: z.boolean().optional().default(false), required: z.boolean().optional().default(false),
options: z.array(z.string()).optional(), options: z.array(z.string()).optional(),
helpText: 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({ export const createFormTemplateSchema = z.object({

View File

@@ -67,7 +67,12 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
return; return;
} }
const s = io(process.env.NEXT_PUBLIC_APP_URL!, { // Connect to whatever origin the page was loaded from — `io()` with
// no URL defaults to window.location. This used to read
// NEXT_PUBLIC_APP_URL, which baked the deploy-time canonical URL
// (localhost in dev) and broke realtime when the same dev server
// was hit from a LAN IP.
const s = io({
path: '/socket.io/', path: '/socket.io/',
withCredentials: true, withCredentials: true,
auth: { portId: currentPortId }, auth: { portId: currentPortId },