247 lines
15 KiB
TypeScript
247 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { api } from '@/lib/api';
|
|
import { PageHeader } from '@/components/ui/page-header';
|
|
import { DataTable } from '@/components/ui/data-table';
|
|
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ActionIcon } from '@/components/ui/action-icon';
|
|
import { Modal } from '@/components/ui/modal';
|
|
import { FormModal } from '@/components/ui/form-modal';
|
|
import { useToast } from '@/components/ui/toast';
|
|
import { useAuthStore } from '@/stores/auth.store';
|
|
import { CreateExpenseModal } from '@/components/modals/create-expense-modal';
|
|
|
|
const CATEGORIES = ['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other'];
|
|
|
|
export default function ExpensesPage() {
|
|
const [tab, setTab] = useState<'expenses' | 'recurring'>('expenses');
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex-shrink-0">
|
|
<PageHeader title="Expenses" description="Track, approve, and manage business expenses" />
|
|
<div className="mt-4 flex gap-1 border-b border-surface-200 dark:border-surface-700 mb-5">
|
|
{([['expenses', 'Expenses'], ['recurring', 'Recurring Setup']] as const).map(([key, label]) => (
|
|
<button key={key} onClick={() => setTab(key as any)}
|
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors cursor-pointer ${
|
|
tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-surface-500 hover:text-surface-700 dark:text-surface-400'
|
|
}`}>{label}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
{tab === 'expenses' && <ExpensesTab />}
|
|
{tab === 'recurring' && <RecurringTab />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExpensesTab() {
|
|
const { toast } = useToast();
|
|
const currentUser = useAuthStore((s) => s.user);
|
|
const [expenses, setExpenses] = useState<any[]>([]);
|
|
const [summary, setSummary] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [detailTarget, setDetailTarget] = useState<any>(null);
|
|
const [approveTarget, setApproveTarget] = useState<any>(null);
|
|
const [rejectTarget, setRejectTarget] = useState<any>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [r, s] = await Promise.all([api.get('/expenses'), api.get('/expenses/summary')]);
|
|
setExpenses(r.data.data); setSummary(s.data.data);
|
|
} catch { toast('Failed to load', 'error'); }
|
|
finally { setLoading(false); }
|
|
}, [toast]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
async function handleApprove() {
|
|
try { await api.patch(`/expenses/${approveTarget.id}/approve`); toast('Expense approved', 'success'); load(); }
|
|
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
|
|
finally { setApproveTarget(null); }
|
|
}
|
|
|
|
async function handleReject() {
|
|
try { await api.patch(`/expenses/${rejectTarget.id}/reject`); toast('Expense rejected', 'success'); load(); }
|
|
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
|
|
finally { setRejectTarget(null); }
|
|
}
|
|
|
|
const filtered = search ? expenses.filter((e: any) => e.description.toLowerCase().includes(search.toLowerCase()) || e.category.toLowerCase().includes(search.toLowerCase())) : expenses;
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title="Expenses" description="Track and approve business expenses" action={<Button onClick={() => setShowCreate(true)}>New Expense</Button>} />
|
|
|
|
{summary && (
|
|
<div className="mt-5 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5">
|
|
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Pending Approval</p>
|
|
<p className="mt-2 text-2xl font-bold text-amber-600">PHP {summary.pending.total.toLocaleString()}</p>
|
|
<p className="text-xs text-surface-400">{summary.pending.count} expenses</p>
|
|
</div>
|
|
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5">
|
|
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Total Approved</p>
|
|
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-100">PHP {summary.approved.total.toLocaleString()}</p>
|
|
<p className="text-xs text-surface-400">{summary.approved.count} expenses</p>
|
|
</div>
|
|
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5">
|
|
<p className="text-[13px] font-medium text-surface-500 dark:text-surface-400">Top Category</p>
|
|
<p className="mt-2 text-2xl font-bold text-surface-900 dark:text-surface-100">{summary.byCategory[0]?.category || '—'}</p>
|
|
<p className="text-xs text-surface-400">{summary.byCategory[0] ? `PHP ${summary.byCategory[0].total.toLocaleString()}` : 'No data'}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-5">
|
|
<DataTable data={filtered} loading={loading} keyExtractor={(e: any) => e.id}
|
|
emptyTitle="No expenses" searchPlaceholder="Search by description or category..."
|
|
searchValue={search} onSearchChange={setSearch}
|
|
onRowClick={(e: any) => setDetailTarget(e)}
|
|
columns={[
|
|
{ key: 'expenseDate', label: 'Date', sortable: true, render: (e: any) => <span className="text-surface-500">{new Date(e.expenseDate).toLocaleDateString()}</span> },
|
|
{ key: 'category', label: 'Category', sortable: true, render: (e: any) => <Badge label={e.category} /> },
|
|
{ key: 'description', label: 'Description', render: (e: any) => <span className="text-surface-800 dark:text-surface-200">{e.description}</span> },
|
|
{ key: 'amount', label: 'Amount', align: 'right' as const, sortable: true, render: (e: any) => <span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(e.amount).toLocaleString()}</span> },
|
|
{ key: 'status', label: 'Status', sortable: true, render: (e: any) => <Badge label={e.status} variant={statusBadgeVariant(e.status)} /> },
|
|
{ key: 'actions', label: '', align: 'right' as const, render: (e: any) => e.status === 'pending' && e.createdById !== currentUser?.id ? (
|
|
<div className="flex gap-1 justify-end" onClick={(ev) => ev.stopPropagation()}>
|
|
<ActionIcon icon="check" variant="primary" label="Approve" onClick={() => setApproveTarget(e)} />
|
|
<ActionIcon icon="x" variant="danger" label="Reject" onClick={() => setRejectTarget(e)} />
|
|
</div>
|
|
) : null },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<CreateExpenseModal open={showCreate} onClose={() => setShowCreate(false)} onSuccess={load} />
|
|
<Modal open={!!approveTarget} onClose={() => setApproveTarget(null)} title="Approve Expense" description={`Approve PHP ${Number(approveTarget?.amount || 0).toLocaleString()} for "${approveTarget?.description}"?`} confirmLabel="Approve" onConfirm={handleApprove} />
|
|
<Modal open={!!rejectTarget} onClose={() => setRejectTarget(null)} title="Reject Expense" description={`Reject "${rejectTarget?.description}"?`} variant="danger" confirmLabel="Reject" onConfirm={handleReject} />
|
|
|
|
{/* Expense Detail Modal */}
|
|
<Modal open={!!detailTarget} onClose={() => setDetailTarget(null)}
|
|
title="Expense Details" description={detailTarget?.description}>
|
|
{detailTarget && (
|
|
<div className="space-y-3 text-sm">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><span className="text-surface-500 dark:text-surface-400">Date:</span> <span className="text-surface-700 dark:text-surface-300">{new Date(detailTarget.expenseDate).toLocaleDateString()}</span></div>
|
|
<div><span className="text-surface-500 dark:text-surface-400">Category:</span> <Badge label={detailTarget.category} /></div>
|
|
<div><span className="text-surface-500 dark:text-surface-400">Amount:</span> <span className="font-bold text-surface-900 dark:text-surface-100">PHP {Number(detailTarget.amount).toLocaleString()}</span></div>
|
|
<div><span className="text-surface-500 dark:text-surface-400">Status:</span> <Badge label={detailTarget.status} variant={statusBadgeVariant(detailTarget.status)} /></div>
|
|
{detailTarget.notes && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Notes:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.notes}</span></div>}
|
|
{detailTarget.approvedBy && <div className="col-span-2"><span className="text-surface-500 dark:text-surface-400">Approved by:</span> <span className="text-surface-700 dark:text-surface-300">{detailTarget.approvedBy.firstName} {detailTarget.approvedBy.lastName}</span></div>}
|
|
</div>
|
|
<div className="flex justify-end pt-2 border-t border-surface-200">
|
|
<Button size="sm" variant="ghost" onClick={() => setDetailTarget(null)}>Close</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Recurring Expenses Tab ──────────────────────────────────
|
|
|
|
function RecurringTab() {
|
|
const { toast } = useToast();
|
|
const [items, setItems] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try { const r = await api.get('/expenses/recurring'); setItems(r.data.data); }
|
|
catch { toast('Failed to load', 'error'); }
|
|
finally { setLoading(false); }
|
|
}, [toast]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
async function toggle(id: string) {
|
|
try { await api.patch(`/expenses/recurring/${id}/toggle`); load(); }
|
|
catch { toast('Failed', 'error'); }
|
|
}
|
|
|
|
async function remove(id: string) {
|
|
try { await api.delete(`/expenses/recurring/${id}`); toast('Deleted', 'success'); load(); }
|
|
catch { toast('Failed', 'error'); }
|
|
}
|
|
|
|
const freqLabels: Record<string, string> = { monthly: 'Monthly', quarterly: 'Quarterly', yearly: 'Yearly' };
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-sm text-surface-500">Recurring expenses auto-generate pending expenses on schedule. They still require approval before creating an accounting entry.</p>
|
|
<Button onClick={() => setShowCreate(true)}>Add Recurring</Button>
|
|
</div>
|
|
|
|
{loading ? <p className="text-surface-400">Loading...</p> : items.length === 0 ? (
|
|
<div className="text-center py-12 text-surface-400">No recurring expenses set up yet.</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{items.map((item: any) => (
|
|
<div key={item.id} className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200/80 dark:border-surface-700/80 p-5 flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium text-surface-900 dark:text-surface-100">{item.description}</h3>
|
|
<Badge label={item.category} />
|
|
<Badge label={freqLabels[item.frequency] || item.frequency} variant="info" />
|
|
{!item.isActive && <Badge label="Paused" variant="warning" />}
|
|
</div>
|
|
<p className="text-xs text-surface-400 mt-1">Next run: {new Date(item.nextRunDate).toLocaleDateString()}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-bold text-surface-900 dark:text-surface-100">PHP {Number(item.amount).toLocaleString()}</span>
|
|
<Button size="sm" variant="ghost" onClick={() => toggle(item.id)}>{item.isActive ? 'Pause' : 'Resume'}</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => remove(item.id)}>Delete</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<FormModal open={showCreate} onClose={() => setShowCreate(false)} title="Add Recurring Expense" description="Auto-generates a pending expense on schedule.">
|
|
<RecurringForm onSuccess={() => { setShowCreate(false); load(); }} onClose={() => setShowCreate(false)} />
|
|
</FormModal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RecurringForm({ onSuccess, onClose }: { onSuccess: () => void; onClose: () => void }) {
|
|
const { toast } = useToast();
|
|
const [form, setForm] = useState({ category: 'utilities', description: '', amount: 0, frequency: 'monthly' });
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault(); setSubmitting(true);
|
|
try { await api.post('/expenses/recurring', form); toast('Recurring expense created', 'success'); onSuccess(); }
|
|
catch (e: any) { toast(e.response?.data?.error || 'Failed', 'error'); }
|
|
finally { setSubmitting(false); }
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Category</label>
|
|
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className={ic}>
|
|
{CATEGORIES.map((c) => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
|
|
</select></div>
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Frequency</label>
|
|
<select value={form.frequency} onChange={(e) => setForm({ ...form, frequency: e.target.value })} className={ic}>
|
|
<option value="monthly">Monthly</option><option value="quarterly">Quarterly</option><option value="yearly">Yearly</option>
|
|
</select></div>
|
|
</div>
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description</label><input type="text" required minLength={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className={ic} placeholder="e.g. Electricity bill" /></div>
|
|
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Amount (PHP)</label><input type="number" required min={1} step={0.01} value={form.amount || ''} onChange={(e) => setForm({ ...form, amount: parseFloat(e.target.value) || 0 })} className={ic} /></div>
|
|
<div className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button><Button type="submit" loading={submitting}>Create Recurring</Button></div>
|
|
</form>
|
|
);
|
|
}
|