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:
314
app/(app)/accounting/company-accounts/page.tsx
Normal file
314
app/(app)/accounting/company-accounts/page.tsx
Normal file
@@ -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<CompanyAccountType, "default" | "success" | "warning"> = {
|
||||
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<CompanyAccount | null>(null);
|
||||
const [transferForm, setTransferForm] = useState({ toAccountId: "", amount: "", date: today, note: "" });
|
||||
|
||||
const { data: accounts = [], isLoading, refetch: refetchAccounts } = useQuery<CompanyAccount[]>({
|
||||
queryKey: ["company-accounts"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<CompanyAccount[] | { data: CompanyAccount[] }>("/api/v1/company-accounts");
|
||||
const d = res.data;
|
||||
return Array.isArray(d) ? d : (d as { data: CompanyAccount[] }).data ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: transfers = [], refetch: refetchTransfers } = useQuery<Transfer[]>({
|
||||
queryKey: ["transfers"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Transfer[] | { data: Transfer[] }>("/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 (
|
||||
<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">Company cash, bank, and e-wallet accounts</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowAdd(true)}>+ Add Account</Button>
|
||||
</div>
|
||||
|
||||
<AccountingNav />
|
||||
|
||||
{/* Account Cards */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-36 animate-pulse bg-gray-100 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-gray-400">
|
||||
No company accounts yet. Add your first account to get started.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{accounts.map((acct) => (
|
||||
<Card key={acct.id}>
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{acct.name}</p>
|
||||
{acct.bankName && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{acct.bankName}</p>
|
||||
)}
|
||||
{acct.accountNumber && (
|
||||
<p className="text-xs text-gray-400 font-mono">{acct.accountNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={TYPE_COLORS[acct.type]}>{acct.type}</Badge>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{formatCurrency(Number(acct.balance))}</p>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTransferAccount(acct);
|
||||
setTransferForm({ toAccountId: "", amount: "", date: today, note: "" });
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 mr-1" /> Transfer
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Transfers */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Transfers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<Th>Date</Th>
|
||||
<Th>From</Th>
|
||||
<Th></Th>
|
||||
<Th>To</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Note</Th>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{transfers.length === 0 ? (
|
||||
<EmptyState message="No transfers yet" />
|
||||
) : (
|
||||
transfers.slice(0, 20).map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<Td className="text-sm">{formatDate(t.date)}</Td>
|
||||
<Td className="font-medium text-sm">{t.fromAccount?.name ?? "—"}</Td>
|
||||
<Td className="text-gray-400"><ArrowRight className="h-4 w-4" /></Td>
|
||||
<Td className="font-medium text-sm">{t.toAccount?.name ?? "—"}</Td>
|
||||
<Td className="font-semibold text-sm">{formatCurrency(Number(t.amount))}</Td>
|
||||
<Td className="text-sm text-gray-500">{t.note ?? "—"}</Td>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Account Modal */}
|
||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add Company Account">
|
||||
<form onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }} className="space-y-4">
|
||||
<Input
|
||||
label="Account Name *"
|
||||
value={addForm.name}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g. BDO Savings"
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Type *</label>
|
||||
<select
|
||||
className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={addForm.type}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, type: e.target.value as CompanyAccountType }))}
|
||||
>
|
||||
{ACCOUNT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Bank Name"
|
||||
value={addForm.bankName}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bankName: e.target.value }))}
|
||||
placeholder="e.g. BDO"
|
||||
/>
|
||||
<Input
|
||||
label="Account Number"
|
||||
value={addForm.accountNumber}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, accountNumber: e.target.value }))}
|
||||
placeholder="e.g. 0012-3456-7890"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Initial Balance (₱)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={addForm.initialBalance}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, initialBalance: e.target.value }))}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowAdd(false)}>Cancel</Button>
|
||||
<Button type="submit" size="sm" isLoading={addMutation.isPending} disabled={!addForm.name.trim()}>
|
||||
Add Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Transfer Modal */}
|
||||
<Modal
|
||||
isOpen={!!transferAccount}
|
||||
onClose={() => setTransferAccount(null)}
|
||||
title={`Transfer from ${transferAccount?.name ?? ""}`}
|
||||
>
|
||||
<form onSubmit={(e) => { e.preventDefault(); transferMutation.mutate(); }} className="space-y-4">
|
||||
<div className="p-3 rounded-lg bg-blue-50 text-sm">
|
||||
<span className="text-blue-700 font-medium">Available balance: </span>
|
||||
<span className="text-blue-900 font-bold">{formatCurrency(Number(transferAccount?.balance ?? 0))}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">To Account *</label>
|
||||
<select
|
||||
className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={transferForm.toAccountId}
|
||||
onChange={(e) => setTransferForm((f) => ({ ...f, toAccountId: e.target.value }))}
|
||||
>
|
||||
<option value="">Select destination account</option>
|
||||
{toAccounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} ({a.type})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Amount (₱) *"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={transferForm.amount}
|
||||
onChange={(e) => setTransferForm((f) => ({ ...f, amount: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<Input
|
||||
label="Date *"
|
||||
type="date"
|
||||
value={transferForm.date}
|
||||
onChange={(e) => setTransferForm((f) => ({ ...f, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Note"
|
||||
value={transferForm.note}
|
||||
onChange={(e) => setTransferForm((f) => ({ ...f, note: e.target.value }))}
|
||||
placeholder="Optional note"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setTransferAccount(null)}>Cancel</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={transferMutation.isPending}
|
||||
disabled={!transferForm.toAccountId || !transferForm.amount || !transferForm.date}
|
||||
>
|
||||
Transfer Funds
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
app/(app)/accounting/expenses/page.tsx
Normal file
210
app/(app)/accounting/expenses/page.tsx
Normal file
@@ -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<Expense[]>({
|
||||
queryKey: ["expenses", from, to],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Expense[] | { data: Expense[] }>(`/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<Account[]>({
|
||||
queryKey: ["accounts-expense"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Account[] | { data: Account[] }>("/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 (
|
||||
<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">Track business expenses</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowAdd(true)}>+ Record Expense</Button>
|
||||
</div>
|
||||
|
||||
<AccountingNav />
|
||||
|
||||
{/* Date filter */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<label className="text-gray-500">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<label className="text-gray-500">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Expenses ({expenses.length})
|
||||
{expenses.length > 0 && (
|
||||
<span className="ml-3 text-sm font-normal text-gray-500">
|
||||
Total: <span className="font-semibold text-gray-800">{formatCurrency(totalAmount)}</span>
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<Th>Date</Th>
|
||||
<Th>Vendor</Th>
|
||||
<Th>Account</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Description</Th>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{[1,2,3,4,5].map((j) => (
|
||||
<Td key={j}><div className="h-4 w-20 animate-pulse bg-gray-100 rounded" /></Td>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : expenses.length === 0 ? (
|
||||
<EmptyState message="No expenses in this period" />
|
||||
) : (
|
||||
expenses.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<Td className="text-sm">{formatDate(e.date)}</Td>
|
||||
<Td className="font-medium">{e.vendor ?? <span className="text-gray-300">—</span>}</Td>
|
||||
<Td className="text-sm text-gray-600">
|
||||
{e.account ? `${e.account.code} — ${e.account.name}` : "—"}
|
||||
</Td>
|
||||
<Td className="font-semibold text-sm">{formatCurrency(Number(e.amount))}</Td>
|
||||
<Td className="text-sm text-gray-500 max-w-xs truncate">
|
||||
{e.description ?? <span className="text-gray-300">—</span>}
|
||||
</Td>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Record Expense">
|
||||
<form onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }} className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Expense Account *</label>
|
||||
<select
|
||||
className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={form.accountId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, 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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Amount (₱) *"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={form.amount}
|
||||
onChange={(e) => setForm((f) => ({ ...f, amount: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<Input
|
||||
label="Date *"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Vendor"
|
||||
value={form.vendor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vendor: e.target.value }))}
|
||||
placeholder="e.g. PLDT, Globe"
|
||||
/>
|
||||
<Input
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="What was this expense for?"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowAdd(false)}>Cancel</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={addMutation.isPending}
|
||||
disabled={!form.accountId || !form.amount || !form.date}
|
||||
>
|
||||
Record Expense
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
231
app/(app)/accounting/page.tsx
Normal file
231
app/(app)/accounting/page.tsx
Normal file
@@ -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<AccountType, "default" | "success" | "warning" | "danger" | "muted"> = {
|
||||
ASSET: "success",
|
||||
LIABILITY: "danger",
|
||||
EQUITY: "warning",
|
||||
REVENUE: "default",
|
||||
EXPENSE: "muted",
|
||||
};
|
||||
|
||||
export default function ChartOfAccountsPage() {
|
||||
const [activeType, setActiveType] = useState<AccountType | "ALL">("ALL");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ code: "", name: "", type: "ASSET" as AccountType });
|
||||
|
||||
const { data, isLoading, refetch } = 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 ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
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 (
|
||||
<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">Chart of accounts, journals, and financial reports</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!hasAccounts && (
|
||||
<Button variant="outline" size="sm" onClick={() => seedMutation.mutate()} isLoading={seedMutation.isPending}>
|
||||
Seed Defaults
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => setShowAdd(true)}>+ Add Account</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountingNav />
|
||||
|
||||
{/* Type filter tabs */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{TYPE_TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveType(key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
activeType === key
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{key !== "ALL" && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
({accounts.filter((a) => a.type === key).length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{activeType === "ALL" ? "All Accounts" : activeType} ({filtered.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<Th>Code</Th>
|
||||
<Th>Name</Th>
|
||||
<Th>Type</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Actions</Th>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{[1,2,3,4,5].map((j) => (
|
||||
<Td key={j}><div className="h-4 w-20 animate-pulse bg-gray-100 rounded" /></Td>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState message={hasAccounts ? "No accounts of this type" : "No accounts yet — seed defaults or add manually"} />
|
||||
) : (
|
||||
filtered.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<Td className="font-mono text-sm text-gray-700">{a.code}</Td>
|
||||
<Td className="font-medium">{a.name}</Td>
|
||||
<Td>
|
||||
<Badge variant={TYPE_COLORS[a.type]}>{a.type}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={a.isActive ? "success" : "muted"}>
|
||||
{a.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleMutation.mutate({ id: a.id, isActive: a.isActive })}
|
||||
>
|
||||
{a.isActive ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
</Td>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add Account">
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Input
|
||||
label="Account Code"
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||
placeholder="e.g. 1001"
|
||||
/>
|
||||
<Input
|
||||
label="Account Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g. Cash on Hand"
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Type</label>
|
||||
<select
|
||||
className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as AccountType }))}
|
||||
>
|
||||
{ACCOUNT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowAdd(false)}>Cancel</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={addMutation.isPending}
|
||||
disabled={!form.code.trim() || !form.name.trim()}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
339
app/(app)/accounting/reports/page.tsx
Normal file
339
app/(app)/accounting/reports/page.tsx
Normal file
@@ -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<TrialBalanceLine[]>({
|
||||
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 <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Trial Balance</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 py-8 text-center">No data for this period</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500">Code</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500">Account</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500">Debit</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500">Credit</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((l, i) => (
|
||||
<tr key={i} className="border-t hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500">{l.code}</td>
|
||||
<td className="px-4 py-2.5 font-medium">{l.name}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-sm">
|
||||
{Number(l.debit) > 0 ? formatCurrency(Number(l.debit)) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-sm">
|
||||
{Number(l.credit) > 0 ? formatCurrency(Number(l.credit)) : "—"}
|
||||
</td>
|
||||
<td className={`px-4 py-2.5 text-right font-mono text-sm font-semibold ${Number(l.balance) < 0 ? "text-red-600" : "text-gray-800"}`}>
|
||||
{formatCurrency(Math.abs(Number(l.balance)))}
|
||||
{Number(l.balance) < 0 && " Cr"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td colSpan={2} className="px-4 py-2.5 text-sm">Totals</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-sm">{formatCurrency(totalDebit)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-sm">{formatCurrency(totalCredit)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{Math.abs(totalDebit - totalCredit) < 0.01 ? (
|
||||
<span className="text-green-600 text-xs">✓ Balanced</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs">Off by {formatCurrency(Math.abs(totalDebit - totalCredit))}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── P&L ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfitLoss({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) {
|
||||
const { data, isLoading } = useQuery<ProfitLossReport>({
|
||||
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 <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
||||
if (!data) return <p className="text-sm text-gray-400 py-8 text-center">No data for this period</p>;
|
||||
|
||||
const netIncome = Number(data.netIncome ?? (Number(data.totalRevenue) - Number(data.totalExpenses)));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Profit & Loss Statement</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Revenue */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">Revenue</h3>
|
||||
<div className="space-y-1">
|
||||
{(data.revenue ?? []).map((r, i) => (
|
||||
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
||||
<span className="text-gray-700">{r.name}</span>
|
||||
<span className="font-mono font-medium">{formatCurrency(Number(r.amount))}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-sm font-bold py-1 border-t">
|
||||
<span>Total Revenue</span>
|
||||
<span className="text-green-700">{formatCurrency(Number(data.totalRevenue))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expenses */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">Expenses</h3>
|
||||
<div className="space-y-1">
|
||||
{(data.expenses ?? []).map((e, i) => (
|
||||
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
||||
<span className="text-gray-700">{e.name}</span>
|
||||
<span className="font-mono font-medium">{formatCurrency(Number(e.amount))}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-sm font-bold py-1 border-t">
|
||||
<span>Total Expenses</span>
|
||||
<span className="text-red-600">{formatCurrency(Number(data.totalExpenses))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net Income */}
|
||||
<div className={`flex justify-between text-base font-bold p-3 rounded-lg ${netIncome >= 0 ? "bg-green-50 text-green-800" : "bg-red-50 text-red-800"}`}>
|
||||
<span>Net Income</span>
|
||||
<span>{formatCurrency(netIncome)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Balance Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function BalanceSheet({ asOf }: { asOf: string }) {
|
||||
const { data, isLoading } = useQuery<BalanceSheetReport>({
|
||||
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 <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
||||
if (!data) return <p className="text-sm text-gray-400 py-8 text-center">No data</p>;
|
||||
|
||||
function Section({ title, items, total, color }: { title: string; items: Array<{ name: string; amount: number }>; total: number; color: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">{title}</h3>
|
||||
<div className="space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
||||
<span className="text-gray-700">{item.name}</span>
|
||||
<span className="font-mono">{formatCurrency(Number(item.amount))}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={`flex justify-between text-sm font-bold py-1 border-t ${color}`}>
|
||||
<span>Total {title}</span>
|
||||
<span>{formatCurrency(Number(total))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Balance Sheet</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Section title="Assets" items={data.assets ?? []} total={data.totalAssets} color="text-blue-700" />
|
||||
<div className="space-y-6">
|
||||
<Section title="Liabilities" items={data.liabilities ?? []} total={data.totalLiabilities} color="text-red-600" />
|
||||
<Section title="Equity" items={data.equity ?? []} total={data.totalEquity} color="text-purple-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 p-3 rounded-lg bg-gray-50 flex justify-between text-sm font-bold">
|
||||
<span>Total Liabilities + Equity</span>
|
||||
<span>{formatCurrency(Number(data.totalLiabilities) + Number(data.totalEquity))}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cash Flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CashFlow({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) {
|
||||
const { data, isLoading } = useQuery<CashFlowReport>({
|
||||
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 <div className="h-64 animate-pulse bg-gray-100 rounded-xl" />;
|
||||
if (!data) return <p className="text-sm text-gray-400 py-8 text-center">No data for this period</p>;
|
||||
|
||||
function CashSection({ title, items, net }: { title: string; items: Array<{ name: string; amount: number }>; net: number }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2 uppercase tracking-wide">{title}</h3>
|
||||
<div className="space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm py-1 border-b border-gray-50">
|
||||
<span className="text-gray-700">{item.name}</span>
|
||||
<span className={`font-mono ${Number(item.amount) < 0 ? "text-red-600" : ""}`}>
|
||||
{formatCurrency(Number(item.amount))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between text-sm font-bold py-1 border-t">
|
||||
<span>Net {title}</span>
|
||||
<span className={Number(net) >= 0 ? "text-green-700" : "text-red-600"}>
|
||||
{formatCurrency(Number(net))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Cash Flow Statement</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<CashSection title="Operating Activities" items={data.operating ?? []} net={data.netOperating} />
|
||||
<CashSection title="Investing Activities" items={data.investing ?? []} net={data.netInvesting} />
|
||||
<CashSection title="Financing Activities" items={data.financing ?? []} net={data.netFinancing} />
|
||||
<div className={`flex justify-between text-base font-bold p-3 rounded-lg ${Number(data.netCashFlow) >= 0 ? "bg-green-50 text-green-800" : "bg-red-50 text-red-800"}`}>
|
||||
<span>Net Cash Flow</span>
|
||||
<span>{formatCurrency(Number(data.netCashFlow))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<ReportTab>("trial-balance");
|
||||
const [dateFrom, setDateFrom] = useState(firstOfYear);
|
||||
const [dateTo, setDateTo] = useState(today);
|
||||
const [asOf, setAsOf] = useState(today);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Accounting</h1>
|
||||
<p className="text-sm text-gray-500">Financial statements and accounting reports</p>
|
||||
</div>
|
||||
{activeTab !== "balance-sheet" ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<label className="text-gray-500">From</label>
|
||||
<input type="date" value={dateFrom} onChange={(e) => 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" />
|
||||
<label className="text-gray-500">To</label>
|
||||
<input type="date" value={dateTo} onChange={(e) => 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" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<label className="text-gray-500">As of</label>
|
||||
<input type="date" value={asOf} onChange={(e) => 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" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccountingNav />
|
||||
|
||||
{/* Report tabs */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === key
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "trial-balance" && <TrialBalance dateFrom={dateFrom} dateTo={dateTo} />}
|
||||
{activeTab === "profit-loss" && <ProfitLoss dateFrom={dateFrom} dateTo={dateTo} />}
|
||||
{activeTab === "balance-sheet" && <BalanceSheet asOf={asOf} />}
|
||||
{activeTab === "cash-flow" && <CashFlow dateFrom={dateFrom} dateTo={dateTo} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user