From f085202d68f8f4c2871ca29a1f3d9d1100ce7a3d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:48:35 +0000 Subject: [PATCH 1/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 75 +++++++++++++++++++ ...2_66f77211-1c95-4974-b591-7e7c674a5987.sql | 58 ++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 supabase/migrations/20260417014832_66f77211-1c95-4974-b591-7e7c674a5987.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 9d6560c..1432806 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -205,6 +205,10 @@ export type Database = { } collection_ledger_entries: { Row: { + account: string | null + admin: number + assess: number + bank: number collection_id: string created_at: string created_by: string | null @@ -213,10 +217,19 @@ export type Database = { description: string | null entry_date: string id: string + interest: number + late: number + legal: number + payment: number transaction_type: string updated_at: string + viol: number } Insert: { + account?: string | null + admin?: number + assess?: number + bank?: number collection_id: string created_at?: string created_by?: string | null @@ -225,10 +238,19 @@ export type Database = { description?: string | null entry_date?: string id?: string + interest?: number + late?: number + legal?: number + payment?: number transaction_type: string updated_at?: string + viol?: number } Update: { + account?: string | null + admin?: number + assess?: number + bank?: number collection_id?: string created_at?: string created_by?: string | null @@ -237,8 +259,13 @@ export type Database = { description?: string | null entry_date?: string id?: string + interest?: number + late?: number + legal?: number + payment?: number transaction_type?: string updated_at?: string + viol?: number } Relationships: [ { @@ -250,6 +277,48 @@ export type Database = { }, ] } + collection_payment_allocations: { + Row: { + amount: number + bucket: string + collection_id: string + created_at: string + entry_id: string + id: string + } + Insert: { + amount?: number + bucket: string + collection_id: string + created_at?: string + entry_id: string + id?: string + } + Update: { + amount?: number + bucket?: string + collection_id?: string + created_at?: string + entry_id?: string + id?: string + } + Relationships: [ + { + foreignKeyName: "collection_payment_allocations_collection_id_fkey" + columns: ["collection_id"] + isOneToOne: false + referencedRelation: "collections" + referencedColumns: ["id"] + }, + { + foreignKeyName: "collection_payment_allocations_entry_id_fkey" + columns: ["entry_id"] + isOneToOne: false + referencedRelation: "collection_ledger_entries" + referencedColumns: ["id"] + }, + ] + } collection_tasks: { Row: { assignee_id: string | null @@ -391,6 +460,8 @@ export type Database = { current_stage: string | null homeowner_id: string id: string + interest_rate_override: number | null + name: string | null notes: string | null opened_at: string status: string @@ -404,6 +475,8 @@ export type Database = { current_stage?: string | null homeowner_id: string id?: string + interest_rate_override?: number | null + name?: string | null notes?: string | null opened_at?: string status?: string @@ -417,6 +490,8 @@ export type Database = { current_stage?: string | null homeowner_id?: string id?: string + interest_rate_override?: number | null + name?: string | null notes?: string | null opened_at?: string status?: string diff --git a/supabase/migrations/20260417014832_66f77211-1c95-4974-b591-7e7c674a5987.sql b/supabase/migrations/20260417014832_66f77211-1c95-4974-b591-7e7c674a5987.sql new file mode 100644 index 0000000..b75b068 --- /dev/null +++ b/supabase/migrations/20260417014832_66f77211-1c95-4974-b591-7e7c674a5987.sql @@ -0,0 +1,58 @@ +-- Add category columns to ledger entries +ALTER TABLE public.collection_ledger_entries + ADD COLUMN IF NOT EXISTS assess numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS late numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS admin numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS legal numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS viol numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS interest numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS bank numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS payment numeric NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS account text; + +-- Backfill: split existing debit/credit into the appropriate column based on transaction_type +UPDATE public.collection_ledger_entries +SET + assess = CASE WHEN transaction_type = 'assessment' THEN COALESCE(debit,0) ELSE 0 END, + late = CASE WHEN transaction_type = 'late_fee' THEN COALESCE(debit,0) ELSE 0 END, + admin = CASE WHEN transaction_type = 'admin_fee' THEN COALESCE(debit,0) ELSE 0 END, + legal = CASE WHEN transaction_type = 'legal_fee' THEN COALESCE(debit,0) ELSE 0 END, + viol = CASE WHEN transaction_type = 'violation' THEN COALESCE(debit,0) ELSE 0 END, + interest = CASE WHEN transaction_type = 'interest' THEN COALESCE(debit,0) ELSE 0 END, + bank = CASE WHEN transaction_type IN ('bank_fee') THEN COALESCE(debit,0) ELSE 0 END, + payment = CASE WHEN transaction_type IN ('payment','adjustment') THEN COALESCE(credit,0) ELSE 0 END, + account = transaction_type +WHERE assess = 0 AND late = 0 AND admin = 0 AND legal = 0 AND viol = 0 AND interest = 0 AND bank = 0 AND payment = 0; + +-- Add ledger metadata to collections +ALTER TABLE public.collections + ADD COLUMN IF NOT EXISTS name text, + ADD COLUMN IF NOT EXISTS interest_rate_override numeric; + +-- Payment allocations: how a payment row is applied across priority buckets +CREATE TABLE IF NOT EXISTS public.collection_payment_allocations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + entry_id uuid NOT NULL REFERENCES public.collection_ledger_entries(id) ON DELETE CASCADE, + collection_id uuid NOT NULL REFERENCES public.collections(id) ON DELETE CASCADE, + bucket text NOT NULL, -- 'bank' | 'interest' | 'late' | 'legal' | 'admin' | 'viol' | 'assess' + amount numeric NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.collection_payment_allocations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "cpa_select_case" ON public.collection_payment_allocations + FOR SELECT TO authenticated USING ( + EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND can_access_case(c.case_id, auth.uid())) + ); +CREATE POLICY "cpa_insert_case" ON public.collection_payment_allocations + FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND can_access_case(c.case_id, auth.uid())) + ); +CREATE POLICY "cpa_delete_case" ON public.collection_payment_allocations + FOR DELETE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND can_access_case(c.case_id, auth.uid())) + ); + +CREATE INDEX IF NOT EXISTS idx_cpa_entry ON public.collection_payment_allocations(entry_id); +CREATE INDEX IF NOT EXISTS idx_cpa_collection ON public.collection_payment_allocations(collection_id); From da390accae1748bbba378c5b7a58b7fd0250f658 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:49:02 +0000 Subject: [PATCH 2/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/ledger.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/lib/ledger.ts diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts new file mode 100644 index 0000000..a937f60 --- /dev/null +++ b/src/lib/ledger.ts @@ -0,0 +1,95 @@ +// Priority order for payment allocation: Bank → Interest → Late → Legal → Admin → Violations → Assessments +export const BUCKETS = ["bank", "interest", "late", "legal", "admin", "viol", "assess"] as const; +export type Bucket = (typeof BUCKETS)[number]; + +export const BUCKET_LABEL: Record = { + bank: "Bank Fees", + interest: "Interest", + late: "Late Fees", + legal: "Legal Fees", + admin: "Admin Fees", + viol: "Violations", + assess: "Assessments", +}; + +export const BUCKET_SHORT: Record = { + bank: "Bank", + interest: "Int", + late: "Late", + legal: "Legal", + admin: "Admin", + viol: "Viol", + assess: "Assess", +}; + +export interface LedgerEntry { + id: string; + collection_id: string; + entry_date: string; + description: string | null; + account: string | null; + assess: number; + late: number; + admin: number; + legal: number; + viol: number; + interest: number; + bank: number; + payment: number; + debit?: number; + credit?: number; + transaction_type?: string; +} + +export const num = (v: any) => Number(v) || 0; + +/** Sum of all charge columns for one row */ +export const rowCharges = (e: Pick) => + BUCKETS.reduce((s, b) => s + num((e as any)[b]), 0); + +/** Compute live remaining balance per bucket given chronological entries (FIFO payment allocation by priority). */ +export function computeBucketBalances( + entries: LedgerEntry[], + opening = 0, +): Record & { total: number } { + const bal: Record = { + bank: 0, + interest: 0, + late: 0, + legal: 0, + admin: 0, + viol: 0, + assess: 0, + }; + // Treat opening balance as outstanding assessments + bal.assess += opening; + + for (const e of entries) { + BUCKETS.forEach((b) => { + bal[b] += num((e as any)[b]); + }); + let pay = num(e.payment); + if (pay > 0) { + for (const b of BUCKETS) { + if (pay <= 0) break; + if (bal[b] <= 0) continue; + const take = Math.min(bal[b], pay); + bal[b] -= take; + pay -= take; + } + // any leftover credit goes against assessments (overpayment) + if (pay > 0) bal.assess -= pay; + } + } + const total = BUCKETS.reduce((s, b) => s + bal[b], 0); + return { ...bal, total }; +} + +/** Running balance per row */ +export function withRunningBalance(entries: LedgerEntry[], opening = 0) { + let bal = opening; + return entries.map((e) => { + bal += rowCharges(e) - num(e.payment); + return { ...e, runningBalance: bal }; + }); +} From d0c860b8feac7ac650d58153b3181b6f69e05dac Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:50:23 +0000 Subject: [PATCH 3/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 593 +++++++++++++---------- 1 file changed, 344 insertions(+), 249 deletions(-) diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index fc9a269..099d2f8 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -302,7 +302,7 @@ export function CaseCollectionsTab({ export function CollectionDetail({ collection, annualRate, - currentBalance, + currentBalance: _currentBalance, onBack, onChange, }: { @@ -315,8 +315,23 @@ export function CollectionDetail({ const { user } = useAuth(); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); - const [postOpen, setPostOpen] = useState(false); - const [interestOpen, setInterestOpen] = useState(false); + const [chargeOpen, setChargeOpen] = useState(false); + const [paymentOpen, setPaymentOpen] = useState(false); + const [bankOpen, setBankOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + + // Editable header fields + const [ledgerName, setLedgerName] = useState(collection.name ?? ""); + const [rateOverride, setRateOverride] = useState( + collection.interest_rate_override != null + ? String(collection.interest_rate_override) + : "", + ); + + const effectiveRate = + rateOverride && !isNaN(parseFloat(rateOverride)) + ? parseFloat(rateOverride) + : (annualRate ?? 0); const load = async () => { setLoading(true); @@ -336,42 +351,38 @@ export function CollectionDetail({ }, [collection.id]); const opening = Number(collection.homeowner?.opening_balance ?? 0); + const ho = collection.homeowner; - const withRunning = useMemo(() => { - let bal = opening; - return entries.map((e) => { - bal += Number(e.debit) - Number(e.credit); - return { ...e, runningBalance: bal }; - }); - }, [entries, opening]); + const computed = useMemo( + () => computeBucketBalances(entries as any, opening), + [entries, opening], + ); + const withRunning = useMemo( + () => withRunningBalance(entries as any, opening), + [entries, opening], + ); - // Unpaid assessment balance for interest calc — assessments minus payments+adjustments applied - const unpaidAssessmentBalance = useMemo(() => { - let assessments = 0; - let payments = 0; - entries.forEach((e) => { - const t = String(e.transaction_type || "").toLowerCase(); - const debit = Number(e.debit) || 0; - const credit = Number(e.credit) || 0; - if (PAYMENT_TYPES.has(t)) { - payments += credit - debit; - } else if (t === "assessment") { - assessments += debit - credit; - } + const totals = useMemo(() => { + const t: Record = { + assess: 0, late: 0, admin: 0, legal: 0, viol: 0, interest: 0, bank: 0, payment: 0, + }; + entries.forEach((e: any) => { + BUCKETS.forEach((b) => (t[b] += num(e[b]))); + t.payment += num(e.payment); }); - return Math.max(0, assessments - payments); + return t; }, [entries]); + // Save header fields (debounced manual save) + const saveHeader = async (patch: Record) => { + const { error } = await supabase.from("collections").update(patch).eq("id", collection.id); + if (error) toast.error("Could not save", { description: error.message }); + else onChange(); + }; + const updateStatus = async (status: string) => { - const { error } = await supabase - .from("collections") - .update({ status }) - .eq("id", collection.id); - if (error) toast.error("Could not update status", { description: error.message }); - else { - toast.success("Status updated"); - onChange(); - } + await saveHeader({ status }); + toast.success("Status updated"); }; const removeEntry = async (id: string) => { @@ -388,37 +399,114 @@ export function CollectionDetail({ } }; - const exportCSV = () => { + const calcInterestRow = async () => { + if (!effectiveRate || effectiveRate <= 0) { + toast.error("Set an interest rate first"); + return; + } + const monthlyRate = effectiveRate / 100 / 12; + const assessOutstanding = computed.assess; // live unpaid assessment balance + const amount = Math.round(assessOutstanding * monthlyRate * 100) / 100; + if (amount <= 0) { + toast.info("Nothing to post — no unpaid assessments."); + return; + } + const today = new Date().toISOString().slice(0, 10); + const { error } = await supabase.from("collection_ledger_entries").insert({ + collection_id: collection.id, + entry_date: today, + transaction_type: "interest", + account: "interest", + description: `Interest @ ${effectiveRate}% per annum on $${assessOutstanding.toFixed(2)}`, + interest: amount, + created_by: user?.id, + }); + if (error) toast.error(error.message); + else { toast.success(`Posted ${formatCurrency(amount)} interest`); load(); onChange(); } + }; + + const exportStatement = () => { const rows = [ - ["Date", "Type", "Description", "Charge", "Payment", "Balance"].join(","), - ...withRunning.map((e) => - [ - e.entry_date, - txnLabel(e.transaction_type), - `"${(e.description || "").replace(/"/g, '""')}"`, - Number(e.debit).toFixed(2), - Number(e.credit).toFixed(2), - e.runningBalance.toFixed(2), - ].join(","), - ), + ["Date", "Description", "Account", "Assess", "Late", "Admin", "Legal", "Viol", "Int", "Bank", "Payment", "Balance"].join(","), + ...withRunning.map((e: any) => [ + e.entry_date, + `"${(e.description || "").replace(/"/g, '""')}"`, + e.account || e.transaction_type || "", + num(e.assess).toFixed(2), + num(e.late).toFixed(2), + num(e.admin).toFixed(2), + num(e.legal).toFixed(2), + num(e.viol).toFixed(2), + num(e.interest).toFixed(2), + num(e.bank).toFixed(2), + num(e.payment).toFixed(2), + e.runningBalance.toFixed(2), + ].join(",")), ].join("\n"); const blob = new Blob([rows], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); - a.download = `Ledger_${collection.homeowner?.last_name || "homeowner"}.csv`; + a.download = `Statement_${ho?.last_name || "homeowner"}.csv`; a.click(); }; - const ho = collection.homeowner; + const loadFromUnitLedger = async () => { + // Re-pull homeowner opening balance and any other future "saved ledger" data + const { data, error } = await supabase + .from("homeowners").select("opening_balance").eq("id", collection.homeowner_id).single(); + if (error) { toast.error(error.message); return; } + toast.success(`Unit opening balance: ${formatCurrency(Number(data.opening_balance) || 0)}`); + onChange(); + }; + + const syncAutomatedHistory = async () => { + if (!confirm("Re-calculate all auto-posted interest from scratch? This will delete prior interest entries and re-apply monthly interest.")) return; + if (!effectiveRate || effectiveRate <= 0) { toast.error("Set an interest rate first"); return; } + // Delete existing interest rows + const { error: delErr } = await supabase + .from("collection_ledger_entries").delete() + .eq("collection_id", collection.id).eq("transaction_type", "interest"); + if (delErr) { toast.error(delErr.message); return; } + // Recompute month-by-month from opened_at to today + const start = new Date(collection.opened_at); + const end = new Date(); + const monthlyRate = effectiveRate / 100 / 12; + // Reload entries (without interest) to compute outstanding per month + const { data: fresh } = await supabase + .from("collection_ledger_entries").select("*") + .eq("collection_id", collection.id) + .order("entry_date", { ascending: true }); + const list: any[] = fresh ?? []; + const cursor = new Date(start); + cursor.setDate(1); + const newRows: any[] = []; + while (cursor <= end) { + const cutoff = new Date(cursor); cutoff.setMonth(cutoff.getMonth() + 1); + const upto = list.filter((e) => new Date(e.entry_date) < cutoff); + const bal = computeBucketBalances(upto as any, opening).assess; + const amt = Math.round(bal * monthlyRate * 100) / 100; + if (amt > 0) { + newRows.push({ + collection_id: collection.id, + entry_date: cutoff.toISOString().slice(0, 10), + transaction_type: "interest", account: "interest", + description: `Auto interest @ ${effectiveRate}% / yr on $${bal.toFixed(2)}`, + interest: amt, created_by: user?.id, + }); + } + cursor.setMonth(cursor.getMonth() + 1); + } + if (newRows.length) { + const { error } = await supabase.from("collection_ledger_entries").insert(newRows); + if (error) { toast.error(error.message); return; } + } + toast.success(`Synced — posted ${newRows.length} interest entries`); + load(); onChange(); + }; return (
- @@ -433,220 +521,227 @@ export function CollectionDetail({ {ho?.email && ` · ${ho.email}`}

-
- - - -
- - -
- - -
- Current balance -
-
0 - ? "text-destructive font-semibold" - : currentBalance < 0 - ? "text-emerald-600" - : "" - }`} - > - {formatCurrency(Math.abs(currentBalance))} - {currentBalance < 0 ? " CR" : currentBalance > 0 ? " DUE" : ""} -
-
-
- - -
- Unpaid assessments -
-
- {formatCurrency(unpaidAssessmentBalance)} -
-
-
- - -
- Ledger entries -
-
{entries.length}
-
-
+
+ {/* Header controls */} - - {loading ? ( -
- Loading… + +
+
+ + setLedgerName(e.target.value)} + onBlur={() => saveHeader({ name: ledgerName || null })} + />
- ) : withRunning.length === 0 ? ( -
- No ledger entries yet. Post an entry to get started. +
+ + setRateOverride(e.target.value)} + onBlur={() => saveHeader({ interest_rate_override: rateOverride === "" ? null : parseFloat(rateOverride) })} + /> +

Interest is calculated on the Assessment balance.

- ) : ( - - - - Date - Type - Description - Charge - Payment - Balance - - - - - {opening !== 0 && ( - - - {formatDate(collection.opened_at)} - - - - Opening - - - - Opening balance - - - {opening > 0 ? formatCurrency(opening) : "—"} - - - {opening < 0 ? formatCurrency(-opening) : "—"} - - - {formatCurrency(Math.abs(opening))} - {opening < 0 ? " CR" : ""} - - - - )} - {withRunning.map((e) => ( - - - {formatDate(e.entry_date)} - - - - {txnLabel(e.transaction_type)} - - - - {e.description || "—"} - - - {Number(e.debit) > 0 ? ( - - {formatCurrency(Number(e.debit))} - - ) : ( - "—" - )} - - - {Number(e.credit) > 0 ? ( - - {formatCurrency(Number(e.credit))} - - ) : ( - "—" - )} - - 0 - ? "text-destructive" - : e.runningBalance < 0 - ? "text-emerald-600" - : "" - }`} - > - {formatCurrency(Math.abs(e.runningBalance))} - {e.runningBalance < 0 ? " CR" : ""} - - - - - - ))} - -
- )} +
+ + +
+
+
+ + +
-
- + {/* Total Due + Breakdown */} +
+ + +
Total Due
+
{formatCurrency(Math.max(0, computed.total))}
+
+ {computed.total > 0 ? "Outstanding Balance" : computed.total < 0 ? `Credit: ${formatCurrency(-computed.total)}` : "Paid in full"} +
+
+
+ + +
Amount Due Breakdown (in priority order)
+
+ {BUCKETS.map((b, i) => { + const v = computed[b]; + const active = v > 0; + return ( +
+
+ {i + 1}. {BUCKET_LABEL[b]} +
+
+ {formatCurrency(Math.max(0, v))} +
+
+ ); + })} +
+
+
- { - load(); - onChange(); - }} - /> + {/* Ledger table */} + + + {loading ? ( +
Loading…
+ ) : ( + + + + + + + + + + + + + + + + + + + + {opening !== 0 && ( + + + + + + + + + + )} + {withRunning.length === 0 && opening === 0 && ( + + )} + {withRunning.map((e: any) => ( + + + + + + + + + + + + + + + + ))} + + + + + + + + + + + + + + +
DateDescriptionAccountAssess ($)Late ($)Admin ($)Legal ($)Viol ($)Int ($)Bank ($)Pay (AR)Balance
{formatDate(collection.opened_at)}Opening balanceopening{opening > 0 ? opening.toFixed(2) : "0.00"}0.00{formatCurrency(opening)}
No ledger entries yet. Use the buttons below to add one.
{formatDate(e.entry_date)}{e.description || "—"}{e.account || e.transaction_type || "—"} 0 ? "" : e.runningBalance < 0 ? "text-emerald-600" : "text-muted-foreground"}`}> + {formatCurrency(e.runningBalance)} + + +
Totals:{formatCurrency(totals.assess)}{formatCurrency(totals.late)}{formatCurrency(totals.admin)}{formatCurrency(totals.legal)}{formatCurrency(totals.viol)}{formatCurrency(totals.interest)}{formatCurrency(totals.bank)}{formatCurrency(totals.payment)}{formatCurrency(computed.total)}
+ )} - + + + + +
+ + + + { - load(); - onChange(); - }} + onSaved={() => { load(); onChange(); }} + /> + { load(); onChange(); }} + /> + { load(); onChange(); }} + /> + { load(); onChange(); }} />
); } +function BucketCell({ value, danger, positive }: { value: number; danger?: boolean; positive?: boolean }) { + if (!value) return 0.00; + return ( + + {value.toFixed(2)} + + ); +} + + // ─── Add collection dialog ────────────────────────────────────────── function AddCollectionDialog({ open, From b0e58bb513a2e97c07d8cff914fb201c8976c441 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:50:36 +0000 Subject: [PATCH 4/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index 099d2f8..c4c9d18 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -35,13 +35,23 @@ import { ArrowLeft, Calculator, ChevronRight, + Download, Loader2, Plus, + RefreshCw, Trash2, + Upload, UserPlus, Users, } from "lucide-react"; import { toast } from "sonner"; +import { + BUCKETS, + BUCKET_LABEL, + computeBucketBalances, + num, + withRunningBalance, +} from "@/lib/ledger"; const TXN_TYPES = [ { value: "assessment", label: "Assessment" }, @@ -375,7 +385,7 @@ export function CollectionDetail({ // Save header fields (debounced manual save) const saveHeader = async (patch: Record) => { - const { error } = await supabase.from("collections").update(patch).eq("id", collection.id); + const { error } = await (supabase.from("collections") as any).update(patch).eq("id", collection.id); if (error) toast.error("Could not save", { description: error.message }); else onChange(); }; From 13b6765214fbe8b6dba9fd2b6afb23110772d599 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:51:29 +0000 Subject: [PATCH 5/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/collections-tab.tsx | 380 ++++++++++------------- 1 file changed, 170 insertions(+), 210 deletions(-) diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index c4c9d18..c28c607 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -1045,266 +1045,226 @@ function NewHomeownerDialog({ ); } -// ─── Post ledger entry dialog ─────────────────────────────────────── -function PostEntryDialog({ - open, - onOpenChange, - collectionId, - userId, - onSaved, -}: { - open: boolean; - onOpenChange: (v: boolean) => void; - collectionId: string; - userId?: string; - onSaved: () => void; -}) { - const [form, setForm] = useState({ - entry_date: new Date().toISOString().slice(0, 10), - transaction_type: "assessment", - description: "", - amount: "", - }); +// ─── Add Charge dialog (multi-bucket) ─────────────────────────────── +const CHARGE_BUCKETS: { key: typeof BUCKETS[number]; label: string }[] = [ + { key: "assess", label: "Assessment" }, + { key: "late", label: "Late Fee" }, + { key: "admin", label: "Admin Fee" }, + { key: "legal", label: "Legal Fee" }, + { key: "viol", label: "Violation Fine" }, + { key: "interest", label: "Interest" }, +]; + +function AddChargeDialog({ + open, onOpenChange, collectionId, userId, onSaved, +}: { open: boolean; onOpenChange: (v: boolean) => void; collectionId: string; userId?: string; onSaved: () => void }) { + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [description, setDescription] = useState(""); + const [account, setAccount] = useState("assess"); + const [amount, setAmount] = useState(""); const [submitting, setSubmitting] = useState(false); useEffect(() => { - if (open) { - setForm({ - entry_date: new Date().toISOString().slice(0, 10), - transaction_type: "assessment", - description: "", - amount: "", - }); - } + if (open) { setDate(new Date().toISOString().slice(0, 10)); setDescription(""); setAccount("assess"); setAmount(""); } }, [open]); - const isPayment = PAYMENT_TYPES.has(form.transaction_type); - const submit = async () => { - const amount = parseFloat(form.amount); - if (isNaN(amount) || amount <= 0) { - toast.error("Enter a valid amount"); - return; - } + const amt = parseFloat(amount); + if (isNaN(amt) || amt <= 0) { toast.error("Enter an amount"); return; } setSubmitting(true); - const { error } = await supabase - .from("collection_ledger_entries") - .insert({ - collection_id: collectionId, - entry_date: form.entry_date, - transaction_type: form.transaction_type, - description: form.description || null, - debit: isPayment ? 0 : amount, - credit: isPayment ? amount : 0, - created_by: userId, - }); + const row: any = { + collection_id: collectionId, entry_date: date, + transaction_type: account, account, description: description || null, + created_by: userId, + }; + row[account] = amt; + const { error } = await supabase.from("collection_ledger_entries").insert(row); setSubmitting(false); - if (error) { - toast.error("Could not post", { description: error.message }); - return; - } - toast.success("Entry posted"); - onOpenChange(false); - onSaved(); + if (error) { toast.error(error.message); return; } + toast.success("Charge posted"); onOpenChange(false); onSaved(); }; return ( - - Post ledger entry - + Add Charge (Debit)
+
setDate(e.target.value)} />
- - - setForm({ ...form, entry_date: e.target.value }) - } - /> -
-
- - setAccount(v as any)}> + - {TXN_TYPES.map((t) => ( - - {t.label} - - ))} + {CHARGE_BUCKETS.map((b) => ({b.label}))}
-
- - setForm({ ...form, amount: e.target.value })} - /> -
-
- -