From b719d87e1bad3f355fb1c92ed60146607fda5062 Mon Sep 17 00:00:00 2001 From: "Nemo (Claude Code)" Date: Tue, 31 Mar 2026 23:35:29 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Accounting=20web=20module=20=E2=80=94?= =?UTF-8?q?=20COA,=20journals,=20expenses,=20accounts,=20reports=20+=20nav?= =?UTF-8?q?=20(FIBEROPS-234-239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../accounting/company-accounts/page.tsx | 314 +++++++++++++++ app/(app)/accounting/expenses/page.tsx | 210 +++++++++++ app/(app)/accounting/journal-entries/page.tsx | 356 ++++++++++++++++++ app/(app)/accounting/page.tsx | 231 ++++++++++++ app/(app)/accounting/reports/page.tsx | 339 +++++++++++++++++ src/components/accounting/AccountingNav.tsx | 41 ++ src/components/layout/Sidebar.tsx | 2 + src/types/index.ts | 104 +++++ 8 files changed, 1597 insertions(+) create mode 100644 app/(app)/accounting/company-accounts/page.tsx create mode 100644 app/(app)/accounting/expenses/page.tsx create mode 100644 app/(app)/accounting/journal-entries/page.tsx create mode 100644 app/(app)/accounting/page.tsx create mode 100644 app/(app)/accounting/reports/page.tsx create mode 100644 src/components/accounting/AccountingNav.tsx diff --git a/app/(app)/accounting/company-accounts/page.tsx b/app/(app)/accounting/company-accounts/page.tsx new file mode 100644 index 0000000..62cf74d --- /dev/null +++ b/app/(app)/accounting/company-accounts/page.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { ArrowRight } 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 { Badge } from "@/components/ui/Badge"; +import { Modal } from "@/components/ui/Modal"; +import { AccountingNav } from "@/components/accounting/AccountingNav"; +import { formatCurrency } from "@/lib/utils"; +import api from "@/lib/api"; +import type { CompanyAccount, CompanyAccountType, Transfer } from "@/types/index"; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-PH", { year: "numeric", month: "short", day: "numeric" }); +} + +const TYPE_COLORS: Record = { + CASH: "success", + BANK: "default", + EWALLET: "warning", +}; + +const ACCOUNT_TYPES: CompanyAccountType[] = ["CASH", "BANK", "EWALLET"]; + +export default function CompanyAccountsPage() { + const today = new Date().toISOString().split("T")[0]; + + const [showAdd, setShowAdd] = useState(false); + const [addForm, setAddForm] = useState({ + name: "", type: "BANK" as CompanyAccountType, + bankName: "", accountNumber: "", initialBalance: "0", + }); + + const [transferAccount, setTransferAccount] = useState(null); + const [transferForm, setTransferForm] = useState({ toAccountId: "", amount: "", date: today, note: "" }); + + const { data: accounts = [], isLoading, refetch: refetchAccounts } = useQuery({ + queryKey: ["company-accounts"], + queryFn: async () => { + const res = await api.get("/api/v1/company-accounts"); + const d = res.data; + return Array.isArray(d) ? d : (d as { data: CompanyAccount[] }).data ?? []; + }, + }); + + const { data: transfers = [], refetch: refetchTransfers } = useQuery({ + queryKey: ["transfers"], + queryFn: async () => { + const res = await api.get("/api/v1/transfers"); + const d = res.data; + return Array.isArray(d) ? d : (d as { data: Transfer[] }).data ?? []; + }, + }); + + const addMutation = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/company-accounts", { + name: addForm.name, + type: addForm.type, + bankName: addForm.bankName || undefined, + accountNumber: addForm.accountNumber || undefined, + initialBalance: parseFloat(addForm.initialBalance) || 0, + }); + }, + onSuccess: () => { + toast.success("Account added"); + setShowAdd(false); + setAddForm({ name: "", type: "BANK", bankName: "", accountNumber: "", initialBalance: "0" }); + refetchAccounts(); + }, + onError: () => toast.error("Failed to add account"), + }); + + const transferMutation = useMutation({ + mutationFn: async () => { + if (!transferAccount) return; + await api.post("/api/v1/transfers", { + fromAccountId: transferAccount.id, + toAccountId: transferForm.toAccountId, + amount: parseFloat(transferForm.amount), + date: transferForm.date, + note: transferForm.note || undefined, + }); + }, + onSuccess: () => { + toast.success("Transfer completed"); + setTransferAccount(null); + setTransferForm({ toAccountId: "", amount: "", date: today, note: "" }); + refetchAccounts(); + refetchTransfers(); + }, + onError: () => toast.error("Failed to process transfer"), + }); + + const toAccounts = accounts.filter((a) => a.id !== transferAccount?.id); + + return ( +
+
+
+

Accounting

+

Company cash, bank, and e-wallet accounts

+
+ +
+ + + + {/* Account Cards */} + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : accounts.length === 0 ? ( + + + No company accounts yet. Add your first account to get started. + + + ) : ( +
+ {accounts.map((acct) => ( + + +
+
+

{acct.name}

+ {acct.bankName && ( +

{acct.bankName}

+ )} + {acct.accountNumber && ( +

{acct.accountNumber}

+ )} +
+ {acct.type} +
+

{formatCurrency(Number(acct.balance))}

+
+ +
+
+
+ ))} +
+ )} + + {/* Recent Transfers */} + + + Recent Transfers + + + + + + + + + + + + + + + {transfers.length === 0 ? ( + + ) : ( + transfers.slice(0, 20).map((t) => ( + + + + + + + + + )) + )} + +
DateFromToAmountNote{formatDate(t.date)}{t.fromAccount?.name ?? "—"}{t.toAccount?.name ?? "—"}{formatCurrency(Number(t.amount))}{t.note ?? "—"}
+
+
+ + {/* Add Account Modal */} + setShowAdd(false)} title="Add Company Account"> +
{ e.preventDefault(); addMutation.mutate(); }} className="space-y-4"> + setAddForm((f) => ({ ...f, name: e.target.value }))} + placeholder="e.g. BDO Savings" + /> +
+ + +
+
+ setAddForm((f) => ({ ...f, bankName: e.target.value }))} + placeholder="e.g. BDO" + /> + setAddForm((f) => ({ ...f, accountNumber: e.target.value }))} + placeholder="e.g. 0012-3456-7890" + /> +
+ setAddForm((f) => ({ ...f, initialBalance: e.target.value }))} + /> +
+ + +
+
+
+ + {/* Transfer Modal */} + setTransferAccount(null)} + title={`Transfer from ${transferAccount?.name ?? ""}`} + > +
{ e.preventDefault(); transferMutation.mutate(); }} className="space-y-4"> +
+ Available balance: + {formatCurrency(Number(transferAccount?.balance ?? 0))} +
+
+ + +
+
+ setTransferForm((f) => ({ ...f, amount: e.target.value }))} + placeholder="0.00" + /> + setTransferForm((f) => ({ ...f, date: e.target.value }))} + /> +
+ setTransferForm((f) => ({ ...f, note: e.target.value }))} + placeholder="Optional note" + /> +
+ + +
+
+
+
+ ); +} diff --git a/app/(app)/accounting/expenses/page.tsx b/app/(app)/accounting/expenses/page.tsx new file mode 100644 index 0000000..322c311 --- /dev/null +++ b/app/(app)/accounting/expenses/page.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +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 { Expense, Account } from "@/types/index"; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-PH", { year: "numeric", month: "short", day: "numeric" }); +} + +export default function ExpensesPage() { + const today = new Date().toISOString().split("T")[0]; + const firstOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split("T")[0]; + + const [from, setFrom] = useState(firstOfMonth); + const [to, setTo] = useState(today); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ accountId: "", amount: "", date: today, vendor: "", description: "" }); + + const { data: expenses = [], isLoading, refetch } = useQuery({ + queryKey: ["expenses", from, to], + queryFn: async () => { + const res = await api.get(`/api/v1/expenses?dateFrom=${from}&dateTo=${to}`); + const d = res.data; + return Array.isArray(d) ? d : (d as { data: Expense[] }).data ?? []; + }, + }); + + const { data: accounts = [] } = useQuery({ + queryKey: ["accounts-expense"], + queryFn: async () => { + const res = await api.get("/api/v1/accounts?type=EXPENSE"); + const d = res.data; + return (Array.isArray(d) ? d : (d as { data: Account[] }).data ?? []).filter((a) => a.isActive && a.type === "EXPENSE"); + }, + }); + + const addMutation = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/expenses", { + accountId: form.accountId, + amount: parseFloat(form.amount), + date: form.date, + vendor: form.vendor || undefined, + description: form.description || undefined, + }); + }, + onSuccess: () => { + toast.success("Expense recorded"); + setShowAdd(false); + setForm({ accountId: "", amount: "", date: today, vendor: "", description: "" }); + refetch(); + }, + onError: () => toast.error("Failed to record expense"), + }); + + const totalAmount = expenses.reduce((s, e) => s + Number(e.amount), 0); + + return ( +
+
+
+

Accounting

+

Track business expenses

+
+ +
+ + + + {/* Date filter */} +
+ + setFrom(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + setTo(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + + + + Expenses ({expenses.length}) + {expenses.length > 0 && ( + + Total: {formatCurrency(totalAmount)} + + )} + + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + {[1,2,3,4,5].map((j) => ( + + ))} + + )) + ) : expenses.length === 0 ? ( + + ) : ( + expenses.map((e) => ( + + + + + + + + )) + )} + +
DateVendorAccountAmountDescription
{formatDate(e.date)}{e.vendor ?? } + {e.account ? `${e.account.code} — ${e.account.name}` : "—"} + {formatCurrency(Number(e.amount))} + {e.description ?? } +
+
+
+ + setShowAdd(false)} title="Record Expense"> +
{ e.preventDefault(); addMutation.mutate(); }} className="space-y-4"> +
+ + +
+
+ setForm((f) => ({ ...f, amount: e.target.value }))} + placeholder="0.00" + /> + setForm((f) => ({ ...f, date: e.target.value }))} + /> +
+ setForm((f) => ({ ...f, vendor: e.target.value }))} + placeholder="e.g. PLDT, Globe" + /> + setForm((f) => ({ ...f, description: e.target.value }))} + placeholder="What was this expense for?" + /> +
+ + +
+
+
+
+ ); +} diff --git a/app/(app)/accounting/journal-entries/page.tsx b/app/(app)/accounting/journal-entries/page.tsx new file mode 100644 index 0000000..8a387f8 --- /dev/null +++ b/app/(app)/accounting/journal-entries/page.tsx @@ -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(null); + const [form, setForm] = useState({ date: "", description: "", reference: "" }); + const [lines, setLines] = useState([emptyLine(), emptyLine()]); + + const { data: entries = [], isLoading, refetch } = useQuery({ + queryKey: ["journal-entries"], + queryFn: async () => { + const res = await api.get("/api/v1/journal-entries"); + const d = res.data; + return Array.isArray(d) ? d : (d as { data: JournalEntry[] }).data ?? []; + }, + }); + + const { data: accounts = [] } = useQuery({ + queryKey: ["accounts"], + queryFn: async () => { + const res = await api.get("/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 ( +
+
+
+

Accounting

+

Journal entries and double-entry bookkeeping

+
+ +
+ + + + + + Journal Entries ({entries.length}) + + + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + {[1,2,3,4,5,6,7].map((j) => ( + + ))} + + )) + ) : entries.length === 0 ? ( + + ) : ( + entries.map((e) => ( + + + + + + + + + + )) + )} + +
DateReferenceDescriptionSourceLinesTotal Debit
{formatDate(e.date)}{e.reference ?? "—"}{e.description}{e.sourceType ?? "MANUAL"}{e.lines?.length ?? 0}{formatCurrency(entryTotalDebit(e))} + +
+
+
+ + {/* New Entry Modal */} + setShowNew(false)} title="New Journal Entry" className="max-w-3xl"> +
{ e.preventDefault(); createMutation.mutate(); }} className="space-y-4"> +
+ setForm((f) => ({ ...f, date: e.target.value }))} + /> + setForm((f) => ({ ...f, reference: e.target.value }))} + placeholder="e.g. JE-001" + /> +
+
+ setForm((f) => ({ ...f, description: e.target.value }))} + placeholder="Describe this journal entry" + /> + + {/* Lines */} +
+
+

