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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ?? []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user