diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index f526cc9..2f587e8 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -50,6 +50,8 @@ import { X, } from "lucide-react"; import { ClientHomeownerPicker } from "./form-pickers"; +import { FormFieldDefsDialog } from "./form-field-defs-dialog"; +import { FormFieldsFillPanel } from "./form-fields-fill-panel"; import { applyVariables, fetchFirm, @@ -63,6 +65,7 @@ import { type FirmInfo, type CustomFieldVar, type CaseLite, + type FormFieldDef, } from "@/lib/forms-shared"; import { jsPDF } from "jspdf"; import { @@ -137,6 +140,7 @@ interface SavedTemplate { font_family: string; font_size_pt: number; signature_blocks: Array<{ name: string; title?: string }>; + fields: FormFieldDef[]; updated_at: string; } @@ -161,6 +165,11 @@ export function CustomFormBuilder() { const [saveAsName, setSaveAsName] = useState(""); const [saveAsDescription, setSaveAsDescription] = useState(""); + // Fillable fields + const [formFields, setFormFields] = useState([]); + const [fieldValues, setFieldValues] = useState>({}); + const [fieldsDialogOpen, setFieldsDialogOpen] = useState(false); + // Save-to-case const [saveToCaseOpen, setSaveToCaseOpen] = useState(false); const [cases, setCases] = useState([]); @@ -321,7 +330,13 @@ export function CustomFormBuilder() { if (selectedCaseId) { customValues = await fetchCaseCustomValues(selectedCaseId); } - return { client, homeowner, firmName: firm?.company_name ?? "", customValues }; + return { + client, + homeowner, + firmName: firm?.company_name ?? "", + customValues, + fieldValues, + }; }; const renderTitle = (ctx: any) => applyVariables(title, ctx); @@ -589,6 +604,7 @@ export function CustomFormBuilder() { font_family: fontFamily, font_size_pt: fontSize, signature_blocks: [], + fields: formFields as any, }; if (asNew || !activeTemplateId) { const { data: u } = await supabase.auth.getUser(); @@ -625,6 +641,12 @@ export function CustomFormBuilder() { setFontFamily(t.font_family); setFontSize(t.font_size_pt); editor.commands.setContent(t.body_html || "

"); + const loadedFields = Array.isArray(t.fields) ? t.fields : []; + setFormFields(loadedFields); + // seed values with defaults + const seeded: Record = {}; + for (const f of loadedFields) if (f.default) seeded[f.key] = f.default; + setFieldValues(seeded); setActiveTemplateId(t.id); setLoadDialogOpen(false); toast.success(`Loaded "${t.name}"`); @@ -659,13 +681,18 @@ export function CustomFormBuilder() { description: d.label + (d.description ? ` — ${d.description}` : ""), kind: "custom" as const, })), + ...formFields.map((f) => ({ + key: `{{field.${f.key}}}`, + description: `${f.label} (${f.type})`, + kind: "field" as const, + })), ]; return all.filter( (v) => v.key.toLowerCase().includes(search.toLowerCase()) || v.description.toLowerCase().includes(search.toLowerCase()), ); - }, [customDefs, search]); + }, [customDefs, formFields, search]); const filteredCases = useMemo(() => { const s = caseSearch.toLowerCase(); @@ -746,47 +773,72 @@ export function CustomFormBuilder() {
- - -
- -

Click to insert at cursor.

-
-
- - setSearch(e.target.value)} - className="pl-8 h-8 text-xs" - /> -
- -
- {filteredVars.map((v) => ( - - ))} - {customDefs.length === 0 && ( -

- Tip: define custom case fields in Settings → Custom case fields to use them here as {"{{custom.key}}"}. -

- )} +
+ + +
+ +

Click to insert at cursor.

- -
-
+
+ + setSearch(e.target.value)} + className="pl-8 h-8 text-xs" + /> +
+ +
+ {filteredVars.map((v) => ( + + ))} + {customDefs.length === 0 && formFields.length === 0 && ( +

+ Tip: define fillable fields below, or custom case fields in Settings → Custom case fields. +

+ )} +
+
+ + + + + +
+ + +
+ + + +
+
+
@@ -1017,6 +1069,23 @@ export function CustomFormBuilder() { + + { + setFormFields(next); + // prune values whose keys no longer exist; seed defaults for new keys + setFieldValues((prev) => { + const out: Record = {}; + for (const f of next) { + out[f.key] = prev[f.key] ?? f.default ?? ""; + } + return out; + }); + }} + />
); } 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" && ( +
+ +