Compare commits
4 Commits
6aaccb6d33
...
52493801e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 52493801e0 | |||
| f6cb733424 | |||
| 91be0f9136 | |||
| be261f3f90 |
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
35
src/app/api/v1/clients/[id]/field-history/route.ts
Normal file
35
src/app/api/v1/clients/[id]/field-history/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
50
src/app/api/v1/yachts/[id]/field-history/route.ts
Normal file
50
src/app/api/v1/yachts/[id]/field-history/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +150,7 @@ function OverviewTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldHistoryProvider scope={{ type: 'client', id: clientId }}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||||
@@ -145,7 +161,7 @@ function OverviewTab({
|
|||||||
<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">
|
||||||
@@ -230,6 +246,7 @@ function OverviewTab({
|
|||||||
<RemindersInline clientId={clientId} />
|
<RemindersInline clientId={clientId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldHistoryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +200,7 @@ 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 flex items-center gap-1">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
||||||
<InlinePhoneField
|
<InlinePhoneField
|
||||||
@@ -226,6 +228,17 @@ function ContactRow({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* Bottom / right: tag + actions.
|
{/* Bottom / right: tag + actions.
|
||||||
|
|||||||
@@ -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,6 +993,7 @@ function OverviewTab({
|
|||||||
const futureMilestones = milestones.filter((m) => m.phase === 'future');
|
const futureMilestones = milestones.filter((m) => m.phase === 'future');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldHistoryProvider scope={{ type: 'interest', id: interestId }}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Skip-ahead nudge - informational only; fires when the deal jumped
|
{/* Skip-ahead nudge - informational only; fires when the deal jumped
|
||||||
past a milestone without stamping the matching date. */}
|
past a milestone without stamping the matching date. */}
|
||||||
@@ -1147,7 +1164,7 @@ function OverviewTab({
|
|||||||
<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}
|
||||||
@@ -1160,7 +1177,7 @@ function OverviewTab({
|
|||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Phone">
|
<EditableRow label="Phone" historyPath="client.primaryPhone">
|
||||||
{interest.clientId ? (
|
{interest.clientId ? (
|
||||||
<ClientChannelEditor
|
<ClientChannelEditor
|
||||||
clientId={interest.clientId}
|
clientId={interest.clientId}
|
||||||
@@ -1182,8 +1199,8 @@ function OverviewTab({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-1 text-xs text-muted-foreground italic">
|
<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
|
No contact activity logged yet - log a call, email, or meeting from the Contact
|
||||||
tab to start tracking.
|
log tab to start tracking.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{interest.reservationStatus ? (
|
{interest.reservationStatus ? (
|
||||||
@@ -1245,7 +1262,9 @@ function OverviewTab({
|
|||||||
<EditableRow label={`Desired width (${unitLabel})`}>
|
<EditableRow label={`Desired width (${unitLabel})`}>
|
||||||
<InlineEditableField
|
<InlineEditableField
|
||||||
value={
|
value={
|
||||||
unitIsM ? (interest.desiredWidthM ?? null) : (interest.desiredWidthFt ?? null)
|
unitIsM
|
||||||
|
? (interest.desiredWidthM ?? null)
|
||||||
|
: (interest.desiredWidthFt ?? null)
|
||||||
}
|
}
|
||||||
onSave={onSavePair(
|
onSave={onSavePair(
|
||||||
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
|
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
|
||||||
@@ -1258,7 +1277,9 @@ function OverviewTab({
|
|||||||
<EditableRow label={`Desired draft (${unitLabel})`}>
|
<EditableRow label={`Desired draft (${unitLabel})`}>
|
||||||
<InlineEditableField
|
<InlineEditableField
|
||||||
value={
|
value={
|
||||||
unitIsM ? (interest.desiredDraftM ?? null) : (interest.desiredDraftFt ?? null)
|
unitIsM
|
||||||
|
? (interest.desiredDraftM ?? null)
|
||||||
|
: (interest.desiredDraftFt ?? null)
|
||||||
}
|
}
|
||||||
onSave={onSavePair(
|
onSave={onSavePair(
|
||||||
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
|
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
|
||||||
@@ -1393,6 +1414,7 @@ function OverviewTab({
|
|||||||
onOpenChange={setEoiGenerateOpen}
|
onOpenChange={setEoiGenerateOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldHistoryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
167
src/components/shared/field-history.tsx
Normal file
167
src/components/shared/field-history.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,12 +167,13 @@ function OverviewTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Identity */}
|
{/* Identity */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<EditableRow label="Name">
|
<EditableRow label="Name" historyPath="yacht.name">
|
||||||
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Hull Number">
|
<EditableRow label="Hull Number">
|
||||||
@@ -206,13 +222,13 @@ function OverviewTab({
|
|||||||
<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>
|
||||||
@@ -260,6 +276,7 @@ function OverviewTab({
|
|||||||
<RemindersInline yachtId={yachtId} />
|
<RemindersInline yachtId={yachtId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldHistoryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
src/lib/branding/url.ts
Normal file
52
src/lib/branding/url.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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 ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
206
src/lib/templates/bindable-fields.ts
Normal file
206
src/lib/templates/bindable-fields.ts
Normal 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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user