fix(audit-wave-9): unified template token picker with custom-field group

Build a shared <TemplateTokenPicker> that renders the canonical
MERGE_FIELDS catalog grouped by scope, plus a dynamically-fetched
"Custom (port-specific)" group surfaced from /api/v1/admin/custom-fields.
The custom group is filtered to entity types the resolver actually
expands at send time (client/interest/berth - see
mergeCustomFieldValues in document-sends.service).

Wire it into both consumers:
- admin/document-templates/template-form.tsx (replaces TEMPLATE_VARIABLES
  list which had drifted from the canonical catalog)
- admin/sales-email-config-card.tsx (replaces flat alphabetical dump)

Closes custom-fields §B "UI surfacing of {{custom.…}} tokens".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:57:37 +02:00
parent a49ee1c347
commit 153f6ac797
4 changed files with 114 additions and 19 deletions

View File

@@ -0,0 +1,104 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { MERGE_FIELDS } from '@/lib/templates/merge-fields';
interface CustomFieldDef {
id: string;
entityType: string;
fieldName: string;
fieldLabel: string;
}
const SCOPE_LABELS: Record<string, string> = {
client: 'Client',
yacht: 'Yacht',
company: 'Company',
owner: 'Owner',
interest: 'Interest',
berth: 'Berth',
eoi: 'EOI',
reservation: 'Reservation',
port: 'Port',
date: 'Date',
};
const CUSTOM_RESOLVABLE_ENTITY_TYPES = new Set(['client', 'interest', 'berth']);
function useCustomFieldTokens() {
return useQuery<{ data: CustomFieldDef[] }>({
queryKey: ['admin', 'custom-fields', 'token-picker'],
queryFn: () => apiFetch<{ data: CustomFieldDef[] }>('/api/v1/admin/custom-fields'),
staleTime: 60_000,
});
}
/**
* Reusable token reference for template / email editors.
*
* Renders the canonical `MERGE_FIELDS` catalog grouped by entity scope.
* Below the static catalog, lazily fetches per-port custom field
* definitions and renders any whose entityType is resolvable at
* send-time (client / interest / berth — see `mergeCustomFieldValues`
* in `document-sends.service.ts`) as a "Custom" group.
*
* The validator accepts any `{{custom.<fieldName>}}` shape, but only
* the three entity types above resolve to real values, so we only show
* those — surfacing client-portal-only fields would mis-set expectations.
*/
export function TemplateTokenPicker() {
const customQuery = useCustomFieldTokens();
const customFields = (customQuery.data?.data ?? []).filter((d) =>
CUSTOM_RESOLVABLE_ENTITY_TYPES.has(d.entityType),
);
return (
<div className="space-y-4">
{Object.entries(MERGE_FIELDS).map(([scope, fields]) => (
<div key={scope}>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{SCOPE_LABELS[scope] ?? scope}
</p>
<div className="grid grid-cols-1 gap-x-3 gap-y-0.5 text-[11px] sm:grid-cols-2">
{fields.map((f) => (
<div key={f.token} className="flex items-baseline gap-1.5">
<code className="rounded bg-muted px-1 font-mono text-[11px]">{f.token}</code>
<span className="truncate text-muted-foreground">{f.label}</span>
</div>
))}
</div>
</div>
))}
{customFields.length > 0 ? (
<div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Custom (port-specific)
</p>
<div className="grid grid-cols-1 gap-x-3 gap-y-0.5 text-[11px] sm:grid-cols-2">
{customFields
.slice()
.sort((a, b) =>
a.entityType === b.entityType
? a.fieldName.localeCompare(b.fieldName)
: a.entityType.localeCompare(b.entityType),
)
.map((f) => (
<div key={f.id} className="flex items-baseline gap-1.5">
<code className="rounded bg-muted px-1 font-mono text-[11px]">
{`{{custom.${f.fieldName}}}`}
</code>
<span className="truncate text-muted-foreground">
{f.fieldLabel}{' '}
<span className="text-muted-foreground/70">({f.entityType})</span>
</span>
</div>
))}
</div>
</div>
) : null}
</div>
);
}