Line Items

+ +
+
+ + + + + + + + + + + {lines.map((line, i) => ( + + + + + + + + ))} + + + + + + + + + +
AccountDebitCreditMemo +
+ + + setLine(i, "debit", e.target.value)} + placeholder="0.00" + /> + + setLine(i, "credit", e.target.value)} + placeholder="0.00" + /> + + setLine(i, "memo", e.target.value)} + placeholder="Optional" + /> + + +
Totals{formatCurrency(totalDebit)}{formatCurrency(totalCredit)} + {totalDebit > 0 && ( + + {isBalanced ? "✓ Balanced" : `Off by ${formatCurrency(Math.abs(totalDebit - totalCredit))}`} + + )} +
+
+
+ +
+ + +
+ + + + {/* View Entry Modal */} + {viewEntry && ( + setViewEntry(null)} title="Journal Entry" className="max-w-2xl"> +
+
+
+

Date

+

{formatDate(viewEntry.date)}

+
+
+

Reference

+

{viewEntry.reference ?? "—"}

+
+
+

Source

+

{viewEntry.sourceType ?? "MANUAL"}

+
+
+
+

Description

+

{viewEntry.description}

+
+ + + + + + + + + + + {viewEntry.lines?.map((l, i) => ( + + + + + + + ))} + + + + + + + + +
AccountDebitCreditMemo
{l.account ? `${l.account.code} — ${l.account.name}` : l.accountId}{Number(l.debit) > 0 ? formatCurrency(Number(l.debit)) : "—"}{Number(l.credit) > 0 ? formatCurrency(Number(l.credit)) : "—"}{l.memo ?? ""}
Total{formatCurrency(entryTotalDebit(viewEntry))}{formatCurrency(entryTotalDebit(viewEntry))} +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/app/(app)/accounting/page.tsx b/app/(app)/accounting/page.tsx new file mode 100644 index 0000000..4bab09c --- /dev/null +++ b/app/(app)/accounting/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +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 { Badge } from "@/components/ui/Badge"; +import { Modal } from "@/components/ui/Modal"; +import { AccountingNav } from "@/components/accounting/AccountingNav"; +import api from "@/lib/api"; +import type { Account, AccountType } from "@/types/index"; + +const ACCOUNT_TYPES: AccountType[] = ["ASSET", "LIABILITY", "EQUITY", "REVENUE", "EXPENSE"]; +const TYPE_TABS: { key: AccountType | "ALL"; label: string }[] = [ + { key: "ALL", label: "All" }, + { key: "ASSET", label: "Assets" }, + { key: "LIABILITY", label: "Liabilities" }, + { key: "EQUITY", label: "Equity" }, + { key: "REVENUE", label: "Revenue" }, + { key: "EXPENSE", label: "Expenses" }, +]; + +const TYPE_COLORS: Record = { + ASSET: "success", + LIABILITY: "danger", + EQUITY: "warning", + REVENUE: "default", + EXPENSE: "muted", +}; + +export default function ChartOfAccountsPage() { + const [activeType, setActiveType] = useState("ALL"); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ code: "", name: "", type: "ASSET" as AccountType }); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ["accounts"], + queryFn: async () => { + const res = await api.get("/api/v1/accounts"); + const d = res.data; + return Array.isArray(d) ? d : (d as { data: Account[] }).data ?? []; + }, + }); + + const accounts = data ?? []; + const hasAccounts = accounts.length > 0; + + const filtered = activeType === "ALL" ? accounts : accounts.filter((a) => a.type === activeType); + + const addMutation = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/accounts", form); + }, + onSuccess: () => { + toast.success("Account created"); + setShowAdd(false); + setForm({ code: "", name: "", type: "ASSET" }); + refetch(); + }, + onError: () => toast.error("Failed to create account"), + }); + + const seedMutation = useMutation({ + mutationFn: async () => { + await api.post("/api/v1/accounts/seed", {}); + }, + onSuccess: () => { + toast.success("Default accounts seeded"); + refetch(); + }, + onError: () => toast.error("Failed to seed accounts"), + }); + + const toggleMutation = useMutation({ + mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => { + await api.patch(`/api/v1/accounts/${id}`, { isActive: !isActive }); + }, + onSuccess: () => { + toast.success("Account updated"); + refetch(); + }, + onError: () => toast.error("Failed to update account"), + }); + + return ( +
+
+
+

