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)
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] 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.:
> - 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`, ...

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <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
// 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.
devIndicators: false,
// 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
// warning for cross-origin /_next/* fetches unless we allow-list the
// origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so
// any LAN device works without a config edit per network.
...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }),
// local IP (e.g. 192.168.x.x), not localhost. Next surfaces a warning
// and blocks cross-origin /_next/* fetches (incl. HMR) unless we
// allow-list the origins explicitly. When HMR is blocked the page
// never fully hydrates and form click handlers fall back to native
// 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
// bundle them into the route trace (slower cold start + risk that
// 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 { env } from '@/lib/env';
export async function POST(): Promise<NextResponse> {
const response = NextResponse.redirect(new URL('/portal/login', env.APP_URL));
export async function POST(req: NextRequest): Promise<NextResponse> {
// 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);

View File

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

View File

@@ -6,7 +6,6 @@ import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { uploadFile } from '@/lib/services/files';
import { errorResponse, ValidationError } from '@/lib/errors';
import { env } from '@/lib/env';
const MAX_BYTES = 5 * 1024 * 1024;
@@ -77,11 +76,11 @@ export const POST = withAuth(
},
);
const baseUrl = env.APP_URL.replace(/\/+$/, '');
// Branding assets must survive in email-inbox land where no session
// cookie travels — route through the public-by-id surface gated on
// `category='branding'` rather than the authenticated preview path.
const url = `${baseUrl}/api/public/files/${record.id}`;
// Path-only so the in-app `<img src>` resolves against whatever
// host the page was loaded from (localhost, LAN IP, prod domain).
// Email shell calls `absolutizeBrandingUrl()` to prepend APP_URL
// for mail clients, which have no origin context.
const url = `/api/public/files/${record.id}`;
return NextResponse.json({ data: { fileId: record.id, url } });
} 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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { FormField } from '@/lib/validators/form-templates';
import {
bindableFieldsByEntity,
getBindableField,
type BindableType,
} from '@/lib/templates/bindable-fields';
const BIND_TO_NONE = '__none__';
interface FormTemplate {
id: string;
@@ -103,6 +113,41 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
}
function changeBinding(idx: number, raw: string) {
if (raw === BIND_TO_NONE) {
// Clear the binding but leave the rest of the field untouched —
// admins may want to keep a custom field that no longer autofills.
setFields((prev) =>
prev.map((f, i) => {
if (i !== idx) return f;
const { bindTo, ...rest } = f;
void bindTo;
return rest;
}),
);
return;
}
const meta = getBindableField(raw);
if (!meta) return;
// Adopt the binding + auto-derive input type and label when the admin
// hasn't typed one yet (saves repetitive data entry on the common
// "bind to client email" flow). Pre-existing labels are preserved so
// admins can override the friendly name per template.
setFields((prev) =>
prev.map((f, i) =>
i === idx
? {
...f,
bindTo: raw,
type: coerceFieldType(meta.inputType, f.type),
label: f.label.trim() ? f.label : meta.label,
key: f.key.trim() ? f.key : meta.path.split('.').pop()!,
}
: f,
),
);
}
function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
}
@@ -158,6 +203,40 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Button>
)}
</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="space-y-1">
<Label className="text-xs">Key (no spaces)</Label>
@@ -240,3 +319,18 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</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 { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { RemindersInline } from '@/components/reminders/reminders-inline';
@@ -56,11 +57,25 @@ function useClientPatch(clientId: string) {
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
function EditableRow({
label,
children,
historyPath,
}: {
label: string;
children: React.ReactNode;
/** When set, renders a clock icon (if any override rows exist) that
* opens the field-history popover. The icon component renders nothing
* when the field has no history, so it's safe to pass on every row. */
historyPath?: string;
}) {
return (
<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>
<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>
);
}
@@ -135,101 +150,103 @@ function OverviewTab({
};
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
<FieldHistoryProvider scope={{ type: 'client', id: clientId }}>
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Country">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
// Auto-default the timezone to the country's primary
// zone when none is set yet — saves the rep a click
// and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a
// value the rep deliberately picked.
const patch: { nationalityIso: string | null; timezone?: string | null } = {
nationalityIso: iso,
};
if (iso && !client.timezone) {
const defaultTz = primaryTimezoneFor(iso as CountryCode);
if (defaultTz) patch.timezone = defaultTz;
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<EditableRow label="Full Name" historyPath="client.fullName">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Country">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
// Auto-default the timezone to the country's primary
// zone when none is set yet — saves the rep a click
// and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a
// value the rep deliberately picked.
const patch: { nationalityIso: string | null; timezone?: string | null } = {
nationalityIso: iso,
};
if (iso && !client.timezone) {
const defaultTz = primaryTimezoneFor(iso as CountryCode);
if (defaultTz) patch.timezone = defaultTz;
}
await mutation.mutateAsync(patch);
}}
data-testid="client-country-inline"
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={
client.timezone ??
(client.nationalityIso
? primaryTimezoneFor(client.nationalityIso as CountryCode)
: null)
}
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)
}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</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>
{/* 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>
</FieldHistoryProvider>
);
}

View File

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

View File

@@ -19,6 +19,7 @@ import {
} from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline';
@@ -222,11 +223,26 @@ function useStageMutation(interestId: string) {
});
}
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
function EditableRow({
label,
children,
historyPath,
}: {
label: string;
children: React.ReactNode;
/** When set, renders a clock icon (when at least one override row
* exists for this path on the surrounding FieldHistoryProvider scope)
* that opens the field-history popover. The icon renders nothing
* without history, so it's safe to pass on every row. */
historyPath?: string;
}) {
return (
<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>
<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>
);
}
@@ -977,27 +993,28 @@ function OverviewTab({
const futureMilestones = milestones.filter((m) => m.phase === 'future');
return (
<div className="space-y-6">
{/* Skip-ahead nudge - informational only; fires when the deal jumped
<FieldHistoryProvider scope={{ type: 'interest', id: interestId }}>
<div className="space-y-6">
{/* Skip-ahead nudge - informational only; fires when the deal jumped
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
surfaces the situation so they treat the deal as a backup. */}
<InterestBerthStatusBanner
interestId={interestId}
interestPipelineStage={interest.pipelineStage}
interestOutcome={interest.outcome}
archivedAt={null}
/>
<InterestBerthStatusBanner
interestId={interestId}
interestPipelineStage={interest.pipelineStage}
interestOutcome={interest.outcome}
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
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
running deposit total here drives the auto-advance into the
deposit_paid stage server-side. Hidden before the reservation
@@ -1005,138 +1022,138 @@ function OverviewTab({
noise - the next-milestone card carries the actionable copy
instead. Render order: deprioritized below the milestone strip
so the rep's eye lands on the active step first. */}
{/* Pre-reservation: the dedicated "Next step" guidance card was
{/* Pre-reservation: the dedicated "Next step" guidance card was
removed in favour of a brighter NEXT STEP pill on the active
MilestoneSection below (it already owns the workflow actions -
two surfaces was redundant). Nurturing keeps a slim helper
since no milestone is naturally "current" while a deal is
paused. */}
{interest.pipelineStage === 'nurturing' ? (
<div className="rounded-xl border bg-card p-4 text-sm">
<p className="font-medium text-foreground">Deal is on nurture</p>
<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
them back to Qualified.
</p>
</div>
) : null}
{interest.pipelineStage === 'nurturing' ? (
<div className="rounded-xl border bg-card p-4 text-sm">
<p className="font-medium text-foreground">Deal is on nurture</p>
<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
them back to Qualified.
</p>
</div>
) : null}
{/* Sales-process milestones - phase-aware so the user only sees
{/* Sales-process milestones - phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future
milestones are hidden behind a toggle so reps can still
skip-ahead when reality calls for it (an override-confirm
gates the actual stage move). */}
{pastMilestones.length > 0 && (
<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">
<span>Past</span>
</div>
<Accordion type="multiple" className="px-4">
{pastMilestones.map((m) => (
<AccordionItem key={m.key} value={m.key} className="border-0">
<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">
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
<span className="font-medium text-foreground">{m.title}</span>
<span className="text-[10px]">·</span>
<span className="truncate text-xs">{m.pastSummary}</span>
</div>
</AccordionTrigger>
<AccordionContent>
{/* Reuse the same MilestoneSection layout used for the
{pastMilestones.length > 0 && (
<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">
<span>Past</span>
</div>
<Accordion type="multiple" className="px-4">
{pastMilestones.map((m) => (
<AccordionItem key={m.key} value={m.key} className="border-0">
<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">
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
<span className="font-medium text-foreground">{m.title}</span>
<span className="text-[10px]">·</span>
<span className="truncate text-xs">{m.pastSummary}</span>
</div>
</AccordionTrigger>
<AccordionContent>
{/* Reuse the same MilestoneSection layout used for the
current milestone — the steps list, sub-status badge,
and any inline doc actions all render the same way.
`isActive={false}` keeps the NEXT-STEP pill off. */}
<MilestoneSection
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={false}
steps={m.steps}
footer={m.footer}
/>
</AccordionContent>
</AccordionItem>
<MilestoneSection
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={false}
steps={m.steps}
footer={m.footer}
/>
</AccordionContent>
</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 && (
<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}
/>
))}
</div>
)}
{futureMilestones.length > 0 && (
<FutureMilestones
milestones={futureMilestones}
stageMutation={stageMutation}
advance={advance}
activeMilestone={activeMilestone}
currentStage={interest.pipelineStage}
/>
)}
{futureMilestones.length > 0 && (
<FutureMilestones
milestones={futureMilestones}
stageMutation={stageMutation}
advance={advance}
activeMilestone={activeMilestone}
currentStage={interest.pipelineStage}
/>
)}
{/* Payments section relocated below milestones (was above): the
{/* Payments section relocated below milestones (was above): the
deposit-tracking surface is reference/history, not the rep's
primary focus once they're at Reservation+. The active
milestone above carries the actionable copy. */}
{showPaymentsSection ? (
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
) : null}
{showPaymentsSection ? (
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Lead & Source (editable) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Lead</h3>
<dl>
<EditableRow label="Lead Category">
<InlineEditableField
variant="select"
options={LEAD_CATEGORY_OPTIONS}
value={interest.leadCategory}
onSave={save('leadCategory')}
/>
</EditableRow>
<EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
value={interest.source}
onSave={save('source')}
/>
</EditableRow>
</dl>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Lead & Source (editable) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Lead</h3>
<dl>
<EditableRow label="Lead Category">
<InlineEditableField
variant="select"
options={LEAD_CATEGORY_OPTIONS}
value={interest.leadCategory}
onSave={save('leadCategory')}
/>
</EditableRow>
<EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
value={interest.source}
onSave={save('source')}
/>
</EditableRow>
</dl>
</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
contact log. Phone is rendered via libphonenumber-js's
international formatter so `+33633219796` reads as
@@ -1144,255 +1161,260 @@ function OverviewTab({
Both email + phone are click-to-edit: the PATCH flows to the
underlying client_contacts row (resolved via the
`*ContactId` fields surfaced by the interest read). */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<EditableRow label="Email">
{interest.clientId ? (
<ClientChannelEditor
clientId={interest.clientId}
channel="email"
primaryContactId={interest.clientPrimaryEmailContactId ?? null}
primaryValue={interest.clientPrimaryEmail ?? null}
invalidateKeys={[['interest', interest.id]]}
/>
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<EditableRow label="Email" historyPath="client.primaryEmail">
{interest.clientId ? (
<ClientChannelEditor
clientId={interest.clientId}
channel="email"
primaryContactId={interest.clientPrimaryEmailContactId ?? null}
primaryValue={interest.clientPrimaryEmail ?? null}
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>
<EditableRow label="Phone">
{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)} />
</>
) : (
<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>
{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
leaving the Overview tab. These values drive the auto-tick on
the "Dimensions confirmed" qualification row + the
BerthRecommenderPanel rankings below. */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
{(() => {
// Honour the interest's `desiredLengthUnit` so a deal whose rep
// entered metric values doesn't render labelled "(ft)" with
// empty inputs. On save we patch BOTH the chosen-unit column
// and the canonical counterpart so downstream surfaces
// (recommender, EOI merge fields) stay in lockstep.
const unitIsM = interest.desiredLengthUnit === 'm';
const FT_PER_M = 3.28084;
const toCounterpart = (v: string | null): string | null => {
if (!v) return null;
const n = Number(v);
if (!Number.isFinite(n)) return null;
return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4);
};
const onSavePair =
(
primary: InterestPatchField,
counterpart: InterestPatchField,
): ((next: string | null) => Promise<void>) =>
async (next: string | null) => {
await mutation.mutateAsync({
[primary]: next,
[counterpart]: toCounterpart(next),
});
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
{(() => {
// Honour the interest's `desiredLengthUnit` so a deal whose rep
// entered metric values doesn't render labelled "(ft)" with
// empty inputs. On save we patch BOTH the chosen-unit column
// and the canonical counterpart so downstream surfaces
// (recommender, EOI merge fields) stay in lockstep.
const unitIsM = interest.desiredLengthUnit === 'm';
const FT_PER_M = 3.28084;
const toCounterpart = (v: string | null): string | null => {
if (!v) return null;
const n = Number(v);
if (!Number.isFinite(n)) return null;
return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4);
};
const unitLabel = unitIsM ? 'm' : 'ft';
return (
<dl>
<EditableRow label={`Desired length (${unitLabel})`}>
<InlineEditableField
value={
unitIsM
? (interest.desiredLengthM ?? null)
: (interest.desiredLengthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
)}
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
emptyText=" - "
/>
</EditableRow>
<EditableRow label={`Desired width (${unitLabel})`}>
<InlineEditableField
value={
unitIsM ? (interest.desiredWidthM ?? null) : (interest.desiredWidthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
)}
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
emptyText=" - "
/>
</EditableRow>
<EditableRow label={`Desired draft (${unitLabel})`}>
<InlineEditableField
value={
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>
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';
return (
<dl>
<EditableRow label={`Desired length (${unitLabel})`}>
<InlineEditableField
value={
unitIsM
? (interest.desiredLengthM ?? null)
: (interest.desiredLengthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
)}
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
emptyText=" - "
/>
</EditableRow>
<EditableRow label={`Desired width (${unitLabel})`}>
<InlineEditableField
value={
unitIsM
? (interest.desiredWidthM ?? null)
: (interest.desiredWidthFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
)}
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
emptyText=" - "
/>
</EditableRow>
<EditableRow label={`Desired draft (${unitLabel})`}>
<InlineEditableField
value={
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`),
but the Overview surface for them is hidden: the REMINDERS
section below shows the full reminders table and the bell-in-
header surfaces active counts. Removing the duplicate read-only
panel cleans Overview without affecting the backend job. */}
{/* Most-recent threaded note teaser. Saves a click into the Notes
{/* Most-recent threaded note teaser. Saves a click into the Notes
tab when the rep just wants to peek at "what was discussed last."
Always rendered now that the redundant `interests.notes` blob is
gone - falls back to an empty-state prompt so reps still have an
obvious entry point to the Notes tab from Overview. */}
<div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3>
<Link
href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline"
>
{interest.recentNote
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
: 'Add note'}
</Link>
</div>
{interest.recentNote ? (
<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">
{interest.recentNote.content}
</p>
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{/* Stage pill = the deal's current stage. Source-of-truth
<div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3>
<Link
href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline"
>
{interest.recentNote
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
: 'Add note'}
</Link>
</div>
{interest.recentNote ? (
<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">
{interest.recentNote.content}
</p>
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{/* Stage pill = the deal's current stage. Source-of-truth
interpretation: the note is about the deal as it
stands today; reading it on Overview, "current stage"
answers the implicit "where in the deal is this?". A
historical "stage-at-note-time" lookup would need an
audit_logs read per teaser render — over-engineered for
a context hint. */}
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
'bg-muted text-muted-foreground',
)}
>
{stageLabel(interest.pipelineStage)}
</span>
<span>
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${
interest.recentNote.authorId === 'system'
? 'system'
: (interest.recentNote.authorName ?? 'Unknown')
}`
: ''}
</span>
</p>
</div>
) : (
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
No notes yet.
</div>
)}
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
'bg-muted text-muted-foreground',
)}
>
{stageLabel(interest.pipelineStage)}
</span>
<span>
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${
interest.recentNote.authorId === 'system'
? 'system'
: (interest.recentNote.authorName ?? 'Unknown')
}`
: ''}
</span>
</p>
</div>
) : (
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
No notes yet.
</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>
<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>
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
what's already linked before browsing more options. Each row exposes
per-berth role-flag toggles and the EOI bypass control (only visible
once the parent interest's primary EOI is signed). */}
{/* Won-status wrap-up checklist - only renders when this interest's
{/* Won-status wrap-up checklist - only renders when this interest's
outcome is `won`. Surfaces upload slots for the manual paperwork
that didn't flow through the EOI->Contract chain automatically. */}
<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 /
correct details before the EOI is drafted. Hides itself once
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
when dimensions aren't set yet. */}
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0}
/>
{confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0}
/>
{confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
footer button can launch the dialog without leaving the tab. Same
dialog component the dedicated EOI tab uses - single source of
truth for the editing/confirmation flow. */}
<EoiGenerateDialog
interestId={interestId}
clientId={clientId}
open={eoiGenerateOpen}
onOpenChange={setEoiGenerateOpen}
/>
</div>
<EoiGenerateDialog
interestId={interestId}
clientId={clientId}
open={eoiGenerateOpen}
onOpenChange={setEoiGenerateOpen}
/>
</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 { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
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 (
<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>
<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>
);
}
@@ -152,114 +167,116 @@ function OverviewTab({
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Identity */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>
<dl>
<EditableRow label="Name">
<InlineEditableField value={yacht.name} onSave={save('name')} />
</EditableRow>
<EditableRow label="Hull Number">
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
</EditableRow>
<EditableRow label="Registration">
<InlineEditableField value={yacht.registration} onSave={save('registration')} />
</EditableRow>
<EditableRow label="Flag">
<InlineEditableField value={yacht.flag} onSave={save('flag')} />
</EditableRow>
<EditableRow label="Year Built">
<InlineEditableField
value={yacht.yearBuilt?.toString() ?? null}
onSave={save('yearBuilt', yearTransform)}
/>
</EditableRow>
<EditableRow label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={yacht.status}
onSave={save('status')}
/>
</EditableRow>
</dl>
</div>
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Identity */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>
<dl>
<EditableRow label="Name" historyPath="yacht.name">
<InlineEditableField value={yacht.name} onSave={save('name')} />
</EditableRow>
<EditableRow label="Hull Number">
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
</EditableRow>
<EditableRow label="Registration">
<InlineEditableField value={yacht.registration} onSave={save('registration')} />
</EditableRow>
<EditableRow label="Flag">
<InlineEditableField value={yacht.flag} onSave={save('flag')} />
</EditableRow>
<EditableRow label="Year Built">
<InlineEditableField
value={yacht.yearBuilt?.toString() ?? null}
onSave={save('yearBuilt', yearTransform)}
/>
</EditableRow>
<EditableRow label="Status">
<InlineEditableField
variant="select"
options={STATUS_OPTIONS}
value={yacht.status}
onSave={save('status')}
/>
</EditableRow>
</dl>
</div>
{/* Build */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3>
<dl>
<EditableRow label="Builder">
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
</EditableRow>
<EditableRow label="Model">
<InlineEditableField value={yacht.model} onSave={save('model')} />
</EditableRow>
<EditableRow label="Hull Material">
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
</EditableRow>
</dl>
</div>
{/* Build */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3>
<dl>
<EditableRow label="Builder">
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
</EditableRow>
<EditableRow label="Model">
<InlineEditableField value={yacht.model} onSave={save('model')} />
</EditableRow>
<EditableRow label="Hull Material">
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
</EditableRow>
</dl>
</div>
{/* Dimensions (ft) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<EditableRow label="Length (ft)">
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
</EditableRow>
<EditableRow label="Width (ft)">
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
</EditableRow>
<EditableRow label="Draft (ft)">
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
</EditableRow>
</dl>
</div>
{/* Dimensions (ft) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<EditableRow label="Length (ft)" historyPath="yacht.lengthFt">
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
</EditableRow>
<EditableRow label="Width (ft)" historyPath="yacht.widthFt">
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
</EditableRow>
<EditableRow label="Draft (ft)" historyPath="yacht.draftFt">
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
</EditableRow>
</dl>
</div>
{/* Dimensions (m) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
</EditableRow>
<EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
</EditableRow>
<EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
</EditableRow>
</dl>
</div>
{/* Dimensions (m) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
</EditableRow>
<EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
</EditableRow>
<EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
</EditableRow>
</dl>
</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
for the EOI/contract merge-field path; OverviewTab no longer
exposes it for editing here. */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<NotesList
entityType="yachts"
entityId={yachtId}
currentUserId={currentUserId}
parentInvalidateKey={['yachts', yachtId]}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<NotesList
entityType="yachts"
entityId={yachtId}
currentUserId={currentUserId}
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
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
<div className="md:col-span-2">
<RemindersInline yachtId={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 })`.
*/
import { absolutizeBrandingUrl } from '@/lib/branding/url';
// Neutral defaults — no tenant-specific imagery leaks across ports.
// 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
@@ -42,8 +44,10 @@ interface ShellOpts {
}
export function renderShell({ title, body, branding }: ShellOpts): string {
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL;
// Branding URLs are stored path-only (so in-app rendering works across
// 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 footerHtml = branding?.emailFooterHtml ?? '';

View File

@@ -8,6 +8,7 @@
* env var when neither is set.
*/
import { env } from '@/lib/env';
import { normalizeBrandingUrl } from '@/lib/branding/url';
import { getSetting } from '@/lib/services/settings.service';
// ─── Setting key constants ───────────────────────────────────────────────────
@@ -572,8 +573,14 @@ export async function getPortBrandingConfig(portId: string): Promise<PortBrandin
]);
return {
logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl,
emailBackgroundUrl: emailBackgroundUrl ?? DEFAULT_BRANDING.emailBackgroundUrl,
// Branding URLs that bake a localhost/LAN host (uploaded while running
// 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,
appName: appName ?? DEFAULT_BRANDING.appName,
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,

View File

@@ -337,6 +337,23 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
countryIso: input.country ?? null,
isPrimary: true,
});
// Insert-path: every populated field is a "from null → value"
// override so the history panel surfaces the initial population
// the same way it surfaces later edits.
if (input.address) {
overrides.push({
fieldPath: 'client.address.streetAddress',
oldValue: null,
newValue: input.address,
});
}
if (input.country) {
overrides.push({
fieldPath: 'client.address.countryIso',
oldValue: null,
newValue: input.country,
});
}
} else {
const addrPatch: Record<string, unknown> = {};
if (input.address && input.address !== existingAddr.streetAddress) {
@@ -407,7 +424,17 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
valueCountry: input.phoneCountry,
isPrimary: true,
});
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: null,
newValue: input.phoneE164,
});
} else if (existing.valueE164 !== input.phoneE164) {
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: existing.valueE164 ?? existing.value,
newValue: input.phoneE164,
});
await tx
.update(clientContacts)
.set({
@@ -425,11 +452,42 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
where: eq(interests.id, row.interestId),
});
if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) {
const existingYacht = await tx.query.yachts.findFirst({
where: eq(yachts.id, interest.yachtId),
});
const yachtPatch: Record<string, unknown> = {};
if (input.yachtName) yachtPatch.name = input.yachtName;
if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt);
if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt);
if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt);
if (input.yachtName && input.yachtName !== existingYacht?.name) {
yachtPatch.name = input.yachtName;
overrides.push({
fieldPath: 'yacht.name',
oldValue: existingYacht?.name ?? null,
newValue: input.yachtName,
});
}
if (input.yachtLengthFt !== null && String(input.yachtLengthFt) !== existingYacht?.lengthFt) {
yachtPatch.lengthFt = String(input.yachtLengthFt);
overrides.push({
fieldPath: 'yacht.lengthFt',
oldValue: existingYacht?.lengthFt ?? null,
newValue: String(input.yachtLengthFt),
});
}
if (input.yachtWidthFt !== null && String(input.yachtWidthFt) !== existingYacht?.widthFt) {
yachtPatch.widthFt = String(input.yachtWidthFt);
overrides.push({
fieldPath: 'yacht.widthFt',
oldValue: existingYacht?.widthFt ?? null,
newValue: String(input.yachtWidthFt),
});
}
if (input.yachtDraftFt !== null && String(input.yachtDraftFt) !== existingYacht?.draftFt) {
yachtPatch.draftFt = String(input.yachtDraftFt);
overrides.push({
fieldPath: 'yacht.draftFt',
oldValue: existingYacht?.draftFt ?? null,
newValue: String(input.yachtDraftFt),
});
}
if (Object.keys(yachtPatch).length > 0) {
await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId));
}

View File

@@ -15,6 +15,27 @@ import type { ServerToClientEvents, ClientToServerEvents } from './events';
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
* for the given portId. The Socket.IO auth middleware uses this to decide
@@ -77,7 +98,11 @@ export function initSocketServer(
path: '/socket.io/',
adapter: createAdapter(pubClient, subClient),
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,
},
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 { BINDABLE_PATHS } from '@/lib/templates/bindable-fields';
export const formFieldSchema = z.object({
key: z.string().min(1).max(80),
label: z.string().min(1).max(200),
@@ -7,6 +9,19 @@ export const formFieldSchema = z.object({
required: z.boolean().optional().default(false),
options: z.array(z.string()).optional(),
helpText: z.string().optional(),
/**
* Optional binding to an Interest/Client/Yacht column. When set, the
* supplemental-form runtime prefills the field from the linked entity
* AND writes the submitted value back to that column on apply (with an
* `interest_field_history` row capturing the override). Validated
* against `BINDABLE_FIELDS` so unknown paths can't sneak in.
*/
bindTo: z
.string()
.refine((v) => BINDABLE_PATHS.includes(v), {
message: 'Unknown bindable path',
})
.optional(),
});
export const createFormTemplateSchema = z.object({

View File

@@ -67,7 +67,12 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
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/',
withCredentials: true,
auth: { portId: currentPortId },