From 6b56628dd8e49a69eb2eefab1521a9f8830db422 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:04:18 +0000 Subject: [PATCH 01/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 3 +++ .../20260418020415_625732b5-4919-4c8d-8606-c0a8676544fa.sql | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 supabase/migrations/20260418020415_625732b5-4919-4c8d-8606-c0a8676544fa.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 78fdb4c..e20ce44 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -955,6 +955,7 @@ export type Database = { created_at: string created_by: string | null description: string | null + fields: Json font_family: string font_size_pt: number hide_title: boolean @@ -969,6 +970,7 @@ export type Database = { created_at?: string created_by?: string | null description?: string | null + fields?: Json font_family?: string font_size_pt?: number hide_title?: boolean @@ -983,6 +985,7 @@ export type Database = { created_at?: string created_by?: string | null description?: string | null + fields?: Json font_family?: string font_size_pt?: number hide_title?: boolean diff --git a/supabase/migrations/20260418020415_625732b5-4919-4c8d-8606-c0a8676544fa.sql b/supabase/migrations/20260418020415_625732b5-4919-4c8d-8606-c0a8676544fa.sql new file mode 100644 index 0000000..818b251 --- /dev/null +++ b/supabase/migrations/20260418020415_625732b5-4919-4c8d-8606-c0a8676544fa.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.custom_form_templates + ADD COLUMN IF NOT EXISTS fields jsonb NOT NULL DEFAULT '[]'::jsonb; \ No newline at end of file From 00c484d102791e6d380c8c573b076448bbac7073 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:04:41 +0000 Subject: [PATCH 02/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/forms-shared.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/lib/forms-shared.ts b/src/lib/forms-shared.ts index 714e795..88bf9c1 100644 --- a/src/lib/forms-shared.ts +++ b/src/lib/forms-shared.ts @@ -102,6 +102,7 @@ export function applyVariables( homeowner?: HomeownerLite | null; firmName?: string; customValues?: Record; + fieldValues?: Record; }, ): string { const today = format(new Date(), "MMMM d, yyyy"); @@ -119,15 +120,42 @@ export function applyVariables( for (const [k, v] of Object.entries(replacements)) { out = out.split(k).join(v); } - // Custom variables like {{custom.mortgage_holder}} + // Custom case-field variables like {{custom.mortgage_holder}} if (ctx.customValues) { for (const [k, v] of Object.entries(ctx.customValues)) { out = out.split(`{{custom.${k}}}`).join(v ?? ""); } } + // Per-form fillable fields like {{field.tenant_name}} + if (ctx.fieldValues) { + for (const [k, v] of Object.entries(ctx.fieldValues)) { + out = out.split(`{{field.${k}}}`).join(v ?? ""); + } + } return out; } +// ---------- Per-template fillable fields ---------- +export type FormFieldType = + | "text" + | "textarea" + | "number" + | "date" + | "dropdown" + | "county" + | "client" + | "homeowner"; + +export interface FormFieldDef { + key: string; + label: string; + type: FormFieldType; + required?: boolean; + options?: string[]; // for dropdown + placeholder?: string; + default?: string; +} + export function fmtCurrency(n: number | string | null | undefined): string { const v = typeof n === "string" ? parseFloat(n) : (n ?? 0); return (Number.isFinite(v) ? v : 0).toLocaleString("en-US", { From 6224ddd8127a0ab94b07ebb2a93dac56d7180aea Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:05:13 +0000 Subject: [PATCH 03/13] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- .../forms/form-field-defs-dialog.tsx | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/components/forms/form-field-defs-dialog.tsx diff --git a/src/components/forms/form-field-defs-dialog.tsx b/src/components/forms/form-field-defs-dialog.tsx new file mode 100644 index 0000000..d13f3cf --- /dev/null +++ b/src/components/forms/form-field-defs-dialog.tsx @@ -0,0 +1,252 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Switch } from "@/components/ui/switch"; +import { Trash2, Plus, ArrowUp, ArrowDown } from "lucide-react"; +import type { FormFieldDef, FormFieldType } from "@/lib/forms-shared"; +import { toast } from "sonner"; + +const TYPE_LABEL: Record = { + text: "Text", + textarea: "Long text", + number: "Number", + date: "Date", + dropdown: "Dropdown", + county: "Florida County", + client: "Client / Association", + homeowner: "Homeowner", +}; + +function slug(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 40); +} + +export function FormFieldDefsDialog({ + open, + onOpenChange, + fields, + onChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + fields: FormFieldDef[]; + onChange: (next: FormFieldDef[]) => void; +}) { + const [draft, setDraft] = useState(fields); + + // re-sync when reopened + const reset = () => setDraft(fields); + + const update = (i: number, patch: Partial) => { + setDraft((prev) => prev.map((f, idx) => (idx === i ? { ...f, ...patch } : f))); + }; + const remove = (i: number) => setDraft((prev) => prev.filter((_, idx) => idx !== i)); + const move = (i: number, dir: -1 | 1) => { + setDraft((prev) => { + const next = [...prev]; + const j = i + dir; + if (j < 0 || j >= next.length) return prev; + [next[i], next[j]] = [next[j], next[i]]; + return next; + }); + }; + const add = () => { + const base = "field"; + let n = 1; + const used = new Set(draft.map((d) => d.key)); + while (used.has(`${base}_${n}`)) n++; + setDraft((prev) => [ + ...prev, + { key: `${base}_${n}`, label: `Field ${n}`, type: "text", required: false }, + ]); + }; + + const save = () => { + // de-dupe + slug keys + const seen = new Set(); + for (const f of draft) { + if (!f.label.trim()) { + toast.error("Each field needs a label"); + return; + } + const key = slug(f.key || f.label); + if (!key) { + toast.error(`Invalid key for "${f.label}"`); + return; + } + if (seen.has(key)) { + toast.error(`Duplicate field key: ${key}`); + return; + } + seen.add(key); + f.key = key; + } + onChange(draft); + onOpenChange(false); + toast.success("Fields saved"); + }; + + return ( + { + onOpenChange(v); + if (v) reset(); + }} + > + + + Form fillable fields + + Define inputs that will appear when generating this form. Insert them in the body using{" "} + {"{{field.key}}"}. + + + +
+ {draft.length === 0 && ( +

+ No fields yet. Add one to start collecting input. +

+ )} + {draft.map((f, i) => ( +
+
+
+ + update(i, { label: e.target.value })} + /> +
+
+ + update(i, { key: e.target.value })} + onBlur={(e) => update(i, { key: slug(e.target.value || f.label) })} + /> +
+
+ + +
+
+ + + +
+
+ + {f.type === "dropdown" && ( +
+ +