From 5548328513f43b66d7ece25c0f8468c500e146e8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:36:13 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- .../cases/ledger-template-dialog.tsx | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 src/components/cases/ledger-template-dialog.tsx diff --git a/src/components/cases/ledger-template-dialog.tsx b/src/components/cases/ledger-template-dialog.tsx new file mode 100644 index 0000000..0fc23c0 --- /dev/null +++ b/src/components/cases/ledger-template-dialog.tsx @@ -0,0 +1,416 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { SearchableSelect } from "@/components/ui/searchable-select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, Save, Trash2, Wand2, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { BUCKETS, num } from "@/lib/ledger"; +import { formatCurrency, formatDate } from "@/lib/format"; + +type Mode = "save" | "apply" | "copy"; + +interface Props { + open: boolean; + onOpenChange: (v: boolean) => void; + collectionId: string; + caseId: string; + /** Current ledger entries — used for "save as template" mode. */ + currentEntries: any[]; + onApplied: () => void; +} + +/** Template entry shape stored in jsonb. Dates are stored as day offsets + * from "today" so the template stays portable across cases. */ +interface TemplateEntry { + day_offset: number; + description: string | null; + account: string | null; + transaction_type: string; + assess: number; + late: number; + admin: number; + legal: number; + viol: number; + interest: number; + bank: number; + payment: number; +} + +export function LedgerTemplateDialog({ + open, + onOpenChange, + collectionId, + caseId, + currentEntries, + onApplied, +}: Props) { + const { user } = useAuth(); + const [mode, setMode] = useState("apply"); + const [busy, setBusy] = useState(false); + + // ── Save tab state + const [tplName, setTplName] = useState(""); + const [tplDesc, setTplDesc] = useState(""); + + // ── Apply tab state + const [templates, setTemplates] = useState([]); + const [selectedTpl, setSelectedTpl] = useState(""); + const [replaceOnApply, setReplaceOnApply] = useState(false); + + // ── Copy tab state + const [otherCols, setOtherCols] = useState([]); + const [selectedCol, setSelectedCol] = useState(""); + const [replaceOnCopy, setReplaceOnCopy] = useState(false); + + useEffect(() => { + if (!open) return; + // load templates + (async () => { + const { data } = await supabase + .from("ledger_templates" as any) + .select("*") + .order("name", { ascending: true }); + setTemplates((data as any[]) ?? []); + })(); + // load other collections (across all cases) for "copy from" + (async () => { + const { data } = await supabase + .from("collections") + .select("id, name, opened_at, homeowner:homeowners(first_name,last_name,unit_number), case:cases(case_number,title)") + .neq("id", collectionId) + .order("created_at", { ascending: false }) + .limit(500); + setOtherCols((data as any[]) ?? []); + })(); + setTplName(""); + setTplDesc(""); + setSelectedTpl(""); + setSelectedCol(""); + setReplaceOnApply(false); + setReplaceOnCopy(false); + }, [open, collectionId]); + + const collectionOptions = useMemo( + () => + otherCols.map((c) => { + const ho = c.homeowner; + const hoLabel = ho ? `${ho.last_name ?? ""}, ${ho.first_name ?? ""}${ho.unit_number ? ` · Unit ${ho.unit_number}` : ""}` : "—"; + const caseLabel = c.case ? `${c.case.case_number} · ${c.case.title}` : ""; + return { + value: c.id, + label: `${hoLabel}${c.name ? ` — ${c.name}` : ""}`, + description: caseLabel, + }; + }), + [otherCols], + ); + + // ─── Build TemplateEntry[] from raw ledger rows + const buildTemplateEntries = (rows: any[], baseDateISO?: string): TemplateEntry[] => { + const baseDate = baseDateISO ? new Date(baseDateISO) : new Date(rows[0]?.entry_date ?? new Date()); + return rows.map((e) => { + const d = new Date(e.entry_date); + const offset = Math.round((d.getTime() - baseDate.getTime()) / (1000 * 60 * 60 * 24)); + return { + day_offset: offset, + description: e.description ?? null, + account: e.account ?? null, + transaction_type: e.transaction_type ?? "adjustment", + assess: num(e.assess), + late: num(e.late), + admin: num(e.admin), + legal: num(e.legal), + viol: num(e.viol), + interest: num(e.interest), + bank: num(e.bank), + payment: num(e.payment), + }; + }); + }; + + // ─── Save current ledger as template + const handleSave = async () => { + if (!tplName.trim()) { toast.error("Name is required"); return; } + if (!currentEntries.length) { toast.error("Nothing to save — ledger is empty"); return; } + setBusy(true); + try { + const earliest = currentEntries + .map((e) => e.entry_date) + .sort()[0]; + const entries = buildTemplateEntries(currentEntries, earliest); + const { error } = await (supabase.from("ledger_templates" as any) as any).insert({ + name: tplName.trim(), + description: tplDesc.trim() || null, + entries, + created_by: user?.id, + }); + if (error) { toast.error(error.message); return; } + toast.success(`Saved template "${tplName}" with ${entries.length} entries`); + onOpenChange(false); + } finally { setBusy(false); } + }; + + // ─── Insert rows into THIS collection + const insertRows = async ( + source: TemplateEntry[] | any[], + isTemplate: boolean, + replaceExisting: boolean, + ) => { + if (!source.length) { toast.error("Nothing to apply"); return; } + setBusy(true); + try { + if (replaceExisting) { + const { error: delErr } = await supabase + .from("collection_ledger_entries") + .delete() + .eq("collection_id", collectionId); + if (delErr) { toast.error(`Delete failed: ${delErr.message}`); return; } + } + + // Build new rows for THIS collection + const today = new Date(); + const todayISO = today.toISOString().slice(0, 10); + let sortBase = 0; + // get current max sort_order if appending + if (!replaceExisting) { + const { data } = await supabase + .from("collection_ledger_entries") + .select("sort_order") + .eq("collection_id", collectionId) + .order("sort_order", { ascending: false }) + .limit(1); + sortBase = Number((data as any[])?.[0]?.sort_order ?? 0); + } + + let earliestSourceDate: Date | null = null; + if (!isTemplate) { + const dates = (source as any[]) + .map((e) => new Date(e.entry_date)) + .filter((d) => !isNaN(d.getTime())) + .sort((a, b) => a.getTime() - b.getTime()); + earliestSourceDate = dates[0] ?? null; + } + + const rows = source.map((e: any, i: number) => { + let entryDate: string; + if (isTemplate) { + const d = new Date(today); + d.setDate(d.getDate() + (Number(e.day_offset) || 0)); + entryDate = d.toISOString().slice(0, 10); + } else if (earliestSourceDate) { + const src = new Date(e.entry_date); + const offset = Math.round((src.getTime() - earliestSourceDate.getTime()) / (1000 * 60 * 60 * 24)); + const d = new Date(today); + d.setDate(d.getDate() + offset); + entryDate = d.toISOString().slice(0, 10); + } else { + entryDate = todayISO; + } + return { + collection_id: collectionId, + entry_date: entryDate, + description: e.description ?? null, + account: e.account ?? null, + transaction_type: e.transaction_type ?? "adjustment", + assess: num(e.assess), + late: num(e.late), + admin: num(e.admin), + legal: num(e.legal), + viol: num(e.viol), + interest: num(e.interest), + bank: num(e.bank), + payment: num(e.payment), + sort_order: sortBase + (i + 1) * 10, + created_by: user?.id, + }; + }); + + const { error } = await supabase + .from("collection_ledger_entries") + .insert(rows); + if (error) { toast.error(error.message); return; } + toast.success(`Added ${rows.length} entries`); + onApplied(); + onOpenChange(false); + } finally { setBusy(false); } + }; + + const handleApply = async () => { + const tpl = templates.find((t) => t.id === selectedTpl); + if (!tpl) { toast.error("Pick a template"); return; } + await insertRows(tpl.entries ?? [], true, replaceOnApply); + }; + + const handleCopy = async () => { + if (!selectedCol) { toast.error("Pick a ledger to copy from"); return; } + const { data, error } = await supabase + .from("collection_ledger_entries") + .select("*") + .eq("collection_id", selectedCol) + .order("sort_order", { ascending: true }) + .order("entry_date", { ascending: true }); + if (error) { toast.error(error.message); return; } + await insertRows((data as any[]) ?? [], false, replaceOnCopy); + }; + + const handleDeleteTemplate = async (id: string) => { + if (!confirm("Delete this template?")) return; + const { error } = await supabase.from("ledger_templates" as any).delete().eq("id", id); + if (error) { toast.error(error.message); return; } + setTemplates((prev) => prev.filter((t) => t.id !== id)); + if (selectedTpl === id) setSelectedTpl(""); + toast.success("Template deleted"); + }; + + return ( + + + + Ledger templates + + Save the current ledger as a reusable template, apply a saved template, or copy entries from another homeowner ledger. + + + + setMode(v as Mode)}> + + Apply template + Copy from ledger + Save as template + + + {/* Apply */} + + {templates.length === 0 ? ( +

+ No templates yet. Save one from an existing ledger first. +

+ ) : ( + <> +
+ {templates.map((t) => ( + + ))} +
+ +

+ Dates are anchored to today using the original day offsets. +

+ + )} +
+ + {/* Copy */} + +
+ + +
+ +

+ The earliest source entry will be dated today; subsequent entries keep their relative spacing. +

+
+ + {/* Save */} + +
+ + setTplName(e.target.value)} placeholder="e.g. Standard delinquency cycle" /> +
+
+ +