Compare commits
8 Commits
fix/FIBERO
...
feat/FIBER
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43379905f9 | ||
| 2b047055a2 | |||
| eaa03c69e0 | |||
| 45325b3e1b | |||
|
|
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>
|
||||
);
|
||||
}
|
||||
@@ -547,6 +547,15 @@ export default function ClientDetailPage() {
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-gray-400 uppercase tracking-wide">Portal Access</dt>
|
||||
<dd className="mt-0.5 text-sm">
|
||||
{client.portalAccessEnabled
|
||||
? <span className="text-green-600 font-medium">Enabled</span>
|
||||
: <span className="text-gray-400">Disabled</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
19
app/(portal)/layout.tsx
Normal file
19
app/(portal)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', backgroundColor: '#F8FAFC', fontFamily: 'Fira Sans, sans-serif' }}>
|
||||
<header style={{ backgroundColor: '#ffffff', borderBottom: '1px solid #E2E8F0', padding: '0 24px' }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', display: 'flex', alignItems: 'center', height: 56 }}>
|
||||
<span style={{ fontSize: 20, fontWeight: 700, color: '#0891B2', letterSpacing: '-0.02em' }}>
|
||||
FiberOps
|
||||
</span>
|
||||
<span style={{ marginLeft: 8, fontSize: 13, color: '#64748B', fontWeight: 500 }}>
|
||||
Subscriber Portal
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
app/(portal)/portal/dashboard/page.tsx
Normal file
206
app/(portal)/portal/dashboard/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { usePortalAuthStore } from '@/lib/portal-auth-store';
|
||||
import portalApi from '@/lib/portal-api';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
interface AccountData {
|
||||
accountNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
subscription?: {
|
||||
planName: string;
|
||||
downloadMbps: number;
|
||||
uploadMbps: number;
|
||||
monthlyRate: number;
|
||||
status: string;
|
||||
};
|
||||
balanceDue: number;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, { bg: string; text: string }> = {
|
||||
ACTIVE: { bg: '#DCFCE7', text: '#16A34A' },
|
||||
SUSPENDED: { bg: '#FEF9C3', text: '#CA8A04' },
|
||||
CANCELLED: { bg: '#FEE2E2', text: '#DC2626' },
|
||||
PENDING: { bg: '#F1F5F9', text: '#64748B' },
|
||||
};
|
||||
|
||||
export default function PortalDashboardPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, subscriber, logout } = usePortalAuthStore();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [account, setAccount] = useState<AccountData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!isAuthenticated) { router.replace('/portal/login'); return; }
|
||||
portalApi.get('/api/v1/portal/account')
|
||||
.then((res) => setAccount(res.data))
|
||||
.catch(() => setError('Failed to load account info.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [mounted, isAuthenticated, router]);
|
||||
|
||||
if (!mounted || !isAuthenticated) return null;
|
||||
|
||||
const handleLogout = () => { logout(); router.replace('/portal/login'); };
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: '32px 24px' }}>
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#0F172A', marginBottom: 2 }}>
|
||||
Welcome, {subscriber?.firstName ?? 'Subscriber'}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: '#64748B' }}>Account #{subscriber?.accountNumber}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{ fontSize: 13, color: '#64748B', background: 'none', border: '1px solid #E2E8F0', borderRadius: 8, padding: '7px 14px', cursor: 'pointer' }}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ backgroundColor: '#FEF2F2', border: '1px solid #FECACA', borderRadius: 8, padding: '12px 16px', color: '#DC2626', fontSize: 14, marginBottom: 20 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ backgroundColor: '#ffffff', borderRadius: 12, border: '1px solid #E2E8F0', padding: 24, height: 100, animation: 'pulse 1.5s infinite' }} />
|
||||
))}
|
||||
</div>
|
||||
) : account && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Account Info */}
|
||||
<div style={cardStyle}>
|
||||
<h2 style={cardTitleStyle}>Account Information</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', marginTop: 12 }}>
|
||||
{[
|
||||
{ label: 'Full Name', value: `${account.firstName} ${account.lastName}` },
|
||||
{ label: 'Account Number', value: account.accountNumber },
|
||||
{ label: 'Email', value: account.email || '—' },
|
||||
{ label: 'Phone', value: account.phone || '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p style={{ fontSize: 11, fontWeight: 600, color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{label}</p>
|
||||
<p style={{ fontSize: 14, color: '#0F172A' }}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
<div style={cardStyle}>
|
||||
<h2 style={cardTitleStyle}>Active Subscription</h2>
|
||||
{account.subscription ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', marginTop: 12 }}>
|
||||
<div>
|
||||
<p style={labelStyle}>Plan</p>
|
||||
<p style={{ fontSize: 14, color: '#0F172A', fontWeight: 600 }}>{account.subscription.planName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style={labelStyle}>Speed</p>
|
||||
<p style={{ fontSize: 14, color: '#0F172A', fontFamily: 'Fira Code, monospace' }}>
|
||||
{account.subscription.downloadMbps}↓ / {account.subscription.uploadMbps}↑ Mbps
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style={labelStyle}>Monthly Rate</p>
|
||||
<p style={{ fontSize: 14, color: '#0F172A', fontWeight: 600 }}>{formatCurrency(account.subscription.monthlyRate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style={labelStyle}>Status</p>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 20,
|
||||
backgroundColor: (statusColors[account.subscription.status] ?? statusColors.PENDING).bg,
|
||||
color: (statusColors[account.subscription.status] ?? statusColors.PENDING).text,
|
||||
}}>
|
||||
{account.subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 14, color: '#94A3B8', marginTop: 12 }}>No active subscription.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balance Due */}
|
||||
<div style={{ ...cardStyle, border: account.balanceDue > 0 ? '1px solid #FECACA' : '1px solid #E2E8F0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={cardTitleStyle}>Balance Due</h2>
|
||||
<span style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Fira Code, monospace',
|
||||
color: account.balanceDue > 0 ? '#DC2626' : '#16A34A',
|
||||
}}>
|
||||
{formatCurrency(account.balanceDue)}
|
||||
</span>
|
||||
</div>
|
||||
{account.balanceDue > 0 && (
|
||||
<p style={{ fontSize: 13, color: '#DC2626', marginTop: 8 }}>
|
||||
You have an outstanding balance. Please settle your invoices to avoid service interruption.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<Link href="/portal/invoices" style={{ ...cardStyle, textDecoration: 'none', display: 'block', textAlign: 'center', padding: '20px 16px' }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>🧾</div>
|
||||
<p style={{ fontSize: 15, fontWeight: 600, color: '#0891B2' }}>View Invoices</p>
|
||||
<p style={{ fontSize: 13, color: '#64748B', marginTop: 2 }}>See your billing history</p>
|
||||
</Link>
|
||||
<Link href="/portal/tickets" style={{ ...cardStyle, textDecoration: 'none', display: 'block', textAlign: 'center', padding: '20px 16px' }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>🎫</div>
|
||||
<p style={{ fontSize: 15, fontWeight: 600, color: '#0891B2' }}>Support Tickets</p>
|
||||
<p style={{ fontSize: 13, color: '#64748B', marginTop: 2 }}>View or raise a ticket</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #E2E8F0',
|
||||
padding: 24,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
};
|
||||
|
||||
const cardTitleStyle: React.CSSProperties = {
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#0F172A',
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#94A3B8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 2,
|
||||
};
|
||||
114
app/(portal)/portal/invoices/page.tsx
Normal file
114
app/(portal)/portal/invoices/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { usePortalAuthStore } from '@/lib/portal-auth-store';
|
||||
import portalApi from '@/lib/portal-api';
|
||||
import { formatCurrency, formatDate } from '@/lib/utils';
|
||||
|
||||
interface PortalInvoice {
|
||||
id: string;
|
||||
invoiceNumber?: string;
|
||||
total: number;
|
||||
balance: number;
|
||||
dueDate?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const statusBadge: Record<string, { bg: string; text: string }> = {
|
||||
PAID: { bg: '#DCFCE7', text: '#16A34A' },
|
||||
PARTIAL: { bg: '#FEF9C3', text: '#CA8A04' },
|
||||
OVERDUE: { bg: '#FEE2E2', text: '#DC2626' },
|
||||
SENT: { bg: '#F1F5F9', text: '#64748B' },
|
||||
DRAFT: { bg: '#F1F5F9', text: '#64748B' },
|
||||
VOID: { bg: '#F1F5F9', text: '#94A3B8' },
|
||||
};
|
||||
|
||||
export default function PortalInvoicesPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated } = usePortalAuthStore();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [invoices, setInvoices] = useState<PortalInvoice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!isAuthenticated) { router.replace('/portal/login'); return; }
|
||||
portalApi.get('/api/v1/portal/invoices')
|
||||
.then((res) => {
|
||||
const data = res.data;
|
||||
setInvoices(Array.isArray(data) ? data : data.data ?? []);
|
||||
})
|
||||
.catch(() => setError('Failed to load invoices.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [mounted, isAuthenticated, router]);
|
||||
|
||||
if (!mounted || !isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<Link href="/portal/dashboard" style={{ fontSize: 13, color: '#0891B2', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#0F172A', margin: 0 }}>Invoice History</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ backgroundColor: '#FEF2F2', border: '1px solid #FECACA', borderRadius: 8, padding: '12px 16px', color: '#DC2626', fontSize: 14, marginBottom: 20 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ backgroundColor: '#ffffff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#94A3B8', fontSize: 14 }}>Loading…</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#94A3B8', fontSize: 14 }}>No invoices found.</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#F8FAFC', borderBottom: '1px solid #E2E8F0' }}>
|
||||
{['Invoice #', 'Amount', 'Balance', 'Due Date', 'Status'].map((h) => (
|
||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv, i) => {
|
||||
const badge = statusBadge[inv.status] ?? statusBadge.SENT;
|
||||
return (
|
||||
<tr key={inv.id} style={{ borderBottom: i < invoices.length - 1 ? '1px solid #F1F5F9' : 'none' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, color: '#0F172A', fontFamily: 'Fira Code, monospace' }}>
|
||||
{inv.invoiceNumber ?? inv.id.slice(0, 8)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 14, color: '#0F172A' }}>
|
||||
{formatCurrency(Number(inv.total ?? 0))}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 14, fontWeight: Number(inv.balance) > 0 ? 600 : 400, color: Number(inv.balance) > 0 ? '#DC2626' : '#64748B' }}>
|
||||
{formatCurrency(Number(inv.balance ?? 0))}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 14, color: '#64748B' }}>
|
||||
{inv.dueDate ? formatDate(inv.dueDate) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, padding: '3px 10px', borderRadius: 20, backgroundColor: badge.bg, color: badge.text }}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
app/(portal)/portal/login/page.tsx
Normal file
118
app/(portal)/portal/login/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePortalAuthStore } from '@/lib/portal-auth-store';
|
||||
|
||||
export default function PortalLoginPage() {
|
||||
const router = useRouter();
|
||||
const login = usePortalAuthStore((s) => s.login);
|
||||
const [form, setForm] = useState({ tenantSlug: '', accountNumber: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(form.tenantSlug, form.accountNumber, form.password);
|
||||
router.replace('/portal/dashboard');
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as any)?.response?.data?.message ?? 'Login failed. Check your credentials.';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 'calc(100vh - 56px)', padding: '24px' }}>
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ backgroundColor: '#ffffff', borderRadius: 12, border: '1px solid #E2E8F0', padding: 32, boxShadow: '0 1px 3px rgba(0,0,0,0.06)' }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#0F172A', marginBottom: 4 }}>Sign in to your account</h1>
|
||||
<p style={{ fontSize: 14, color: '#64748B', marginBottom: 24 }}>Enter your ISP code and account details to continue.</p>
|
||||
|
||||
{error && (
|
||||
<div style={{ backgroundColor: '#FEF2F2', border: '1px solid #FECACA', borderRadius: 8, padding: '10px 14px', marginBottom: 16, color: '#DC2626', fontSize: 14 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#374151', marginBottom: 6 }}>
|
||||
ISP Code (Tenant Slug)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.tenantSlug}
|
||||
onChange={(e) => setForm({ ...form, tenantSlug: e.target.value })}
|
||||
placeholder="e.g. demo-isp"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#374151', marginBottom: 6 }}>
|
||||
Account Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.accountNumber}
|
||||
onChange={(e) => setForm({ ...form, accountNumber: e.target.value })}
|
||||
placeholder="e.g. ACC-2025-0001"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#374151', marginBottom: 6 }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
backgroundColor: loading ? '#67C5DD' : '#0891B2',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '11px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
marginTop: 4,
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '9px 12px',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
206
app/(portal)/portal/tickets/page.tsx
Normal file
206
app/(portal)/portal/tickets/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { usePortalAuthStore } from '@/lib/portal-auth-store';
|
||||
import portalApi from '@/lib/portal-api';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
interface PortalTicket {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
type?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const statusBadge: Record<string, { bg: string; text: string }> = {
|
||||
OPEN: { bg: '#DBEAFE', text: '#1D4ED8' },
|
||||
IN_PROGRESS: { bg: '#FEF9C3', text: '#CA8A04' },
|
||||
RESOLVED: { bg: '#DCFCE7', text: '#16A34A' },
|
||||
CLOSED: { bg: '#F1F5F9', text: '#64748B' },
|
||||
};
|
||||
|
||||
export default function PortalTicketsPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated } = usePortalAuthStore();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [tickets, setTickets] = useState<PortalTicket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [form, setForm] = useState({ subject: '', description: '' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!isAuthenticated) { router.replace('/portal/login'); return; }
|
||||
loadTickets();
|
||||
}, [mounted, isAuthenticated, router]);
|
||||
|
||||
const loadTickets = () => {
|
||||
setLoading(true);
|
||||
portalApi.get('/api/v1/portal/tickets')
|
||||
.then((res) => {
|
||||
const data = res.data;
|
||||
setTickets(Array.isArray(data) ? data : data.data ?? []);
|
||||
})
|
||||
.catch(() => setError('Failed to load tickets.'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const handleSubmitTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await portalApi.post('/api/v1/portal/tickets', form);
|
||||
setShowModal(false);
|
||||
setForm({ subject: '', description: '' });
|
||||
loadTickets();
|
||||
} catch (err: unknown) {
|
||||
setSubmitError((err as any)?.response?.data?.message ?? 'Failed to submit ticket.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || !isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Link href="/portal/dashboard" style={{ fontSize: 13, color: '#0891B2', textDecoration: 'none' }}>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#0F172A', margin: 0 }}>Support Tickets</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{ backgroundColor: '#059669', color: '#ffffff', border: 'none', borderRadius: 8, padding: '9px 18px', fontSize: 14, fontWeight: 600, cursor: 'pointer' }}
|
||||
>
|
||||
+ New Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ backgroundColor: '#FEF2F2', border: '1px solid #FECACA', borderRadius: 8, padding: '12px 16px', color: '#DC2626', fontSize: 14, marginBottom: 20 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ backgroundColor: '#ffffff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#94A3B8', fontSize: 14 }}>Loading…</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#94A3B8', fontSize: 14 }}>
|
||||
No tickets yet. Click "New Ticket" to raise a support request.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#F8FAFC', borderBottom: '1px solid #E2E8F0' }}>
|
||||
{['Subject', 'Type', 'Status', 'Date'].map((h) => (
|
||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((t, i) => {
|
||||
const badge = statusBadge[t.status] ?? statusBadge.CLOSED;
|
||||
return (
|
||||
<tr key={t.id} style={{ borderBottom: i < tickets.length - 1 ? '1px solid #F1F5F9' : 'none' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: 14, color: '#0F172A', fontWeight: 500 }}>{t.subject}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, color: '#64748B' }}>{t.type ?? '—'}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, padding: '3px 10px', borderRadius: 20, backgroundColor: badge.bg, color: badge.text }}>
|
||||
{t.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, color: '#64748B' }}>{formatDate(t.createdAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Ticket Modal */}
|
||||
{showModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: 24 }}>
|
||||
<div style={{ backgroundColor: '#ffffff', borderRadius: 12, padding: 28, width: '100%', maxWidth: 480, boxShadow: '0 20px 60px rgba(0,0,0,0.15)' }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#0F172A', marginBottom: 4 }}>New Support Ticket</h2>
|
||||
<p style={{ fontSize: 13, color: '#64748B', marginBottom: 20 }}>Describe your issue and our team will get back to you.</p>
|
||||
|
||||
{submitError && (
|
||||
<div style={{ backgroundColor: '#FEF2F2', border: '1px solid #FECACA', borderRadius: 8, padding: '10px 14px', color: '#DC2626', fontSize: 13, marginBottom: 16 }}>
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmitTicket} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#374151', marginBottom: 6 }}>Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
placeholder="e.g. Internet not working"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#374151', marginBottom: 6 }}>Description</label>
|
||||
<textarea
|
||||
required
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Please describe your issue in detail…"
|
||||
rows={4}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowModal(false); setSubmitError(''); setForm({ subject: '', description: '' }); }}
|
||||
style={{ fontSize: 14, color: '#64748B', background: 'none', border: '1px solid #E2E8F0', borderRadius: 8, padding: '9px 18px', cursor: 'pointer' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
style={{ backgroundColor: submitting ? '#67C5DD' : '#0891B2', color: '#ffffff', border: 'none', borderRadius: 8, padding: '9px 18px', fontSize: 14, fontWeight: 600, cursor: submitting ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '9px 12px',
|
||||
border: '1px solid #D1D5DB',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import {
|
||||
LayoutDashboard, Users, UserPlus, FileText, CreditCard,
|
||||
ArrowLeftRight, Ticket, BarChart3, ScrollText, Settings,
|
||||
ArrowLeftRight, Ticket, BarChart3, ScrollText, Settings, BookOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
@@ -17,6 +17,7 @@ const navItems = [
|
||||
{ label: 'Remittances', href: '/remittances', icon: ArrowLeftRight, roles: ['admin', 'collector'] },
|
||||
{ label: 'Tickets', href: '/tickets', icon: Ticket, roles: [] },
|
||||
{ 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: '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();
|
||||
});
|
||||
});
|
||||
42
e2e/portal.spec.ts
Normal file
42
e2e/portal.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Subscriber Portal', () => {
|
||||
test('portal login page loads', async ({ page }) => {
|
||||
await page.goto('/portal/login');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('input[type="text"], input[placeholder*="account" i]').first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('portal login page has password field', async ({ page }) => {
|
||||
await page.goto('/portal/login');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('input[type="password"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('portal login page shows FiberOps branding', async ({ page }) => {
|
||||
await page.goto('/portal/login');
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('text=FiberOps').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('portal login redirects to dashboard on wrong creds', async ({ page }) => {
|
||||
await page.goto('/portal/login');
|
||||
await page.waitForTimeout(2000);
|
||||
// Fill and submit
|
||||
const inputs = page.locator('input');
|
||||
const count = await inputs.count();
|
||||
if (count >= 3) {
|
||||
await inputs.nth(0).fill('demo-isp');
|
||||
await inputs.nth(1).fill('ACC-000001');
|
||||
await inputs.nth(2).fill('wrongpassword');
|
||||
}
|
||||
// Should stay on login (not crash)
|
||||
const btn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign In")').first();
|
||||
if (await btn.isVisible()) {
|
||||
await btn.click();
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
// Should not crash — still render something
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
});
|
||||
});
|
||||
33
lib/portal-api.ts
Normal file
33
lib/portal-api.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const portalApi = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://fiberops-api.juankibin.space',
|
||||
});
|
||||
|
||||
portalApi.interceptors.request.use((config) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('portal_token');
|
||||
const authRaw = localStorage.getItem('portal_auth');
|
||||
const tenantSlug = authRaw ? JSON.parse(authRaw)?.state?.tenantSlug : null;
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
if (tenantSlug) {
|
||||
config.headers['x-tenant-slug'] = tenantSlug;
|
||||
config.headers['X-Tenant-Slug'] = tenantSlug;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
portalApi.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401 && typeof window !== 'undefined') {
|
||||
localStorage.removeItem('portal_token');
|
||||
localStorage.removeItem('portal_auth');
|
||||
window.location.href = '/portal/login';
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default portalApi;
|
||||
53
lib/portal-auth-store.ts
Normal file
53
lib/portal-auth-store.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import portalApi from './portal-api';
|
||||
|
||||
interface PortalSubscriber {
|
||||
accountNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface PortalAuthState {
|
||||
subscriber: PortalSubscriber | null;
|
||||
portalToken: string | null;
|
||||
tenantSlug: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (tenantSlug: string, accountNumber: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const usePortalAuthStore = create<PortalAuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
subscriber: null,
|
||||
portalToken: null,
|
||||
tenantSlug: null,
|
||||
isAuthenticated: false,
|
||||
login: async (tenantSlug, accountNumber, password) => {
|
||||
const res = await portalApi.post('/api/v1/portal/auth/login', {
|
||||
tenantSlug,
|
||||
accountNumber,
|
||||
password,
|
||||
});
|
||||
const { token, subscriber } = res.data;
|
||||
localStorage.setItem('portal_token', token);
|
||||
set({
|
||||
portalToken: token,
|
||||
tenantSlug,
|
||||
subscriber: {
|
||||
accountNumber: subscriber.accountNumber,
|
||||
firstName: subscriber.firstName,
|
||||
lastName: subscriber.lastName,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('portal_token');
|
||||
set({ subscriber: null, portalToken: null, tenantSlug: null, isAuthenticated: false });
|
||||
},
|
||||
}),
|
||||
{ name: 'portal_auth' }
|
||||
)
|
||||
);
|
||||
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,
|
||||
Briefcase,
|
||||
BarChart2,
|
||||
BookOpen,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -32,6 +33,7 @@ const navItems = [
|
||||
{ href: "/users", label: "Users", icon: UserCog },
|
||||
{ href: "/audit-log", label: "Audit Log", icon: ClipboardList },
|
||||
{ href: "/reports", label: "Reports", icon: BarChart2 },
|
||||
{ href: "/accounting", label: "Accounting", icon: BookOpen },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface Client {
|
||||
updatedAt: string;
|
||||
area?: { id: string; name: string };
|
||||
subscriptions?: Subscription[];
|
||||
portalAccessEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
@@ -211,6 +212,110 @@ export interface LegacyPaginatedResponse<T> {
|
||||
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 {
|
||||
id: string;
|
||||
firstName: string;
|
||||
|
||||
Reference in New Issue
Block a user