initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:37 +08:00
commit 3f5bf0e118
88 changed files with 10997 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
'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>
);
}