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:
@@ -42,7 +42,7 @@ Remaining phases — explicitly back-burnered by the user on 2026-05-07:
|
|||||||
- ✅ **Merge tokens** — `{{custom.<fieldName>}}` validators + resolver shipped 2026-05-08. Tokens expand at template-render time for client/interest/berth contexts via `mergeCustomFieldValues` in `document-sends.service.ts`. Banner updated.
|
- ✅ **Merge tokens** — `{{custom.<fieldName>}}` validators + resolver shipped 2026-05-08. Tokens expand at template-render time for client/interest/berth contexts via `mergeCustomFieldValues` in `document-sends.service.ts`. Banner updated.
|
||||||
- **Search index** — DEFERRED as design limitation. Adding GIN coverage requires either joining `custom_field_values` per search (slow at scale) or materializing values into a search_text column on the parent (additive maintenance burden). The amber banner documents this.
|
- **Search index** — DEFERRED as design limitation. Adding GIN coverage requires either joining `custom_field_values` per search (slow at scale) or materializing values into a search_text column on the parent (additive maintenance burden). The amber banner documents this.
|
||||||
- **Audit diff** — N/A. Custom-field values live in their own table, not as a JSONB blob on the parent entity. The `setValues()` service-layer call already creates its own audit log entry (custom-fields.service.ts:349-358), so changes ARE audited — just separately from the entity-diff.
|
- **Audit diff** — N/A. Custom-field values live in their own table, not as a JSONB blob on the parent entity. The `setValues()` service-layer call already creates its own audit log entry (custom-fields.service.ts:349-358), so changes ARE audited — just separately from the entity-diff.
|
||||||
- **UI surfacing of `{{custom.…}}` tokens in template-edit pickers** — Open. The token list dialog currently only shows static catalog tokens. Surface per-port custom-field definitions as a dynamic group under "Custom" so reps can browse them. Backend already accepts the tokens; this is a UI follow-up.
|
- ✅ **UI surfacing of `{{custom.…}}` tokens in template-edit pickers** — landed 2026-05-13. Shared `<TemplateTokenPicker>` (`src/components/admin/shared/template-token-picker.tsx`) renders the canonical `MERGE_FIELDS` catalog grouped by scope plus a dynamically-fetched "Custom (port-specific)" group filtered to entityTypes resolvable at send-time (client/interest/berth). Wired into both `sales-email-config-card.tsx` and `document-templates/template-form.tsx` so both pickers share the same surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-validation';
|
import { TemplateTokenPicker } from '@/components/admin/shared/template-token-picker';
|
||||||
|
|
||||||
const DOCUMENT_TYPES = [
|
const DOCUMENT_TYPES = [
|
||||||
{ value: 'eoi', label: 'Expression of Interest' },
|
{ value: 'eoi', label: 'Expression of Interest' },
|
||||||
@@ -154,8 +154,8 @@ export function TemplateForm({ open, onOpenChange, template, onSuccess }: Templa
|
|||||||
<Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
|
<Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Paste or edit TipTap JSON. Use{' '}
|
Paste or edit TipTap JSON. Use{' '}
|
||||||
<code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
|
<code className="rounded bg-muted px-1 text-xs">{'{{scope.field}}'}</code> tokens for
|
||||||
dynamic content.
|
dynamic content — see the list below.
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
id="template-content"
|
id="template-content"
|
||||||
@@ -176,13 +176,8 @@ export function TemplateForm({ open, onOpenChange, template, onSuccess }: Templa
|
|||||||
<summary className="cursor-pointer text-sm font-medium">
|
<summary className="cursor-pointer text-sm font-medium">
|
||||||
Available Template Variables
|
Available Template Variables
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
<div className="mt-3">
|
||||||
{TEMPLATE_VARIABLES.map((v) => (
|
<TemplateTokenPicker />
|
||||||
<div key={v.key} className="text-xs">
|
|
||||||
<code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
|
|
||||||
<span className="text-muted-foreground">- {v.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields';
|
import { TemplateTokenPicker } from '@/components/admin/shared/template-token-picker';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
interface SalesConfigResponse {
|
interface SalesConfigResponse {
|
||||||
@@ -313,14 +313,10 @@ export function SalesEmailConfigCard() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<details className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
<details className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||||
<summary className="cursor-pointer font-medium text-foreground">
|
<summary className="cursor-pointer font-medium text-foreground">
|
||||||
Available tokens ({Array.from(VALID_MERGE_TOKENS).length})
|
Available tokens
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-3 gap-y-0.5 font-mono text-[11px] text-muted-foreground sm:grid-cols-3">
|
<div className="mt-3">
|
||||||
{Array.from(VALID_MERGE_TOKENS)
|
<TemplateTokenPicker />
|
||||||
.sort()
|
|
||||||
.map((tok) => (
|
|
||||||
<span key={tok}>{tok}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
104
src/components/admin/shared/template-token-picker.tsx
Normal file
104
src/components/admin/shared/template-token-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user