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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user