Added inline sortable rows

X-Lovable-Edit-ID: edt-3558b344-d17a-45e0-975c-ed848847f0cb
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 01:59:29 +00:00
co-authored by renee-png
6 changed files with 318 additions and 63 deletions
BIN
View File
Binary file not shown.
+3
View File
@@ -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",
+145 -63
View File
@@ -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<string, any>) => {
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({
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-3 py-2 font-medium">Date</th>
<th className="text-left px-3 py-2 font-medium">Description</th>
<th className="text-left px-3 py-2 font-medium">Account</th>
<th className="text-right px-2 py-2 font-medium">Assess&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Late&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Admin&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Legal&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Viol&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Int&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium text-destructive">Bank&nbsp;($)</th>
<th className="text-right px-2 py-2 font-medium">Pay&nbsp;(AR)</th>
<th className="w-8"></th>
<th className="text-left px-2 py-2 font-medium">Date</th>
<th className="text-left px-2 py-2 font-medium">Description</th>
<th className="text-left px-2 py-2 font-medium">Account</th>
<th className="text-right px-1 py-2 font-medium">Assess&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Late&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Admin&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Legal&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Viol&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Int&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium text-destructive">Bank&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Pay&nbsp;(AR)</th>
<th className="text-right px-3 py-2 font-medium">Balance</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody>
{opening !== 0 && (
<tr className="border-t bg-muted/20">
<td className="px-3 py-2 text-muted-foreground">{formatDate(collection.opened_at)}</td>
<td className="px-3 py-2 text-muted-foreground italic">Opening balance</td>
<td className="px-3 py-2 text-muted-foreground">opening</td>
<td className="px-2 py-2 text-right font-mono">{opening > 0 ? opening.toFixed(2) : "0.00"}</td>
<td colSpan={7} className="px-2 py-2 text-right font-mono text-muted-foreground">0.00</td>
<td className="px-3 py-2 text-right font-mono font-semibold">{formatCurrency(opening)}</td>
<td></td>
</tr>
)}
{withRunning.length === 0 && opening === 0 && (
<tr><td colSpan={13} className="text-center py-12 text-muted-foreground text-sm">No ledger entries yet. Use the buttons below to add one.</td></tr>
)}
{withRunning.map((e: any) => (
<tr key={e.id} className="border-t hover:bg-muted/20">
<td className="px-3 py-2 whitespace-nowrap">{formatDate(e.entry_date)}</td>
<td className="px-3 py-2">{e.description || "—"}</td>
<td className="px-3 py-2 text-muted-foreground capitalize">{e.account || e.transaction_type || "—"}</td>
<BucketCell value={num(e.assess)} />
<BucketCell value={num(e.late)} />
<BucketCell value={num(e.admin)} />
<BucketCell value={num(e.legal)} />
<BucketCell value={num(e.viol)} />
<BucketCell value={num(e.interest)} />
<BucketCell value={num(e.bank)} danger />
<BucketCell value={num(e.payment)} positive />
<td className={`px-3 py-2 text-right font-mono font-semibold ${e.runningBalance > 0 ? "" : e.runningBalance < 0 ? "text-emerald-600" : "text-muted-foreground"}`}>
{formatCurrency(e.runningBalance)}
</td>
<td className="px-2 py-2 text-right">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => removeEntry(e.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</td>
</tr>
))}
<tr className="border-t bg-muted/30 font-medium">
<td colSpan={3} className="px-3 py-2 text-right text-muted-foreground uppercase text-[11px] tracking-wider">Totals:</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.assess)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.late)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.admin)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.legal)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.viol)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.interest)}</td>
<td className="px-2 py-2 text-right font-mono text-destructive">{formatCurrency(totals.bank)}</td>
<td className="px-2 py-2 text-right font-mono">{formatCurrency(totals.payment)}</td>
<td className="px-3 py-2 text-right font-mono">{formatCurrency(computed.total)}</td>
<td></td>
</tr>
</tbody>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={withRunning.map((e: any) => e.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{opening !== 0 && (
<tr className="border-t bg-muted/20">
<td></td>
<td className="px-2 py-2 text-muted-foreground whitespace-nowrap">{formatDate(collection.opened_at)}</td>
<td className="px-2 py-2 text-muted-foreground italic">Opening balance</td>
<td className="px-2 py-2 text-muted-foreground">opening</td>
<td className="px-1 py-2 text-right font-mono">{opening > 0 ? opening.toFixed(2) : "0.00"}</td>
<td colSpan={7} className="px-1 py-2 text-right font-mono text-muted-foreground">0.00</td>
<td className="px-3 py-2 text-right font-mono font-semibold">{formatCurrency(opening)}</td>
<td></td>
</tr>
)}
{withRunning.length === 0 && opening === 0 && (
<tr><td colSpan={14} className="text-center py-12 text-muted-foreground text-sm">No ledger entries yet. Use the buttons below to add one.</td></tr>
)}
{withRunning.map((e: any) => (
<SortableLedgerRow
key={e.id}
entry={e}
onPatch={patchEntry}
onRemove={removeEntry}
/>
))}
<tr className="border-t bg-muted/30 font-medium">
<td colSpan={4} className="px-2 py-2 text-right text-muted-foreground uppercase text-[11px] tracking-wider">Totals:</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.assess)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.late)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.admin)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.legal)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.viol)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.interest)}</td>
<td className="px-1 py-2 text-right font-mono text-destructive">{formatCurrency(totals.bank)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.payment)}</td>
<td className="px-3 py-2 text-right font-mono">{formatCurrency(computed.total)}</td>
<td></td>
</tr>
</tbody>
</SortableContext>
</DndContext>
</table>
)}
@@ -706,6 +785,9 @@ export function CollectionDetail({
<Button variant="outline" size="sm" onClick={calcInterestRow} disabled={!effectiveRate || effectiveRate <= 0}>
<Calculator className="h-4 w-4 mr-1.5" /> Calculate Interest Row
</Button>
<Button variant="ghost" size="sm" onClick={addBlankRow}>
<Plus className="h-4 w-4 mr-1.5" /> Add Blank Row
</Button>
</div>
</CardContent>
</Card>
+156
View File
@@ -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<EditableEntry>) => void | Promise<void>;
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<EditableEntry>(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 (
<tr ref={setNodeRef} style={style} className="border-t hover:bg-muted/20 group">
<td className="px-1 py-1 align-middle">
<button
type="button"
className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-foreground p-1"
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4" />
</button>
</td>
<td className="px-1 py-1">
<input
type="date"
value={draft.entry_date}
onChange={(e) => 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"
/>
</td>
<td className="px-1 py-1">
<input
type="text"
value={draft.description ?? ""}
onChange={(e) => 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]"
/>
</td>
<td className="px-1 py-1">
<input
type="text"
value={draft.account ?? ""}
onChange={(e) => 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]"
/>
</td>
{NUM_COLS.map(({ key, danger, positive }) => {
const v = num((draft as any)[key]);
return (
<td key={key} className="px-0.5 py-1">
<input
type="number"
step="0.01"
value={v === 0 ? "" : v}
onChange={(e) => 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"
: ""
}`}
/>
</td>
);
})}
<td className={`px-3 py-2 text-right font-mono font-semibold whitespace-nowrap ${
(entry.runningBalance ?? 0) > 0 ? "" : (entry.runningBalance ?? 0) < 0 ? "text-emerald-600" : "text-muted-foreground"
}`}>
{formatCurrency(entry.runningBalance ?? 0)}
</td>
<td className="px-2 py-2 text-right">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-40 group-hover:opacity-100"
onClick={() => onRemove(entry.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</td>
</tr>
);
}
export const LEDGER_NUM_COLS = NUM_COLS;
export { BUCKETS };
+3
View File
@@ -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
@@ -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;