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