From 4ca0769cf26c54c9d69ae4ba0bce062f37aa42fc Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Mon, 6 Apr 2026 13:13:29 -0600 Subject: [PATCH] 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 --- .../templates/TemplatesListClient.tsx | 283 ++++++++++++++++++ .../app/portal/(protected)/templates/page.tsx | 26 ++ 2 files changed, 309 insertions(+) create mode 100644 teressa-copeland-homes/src/app/portal/(protected)/templates/TemplatesListClient.tsx create mode 100644 teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx diff --git a/teressa-copeland-homes/src/app/portal/(protected)/templates/TemplatesListClient.tsx b/teressa-copeland-homes/src/app/portal/(protected)/templates/TemplatesListClient.tsx new file mode 100644 index 0000000..9f4e7cc --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/templates/TemplatesListClient.tsx @@ -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(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 ( +
+
+ {/* Header */} +
+
+

+ Templates +

+

+ {templates.length} template{templates.length !== 1 ? 's' : ''} +

+
+ +
+ + {/* List */} + {templates.length === 0 ? ( +
+

+ No templates yet +

+

+ Create a template to reuse field placements across documents. +

+ +
+ ) : ( +
+ {templates.map((template) => { + const count = (template.signatureFields ?? []).length; + return ( +
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'; }} + > +
+
+ {template.name} +
+
+ {template.formName ?? '—'} +
+
+
+
+ {count} field{count !== 1 ? 's' : ''} +
+
+ {new Date(template.updatedAt).toLocaleDateString()} +
+
+
+ ); + })} +
+ )} +
+ + {/* Create Template Modal */} + {showModal && ( +
{ if (e.target === e.currentTarget) setShowModal(false); }} + > +
+

+ New Template +

+ +
+ + 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(); }} + /> +
+ +
+ + +
+ + {createError && ( +

{createError}

+ )} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx b/teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx new file mode 100644 index 0000000..680d9f3 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/templates/page.tsx @@ -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 ; +}