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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user