Accounting

+

Chart of accounts, journals, and financial reports

+
+
+ {!hasAccounts && ( + + )} + +
+
+ + + + {/* Type filter tabs */} +
+ {TYPE_TABS.map(({ key, label }) => ( + + ))} +
+ + + + + {activeType === "ALL" ? "All Accounts" : activeType} ({filtered.length}) + + + + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + {[1,2,3,4,5].map((j) => ( + + ))} + + )) + ) : filtered.length === 0 ? ( + + ) : ( + filtered.map((a) => ( + + + + + + + + )) + )} + +
CodeNameTypeStatusActions
{a.code}{a.name} + {a.type} + + + {a.isActive ? "Active" : "Inactive"} + + + +
+
+
+ + setShowAdd(false)} title="Add Account"> +
{ e.preventDefault(); addMutation.mutate(); }} + className="space-y-4" + > + setForm((f) => ({ ...f, code: e.target.value }))} + placeholder="e.g. 1001" + /> + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="e.g. Cash on Hand" + /> +
+ + +
+
+ + +
+
+
+
+ ); +} diff --git a/app/(app)/accounting/reports/page.tsx b/app/(app)/accounting/reports/page.tsx new file mode 100644 index 0000000..54f036e --- /dev/null +++ b/app/(app)/accounting/reports/page.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { RefreshCw } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { AccountingNav } from "@/components/accounting/AccountingNav"; +import { formatCurrency } from "@/lib/utils"; +import api from "@/lib/api"; +import type { + TrialBalanceLine, + ProfitLossReport, + BalanceSheetReport, + CashFlowReport, +} from "@/types/index"; + +type ReportTab = "trial-balance" | "profit-loss" | "balance-sheet" | "cash-flow"; + +const TABS: { key: ReportTab; label: string }[] = [ + { key: "trial-balance", label: "Trial Balance" }, + { key: "profit-loss", label: "Profit & Loss" }, + { key: "balance-sheet", label: "Balance Sheet" }, + { key: "cash-flow", label: "Cash Flow" }, +]; + +// ─── Trial Balance ───────────────────────────────────────────────────────────── + +function TrialBalance({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) { + const { data, isLoading } = useQuery({ + queryKey: ["report-trial-balance", dateFrom, dateTo], + queryFn: async () => { + const res = await api.get(`/api/v1/reports/trial-balance?dateFrom=${dateFrom}&dateTo=${dateTo}`); + const d = res.data; + if (Array.isArray(d)) return d; + return (d as { data?: TrialBalanceLine[] }).data ?? d as TrialBalanceLine[]; + }, + }); + + const lines = data ?? []; + const totalDebit = lines.reduce((s, l) => s + Number(l.debit), 0); + const totalCredit = lines.reduce((s, l) => s + Number(l.credit), 0); + + if (isLoading) return
; + + return ( + + Trial Balance + + {lines.length === 0 ? ( +

No data for this period

+ ) : ( + + + + + + + + + + + + {lines.map((l, i) => ( + + + + + + + + ))} + + + + + + + + + +
CodeAccountDebitCreditBalance
{l.code}{l.name} + {Number(l.debit) > 0 ? formatCurrency(Number(l.debit)) : "—"} + + {Number(l.credit) > 0 ? formatCurrency(Number(l.credit)) : "—"} + + {formatCurrency(Math.abs(Number(l.balance)))} + {Number(l.balance) < 0 && " Cr"} +
Totals{formatCurrency(totalDebit)}{formatCurrency(totalCredit)} + {Math.abs(totalDebit - totalCredit) < 0.01 ? ( + ✓ Balanced + ) : ( + Off by {formatCurrency(Math.abs(totalDebit - totalCredit))} + )} +
+ )} +
+
+ ); +} + +// ─── P&L ────────────────────────────────────────────────────────────────────── + +function ProfitLoss({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) { + const { data, isLoading } = useQuery({ + queryKey: ["report-profit-loss", dateFrom, dateTo], + queryFn: async () => { + const res = await api.get(`/api/v1/reports/profit-loss?dateFrom=${dateFrom}&dateTo=${dateTo}`); + return res.data as ProfitLossReport; + }, + }); + + if (isLoading) return
; + if (!data) return

