feat(19-02): templates list page with create-template modal

- Server component at /portal/templates queries documentTemplates with LEFT JOIN to formTemplates
- TemplatesListClient renders list rows with name, form name, field count, last updated
- Create modal POSTs to /api/templates and navigates to /portal/templates/[id] on success
- Empty state with CTA when no templates exist
- Gold #C9A84C accent, navy #1B2B4B headings, inline styles matching portal patterns
This commit is contained in:
Chandler Copeland
2026-04-06 13:13:29 -06:00
parent 6745c2057c
commit 4ca0769cf2
2 changed files with 309 additions and 0 deletions

View File

@@ -0,0 +1,283 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { SignatureFieldData } from '@/lib/db/schema';
interface TemplateRow {
id: string;
name: string;
formName: string | null;
signatureFields: SignatureFieldData[] | null;
updatedAt: Date;
}
interface FormOption {
id: string;
name: string;
}
interface TemplatesListClientProps {
templates: TemplateRow[];
forms: FormOption[];
}
export function TemplatesListClient({ templates, forms }: TemplatesListClientProps) {
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [templateName, setTemplateName] = useState('');
const [formTemplateId, setFormTemplateId] = useState('');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const handleCreate = async () => {
if (!templateName.trim() || !formTemplateId) {
setCreateError('Please provide a template name and select a form.');
return;
}
setCreating(true);
setCreateError(null);
try {
const res = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: templateName.trim(), formTemplateId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Failed to create template' }));
setCreateError(err.error ?? 'Failed to create template');
return;
}
const data = await res.json() as { id: string };
router.push(`/portal/templates/${data.id}`);
} catch {
setCreateError('Failed to create template. Please try again.');
} finally {
setCreating(false);
}
};
return (
<div style={{ backgroundColor: '#FAF9F7', minHeight: '100vh', padding: 32 }}>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: 24 }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: '#1B2B4B', margin: 0 }}>
Templates
</h1>
<p style={{ fontSize: 14, fontWeight: 400, color: '#6B7280', margin: '4px 0 0' }}>
{templates.length} template{templates.length !== 1 ? 's' : ''}
</p>
</div>
<button
onClick={() => { setShowModal(true); setCreateError(null); setTemplateName(''); setFormTemplateId(''); }}
style={{
backgroundColor: '#C9A84C',
color: 'white',
border: 'none',
borderRadius: 6,
height: 36,
padding: '0 16px',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
+ New Template
</button>
</div>
{/* List */}
{templates.length === 0 ? (
<div style={{
backgroundColor: 'white',
borderRadius: 8,
padding: '48px 32px',
textAlign: 'center',
border: '1px solid #E5E7EB',
}}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: '#1B2B4B', marginBottom: 8 }}>
No templates yet
</h2>
<p style={{ fontSize: 14, color: '#6B7280', marginBottom: 24 }}>
Create a template to reuse field placements across documents.
</p>
<button
onClick={() => { setShowModal(true); setCreateError(null); setTemplateName(''); setFormTemplateId(''); }}
style={{
backgroundColor: '#C9A84C',
color: 'white',
border: 'none',
borderRadius: 6,
height: 36,
padding: '0 16px',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
+ Create your first template
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{templates.map((template) => {
const count = (template.signatureFields ?? []).length;
return (
<div
key={template.id}
onClick={() => router.push(`/portal/templates/${template.id}`)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
backgroundColor: 'white',
borderBottom: '1px solid #E5E7EB',
borderRadius: 6,
cursor: 'pointer',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.backgroundColor = '#F0EDE8'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'white'; }}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1B2B4B' }}>
{template.name}
</div>
<div style={{ fontSize: 14, fontWeight: 400, color: '#6B7280', marginTop: 2 }}>
{template.formName ?? '—'}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 12, fontWeight: 400, color: '#6B7280' }}>
{count} field{count !== 1 ? 's' : ''}
</div>
<div style={{ fontSize: 12, fontWeight: 400, color: '#6B7280', marginTop: 2 }}>
{new Date(template.updatedAt).toLocaleDateString()}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Create Template Modal */}
{showModal && (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={(e) => { if (e.target === e.currentTarget) setShowModal(false); }}
>
<div style={{
backgroundColor: '#F9FAFB',
borderRadius: 8,
padding: 24,
maxWidth: 400,
width: '100%',
boxShadow: '0 8px 32px rgba(0,0,0,0.15)',
}}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#1B2B4B', marginBottom: 20 }}>
New Template
</h2>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
Template name
</label>
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="e.g. Buyer Representation Agreement"
style={{
width: '100%',
fontSize: 14,
padding: '8px 12px',
border: '1px solid #E5E7EB',
borderRadius: 6,
outline: 'none',
boxSizing: 'border-box',
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
/>
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
Select form
</label>
<select
value={formTemplateId}
onChange={(e) => setFormTemplateId(e.target.value)}
style={{
width: '100%',
fontSize: 14,
padding: '8px 12px',
border: '1px solid #E5E7EB',
borderRadius: 6,
outline: 'none',
backgroundColor: 'white',
boxSizing: 'border-box',
}}
>
<option value="">-- Select a form --</option>
{forms.map((form) => (
<option key={form.id} value={form.id}>{form.name}</option>
))}
</select>
</div>
{createError && (
<p style={{ fontSize: 13, color: '#DC2626', marginBottom: 12 }}>{createError}</p>
)}
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button
onClick={() => setShowModal(false)}
style={{
fontSize: 14,
padding: '8px 16px',
border: '1px solid #E5E7EB',
borderRadius: 6,
backgroundColor: 'white',
cursor: 'pointer',
color: '#374151',
}}
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={creating}
style={{
fontSize: 14,
fontWeight: 600,
padding: '8px 16px',
backgroundColor: '#C9A84C',
color: 'white',
border: 'none',
borderRadius: 6,
cursor: creating ? 'not-allowed' : 'pointer',
opacity: creating ? 0.7 : 1,
}}
>
{creating ? 'Creating...' : 'Create Template'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { db } from '@/lib/db';
import { documentTemplates, formTemplates } from '@/lib/db/schema';
import { eq, isNull, desc } from 'drizzle-orm';
import { TemplatesListClient } from './TemplatesListClient';
export default async function TemplatesPage() {
const templates = await db
.select({
id: documentTemplates.id,
name: documentTemplates.name,
formName: formTemplates.name,
signatureFields: documentTemplates.signatureFields,
updatedAt: documentTemplates.updatedAt,
})
.from(documentTemplates)
.leftJoin(formTemplates, eq(documentTemplates.formTemplateId, formTemplates.id))
.where(isNull(documentTemplates.archivedAt))
.orderBy(desc(documentTemplates.updatedAt));
const forms = await db
.select({ id: formTemplates.id, name: formTemplates.name })
.from(formTemplates)
.orderBy(formTemplates.name);
return <TemplatesListClient templates={templates} forms={forms} />;
}