Files
acmcc/src/pages/GeneralLedgerPage.tsx
T
2026-06-01 20:19:26 -04:00

446 lines
22 KiB
TypeScript

import { useState, useEffect, useMemo, useRef } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { BookOpen, Download, Search, Printer, PlusCircle, ArrowUpDown, Loader2, Pencil, Trash2, AlertCircle } from "lucide-react";
import { format } from "date-fns";
import type { Tables } from "@/integrations/supabase/types";
const PRINT_STYLE = `
@media print {
body * { visibility: hidden !important; }
#gl-print-area, #gl-print-area * { visibility: visible !important; }
#gl-print-area { position: absolute; left: 0; top: 0; width: 100%; }
.no-print { display: none !important; }
@page { size: letter landscape; margin: 0.5in; }
}
`;
type Account = { id: string; account_number: string; account_name: string; account_type: string; association_id: string };
type JournalEntry = {
id: string; date: string; description: string | null; amount: number; type: string;
chart_of_account_id: string | null; association_id: string; created_at: string;
chart_of_accounts: { account_number: string; account_name: string; account_type: string } | null;
debit: number; credit: number; accountName: string; runningBalance: number;
};
const emptyForm = { date: "", description: "", amount: "", type: "debit" as string, chart_of_account_id: "" };
export default function GeneralLedgerPage() {
const { toast } = useToast();
const printRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [transactions, setTransactions] = useState<JournalEntry[]>([]);
const [associations, setAssociations] = useState<Tables<"associations">[]>([]);
const [accounts, setAccounts] = useState<Account[]>([]);
const [selectedAssocId, setSelectedAssocId] = useState("all");
const [selectedAccountId, setSelectedAccountId] = useState("all");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// Add/Edit
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
// Delete
const [deletingId, setDeletingId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
const style = document.createElement("style");
style.innerHTML = PRINT_STYLE;
document.head.appendChild(style);
return () => { document.head.removeChild(style); };
}, []);
useEffect(() => { fetchInitialData(); }, []);
useEffect(() => { fetchTransactions(); }, [selectedAssocId]);
const fetchInitialData = async () => {
const [assocRes, acctRes] = await Promise.all([
supabase.from("associations").select("id, name").eq("status", "active").order("name"),
supabase.from("chart_of_accounts").select("id, account_number, account_name, account_type, association_id").order("account_number"),
]);
if (assocRes.data) setAssociations(assocRes.data as Tables<"associations">[]);
if (acctRes.data) setAccounts(acctRes.data as Account[]);
};
const fetchTransactions = async () => {
setLoading(true);
let query = supabase
.from("journal_entries")
.select("id, date, description, amount, type, chart_of_account_id, association_id, created_at, chart_of_accounts(account_number, account_name, account_type)");
if (selectedAssocId !== "all") query = query.eq("association_id", selectedAssocId);
const { data, error } = await query;
if (error) {
toast({ variant: "destructive", title: "Error", description: "Failed to load journal entries." });
setLoading(false);
return;
}
const formatted = (data || []).map((t: any) => {
const isDebit = t.type === "debit";
const amt = parseFloat(t.amount || 0);
return {
...t,
debit: isDebit ? amt : 0,
credit: !isDebit ? amt : 0,
accountName: t.chart_of_accounts
? `${t.chart_of_accounts.account_number} - ${t.chart_of_accounts.account_name}`
: "Uncategorized",
runningBalance: 0,
};
});
setTransactions(formatted);
setLoading(false);
};
const processedTransactions = useMemo(() => {
let result = [...transactions];
if (selectedAccountId !== "all") result = result.filter(t => t.chart_of_account_id === selectedAccountId);
if (startDate) result = result.filter(t => t.date >= startDate);
if (endDate) result = result.filter(t => t.date <= endDate);
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(t => (t.description || "").toLowerCase().includes(q) || t.accountName.toLowerCase().includes(q));
}
result.sort((a, b) => {
const d = new Date(a.date).getTime() - new Date(b.date).getTime();
return d !== 0 ? d : new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
let bal = 0;
result = result.map(t => { bal += t.debit - t.credit; return { ...t, runningBalance: bal }; });
if (sortOrder === "desc") result.reverse();
return result;
}, [transactions, selectedAccountId, startDate, endDate, searchQuery, sortOrder]);
const totals = useMemo(() =>
processedTransactions.reduce((a, t) => ({ debit: a.debit + t.debit, credit: a.credit + t.credit, net: a.net + t.debit - t.credit }), { debit: 0, credit: 0, net: 0 }),
[processedTransactions]);
const filteredAccounts = accounts.filter(a => selectedAssocId === "all" || a.association_id === selectedAssocId);
// CSV Export
const exportCSV = async () => {
if (!processedTransactions.length) return;
const rows = [
["Date", "Account", "Description", "Type", "Debit", "Credit", "Balance"].join(","),
...processedTransactions.map(t => [t.date, `"${t.accountName}"`, `"${(t.description || "").replace(/"/g, '""')}"`, t.type, t.debit.toFixed(2), t.credit.toFixed(2), t.runningBalance.toFixed(2)].join(","))
].join("\n");
const { saveCsv } = await import("@/lib/saveFile");
await saveCsv(rows, `General_Ledger_${format(new Date(), "yyyy-MM-dd")}.csv`);
};
// Form handlers
const openAdd = () => {
if (selectedAssocId === "all") {
toast({ variant: "destructive", title: "Select an association first" });
return;
}
setEditingId(null);
setForm({ ...emptyForm, date: format(new Date(), "yyyy-MM-dd") });
setFormOpen(true);
};
const openEdit = (t: JournalEntry) => {
setEditingId(t.id);
setForm({ date: t.date, description: t.description || "", amount: String(t.debit || t.credit), type: t.type, chart_of_account_id: t.chart_of_account_id || "" });
setFormOpen(true);
};
const handleSave = async () => {
if (!form.date || !form.amount) {
toast({ variant: "destructive", title: "Date and amount are required" });
return;
}
setSaving(true);
const payload = {
date: form.date,
description: form.description || null,
amount: parseFloat(form.amount),
type: form.type,
chart_of_account_id: form.chart_of_account_id || null,
association_id: selectedAssocId,
};
const { error } = editingId
? await supabase.from("journal_entries").update(payload).eq("id", editingId)
: await supabase.from("journal_entries").insert(payload);
if (error) {
toast({ variant: "destructive", title: "Error", description: error.message });
} else {
toast({ title: editingId ? "Entry updated" : "Entry added" });
setFormOpen(false);
fetchTransactions();
}
setSaving(false);
};
const handleDelete = async () => {
if (!deletingId) return;
setIsDeleting(true);
const { error } = await supabase.from("journal_entries").delete().eq("id", deletingId);
if (error) {
toast({ variant: "destructive", title: "Error", description: error.message });
} else {
toast({ title: "Entry deleted" });
fetchTransactions();
}
setIsDeleting(false);
setDeletingId(null);
};
const fmtMoney = (v: number) => `$${Math.abs(v).toFixed(2)}${v < 0 ? " CR" : ""}`;
return (
<div className="space-y-6 max-w-7xl mx-auto pb-12 min-h-screen p-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-primary/10">
<BookOpen className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">General Ledger</h1>
<p className="text-sm text-muted-foreground">Journal entries and account activity</p>
</div>
</div>
{/* Actions */}
<div className="no-print flex flex-wrap gap-2">
<Button onClick={openAdd} className="gap-2"><PlusCircle className="h-4 w-4" /> Add Entry</Button>
<Button variant="outline" onClick={() => window.print()} className="gap-2"><Printer className="h-4 w-4" /> Print</Button>
<Button variant="outline" onClick={exportCSV} className="gap-2"><Download className="h-4 w-4" /> Export CSV</Button>
</div>
{/* Filters */}
<Card className="no-print">
<CardContent className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase">Association</Label>
<Select value={selectedAssocId} onValueChange={setSelectedAssocId}>
<SelectTrigger><SelectValue placeholder="All Associations" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Associations</SelectItem>
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase">Account</Label>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger><SelectValue placeholder="All Accounts" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Accounts</SelectItem>
{filteredAccounts.map(a => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase">Start Date</Label>
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase">End Date</Label>
<Input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
<div className="space-y-1.5 sm:col-span-2 lg:col-span-1">
<Label className="text-xs font-semibold text-muted-foreground uppercase">Search</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} className="pl-9" />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<div className="rounded-lg border bg-card overflow-hidden no-print">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-32">
<Button variant="ghost" size="sm" onClick={() => setSortOrder(s => s === "desc" ? "asc" : "desc")} className="gap-1 -ml-3 text-xs font-bold uppercase">
Date <ArrowUpDown className="h-3 w-3" />
</Button>
</TableHead>
<TableHead className="text-xs font-bold uppercase">Account</TableHead>
<TableHead className="text-xs font-bold uppercase">Description</TableHead>
<TableHead className="text-right text-xs font-bold uppercase">Debit</TableHead>
<TableHead className="text-right text-xs font-bold uppercase">Credit</TableHead>
<TableHead className="text-right text-xs font-bold uppercase">Balance</TableHead>
<TableHead className="w-24 text-right text-xs font-bold uppercase">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={7} className="text-center py-16 text-muted-foreground"><Loader2 className="h-8 w-8 animate-spin mx-auto mb-3" />Loading...</TableCell></TableRow>
) : processedTransactions.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center py-16 text-muted-foreground"><BookOpen className="h-10 w-10 mx-auto mb-3 opacity-30" />No transactions found.</TableCell></TableRow>
) : processedTransactions.map(t => (
<TableRow key={t.id} className="group">
<TableCell className="text-sm font-medium whitespace-nowrap">{t.date ? format(new Date(t.date), "MMM dd, yyyy") : "—"}</TableCell>
<TableCell className="text-sm font-semibold">{t.accountName === "Uncategorized" ? <span className="text-amber-600 bg-amber-500/10 px-2 py-0.5 rounded">{t.accountName}</span> : t.accountName}</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.description}</TableCell>
<TableCell className="text-right text-sm font-mono">{t.debit > 0 ? `$${t.debit.toFixed(2)}` : "—"}</TableCell>
<TableCell className="text-right text-sm font-mono">{t.credit > 0 ? `$${t.credit.toFixed(2)}` : "—"}</TableCell>
<TableCell className={`text-right text-sm font-mono font-bold ${t.runningBalance < 0 ? "text-destructive" : ""}`}>{fmtMoney(t.runningBalance)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(t)}><Pencil className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => setDeletingId(t.id)}><Trash2 className="h-4 w-4" /></Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!loading && processedTransactions.length > 0 && (
<div className="bg-muted/50 p-4 border-t flex justify-end gap-10 font-mono text-sm">
<div className="text-right">
<p className="text-[10px] uppercase font-bold text-muted-foreground mb-0.5">Total Debits</p>
<p className="font-bold text-lg">${totals.debit.toFixed(2)}</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase font-bold text-muted-foreground mb-0.5">Total Credits</p>
<p className="font-bold text-lg">${totals.credit.toFixed(2)}</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase font-bold text-muted-foreground mb-0.5">Net Balance</p>
<p className={`font-bold text-lg ${totals.net < 0 ? "text-destructive" : ""}`}>{fmtMoney(totals.net)}</p>
</div>
</div>
)}
</div>
{/* Print area */}
<div id="gl-print-area" ref={printRef} className="bg-background p-6 rounded-lg">
<div className="mb-6 border-b-2 border-primary pb-4">
<h2 className="text-2xl font-bold text-primary">General Ledger</h2>
<p className="text-muted-foreground">
{selectedAssocId === "all" ? "All Associations" : associations.find(a => a.id === selectedAssocId)?.name}
{startDate && endDate && ` | ${startDate} to ${endDate}`}
</p>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b">
<th className="text-left p-2">Date</th><th className="text-left p-2">Account</th><th className="text-left p-2">Description</th>
<th className="text-right p-2">Debit</th><th className="text-right p-2">Credit</th><th className="text-right p-2">Balance</th>
</tr>
</thead>
<tbody>
{processedTransactions.map(t => (
<tr key={t.id} className="border-b border-border/50">
<td className="p-2">{t.date ? format(new Date(t.date), "MM/dd/yyyy") : "—"}</td>
<td className="p-2 font-medium">{t.accountName}</td>
<td className="p-2">{t.description}</td>
<td className="p-2 text-right">{t.debit > 0 ? t.debit.toFixed(2) : ""}</td>
<td className="p-2 text-right">{t.credit > 0 ? t.credit.toFixed(2) : ""}</td>
<td className={`p-2 text-right font-medium ${t.runningBalance < 0 ? "text-destructive" : ""}`}>{fmtMoney(t.runningBalance)}</td>
</tr>
))}
</tbody>
{processedTransactions.length > 0 && (
<tfoot>
<tr className="border-t-2">
<td colSpan={3} className="text-right p-2 font-bold">Totals:</td>
<td className="text-right p-2 font-bold">{totals.debit.toFixed(2)}</td>
<td className="text-right p-2 font-bold">{totals.credit.toFixed(2)}</td>
<td className={`text-right p-2 font-bold ${totals.net < 0 ? "text-destructive" : ""}`}>{fmtMoney(totals.net)}</td>
</tr>
</tfoot>
)}
</table>
</div>
{/* Add/Edit Dialog */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? "Edit Journal Entry" : "Add Journal Entry"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Date</Label>
<Input type="date" value={form.date} onChange={e => setForm({ ...form, date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={form.type} onValueChange={v => setForm({ ...form, type: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debit</SelectItem>
<SelectItem value="credit">Credit</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Amount</Label>
<Input type="number" step="0.01" min="0" value={form.amount} onChange={e => setForm({ ...form, amount: e.target.value })} placeholder="0.00" />
</div>
<div className="space-y-2">
<Label>Account</Label>
<Select value={form.chart_of_account_id} onValueChange={v => setForm({ ...form, chart_of_account_id: v })}>
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
<SelectContent>
{filteredAccounts.map(a => <SelectItem key={a.id} value={a.id}>{a.account_number} - {a.account_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="Transaction description" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving} className="gap-2">
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingId ? "Update" : "Add Entry"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deletingId} onOpenChange={o => !o && setDeletingId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-destructive flex items-center gap-2">
<AlertCircle className="h-5 w-5" /> Delete Ledger Entry
</AlertDialogTitle>
<AlertDialogDescription>This will permanently remove the journal entry and affect balances.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction disabled={isDeleting} onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90 gap-2">
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}