Added custom form fields

X-Lovable-Edit-ID: edt-de97da49-f2de-410f-93d1-381f9610f9cf
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 02:08:05 +00:00
co-authored by renee-png
6 changed files with 629 additions and 43 deletions
+111 -42
View File
@@ -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<FormFieldDef[]>([]);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [fieldsDialogOpen, setFieldsDialogOpen] = useState(false);
// Save-to-case
const [saveToCaseOpen, setSaveToCaseOpen] = useState(false);
const [cases, setCases] = useState<CaseLite[]>([]);
@@ -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 || "<p></p>");
const loadedFields = Array.isArray(t.fields) ? t.fields : [];
setFormFields(loadedFields);
// seed values with defaults
const seeded: Record<string, string> = {};
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() {
</Card>
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-4">
<Card>
<CardContent className="pt-4 space-y-3">
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Variables</Label>
<p className="text-xs text-muted-foreground mt-1">Click to insert at cursor.</p>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search variables..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
<ScrollArea className="h-[420px] pr-2">
<div className="space-y-1">
{filteredVars.map((v) => (
<button
key={v.key}
onClick={() => insertVar(v.key)}
className="w-full text-left px-2.5 py-2 rounded hover:bg-accent transition-colors border border-transparent hover:border-border"
>
<div className="font-mono text-xs font-semibold text-primary flex items-center gap-1">
{v.key}
{v.kind === "custom" && (
<span className="text-[9px] px-1 rounded bg-accent text-accent-foreground">custom</span>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{v.description}</div>
</button>
))}
{customDefs.length === 0 && (
<p className="text-[11px] text-muted-foreground italic px-2 pt-2">
Tip: define custom case fields in <strong>Settings → Custom case fields</strong> to use them here as <code>{"{{custom.key}}"}</code>.
</p>
)}
<div className="space-y-4">
<Card>
<CardContent className="pt-4 space-y-3">
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Variables</Label>
<p className="text-xs text-muted-foreground mt-1">Click to insert at cursor.</p>
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search variables..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
<ScrollArea className="h-[260px] pr-2">
<div className="space-y-1">
{filteredVars.map((v) => (
<button
key={v.key}
onClick={() => insertVar(v.key)}
className="w-full text-left px-2.5 py-2 rounded hover:bg-accent transition-colors border border-transparent hover:border-border"
>
<div className="font-mono text-xs font-semibold text-primary flex items-center gap-1">
{v.key}
{v.kind === "custom" && (
<span className="text-[9px] px-1 rounded bg-accent text-accent-foreground">custom</span>
)}
{v.kind === "field" && (
<span className="text-[9px] px-1 rounded bg-primary/10 text-primary">field</span>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">{v.description}</div>
</button>
))}
{customDefs.length === 0 && formFields.length === 0 && (
<p className="text-[11px] text-muted-foreground italic px-2 pt-2">
Tip: define fillable fields below, or custom case fields in <strong>Settings → Custom case fields</strong>.
</p>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
Fillable fields
</Label>
<Button size="sm" variant="ghost" onClick={() => setFieldsDialogOpen(true)}>
Edit fields
</Button>
</div>
<ScrollArea className="h-[300px] pr-2">
<FormFieldsFillPanel
fields={formFields}
values={fieldValues}
onChange={setFieldValues}
/>
</ScrollArea>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="pt-4">
@@ -1017,6 +1069,23 @@ export function CustomFormBuilder() {
</DialogFooter>
</DialogContent>
</Dialog>
<FormFieldDefsDialog
open={fieldsDialogOpen}
onOpenChange={setFieldsDialogOpen}
fields={formFields}
onChange={(next) => {
setFormFields(next);
// prune values whose keys no longer exist; seed defaults for new keys
setFieldValues((prev) => {
const out: Record<string, string> = {};
for (const f of next) {
out[f.key] = prev[f.key] ?? f.default ?? "";
}
return out;
});
}}
/>
</div>
);
}
@@ -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<FormFieldType, string> = {
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<FormFieldDef[]>(fields);
// re-sync when reopened
const reset = () => setDraft(fields);
const update = (i: number, patch: Partial<FormFieldDef>) => {
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<string>();
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 (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (v) reset();
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Form fillable fields</DialogTitle>
<DialogDescription>
Define inputs that will appear when generating this form. Insert them in the body using{" "}
<code className="text-xs">{"{{field.key}}"}</code>.
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] pr-2">
<div className="space-y-3">
{draft.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No fields yet. Add one to start collecting input.
</p>
)}
{draft.map((f, i) => (
<div key={i} className="border rounded-md p-3 space-y-2 bg-muted/20">
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr_180px_auto] gap-2 items-end">
<div>
<Label className="text-xs">Label</Label>
<Input
value={f.label}
onChange={(e) => update(i, { label: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">
Key <span className="text-muted-foreground">(token: {`{{field.${f.key || "..."}}}`})</span>
</Label>
<Input
value={f.key}
onChange={(e) => update(i, { key: e.target.value })}
onBlur={(e) => update(i, { key: slug(e.target.value || f.label) })}
/>
</div>
<div>
<Label className="text-xs">Type</Label>
<Select
value={f.type}
onValueChange={(v) => update(i, { type: v as FormFieldType })}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TYPE_LABEL) as FormFieldType[]).map((t) => (
<SelectItem key={t} value={t}>
{TYPE_LABEL[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => move(i, -1)} title="Move up">
<ArrowUp className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" onClick={() => move(i, 1)} title="Move down">
<ArrowDown className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => remove(i)}
title="Delete"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
{f.type === "dropdown" && (
<div>
<Label className="text-xs">Options (one per line)</Label>
<Textarea
rows={3}
value={(f.options ?? []).join("\n")}
onChange={(e) =>
update(i, {
options: e.target.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder={"Yes\nNo\nN/A"}
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end">
<div>
<Label className="text-xs">Placeholder</Label>
<Input
value={f.placeholder ?? ""}
onChange={(e) => update(i, { placeholder: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Default value</Label>
<Input
value={f.default ?? ""}
onChange={(e) => update(i, { default: e.target.value })}
/>
</div>
<div className="flex items-center gap-2 pb-2">
<Switch
checked={!!f.required}
onCheckedChange={(v) => update(i, { required: v })}
id={`req-${i}`}
/>
<Label htmlFor={`req-${i}`} className="cursor-pointer text-xs">
Required
</Label>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
<DialogFooter className="flex-wrap gap-2">
<Button variant="outline" onClick={add}>
<Plus className="h-4 w-4 mr-1.5" /> Add field
</Button>
<div className="flex-1" />
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={save}>Save fields</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,232 @@
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
fetchClients,
fetchHomeowners,
type ClientLite,
type HomeownerLite,
type FormFieldDef,
} from "@/lib/forms-shared";
import { FL_COUNTIES } from "@/lib/florida";
export function FormFieldsFillPanel({
fields,
values,
onChange,
}: {
fields: FormFieldDef[];
values: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}) {
const [clients, setClients] = useState<ClientLite[]>([]);
const [homeownersByClient, setHomeownersByClient] = useState<Record<string, HomeownerLite[]>>({});
// Track which client a homeowner field is filtered by (per-field-key)
const [hoClientByField, setHoClientByField] = useState<Record<string, string>>({});
useEffect(() => {
if (fields.some((f) => f.type === "client" || f.type === "homeowner")) {
fetchClients().then(setClients);
}
}, [fields]);
const set = (key: string, v: string) => onChange({ ...values, [key]: v });
const ensureHomeowners = async (clientId: string) => {
if (!clientId || homeownersByClient[clientId]) return;
const hs = await fetchHomeowners(clientId);
setHomeownersByClient((prev) => ({ ...prev, [clientId]: hs }));
};
if (fields.length === 0) {
return (
<p className="text-xs text-muted-foreground italic px-1 py-2">
No fillable fields. Click "Edit fields" to define inputs.
</p>
);
}
return (
<div className="space-y-3">
{fields.map((f) => {
const v = values[f.key] ?? f.default ?? "";
const labelEl = (
<Label className="text-xs">
{f.label}
{f.required && <span className="text-destructive ml-1">*</span>}
</Label>
);
if (f.type === "textarea") {
return (
<div key={f.key}>
{labelEl}
<Textarea
rows={3}
value={v}
placeholder={f.placeholder}
onChange={(e) => set(f.key, e.target.value)}
/>
</div>
);
}
if (f.type === "number") {
return (
<div key={f.key}>
{labelEl}
<Input
type="number"
value={v}
placeholder={f.placeholder}
onChange={(e) => set(f.key, e.target.value)}
/>
</div>
);
}
if (f.type === "date") {
return (
<div key={f.key}>
{labelEl}
<Input
type="date"
value={v}
onChange={(e) => set(f.key, e.target.value)}
/>
</div>
);
}
if (f.type === "dropdown") {
return (
<div key={f.key}>
{labelEl}
<Select value={v} onValueChange={(val) => set(f.key, val)}>
<SelectTrigger className="h-9">
<SelectValue placeholder={f.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{(f.options ?? []).map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (f.type === "county") {
return (
<div key={f.key}>
{labelEl}
<Select value={v} onValueChange={(val) => set(f.key, val)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select Florida county" />
</SelectTrigger>
<SelectContent>
{FL_COUNTIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (f.type === "client") {
return (
<div key={f.key}>
{labelEl}
<Select
value={v}
onValueChange={(val) => {
const c = clients.find((x) => x.name === val);
// store the name as the substituted value
set(f.key, c?.name ?? val);
}}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{clients.map((c) => (
<SelectItem key={c.id} value={c.name}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (f.type === "homeowner") {
const filterClientId = hoClientByField[f.key] ?? "";
const hos = filterClientId ? homeownersByClient[filterClientId] ?? [] : [];
return (
<div key={f.key} className="space-y-1">
{labelEl}
<Select
value={filterClientId}
onValueChange={(val) => {
setHoClientByField((p) => ({ ...p, [f.key]: val }));
ensureHomeowners(val);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Filter by client" />
</SelectTrigger>
<SelectContent>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={v}
onValueChange={(val) => set(f.key, val)}
disabled={!filterClientId}
>
<SelectTrigger className="h-9">
<SelectValue
placeholder={filterClientId ? "Select homeowner" : "Pick client first"}
/>
</SelectTrigger>
<SelectContent>
{hos.map((h) => {
const name = `${h.first_name} ${h.last_name}${h.unit_number ? ` — Unit ${h.unit_number}` : ""}`;
return (
<SelectItem key={h.id} value={name}>
{name}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
}
// text (default)
return (
<div key={f.key}>
{labelEl}
<Input
value={v}
placeholder={f.placeholder}
onChange={(e) => set(f.key, e.target.value)}
/>
</div>
);
})}
</div>
);
}
+3
View File
@@ -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
+29 -1
View File
@@ -102,6 +102,7 @@ export function applyVariables(
homeowner?: HomeownerLite | null;
firmName?: string;
customValues?: Record<string, string>;
fieldValues?: Record<string, string>;
},
): 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", {
@@ -0,0 +1,2 @@
ALTER TABLE public.custom_form_templates
ADD COLUMN IF NOT EXISTS fields jsonb NOT NULL DEFAULT '[]'::jsonb;