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