feat: Accounting web module — COA, journals, expenses, accounts, reports + nav (FIBEROPS-234-239)
- Chart of Accounts (FIBEROPS-234): type-filtered table, add/seed/toggle-active - Journal Entries (FIBEROPS-235): double-entry form with debit=credit validation, read-only view modal - Expenses (FIBEROPS-236): date-range filtered table, record expense against expense accounts - Company Accounts + Transfers (FIBEROPS-237): balance cards, add account modal, transfer modal, transfers table - Financial Reports (FIBEROPS-238): Trial Balance, P&L, Balance Sheet, Cash Flow with date pickers - Sidebar nav entry (FIBEROPS-239): Accounting link with BookOpen icon, placed between Reports and Settings - AccountingNav horizontal sub-nav component shared across all accounting pages - New types: Account, JournalEntry, Expense, CompanyAccount, Transfer and report types added to src/types/index.ts
This commit is contained in:
356
app/(app)/accounting/journal-entries/page.tsx
Normal file
356
app/(app)/accounting/journal-entries/page.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2, Eye } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Table, TableHead, TableBody, TableRow, Th, Td, EmptyState } from "@/components/ui/Table";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { AccountingNav } from "@/components/accounting/AccountingNav";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import api from "@/lib/api";
|
||||
import type { Account, JournalEntry, JournalEntryLine } from "@/types/index";
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-PH", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface LineFormItem {
|
||||
accountId: string;
|
||||
debit: string;
|
||||
credit: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
const emptyLine = (): LineFormItem => ({ accountId: "", debit: "", credit: "", memo: "" });
|
||||
|
||||
export default function JournalEntriesPage() {
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [viewEntry, setViewEntry] = useState<JournalEntry | null>(null);
|
||||
const [form, setForm] = useState({ date: "", description: "", reference: "" });
|
||||
const [lines, setLines] = useState<LineFormItem[]>([emptyLine(), emptyLine()]);
|
||||
|
||||
const { data: entries = [], isLoading, refetch } = useQuery<JournalEntry[]>({
|
||||
queryKey: ["journal-entries"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<JournalEntry[] | { data: JournalEntry[] }>("/api/v1/journal-entries");
|
||||
const d = res.data;
|
||||
return Array.isArray(d) ? d : (d as { data: JournalEntry[] }).data ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: accounts = [] } = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Account[] | { data: Account[] }>("/api/v1/accounts");
|
||||
const d = res.data;
|
||||
return (Array.isArray(d) ? d : (d as { data: Account[] }).data ?? []).filter((a) => a.isActive);
|
||||
},
|
||||
});
|
||||
|
||||
const totalDebit = lines.reduce((s, l) => s + (parseFloat(l.debit) || 0), 0);
|
||||
const totalCredit = lines.reduce((s, l) => s + (parseFloat(l.credit) || 0), 0);
|
||||
const isBalanced = Math.abs(totalDebit - totalCredit) < 0.01 && totalDebit > 0;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
date: form.date,
|
||||
description: form.description,
|
||||
reference: form.reference || undefined,
|
||||
lines: lines
|
||||
.filter((l) => l.accountId)
|
||||
.map((l) => ({
|
||||
accountId: l.accountId,
|
||||
debit: parseFloat(l.debit) || 0,
|
||||
credit: parseFloat(l.credit) || 0,
|
||||
memo: l.memo || undefined,
|
||||
})),
|
||||
};
|
||||
await api.post("/api/v1/journal-entries", payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Journal entry created");
|
||||
setShowNew(false);
|
||||
setForm({ date: "", description: "", reference: "" });
|
||||
setLines([emptyLine(), emptyLine()]);
|
||||
refetch();
|
||||
},
|
||||
onError: () => toast.error("Failed to create journal entry"),
|
||||
});
|
||||
|
||||
function setLine(i: number, field: keyof LineFormItem, value: string) {
|
||||
setLines((prev) => prev.map((l, idx) => idx === i ? { ...l, [field]: value } : l));
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, emptyLine()]);
|
||||
}
|
||||
|
||||
function removeLine(i: number) {
|
||||
if (lines.length <= 2) return;
|
||||
setLines((prev) => prev.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
const entryTotalDebit = (e: JournalEntry) =>
|
||||
e.lines?.reduce((s, l) => s + Number(l.debit), 0) ?? e.totalDebit ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Accounting</h1>
|
||||
<p className="text-sm text-gray-500">Journal entries and double-entry bookkeeping</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowNew(true)}>+ New Entry</Button>
|
||||
</div>
|
||||
|
||||
<AccountingNav />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Journal Entries ({entries.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<Th>Date</Th>
|
||||
<Th>Reference</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Lines</Th>
|
||||
<Th>Total Debit</Th>
|
||||
<Th></Th>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{[1,2,3,4,5,6,7].map((j) => (
|
||||
<Td key={j}><div className="h-4 w-20 animate-pulse bg-gray-100 rounded" /></Td>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState message="No journal entries yet" />
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<Td className="text-sm">{formatDate(e.date)}</Td>
|
||||
<Td className="font-mono text-xs text-gray-500">{e.reference ?? "—"}</Td>
|
||||
<Td className="font-medium max-w-xs truncate">{e.description}</Td>
|
||||
<Td className="text-xs text-gray-500">{e.sourceType ?? "MANUAL"}</Td>
|
||||
<Td className="text-sm">{e.lines?.length ?? 0}</Td>
|
||||
<Td className="font-semibold text-sm">{formatCurrency(entryTotalDebit(e))}</Td>
|
||||
<Td>
|
||||
<Button size="sm" variant="ghost" onClick={() => setViewEntry(e)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Td>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* New Entry Modal */}
|
||||
<Modal isOpen={showNew} onClose={() => setShowNew(false)} title="New Journal Entry" className="max-w-3xl">
|
||||
<form onSubmit={(e) => { e.preventDefault(); createMutation.mutate(); }} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Input
|
||||
label="Date *"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Reference"
|
||||
value={form.reference}
|
||||
onChange={(e) => setForm((f) => ({ ...f, reference: e.target.value }))}
|
||||
placeholder="e.g. JE-001"
|
||||
/>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
<Input
|
||||
label="Description *"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Describe this journal entry"
|
||||
/>
|
||||
|
||||
{/* Lines */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-700">Line Items</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={addLine}>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Line
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-1/3">Account</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-24">Debit</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-24">Credit</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500">Memo</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="px-3 py-1.5">
|
||||
<select
|
||||
className="w-full rounded border-0 bg-transparent text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
|
||||
value={line.accountId}
|
||||
onChange={(e) => setLine(i, "accountId", e.target.value)}
|
||||
>
|
||||
<option value="">Select account</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.code} — {a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full rounded border-0 bg-transparent text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
|
||||
value={line.debit}
|
||||
onChange={(e) => setLine(i, "debit", e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full rounded border-0 bg-transparent text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
|
||||
value={line.credit}
|
||||
onChange={(e) => setLine(i, "credit", e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border-0 bg-transparent text-sm focus:outline-none"
|
||||
value={line.memo}
|
||||
onChange={(e) => setLine(i, "memo", e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(i)}
|
||||
disabled={lines.length <= 2}
|
||||
className="text-gray-300 hover:text-red-400 disabled:opacity-20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 border-t">
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-xs font-medium text-gray-500">Totals</td>
|
||||
<td className="px-3 py-2 text-sm font-bold text-gray-800">{formatCurrency(totalDebit)}</td>
|
||||
<td className="px-3 py-2 text-sm font-bold text-gray-800">{formatCurrency(totalCredit)}</td>
|
||||
<td colSpan={2} className="px-3 py-2">
|
||||
{totalDebit > 0 && (
|
||||
<span className={`text-xs font-medium ${isBalanced ? "text-green-600" : "text-red-500"}`}>
|
||||
{isBalanced ? "✓ Balanced" : `Off by ${formatCurrency(Math.abs(totalDebit - totalCredit))}`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowNew(false)}>Cancel</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={createMutation.isPending}
|
||||
disabled={!form.date || !form.description || !isBalanced}
|
||||
>
|
||||
Create Entry
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* View Entry Modal */}
|
||||
{viewEntry && (
|
||||
<Modal isOpen={!!viewEntry} onClose={() => setViewEntry(null)} title="Journal Entry" className="max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Date</p>
|
||||
<p className="font-medium">{formatDate(viewEntry.date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Reference</p>
|
||||
<p className="font-medium font-mono">{viewEntry.reference ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Source</p>
|
||||
<p className="font-medium">{viewEntry.sourceType ?? "MANUAL"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Description</p>
|
||||
<p className="font-medium">{viewEntry.description}</p>
|
||||
</div>
|
||||
<table className="w-full text-sm border rounded-lg overflow-hidden">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500">Account</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-gray-500">Debit</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-gray-500">Credit</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500">Memo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{viewEntry.lines?.map((l, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-3 py-2">{l.account ? `${l.account.code} — ${l.account.name}` : l.accountId}</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{Number(l.debit) > 0 ? formatCurrency(Number(l.debit)) : "—"}</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{Number(l.credit) > 0 ? formatCurrency(Number(l.credit)) : "—"}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{l.memo ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 border-t">
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-xs font-medium text-gray-500">Total</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-sm">{formatCurrency(entryTotalDebit(viewEntry))}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-sm">{formatCurrency(entryTotalDebit(viewEntry))}</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setViewEntry(null)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user