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