No data for this period

; + + const netIncome = Number(data.netIncome ?? (Number(data.totalRevenue) - Number(data.totalExpenses))); + + return ( + + Profit & Loss Statement + +
+ {/* Revenue */} +
+

Revenue

+
+ {(data.revenue ?? []).map((r, i) => ( +
+ {r.name} + {formatCurrency(Number(r.amount))} +
+ ))} +
+ Total Revenue + {formatCurrency(Number(data.totalRevenue))} +
+
+
+ + {/* Expenses */} +
+

Expenses

+
+ {(data.expenses ?? []).map((e, i) => ( +
+ {e.name} + {formatCurrency(Number(e.amount))} +
+ ))} +
+ Total Expenses + {formatCurrency(Number(data.totalExpenses))} +
+
+
+ + {/* Net Income */} +
= 0 ? "bg-green-50 text-green-800" : "bg-red-50 text-red-800"}`}> + Net Income + {formatCurrency(netIncome)} +
+
+
+
+ ); +} + +// ─── Balance Sheet ───────────────────────────────────────────────────────────── + +function BalanceSheet({ asOf }: { asOf: string }) { + const { data, isLoading } = useQuery({ + queryKey: ["report-balance-sheet", asOf], + queryFn: async () => { + const res = await api.get(`/api/v1/reports/balance-sheet?asOf=${asOf}`); + return res.data as BalanceSheetReport; + }, + }); + + if (isLoading) return
; + if (!data) return

No data

; + + function Section({ title, items, total, color }: { title: string; items: Array<{ name: string; amount: number }>; total: number; color: string }) { + return ( +
+

{title}

+
+ {items.map((item, i) => ( +
+ {item.name} + {formatCurrency(Number(item.amount))} +
+ ))} +
+ Total {title} + {formatCurrency(Number(total))} +
+
+
+ ); + } + + return ( + + Balance Sheet + +
+
+
+
+
+
+
+
+ Total Liabilities + Equity + {formatCurrency(Number(data.totalLiabilities) + Number(data.totalEquity))} +
+
+
+ ); +} + +// ─── Cash Flow ───────────────────────────────────────────────────────────────── + +function CashFlow({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) { + const { data, isLoading } = useQuery({ + queryKey: ["report-cash-flow", dateFrom, dateTo], + queryFn: async () => { + const res = await api.get(`/api/v1/reports/cash-flow?dateFrom=${dateFrom}&dateTo=${dateTo}`); + return res.data as CashFlowReport; + }, + }); + + if (isLoading) return
; + if (!data) return

No data for this period

; + + function CashSection({ title, items, net }: { title: string; items: Array<{ name: string; amount: number }>; net: number }) { + return ( +
+

{title}

+
+ {items.map((item, i) => ( +
+ {item.name} + + {formatCurrency(Number(item.amount))} + +
+ ))} +
+ Net {title} + = 0 ? "text-green-700" : "text-red-600"}> + {formatCurrency(Number(net))} + +
+
+
+ ); + } + + return ( + + Cash Flow Statement + +
+ + + +
= 0 ? "bg-green-50 text-green-800" : "bg-red-50 text-red-800"}`}> + Net Cash Flow + {formatCurrency(Number(data.netCashFlow))} +
+
+
+
+ ); +} + +// ─── Main Page ───────────────────────────────────────────────────────────────── + +export default function AccountingReportsPage() { + const today = new Date().toISOString().split("T")[0]; + const firstOfYear = `${new Date().getFullYear()}-01-01`; + + const [activeTab, setActiveTab] = useState("trial-balance"); + const [dateFrom, setDateFrom] = useState(firstOfYear); + const [dateTo, setDateTo] = useState(today); + const [asOf, setAsOf] = useState(today); + + return ( +
+
+
+

