diff --git a/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx b/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx new file mode 100644 index 0000000..b54c7a2 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePageClient.tsx @@ -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(); + 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(() => + deriveRolesFromFields(initialFields) + ); + const [selectedFieldId, setSelectedFieldId] = useState(null); + const [textFillData, setTextFillData] = useState>(() => { + const data: Record = {}; + 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 ( +
+

+ Edit Template: {name} +

+
+
+ +
+ +
+
+ ); +} diff --git a/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx b/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx new file mode 100644 index 0000000..f8e7d2e --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/_components/TemplatePanel.tsx @@ -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; + onRemoveRole: (label: string) => Promise; + onAiAutoPlace: () => Promise; + onSave: () => Promise; +} + +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(null); + const [editRoleValue, setEditRoleValue] = useState(''); + + // Role removal confirm dialog + const [confirmRemoveRole, setConfirmRemoveRole] = useState(null); + + // Add role input + const [newRoleLabel, setNewRoleLabel] = useState(''); + + // AI button state + const [aiLoading, setAiLoading] = useState(false); + const [aiError, setAiError] = useState(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 ( +
+ {/* Template name */} +
+ + 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', + }} + /> +
+ + {/* Signers / Roles */} +
+
+ Signers / Roles +
+ + {/* Role pills */} +
+ {signers.map((signer) => ( +
+ {/* Color dot */} +
+ + {/* Label or inline edit */} + {editingRole === signer.email ? ( + 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', + }} + /> + ) : ( + handleStartEditRole(signer.email)} + style={{ + flex: 1, + fontSize: 14, + fontWeight: 400, + color: '#1B2B4B', + cursor: 'text', + }} + title="Click to rename" + > + {signer.email} + + )} + + {/* Remove button */} + +
+ ))} +
+ + {/* Add role input */} +
+ 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', + }} + /> + +
+ + {/* Preset chips */} + {availablePresets.length > 0 && ( +
+ {availablePresets.map((preset) => ( + + ))} +
+ )} +
+ + {/* AI Auto-place button */} +
+ + {aiError && ( +

{aiError}

+ )} +
+ + {/* Save button */} +
+ + {saveStatus === 'saved' && ( +

Saved

+ )} + {saveStatus === 'error' && ( +

+ Save failed. Please try again. +

+ )} +
+ + {/* Confirm remove role dialog */} + setConfirmRemoveRole(null)} + /> + + +
+ ); +} diff --git a/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/page.tsx b/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/page.tsx new file mode 100644 index 0000000..8cf01b5 --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/templates/[id]/page.tsx @@ -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 ( + + ); +}