feat(19-02): template editor page, TemplatePageClient, and TemplatePanel

- Server component at /portal/templates/[id] queries documentTemplates with formTemplate relation
- notFound() for archived/missing templates
- TemplatePageClient: state owner mirroring DocumentPageClient pattern
  - deriveRolesFromFields initializes Buyer/Seller defaults or extracts from existing fields
  - handlePersist merges textFillData hints into f.hint for type='text' fields
  - handleAiAutoPlace POSTs to /api/templates/[id]/ai-prepare and increments aiPlacementKey
  - Role rename/remove re-fetches fields and PATCHes with updated signerEmail values
- TemplatePanel: 280px right panel with inline styles matching portal design system
  - Signers/Roles section with color dots, click-to-rename, remove with ConfirmDialog
  - Add role input with preset chips (Buyer, Co-Buyer, Seller, Co-Seller)
  - AI Auto-place button (navy) with spinner/loading state and error display
  - Save Template button (gold) with success 'Saved' text fading after 3s
This commit is contained in:
Chandler Copeland
2026-04-06 13:15:23 -06:00
parent 4ca0769cf2
commit 10ea48d5ba
3 changed files with 645 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
'use client';
import { useState, useCallback } from 'react';
import { PdfViewerWrapper } from '@/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper';
import { TemplatePanel } from './TemplatePanel';
import type { SignatureFieldData, DocumentSigner } from '@/lib/db/schema';
interface TemplatePageClientProps {
templateId: string;
templateName: string;
formName: string;
initialFields: SignatureFieldData[];
}
const ROLE_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
function deriveRolesFromFields(fields: SignatureFieldData[]): DocumentSigner[] {
const seen = new Map<string, string>();
fields.forEach((f) => {
if (f.signerEmail && !seen.has(f.signerEmail)) {
seen.set(f.signerEmail, ROLE_COLORS[seen.size % ROLE_COLORS.length]);
}
});
if (seen.size === 0) {
return [
{ email: 'Buyer', color: ROLE_COLORS[0] },
{ email: 'Seller', color: ROLE_COLORS[1] },
];
}
return Array.from(seen.entries()).map(([email, color]) => ({ email, color }));
}
export function TemplatePageClient({
templateId,
templateName,
initialFields,
}: TemplatePageClientProps) {
const [signers, setSigners] = useState<DocumentSigner[]>(() =>
deriveRolesFromFields(initialFields)
);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const [textFillData, setTextFillData] = useState<Record<string, string>>(() => {
const data: Record<string, string> = {};
initialFields.forEach((f) => {
if (f.hint) data[f.id] = f.hint;
});
return data;
});
const [aiPlacementKey, setAiPlacementKey] = useState(0);
const [name, setName] = useState(templateName);
const handlePersist = useCallback(
async (rawFields: SignatureFieldData[]) => {
const fieldsWithHints = rawFields.map((f) =>
f.type === 'text' && textFillData[f.id]
? { ...f, hint: textFillData[f.id] }
: f
);
await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signatureFields: fieldsWithHints }),
});
},
[templateId, textFillData]
);
const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
setTextFillData((prev) => ({ ...prev, [fieldId]: value }));
}, []);
const handleFieldsChanged = useCallback(() => {
// No preview token to reset in template mode — no-op
}, []);
const handleAiAutoPlace = useCallback(async () => {
const res = await fetch(`/api/templates/${templateId}/ai-prepare`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'AI placement failed' }));
throw new Error((err as { error?: string }).error || 'AI placement failed');
}
setAiPlacementKey((k) => k + 1);
}, [templateId]);
const handleSave = useCallback(async () => {
// Fields are persisted via onPersist on every change (drag/drop/delete/resize).
// The Save button additionally persists the current name.
await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
}, [templateId, name]);
const handleRenameRole = useCallback(
async (oldLabel: string, newLabel: string) => {
setSigners((prev) =>
prev.map((s) => (s.email === oldLabel ? { ...s, email: newLabel } : s))
);
const res = await fetch(`/api/templates/${templateId}/fields`);
if (res.ok) {
const fields = (await res.json()) as SignatureFieldData[];
const updated = fields.map((f) =>
f.signerEmail === oldLabel ? { ...f, signerEmail: newLabel } : f
);
await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signatureFields: updated }),
});
setAiPlacementKey((k) => k + 1);
}
},
[templateId]
);
const handleRemoveRole = useCallback(
async (label: string) => {
setSigners((prev) => prev.filter((s) => s.email !== label));
const res = await fetch(`/api/templates/${templateId}/fields`);
if (res.ok) {
const fields = (await res.json()) as SignatureFieldData[];
const updated = fields.map((f) =>
f.signerEmail === label ? { ...f, signerEmail: undefined } : f
);
await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signatureFields: updated }),
});
setAiPlacementKey((k) => k + 1);
}
},
[templateId]
);
const handleAddRole = useCallback(
(label: string) => {
if (!label.trim() || signers.some((s) => s.email === label.trim())) return;
setSigners((prev) => [
...prev,
{ email: label.trim(), color: ROLE_COLORS[prev.length % ROLE_COLORS.length] },
]);
},
[signers]
);
return (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: 32 }}>
<h1
style={{ fontSize: '1.5rem', fontWeight: 700, color: '#1B2B4B', marginBottom: 24 }}
>
Edit Template: {name}
</h1>
<div style={{ display: 'flex', gap: 24 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<PdfViewerWrapper
docId={templateId}
docStatus="Draft"
onFieldsChanged={handleFieldsChanged}
selectedFieldId={selectedFieldId}
textFillData={textFillData}
onFieldSelect={setSelectedFieldId}
onFieldValueChange={handleFieldValueChange}
aiPlacementKey={aiPlacementKey}
signers={signers}
onPersist={handlePersist}
fieldsUrl={`/api/templates/${templateId}/fields`}
fileUrl={`/api/templates/${templateId}/file`}
/>
</div>
<TemplatePanel
templateId={templateId}
name={name}
onNameChange={setName}
signers={signers}
onAddRole={handleAddRole}
onRenameRole={handleRenameRole}
onRemoveRole={handleRemoveRole}
onAiAutoPlace={handleAiAutoPlace}
onSave={handleSave}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,436 @@
'use client';
import { useState } from 'react';
import type { DocumentSigner } from '@/lib/db/schema';
import { ConfirmDialog } from '@/app/portal/_components/ConfirmDialog';
interface TemplatePanelProps {
templateId: string;
name: string;
onNameChange: (name: string) => void;
signers: DocumentSigner[];
onAddRole: (label: string) => void;
onRenameRole: (oldLabel: string, newLabel: string) => Promise<void>;
onRemoveRole: (label: string) => Promise<void>;
onAiAutoPlace: () => Promise<void>;
onSave: () => Promise<void>;
}
const PRESET_ROLES = ['Buyer', 'Co-Buyer', 'Seller', 'Co-Seller'];
export function TemplatePanel({
templateId,
name,
onNameChange,
signers,
onAddRole,
onRenameRole,
onRemoveRole,
onAiAutoPlace,
onSave,
}: TemplatePanelProps) {
const [nameFocused, setNameFocused] = useState(false);
// Role editing state
const [editingRole, setEditingRole] = useState<string | null>(null);
const [editRoleValue, setEditRoleValue] = useState('');
// Role removal confirm dialog
const [confirmRemoveRole, setConfirmRemoveRole] = useState<string | null>(null);
// Add role input
const [newRoleLabel, setNewRoleLabel] = useState('');
// AI button state
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
// Save button state
const [saving, setSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
const handleNameBlur = async () => {
setNameFocused(false);
await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
};
const handleStartEditRole = (label: string) => {
setEditingRole(label);
setEditRoleValue(label);
};
const handleCommitRename = async () => {
if (!editingRole) return;
const trimmed = editRoleValue.trim();
if (trimmed && trimmed !== editingRole) {
await onRenameRole(editingRole, trimmed);
}
setEditingRole(null);
setEditRoleValue('');
};
const handleCancelRename = () => {
setEditingRole(null);
setEditRoleValue('');
};
const handleRemoveClick = (label: string) => {
setConfirmRemoveRole(label);
};
const handleConfirmRemove = async () => {
if (!confirmRemoveRole) return;
await onRemoveRole(confirmRemoveRole);
setConfirmRemoveRole(null);
};
const handleAddRole = () => {
const trimmed = newRoleLabel.trim();
if (!trimmed) return;
onAddRole(trimmed);
setNewRoleLabel('');
};
const handleAi = async () => {
setAiLoading(true);
setAiError(null);
try {
await onAiAutoPlace();
} catch (e) {
setAiError(
e instanceof Error
? e.message
: 'AI placement failed. Check that the form PDF is accessible and try again.'
);
} finally {
setAiLoading(false);
}
};
const handleSaveClick = async () => {
setSaving(true);
setSaveStatus('idle');
try {
await onSave();
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 3000);
} catch {
setSaveStatus('error');
} finally {
setSaving(false);
}
};
const availablePresets = PRESET_ROLES.filter(
(r) => !signers.some((s) => s.email === r)
);
return (
<div
style={{
width: 280,
flexShrink: 0,
backgroundColor: '#F9FAFB',
borderRadius: 8,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 24,
position: 'sticky',
top: 96,
alignSelf: 'flex-start',
}}
>
{/* Template name */}
<div>
<label
style={{ display: 'block', fontSize: 12, fontWeight: 600, color: '#6B7280', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
Template Name
</label>
<input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
onFocus={() => setNameFocused(true)}
onBlur={handleNameBlur}
style={{
width: '100%',
fontSize: 14,
fontWeight: 400,
color: '#1B2B4B',
border: 'none',
borderBottom: nameFocused ? '1px solid #C9A84C' : '1px solid #E5E7EB',
outline: 'none',
backgroundColor: 'transparent',
padding: '4px 0',
boxSizing: 'border-box',
}}
/>
</div>
{/* Signers / Roles */}
<div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: '#6B7280',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: 12,
}}
>
Signers / Roles
</div>
{/* Role pills */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 12 }}>
{signers.map((signer) => (
<div
key={signer.email}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 8px',
backgroundColor: 'white',
borderRadius: 6,
border: '1px solid #E5E7EB',
}}
>
{/* Color dot */}
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: signer.color,
flexShrink: 0,
}}
/>
{/* Label or inline edit */}
{editingRole === signer.email ? (
<input
type="text"
value={editRoleValue}
onChange={(e) => setEditRoleValue(e.target.value)}
onBlur={handleCommitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCommitRename();
if (e.key === 'Escape') handleCancelRename();
}}
autoFocus
style={{
flex: 1,
fontSize: 14,
fontWeight: 400,
color: '#1B2B4B',
border: 'none',
borderBottom: '1px solid #C9A84C',
outline: 'none',
backgroundColor: 'transparent',
padding: '0',
}}
/>
) : (
<span
onClick={() => handleStartEditRole(signer.email)}
style={{
flex: 1,
fontSize: 14,
fontWeight: 400,
color: '#1B2B4B',
cursor: 'text',
}}
title="Click to rename"
>
{signer.email}
</span>
)}
{/* Remove button */}
<button
onClick={() => handleRemoveClick(signer.email)}
style={{
fontSize: 12,
color: '#9CA3AF',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
flexShrink: 0,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = '#DC2626'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = '#9CA3AF'; }}
title={`Remove role: ${signer.email}`}
>
×
</button>
</div>
))}
</div>
{/* Add role input */}
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input
type="text"
value={newRoleLabel}
onChange={(e) => setNewRoleLabel(e.target.value)}
placeholder="Role label (e.g. Buyer)"
onKeyDown={(e) => { if (e.key === 'Enter') handleAddRole(); }}
style={{
flex: 1,
fontSize: 13,
padding: '6px 10px',
border: '1px solid #E5E7EB',
borderRadius: 6,
outline: 'none',
}}
/>
<button
onClick={handleAddRole}
style={{
fontSize: 13,
fontWeight: 600,
padding: '6px 12px',
backgroundColor: '#C9A84C',
color: 'white',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
flexShrink: 0,
}}
>
Add
</button>
</div>
{/* Preset chips */}
{availablePresets.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{availablePresets.map((preset) => (
<button
key={preset}
onClick={() => onAddRole(preset)}
style={{
fontSize: 12,
padding: '3px 10px',
border: '1px solid #E5E7EB',
borderRadius: 12,
backgroundColor: 'white',
color: '#374151',
cursor: 'pointer',
}}
>
{preset}
</button>
))}
</div>
)}
</div>
{/* AI Auto-place button */}
<div>
<button
onClick={handleAi}
disabled={aiLoading}
style={{
width: '100%',
height: 36,
backgroundColor: '#1B2B4B',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: aiLoading ? 'not-allowed' : 'pointer',
opacity: aiLoading ? 0.8 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}
>
{aiLoading ? (
<>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
border: '2px solid rgba(255,255,255,0.3)',
borderTopColor: 'white',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
Placing...
</>
) : (
'AI Auto-place Fields'
)}
</button>
{aiError && (
<p style={{ fontSize: 13, color: '#DC2626', marginTop: 6 }}>{aiError}</p>
)}
</div>
{/* Save button */}
<div>
<button
onClick={handleSaveClick}
disabled={saving}
style={{
width: '100%',
height: 36,
backgroundColor: '#C9A84C',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
}}
>
{saving ? 'Saving...' : 'Save Template'}
</button>
{saveStatus === 'saved' && (
<p style={{ fontSize: 13, color: '#059669', marginTop: 6 }}>Saved</p>
)}
{saveStatus === 'error' && (
<p style={{ fontSize: 13, color: '#DC2626', marginTop: 6 }}>
Save failed. Please try again.
</p>
)}
</div>
{/* Confirm remove role dialog */}
<ConfirmDialog
isOpen={confirmRemoveRole !== null}
title="Remove role?"
message={
confirmRemoveRole
? `Removing '${confirmRemoveRole}' will unassign its field(s). This cannot be undone.`
: ''
}
confirmLabel="Remove Role"
onConfirm={handleConfirmRemove}
onCancel={() => setConfirmRemoveRole(null)}
/>
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema';
import { and, eq, isNull } from 'drizzle-orm';
import { notFound } from 'next/navigation';
import { TemplatePageClient } from './_components/TemplatePageClient';
export default async function TemplateEditorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const template = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
with: { formTemplate: true },
});
if (!template || !template.formTemplate) notFound();
return (
<TemplatePageClient
templateId={template.id}
templateName={template.name}
formName={template.formTemplate.name}
initialFields={template.signatureFields ?? []}
/>
);
}