Accounting

+

Financial statements and accounting reports

+
+ {activeTab !== "balance-sheet" ? ( +
+ + setDateFrom(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> + + setDateTo(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> +
+ ) : ( +
+ + setAsOf(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> +
+ )} +
+ + + + {/* Report tabs */} +
+ {TABS.map(({ key, label }) => ( + + ))} +
+ + {activeTab === "trial-balance" && } + {activeTab === "profit-loss" && } + {activeTab === "balance-sheet" && } + {activeTab === "cash-flow" && } +
+ ); +} diff --git a/src/components/accounting/AccountingNav.tsx b/src/components/accounting/AccountingNav.tsx new file mode 100644 index 0000000..0bb34b0 --- /dev/null +++ b/src/components/accounting/AccountingNav.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { BookOpen, BookText, Receipt, Building2, BarChart3 } from "lucide-react"; + +const NAV_ITEMS = [ + { href: "/accounting", label: "Chart of Accounts", icon: BookOpen, exact: true }, + { href: "/accounting/journal-entries", label: "Journal Entries", icon: BookText }, + { href: "/accounting/expenses", label: "Expenses", icon: Receipt }, + { href: "/accounting/company-accounts", label: "Company Accounts", icon: Building2 }, + { href: "/accounting/reports", label: "Reports", icon: BarChart3 }, +]; + +export function AccountingNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index a903b9b..905bee9 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -17,6 +17,7 @@ import { Wifi, Briefcase, BarChart2, + BookOpen, X, } from "lucide-react"; @@ -32,6 +33,7 @@ const navItems = [ { href: "/users", label: "Users", icon: UserCog }, { href: "/audit-log", label: "Audit Log", icon: ClipboardList }, { href: "/reports", label: "Reports", icon: BarChart2 }, + { href: "/accounting", label: "Accounting", icon: BookOpen }, { href: "/settings", label: "Settings", icon: Settings }, ]; diff --git a/src/types/index.ts b/src/types/index.ts index b74579c..6ab9792 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -211,6 +211,110 @@ export interface LegacyPaginatedResponse { limit: number; } +// ─── Accounting ──────────────────────────────────────────────────────────────── + +export type AccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'REVENUE' | 'EXPENSE'; + +export interface Account { + id: string; + code: string; + name: string; + type: AccountType; + isActive: boolean; + balance?: number; + createdAt: string; +} + +export interface JournalEntryLine { + id?: string; + accountId: string; + account?: Account; + debit: number; + credit: number; + memo?: string; +} + +export interface JournalEntry { + id: string; + date: string; + reference?: string; + description: string; + sourceType?: string; + lines: JournalEntryLine[]; + totalDebit?: number; + createdAt: string; +} + +export interface Expense { + id: string; + date: string; + vendor?: string; + accountId: string; + account?: Account; + amount: number; + description?: string; + createdAt: string; +} + +export type CompanyAccountType = 'CASH' | 'BANK' | 'EWALLET'; + +export interface CompanyAccount { + id: string; + name: string; + type: CompanyAccountType; + bankName?: string; + accountNumber?: string; + balance: number; + createdAt: string; +} + +export interface Transfer { + id: string; + fromAccountId: string; + fromAccount?: CompanyAccount; + toAccountId: string; + toAccount?: CompanyAccount; + amount: number; + date: string; + note?: string; + createdAt: string; +} + +export interface TrialBalanceLine { + code: string; + name: string; + debit: number; + credit: number; + balance: number; +} + +export interface ProfitLossReport { + revenue: Array<{ name: string; amount: number }>; + expenses: Array<{ name: string; amount: number }>; + totalRevenue: number; + totalExpenses: number; + netIncome: number; +} + +export interface BalanceSheetReport { + assets: Array<{ name: string; amount: number }>; + liabilities: Array<{ name: string; amount: number }>; + equity: Array<{ name: string; amount: number }>; + totalAssets: number; + totalLiabilities: number; + totalEquity: number; +} + +export interface CashFlowReport { + operating: Array<{ name: string; amount: number }>; + investing: Array<{ name: string; amount: number }>; + financing: Array<{ name: string; amount: number }>; + netOperating: number; + netInvesting: number; + netFinancing: number; + netCashFlow: number; +} + export interface Lead { id: string; firstName: string;