diff --git a/bun.lockb b/bun.lockb index f3958de..e5a6a1c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 384fdf0..eb4a257 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ }, "dependencies": { "@cloudflare/vite-plugin": "^1.25.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "3.10.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/src/components/cases/collections-tab.tsx b/src/components/cases/collections-tab.tsx index c28c607..b3c95c1 100644 --- a/src/components/cases/collections-tab.tsx +++ b/src/components/cases/collections-tab.tsx @@ -52,6 +52,22 @@ import { num, withRunningBalance, } from "@/lib/ledger"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { SortableLedgerRow } from "./ledger-row"; const TXN_TYPES = [ { value: "assessment", label: "Assessment" }, @@ -349,12 +365,77 @@ export function CollectionDetail({ .from("collection_ledger_entries") .select("*") .eq("collection_id", collection.id) + .order("sort_order", { ascending: true }) .order("entry_date", { ascending: true }) .order("created_at", { ascending: true }); setEntries(data ?? []); setLoading(false); }; + // Inline-edit a single field (optimistic) + const patchEntry = async (id: string, patch: Record) => { + setEntries((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e))); + const { error } = await (supabase.from("collection_ledger_entries") as any) + .update(patch) + .eq("id", id); + if (error) { + toast.error("Could not save", { description: error.message }); + load(); + } else { + onChange(); + } + }; + + // Insert a blank row at the end (sort_order = max+10) + const addBlankRow = async () => { + const maxSort = entries.reduce((m: number, e: any) => Math.max(m, Number(e.sort_order) || 0), 0); + const today = new Date().toISOString().slice(0, 10); + const { data, error } = await supabase + .from("collection_ledger_entries") + .insert({ + collection_id: collection.id, + entry_date: today, + transaction_type: "adjustment", + sort_order: maxSort + 10, + created_by: user?.id, + }) + .select() + .single(); + if (error) { toast.error(error.message); return; } + setEntries((prev) => [...prev, data]); + }; + + // Drag-and-drop reorder + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const handleDragEnd = async (e: DragEndEvent) => { + const { active, over } = e; + if (!over || active.id === over.id) return; + const oldIdx = entries.findIndex((x: any) => x.id === active.id); + const newIdx = entries.findIndex((x: any) => x.id === over.id); + if (oldIdx < 0 || newIdx < 0) return; + const reordered = arrayMove(entries, oldIdx, newIdx).map((x: any, i: number) => ({ + ...x, + sort_order: (i + 1) * 10, + })); + setEntries(reordered); + // Persist new sort orders + const updates = reordered.map((x: any) => + (supabase.from("collection_ledger_entries") as any) + .update({ sort_order: x.sort_order }) + .eq("id", x.id), + ); + const results = await Promise.all(updates); + const failed = results.find((r: any) => r.error); + if (failed) { + toast.error("Reorder failed", { description: failed.error.message }); + load(); + } + }; + + useEffect(() => { load(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -622,73 +703,71 @@ export function CollectionDetail({ - - - - - - - - - - - + + + + + + + + + + + + - - {opening !== 0 && ( - - - - - - - - - - )} - {withRunning.length === 0 && opening === 0 && ( - - )} - {withRunning.map((e: any) => ( - - - - - - - - - - - - - - - - ))} - - - - - - - - - - - - - - + + e.id)} + strategy={verticalListSortingStrategy} + > + + {opening !== 0 && ( + + + + + + + + + + + )} + {withRunning.length === 0 && opening === 0 && ( + + )} + {withRunning.map((e: any) => ( + + ))} + + + + + + + + + + + + + + + +
DateDescriptionAccountAssess ($)Late ($)Admin ($)Legal ($)Viol ($)Int ($)Bank ($)Pay (AR)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)}
{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.
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)}
)} @@ -706,6 +785,9 @@ export function CollectionDetail({ + diff --git a/src/components/cases/ledger-row.tsx b/src/components/cases/ledger-row.tsx new file mode 100644 index 0000000..e104fe3 --- /dev/null +++ b/src/components/cases/ledger-row.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { BUCKETS, num } from "@/lib/ledger"; +import { formatCurrency } from "@/lib/format"; + +export interface EditableEntry { + 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; + runningBalance?: number; +} + +interface Props { + entry: EditableEntry; + onPatch: (id: string, patch: Partial) => void | Promise; + onRemove: (id: string) => void; +} + +const NUM_COLS: { key: keyof EditableEntry; danger?: boolean; positive?: boolean }[] = [ + { key: "assess" }, + { key: "late" }, + { key: "admin" }, + { key: "legal" }, + { key: "viol" }, + { key: "interest" }, + { key: "bank", danger: true }, + { key: "payment", positive: true }, +]; + +export function SortableLedgerRow({ entry, onPatch, onRemove }: Props) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: entry.id, + }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + backgroundColor: isDragging ? "hsl(var(--muted))" : undefined, + }; + + // Local field state (for typing without re-render thrashing) — committed on blur + const [draft, setDraft] = useState(entry); + useEffect(() => { + setDraft(entry); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entry.id, entry.entry_date, entry.description, entry.account, + entry.assess, entry.late, entry.admin, entry.legal, + entry.viol, entry.interest, entry.bank, entry.payment]); + + const commit = (key: keyof EditableEntry, raw: string | number) => { + let val: any = raw; + if (NUM_COLS.some((c) => c.key === key)) val = raw === "" ? 0 : Number(raw) || 0; + if (val === (entry as any)[key]) return; + setDraft((d) => ({ ...d, [key]: val })); + onPatch(entry.id, { [key]: val } as any); + }; + + return ( + + + + + + setDraft((d) => ({ ...d, entry_date: e.target.value }))} + onBlur={(e) => commit("entry_date", e.target.value)} + className="w-full bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 text-xs outline-none" + /> + + + setDraft((d) => ({ ...d, description: e.target.value }))} + onBlur={(e) => commit("description", e.target.value)} + placeholder="—" + className="w-full bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 text-sm outline-none min-w-[180px]" + /> + + + setDraft((d) => ({ ...d, account: e.target.value }))} + onBlur={(e) => commit("account", e.target.value)} + placeholder="—" + className="w-full bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 text-xs text-muted-foreground capitalize outline-none min-w-[80px]" + /> + + {NUM_COLS.map(({ key, danger, positive }) => { + const v = num((draft as any)[key]); + return ( + + setDraft((d) => ({ ...d, [key]: e.target.value === "" ? 0 : Number(e.target.value) || 0 }))} + onBlur={(e) => commit(key, e.target.value)} + placeholder="0.00" + className={`w-20 text-right font-mono text-sm bg-transparent border border-transparent hover:border-border focus:border-ring rounded px-1.5 py-1 outline-none ${ + v === 0 + ? "text-muted-foreground/40 placeholder:text-muted-foreground/40" + : danger + ? "text-destructive" + : positive + ? "text-emerald-600" + : "" + }`} + /> + + ); + })} + 0 ? "" : (entry.runningBalance ?? 0) < 0 ? "text-emerald-600" : "text-muted-foreground" + }`}> + {formatCurrency(entry.runningBalance ?? 0)} + + + + + + ); +} + +export const LEDGER_NUM_COLS = NUM_COLS; +export { BUCKETS }; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 1432806..7d68dbc 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -221,6 +221,7 @@ export type Database = { late: number legal: number payment: number + sort_order: number transaction_type: string updated_at: string viol: number @@ -242,6 +243,7 @@ export type Database = { late?: number legal?: number payment?: number + sort_order?: number transaction_type: string updated_at?: string viol?: number @@ -263,6 +265,7 @@ export type Database = { late?: number legal?: number payment?: number + sort_order?: number transaction_type?: string updated_at?: string viol?: number diff --git a/supabase/migrations/20260417015724_210748c1-113b-4c0d-883b-6a7ce98b67f7.sql b/supabase/migrations/20260417015724_210748c1-113b-4c0d-883b-6a7ce98b67f7.sql new file mode 100644 index 0000000..b51c9df --- /dev/null +++ b/supabase/migrations/20260417015724_210748c1-113b-4c0d-883b-6a7ce98b67f7.sql @@ -0,0 +1,11 @@ +ALTER TABLE public.collection_ledger_entries ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS collection_ledger_entries_collection_sort_idx ON public.collection_ledger_entries (collection_id, sort_order, entry_date, created_at); +-- Backfill: assign sort_order based on existing chronological order +WITH ordered AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY collection_id ORDER BY entry_date, created_at) * 10 AS new_order + FROM public.collection_ledger_entries +) +UPDATE public.collection_ledger_entries e +SET sort_order = o.new_order +FROM ordered o +WHERE e.id = o.id AND e.sort_order = 0; \ No newline at end of file