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:
@@ -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",
|
||||
|
||||
@@ -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 ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Late ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Admin ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Legal ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Viol ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Int ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium text-destructive">Bank ($)</th>
|
||||
<th className="text-right px-2 py-2 font-medium">Pay (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 ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Late ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Admin ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Legal ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Viol ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Int ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium text-destructive">Bank ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Pay (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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user