Compare commits
4 Commits
fix/FIBERO
...
fix/accoun
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f646cbed | ||
| 7db2a3fb5b | |||
| b719d87e1b | |||
| 89585ab645 |
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, UserPlus, FileText, CreditCard,
|
LayoutDashboard, Users, UserPlus, FileText, CreditCard,
|
||||||
ArrowLeftRight, Ticket, BarChart3, ScrollText, Settings,
|
ArrowLeftRight, Ticket, BarChart3, ScrollText, Settings, BookOpen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -17,6 +17,7 @@ const navItems = [
|
|||||||
{ label: 'Remittances', href: '/remittances', icon: ArrowLeftRight, roles: ['admin', 'collector'] },
|
{ label: 'Remittances', href: '/remittances', icon: ArrowLeftRight, roles: ['admin', 'collector'] },
|
||||||
{ label: 'Tickets', href: '/tickets', icon: Ticket, roles: [] },
|
{ label: 'Tickets', href: '/tickets', icon: Ticket, roles: [] },
|
||||||
{ label: 'Reports', href: '/reports', icon: BarChart3, roles: ['admin', 'staff'] },
|
{ label: 'Reports', href: '/reports', icon: BarChart3, roles: ['admin', 'staff'] },
|
||||||
|
{ label: 'Accounting', href: '/accounting', icon: BookOpen, roles: ['admin'] },
|
||||||
{ label: 'Audit Log', href: '/audit-log', icon: ScrollText, roles: ['admin'] },
|
{ label: 'Audit Log', href: '/audit-log', icon: ScrollText, roles: ['admin'] },
|
||||||
{ label: 'Settings', href: '/settings', icon: Settings, roles: ['admin'] },
|
{ label: 'Settings', href: '/settings', icon: Settings, roles: ['admin'] },
|
||||||
];
|
];
|
||||||
|
|||||||
50
e2e/accounting.spec.ts
Normal file
50
e2e/accounting.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('Accounting', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chart of accounts page loads', async ({ page }) => {
|
||||||
|
await page.goto('/accounting');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await expect(page.locator('h1').filter({ hasText: 'Accounting' }).first()).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accounting sub-nav visible', async ({ page }) => {
|
||||||
|
await page.goto('/accounting');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await expect(page.locator('a').filter({ hasText: /Expenses/ }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('journal entries page loads', async ({ page }) => {
|
||||||
|
await page.goto('/accounting/journal-entries');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await expect(page.locator('h1').filter({ hasText: 'Accounting' }).first()).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expenses page loads', async ({ page }) => {
|
||||||
|
await page.goto('/accounting/expenses');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await expect(page.locator('h1').filter({ hasText: 'Accounting' }).first()).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('company accounts page loads', async ({ page }) => {
|
||||||
|
await page.goto('/accounting/company-accounts');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await expect(page.locator('h1').filter({ hasText: 'Accounting' }).first()).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('financial reports page loads', async ({ page }) => {
|
||||||
|
await page.goto('/accounting/reports');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await expect(page.locator('text=Trial Balance').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accounting link in sidebar', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await expect(page.locator('a[href="/accounting"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/components/accounting/AccountingNav.tsx
Normal file
41
src/components/accounting/AccountingNav.tsx
Normal file
@@ -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 (
|
||||||
|
<nav className="flex gap-1 border-b border-gray-200 mb-6 overflow-x-auto">
|
||||||
|
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
|
||||||
|
const isActive = exact ? pathname === href : pathname === href || pathname.startsWith(href + "/");
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px whitespace-nowrap transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-blue-600 text-blue-700"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Wifi,
|
Wifi,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
|
BookOpen,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ const navItems = [
|
|||||||
{ href: "/users", label: "Users", icon: UserCog },
|
{ href: "/users", label: "Users", icon: UserCog },
|
||||||
{ href: "/audit-log", label: "Audit Log", icon: ClipboardList },
|
{ href: "/audit-log", label: "Audit Log", icon: ClipboardList },
|
||||||
{ href: "/reports", label: "Reports", icon: BarChart2 },
|
{ href: "/reports", label: "Reports", icon: BarChart2 },
|
||||||
|
{ href: "/accounting", label: "Accounting", icon: BookOpen },
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,110 @@ export interface LegacyPaginatedResponse<T> {
|
|||||||
limit: number;
|
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 {
|
export interface Lead {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user