10.
Implementation A partner, not just software HexDive Technologies' local team handles setup, data migration, training and ongoing support — in Sinhala, Tamil and English. [confirm] 1 Discovery Map your network, products and processes. 2 Configuration Set up depots, pricing, schemes, routes and users. 3 Migration & training Move your data and train every team. 4 Go-live & support Dedicated account manager and local support desk.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { expenseCategories as api, getApiError } from '@/lib/api/index'
|
||||
import type { ExpenseCategoryResponse } from '@/types/expenses'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, Folder01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
export default function ExpenseCategoriesPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canManage = hasPermission('expense.manage_categories')
|
||||
|
||||
const [tree, setTree] = useState<ExpenseCategoryResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState<null | { parentId: string | null; parentName?: string }>(null)
|
||||
const [editing, setEditing] = useState<ExpenseCategoryResponse | null>(null)
|
||||
|
||||
useTopLoader(loading)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { setTree(await api.tree()) }
|
||||
catch (err) { toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' }) }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Expense Categories</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Two-level tree. Expenses are tagged with a Title (a child under a Category).
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setDialogOpen({ parentId: null })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} /> Add Category
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border">
|
||||
{!loading && tree.length === 0 ? (
|
||||
<EmptyState icon={Folder01Icon} title="No categories yet" description="Add a top-level category then create titles under it." />
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{tree.map(parent => (
|
||||
<li key={parent.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{parent.name}</span>
|
||||
{parent.code && <span className="ml-2 text-xs text-muted-foreground">{parent.code}</span>}
|
||||
{!parent.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(parent)}>Edit</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDialogOpen({ parentId: parent.id, parentName: parent.name })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} /> Add title
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{parent.children.length > 0 && (
|
||||
<ul className="mt-2 ml-4 divide-y border-l pl-4">
|
||||
{parent.children.map(child => (
|
||||
<li key={child.id} className="py-2 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
{child.name}
|
||||
{!child.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(child)}>Edit</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CategoryDialog
|
||||
open={!!dialogOpen}
|
||||
parentId={dialogOpen?.parentId ?? null}
|
||||
parentName={dialogOpen?.parentName}
|
||||
onClose={() => setDialogOpen(null)}
|
||||
onSaved={() => { setDialogOpen(null); load() }}
|
||||
/>
|
||||
|
||||
<EditCategoryDialog
|
||||
category={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryDialog({
|
||||
open, parentId, parentName, onClose, onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
parentId: string | null
|
||||
parentName?: string
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => { if (open) { setName(''); setCode('') } }, [open])
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.create({ name: name.trim(), code: code || null, parentCategoryId: parentId })
|
||||
toast.add({ title: 'Saved', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{parentId ? `New title under ${parentName}` : 'New category'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
{!parentId && (
|
||||
<Field>
|
||||
<FieldLabel>Code (optional)</FieldLabel>
|
||||
<Input value={code} onChange={e => setCode(e.target.value)} placeholder="e.g. SAL" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function EditCategoryDialog({
|
||||
category, onClose, onSaved,
|
||||
}: {
|
||||
category: ExpenseCategoryResponse | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [active, setActive] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (category) { setName(category.name); setCode(category.code ?? ''); setActive(category.isActive) }
|
||||
}, [category])
|
||||
|
||||
if (!category) return null
|
||||
|
||||
async function submit() {
|
||||
if (!category) return
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.update(category.id, { name: name.trim(), code: code || null, isActive: active })
|
||||
toast.add({ title: 'Updated', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to update', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!category} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {category.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Code</FieldLabel>
|
||||
<Input value={code} onChange={e => setCode(e.target.value)} />
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import {
|
||||
expenseCategories as categoriesApi,
|
||||
recurringExpenses as api,
|
||||
warehouses as warehousesApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type {
|
||||
CreateRecurringExpenseRequest,
|
||||
ExpenseCategoryResponse,
|
||||
RecurringExpenseResponse,
|
||||
RecurringFrequency,
|
||||
} from '@/types/expenses'
|
||||
import type { WarehouseResponse } from '@/types/api'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, Calendar03Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
const fmtAmount = (n: number) =>
|
||||
`Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
|
||||
export default function RecurringExpensesPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canManage = hasPermission('expense.manage_recurring')
|
||||
|
||||
const [items, setItems] = useState<RecurringExpenseResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<RecurringExpenseResponse | 'new' | null>(null)
|
||||
const [runBusy, setRunBusy] = useState(false)
|
||||
|
||||
useTopLoader(loading || runBusy)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { setItems((await api.list({ pageSize: 100 })).items) }
|
||||
catch (err) { toast.add({ title: 'Failed to load templates', description: getApiError(err), type: 'error' }) }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function toggle(t: RecurringExpenseResponse) {
|
||||
try {
|
||||
await api.toggle(t.id, !t.isPaused)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to toggle', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function runDue() {
|
||||
setRunBusy(true)
|
||||
try {
|
||||
const res = await api.runDue()
|
||||
toast.add({ title: `Generated ${res.generated} expense(s)`, type: 'success' })
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to run', description: getApiError(err), type: 'error' })
|
||||
} finally { setRunBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Recurring Expenses</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Templates that materialize into drafts (or posted expenses) when their next-run date arrives.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canManage && (
|
||||
<>
|
||||
<Button variant="outline" disabled={runBusy} onClick={runDue}>
|
||||
{runBusy ? 'Running…' : 'Run due now'}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing('new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} /> New Template
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-md border">
|
||||
{!loading && items.length === 0 ? (
|
||||
<EmptyState icon={Calendar03Icon} title="No templates yet" description="Create a template for a recurring bill — rent, salaries, subscriptions." />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Branch</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead>Frequency</TableHead>
|
||||
<TableHead>Next run</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map(t => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="text-sm">{t.categoryName}</TableCell>
|
||||
<TableCell className="text-sm">{t.warehouseName ?? <em className="text-muted-foreground">Company-wide</em>}</TableCell>
|
||||
<TableCell className="text-right font-mono">{fmtAmount(t.amount)}</TableCell>
|
||||
<TableCell><Badge variant="outline">{t.frequency}{t.autoPost ? ' · auto-post' : ''}</Badge></TableCell>
|
||||
<TableCell>{fmtDate(t.nextRunDate)}</TableCell>
|
||||
<TableCell>{t.isPaused ? <Badge variant="secondary">Paused</Badge> : <Badge>Active</Badge>}</TableCell>
|
||||
<TableCell className="text-right space-x-1">
|
||||
{canManage && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggle(t)}>{t.isPaused ? 'Resume' : 'Pause'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(t)}>Edit</Button>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TemplateDialog
|
||||
template={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateDialog({
|
||||
template, onClose, onSaved,
|
||||
}: {
|
||||
template: RecurringExpenseResponse | 'new' | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const isNew = template === 'new'
|
||||
const current = template !== 'new' ? template : null
|
||||
|
||||
const [cats, setCats] = useState<ExpenseCategoryResponse[]>([])
|
||||
const [whs, setWhs] = useState<WarehouseResponse[]>([])
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleId, setTitleId] = useState('')
|
||||
const [warehouseId, setWarehouseId] = useState<string>('__company__')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [frequency, setFrequency] = useState<RecurringFrequency>('Monthly')
|
||||
const [dayOfMonth, setDayOfMonth] = useState<string>('1')
|
||||
const [dayOfWeek, setDayOfWeek] = useState<string>('1')
|
||||
const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [autoPost, setAutoPost] = useState(false)
|
||||
const [description, setDescription] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!template) return
|
||||
categoriesApi.tree().then(setCats).catch(() => {})
|
||||
warehousesApi.list({ pageSize: 200 }).then(r => setWhs(getPaginatedItems(r))).catch(() => {})
|
||||
|
||||
if (current) {
|
||||
setName(current.name)
|
||||
// Figure out parent category for the title
|
||||
categoriesApi.tree().then(tree => {
|
||||
const parent = tree.find(c => c.children.some(ch => ch.id === current.categoryId))
|
||||
setCategoryId(parent?.id ?? '')
|
||||
setTitleId(current.categoryId)
|
||||
})
|
||||
setWarehouseId(current.warehouseId ?? '__company__')
|
||||
setAmount(String(current.amount))
|
||||
setFrequency(current.frequency)
|
||||
setDayOfMonth(current.dayOfMonth ? String(current.dayOfMonth) : '1')
|
||||
setDayOfWeek(current.dayOfWeek != null ? String(current.dayOfWeek) : '1')
|
||||
setStartDate(current.startDate.slice(0, 10))
|
||||
setEndDate(current.endDate?.slice(0, 10) ?? '')
|
||||
setAutoPost(current.autoPost)
|
||||
setDescription(current.description ?? '')
|
||||
} else {
|
||||
setName(''); setCategoryId(''); setTitleId(''); setWarehouseId('__company__'); setAmount('')
|
||||
setFrequency('Monthly'); setDayOfMonth('1'); setDayOfWeek('1')
|
||||
setStartDate(new Date().toISOString().slice(0, 10)); setEndDate(''); setAutoPost(false); setDescription('')
|
||||
}
|
||||
}, [template, current])
|
||||
|
||||
const titles = useMemo(() => cats.find(c => c.id === categoryId)?.children ?? [], [cats, categoryId])
|
||||
|
||||
if (!template) return null
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim() || !titleId) { toast.add({ title: 'Name and Title are required', type: 'error' }); return }
|
||||
const amt = parseFloat(amount)
|
||||
if (!amt || amt <= 0) { toast.add({ title: 'Amount must be greater than zero', type: 'error' }); return }
|
||||
|
||||
const body: CreateRecurringExpenseRequest = {
|
||||
name: name.trim(),
|
||||
categoryId: titleId,
|
||||
warehouseId: warehouseId === '__company__' ? null : warehouseId,
|
||||
amount: amt,
|
||||
payee: null,
|
||||
description: description || null,
|
||||
frequency,
|
||||
dayOfMonth: frequency === 'Weekly' ? null : parseInt(dayOfMonth),
|
||||
dayOfWeek: frequency === 'Weekly' ? parseInt(dayOfWeek) : null,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
autoPost,
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
if (isNew) await api.create(body)
|
||||
else if (current) await api.update(current.id, body)
|
||||
toast.add({ title: 'Saved', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!template} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isNew ? 'New Recurring Template' : `Edit ${current?.name}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Office rent — Colombo HQ" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Category</FieldLabel>
|
||||
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ''); setTitleId('') }}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a category" /></SelectTrigger>
|
||||
<SelectContent>{cats.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Title</FieldLabel>
|
||||
<Select value={titleId} onValueChange={v => setTitleId(v ?? '')} disabled={!categoryId}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a title" /></SelectTrigger>
|
||||
<SelectContent>{titles.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={warehouseId} onValueChange={v => setWarehouseId(v ?? '__company__')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__company__">Company-wide</SelectItem>
|
||||
{whs.map(w => <SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Amount</FieldLabel>
|
||||
<Input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Frequency</FieldLabel>
|
||||
<Select value={frequency} onValueChange={v => setFrequency((v ?? 'Monthly') as RecurringFrequency)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Weekly">Weekly</SelectItem>
|
||||
<SelectItem value="Monthly">Monthly</SelectItem>
|
||||
<SelectItem value="Quarterly">Quarterly</SelectItem>
|
||||
<SelectItem value="Yearly">Yearly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{frequency === 'Weekly' ? (
|
||||
<Field>
|
||||
<FieldLabel>Day of week</FieldLabel>
|
||||
<Select value={dayOfWeek} onValueChange={v => setDayOfWeek(v ?? '1')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d, i) =>
|
||||
<SelectItem key={i} value={String(i)}>{d}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel>Day of month</FieldLabel>
|
||||
<Input type="number" min="1" max="31" value={dayOfMonth} onChange={e => setDayOfMonth(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={autoPost} onChange={e => setAutoPost(e.target.checked)} />
|
||||
Auto-post (skip Draft)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Start date</FieldLabel>
|
||||
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>End date (optional)</FieldLabel>
|
||||
<Input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy}>{busy ? 'Saving…' : 'Save'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { expenseVendors as api, getApiError } from '@/lib/api/index'
|
||||
import type { ExpenseVendorResponse } from '@/types/expenses'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, UserGroup03Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
export default function ExpenseVendorsPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canManage = hasPermission('expense.manage_categories')
|
||||
|
||||
const [items, setItems] = useState<ExpenseVendorResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<ExpenseVendorResponse | 'new' | null>(null)
|
||||
|
||||
useTopLoader(loading)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.list({ page, pageSize, search: search || undefined })
|
||||
setItems(res.items); setTotal(res.total)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load vendors', description: getApiError(err), type: 'error' })
|
||||
} finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Expense Vendors</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Lightweight vendor directory used by the Expenses module.</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setEditing('new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} /> Add Vendor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-w-sm">
|
||||
<Input placeholder="Search vendors…" value={search} onChange={e => { setPage(1); setSearch(e.target.value) }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border">
|
||||
{!loading && items.length === 0 ? (
|
||||
<EmptyState icon={UserGroup03Icon} title="No vendors yet" description="Add vendors to pick them when creating expenses." />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map(v => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="font-medium">{v.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{v.contactInfo ?? '—'}</TableCell>
|
||||
<TableCell>{v.isActive ? <Badge>Active</Badge> : <Badge variant="secondary">Inactive</Badge>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{canManage && <Button size="sm" variant="ghost" onClick={() => setEditing(v)}>Edit</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{total} vendor(s)</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</Button>
|
||||
<span className="px-2 py-1">{page} / {pageCount}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= pageCount} onClick={() => setPage(p => p + 1)}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VendorDialog
|
||||
vendor={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VendorDialog({
|
||||
vendor, onClose, onSaved,
|
||||
}: {
|
||||
vendor: ExpenseVendorResponse | 'new' | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [contact, setContact] = useState('')
|
||||
const [active, setActive] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (vendor && vendor !== 'new') { setName(vendor.name); setContact(vendor.contactInfo ?? ''); setActive(vendor.isActive) }
|
||||
else if (vendor === 'new') { setName(''); setContact(''); setActive(true) }
|
||||
}, [vendor])
|
||||
|
||||
if (!vendor) return null
|
||||
|
||||
async function submit() {
|
||||
if (!vendor || !name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
if (vendor === 'new') await api.create({ name: name.trim(), contactInfo: contact || null })
|
||||
else await api.update(vendor.id, { name: name.trim(), contactInfo: contact || null, isActive: active })
|
||||
toast.add({ title: 'Saved', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!vendor} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{vendor === 'new' ? 'Add Vendor' : `Edit ${vendor.name}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Contact</FieldLabel>
|
||||
<Input value={contact} onChange={e => setContact(e.target.value)} placeholder="Phone / email" />
|
||||
</Field>
|
||||
{vendor !== 'new' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ExpenseViewClient } from '../_components/expense-view-client'
|
||||
|
||||
export default async function ExpenseViewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
return <ExpenseViewClient expenseId={id} />
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { expenses as expensesApi, getApiError } from '@/lib/api/index'
|
||||
import type { ExpenseResponse, ExpenseStatus } from '@/types/expenses'
|
||||
|
||||
import { PayExpenseDialog } from './pay-expense-dialog'
|
||||
|
||||
const STATUS_VARIANT: Record<ExpenseStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Draft: 'secondary', Posted: 'outline', Paid: 'default', Void: 'destructive',
|
||||
}
|
||||
|
||||
const fmtDate = (v: string | null) =>
|
||||
v ? new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
|
||||
|
||||
const fmtAmount = (n: number) =>
|
||||
`Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
|
||||
export function ExpenseViewClient({ expenseId }: { expenseId: string }) {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = useAuth()
|
||||
const canPost = hasPermission('expense.post')
|
||||
const canPay = hasPermission('expense.pay')
|
||||
const canVoid = hasPermission('expense.void')
|
||||
const canCreate = hasPermission('expense.create')
|
||||
|
||||
const [expense, setExpense] = useState<ExpenseResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
const [voidOpen, setVoidOpen] = useState(false)
|
||||
const [voidReason, setVoidReason] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useTopLoader(loading || busy)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { setExpense(await expensesApi.get(expenseId)) }
|
||||
catch (err) {
|
||||
toast.add({ title: 'Failed to load expense', description: getApiError(err), type: 'error' })
|
||||
router.push('/payments/expenses')
|
||||
} finally { setLoading(false) }
|
||||
}, [expenseId, router])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function handlePost() {
|
||||
setBusy(true)
|
||||
try { await expensesApi.post(expenseId); toast.add({ title: 'Expense posted', type: 'success' }); await load() }
|
||||
catch (err) { toast.add({ title: 'Failed to post', description: getApiError(err), type: 'error' }) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function confirmVoid() {
|
||||
const reason = voidReason.trim()
|
||||
if (!reason) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await expensesApi.void(expenseId, reason)
|
||||
toast.add({ title: 'Expense voided', type: 'success' })
|
||||
setVoidOpen(false)
|
||||
setVoidReason('')
|
||||
await load()
|
||||
}
|
||||
catch (err) { toast.add({ title: 'Failed to void', description: getApiError(err), type: 'error' }) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await expensesApi.uploadAttachment(expenseId, file)
|
||||
toast.add({ title: 'Attachment uploaded', type: 'success' })
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Upload failed', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAttachment(id: string) {
|
||||
if (!window.confirm('Delete this attachment?')) return
|
||||
setBusy(true)
|
||||
try { await expensesApi.deleteAttachment(id); await load() }
|
||||
catch (err) { toast.add({ title: 'Delete failed', description: getApiError(err), type: 'error' }) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (loading && !expense) return null
|
||||
if (!expense) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<nav className="text-sm text-muted-foreground mb-2">
|
||||
<span className="hover:underline cursor-pointer" onClick={() => router.push('/payments/expenses')}>Expenses</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-foreground">{expense.categoryName}</span>
|
||||
</nav>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
{fmtAmount(expense.amount)}
|
||||
<Badge variant={STATUS_VARIANT[expense.status]}>{expense.status}</Badge>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{expense.parentCategoryName ? `${expense.parentCategoryName} → ${expense.categoryName}` : expense.categoryName}
|
||||
{' · '}
|
||||
{expense.warehouseName ?? 'Company-wide'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{expense.status === 'Draft' && canPost && (
|
||||
<Button variant="outline" disabled={busy} onClick={handlePost}>Post</Button>
|
||||
)}
|
||||
{expense.status === 'Posted' && canPay && (
|
||||
<Button disabled={busy} onClick={() => setPayOpen(true)}>Pay</Button>
|
||||
)}
|
||||
{expense.status !== 'Void' && expense.status !== 'Paid' && canVoid && (
|
||||
<Button variant="outline" disabled={busy} onClick={() => { setVoidReason(''); setVoidOpen(true) }}>Void</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-4 rounded-2xl border p-6 sm:grid-cols-4">
|
||||
<Stat label="Bill date" value={fmtDate(expense.billDate)} />
|
||||
<Stat label="Due date" value={fmtDate(expense.dueDate)} />
|
||||
<Stat label="Period" value={expense.periodCovered ? new Date(expense.periodCovered).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) : '—'} />
|
||||
<Stat label="Paid at" value={fmtDate(expense.paidAt)} />
|
||||
<Stat label="Vendor" value={expense.vendorName ?? '—'} />
|
||||
<Stat label="Payee" value={expense.payee ?? '—'} />
|
||||
<Stat label="Bill ref" value={expense.billRef ?? '—'} />
|
||||
<Stat label="Method" value={expense.method ?? '—'} />
|
||||
</div>
|
||||
|
||||
{expense.description && (
|
||||
<div className="mt-4 rounded-2xl border p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Description</p>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{expense.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 rounded-2xl border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium">Attachments</h3>
|
||||
{canCreate && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef} type="file" className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={busy}>
|
||||
Upload
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expense.attachments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No attachments.</p>
|
||||
) : (
|
||||
<ul className="divide-y border rounded-md">
|
||||
{expense.attachments.map(a => (
|
||||
<li key={a.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<div>
|
||||
<a
|
||||
href={expensesApi.downloadAttachmentUrl(a.id)}
|
||||
target="_blank" rel="noreferrer"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{a.originalFileName}
|
||||
</a>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{(a.sizeBytes / 1024).toFixed(1)} KB · {a.contentType}
|
||||
</span>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDeleteAttachment(a.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PayExpenseDialog
|
||||
open={payOpen}
|
||||
onClose={() => setPayOpen(false)}
|
||||
amount={expense.amount}
|
||||
onConfirm={async (payment) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await expensesApi.pay(expenseId, payment)
|
||||
toast.add({ title: 'Expense paid', type: 'success' })
|
||||
setPayOpen(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Payment failed', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={voidOpen} onOpenChange={v => !v && setVoidOpen(false)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Void expense</DialogTitle>
|
||||
<DialogDescription>This marks the expense as void. Enter a reason — it will be stored on the record.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel>Reason <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input
|
||||
autoFocus
|
||||
value={voidReason}
|
||||
onChange={e => setVoidReason(e.target.value)}
|
||||
placeholder="e.g. Duplicate entry"
|
||||
/>
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setVoidOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={confirmVoid} disabled={busy || !voidReason.trim()}>
|
||||
{busy ? 'Voiding…' : 'Void'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, ReceiptDollarIcon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table'
|
||||
import type { TableConfig } from '@/components/shared/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { expenses as expensesApi, getApiError } from '@/lib/api/index'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import type { ExpenseResponse, ExpenseStatus } from '@/types/expenses'
|
||||
|
||||
const STATUS_VARIANT: Record<ExpenseStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Draft: 'secondary',
|
||||
Posted: 'outline',
|
||||
Paid: 'default',
|
||||
Void: 'destructive',
|
||||
}
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
const truncId = (id: string) => id.substring(0, 8)
|
||||
|
||||
export function ExpensesClient() {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = useAuth()
|
||||
const canCreate = hasPermission('expense.create')
|
||||
|
||||
const [data, setData] = useState<ExpenseResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
useTopLoader(loading)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await expensesApi.list({ page: p, pageSize: ps })
|
||||
setData(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load expenses', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData(page, pageSize) }, [fetchData, page, pageSize])
|
||||
|
||||
const tableConfig = useMemo<TableConfig<ExpenseResponse>>(() => ({
|
||||
rowKey: 'id',
|
||||
serverSide: true,
|
||||
stickyHeader: true,
|
||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||
emptyState: (
|
||||
<EmptyState
|
||||
icon={ReceiptDollarIcon}
|
||||
title="No expenses yet"
|
||||
description="Record company expenses like salaries, utilities and rent. Drafts can be approved then paid."
|
||||
action={canCreate ? (
|
||||
<Button size="sm" onClick={() => router.push('/payments/expenses/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} />
|
||||
New Expense
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
),
|
||||
columns: [
|
||||
{
|
||||
key: 'billDate', title: 'Bill Date', fields: ['billDate'], width: 120,
|
||||
cellRender: (r) => fmtDate(r.billDate),
|
||||
},
|
||||
{
|
||||
key: 'category', title: 'Category', fields: [{ key: 'categoryName', primary: true }], minWidth: 180,
|
||||
cellRender: (r) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{r.categoryName}</span>
|
||||
{r.parentCategoryName && (
|
||||
<span className="text-xs text-muted-foreground">{r.parentCategoryName}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vendor', title: 'Vendor / Payee', fields: ['vendorName'], minWidth: 160,
|
||||
cellRender: (r) => <span className="text-sm">{r.vendorName ?? r.payee ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'amount', title: 'Amount', fields: ['amount'], width: 140,
|
||||
align: 'right',
|
||||
cellRender: (r) => <span className="text-sm font-medium">{fmtAmount(r.amount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status', title: 'Status', fields: ['status'], width: 120,
|
||||
cellRender: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{r.status}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'branch', title: 'Branch', fields: ['warehouseName'], width: 160,
|
||||
cellRender: (r) => <span className="text-sm">{r.warehouseName ?? <em className="text-muted-foreground">Company-wide</em>}</span>,
|
||||
},
|
||||
{
|
||||
key: 'id', title: 'Ref', fields: ['id'], width: 110,
|
||||
cellRender: (r) => <span className="font-mono text-xs text-muted-foreground">{truncId(r.id)}</span>,
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'view',
|
||||
label: 'View expense',
|
||||
onClick: (r: ExpenseResponse) => router.push(`/payments/expenses/${r.id}`),
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize, router, canCreate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Expenses</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Record and track company expenses — salaries, utilities, rent, fuel and more
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button onClick={() => router.push('/payments/expenses/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
New Expense
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => { setPage(p.page); setPageSize(p.pageSize) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import {
|
||||
expenseCategories as categoriesApi,
|
||||
expenseVendors as vendorsApi,
|
||||
expenses as expensesApi,
|
||||
warehouses as warehousesApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type {
|
||||
CreateExpenseCategoryRequest,
|
||||
CreateExpenseRequest,
|
||||
CreateExpenseVendorRequest,
|
||||
ExpenseAction,
|
||||
ExpenseCategoryResponse,
|
||||
ExpensePaymentRequest,
|
||||
ExpenseVendorResponse,
|
||||
} from '@/types/expenses'
|
||||
import type { WarehouseResponse } from '@/types/api'
|
||||
|
||||
import { PayExpenseDialog } from './pay-expense-dialog'
|
||||
|
||||
export function NewExpenseClient() {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = useAuth()
|
||||
const canPost = hasPermission('expense.post')
|
||||
const canPay = hasPermission('expense.pay') && canPost
|
||||
const canManageCats = hasPermission('expense.manage_categories')
|
||||
|
||||
const [categories, setCategories] = useState<ExpenseCategoryResponse[]>([])
|
||||
const [vendors, setVendors] = useState<ExpenseVendorResponse[]>([])
|
||||
const [whs, setWhs] = useState<WarehouseResponse[]>([])
|
||||
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleId, setTitleId] = useState('')
|
||||
const [warehouseId, setWarehouseId] = useState<string>('__company__')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [billDate, setBillDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [periodCovered, setPeriodCovered] = useState('')
|
||||
const [vendorId, setVendorId] = useState<string>('')
|
||||
const [payee, setPayee] = useState('')
|
||||
const [billRef, setBillRef] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
const [addTitleOpen, setAddTitleOpen] = useState(false)
|
||||
const [addVendorOpen, setAddVendorOpen] = useState(false)
|
||||
|
||||
useTopLoader(submitting)
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
try { setCategories(getPaginatedItems(await categoriesApi.tree())) }
|
||||
catch (err) { toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' }) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
vendorsApi.list({ pageSize: 200 })
|
||||
.then(r => setVendors(getPaginatedItems(r)))
|
||||
.catch(() => {})
|
||||
warehousesApi.list({ pageSize: 200 })
|
||||
.then(r => setWhs(getPaginatedItems(r)))
|
||||
.catch(() => {})
|
||||
}, [loadCategories])
|
||||
|
||||
const titles = useMemo(
|
||||
() => categories.find(c => c.id === categoryId)?.children ?? [],
|
||||
[categoryId, categories],
|
||||
)
|
||||
|
||||
function buildBody(): CreateExpenseRequest | null {
|
||||
if (!titleId) { toast.add({ title: 'Title is required', type: 'error' }); return null }
|
||||
const amt = parseFloat(amount)
|
||||
if (!amt || amt <= 0) { toast.add({ title: 'Amount must be greater than zero', type: 'error' }); return null }
|
||||
if (!billDate) { toast.add({ title: 'Bill date is required', type: 'error' }); return null }
|
||||
|
||||
return {
|
||||
categoryId: titleId,
|
||||
warehouseId: warehouseId === '__company__' ? null : warehouseId,
|
||||
amount: amt,
|
||||
billDate,
|
||||
dueDate: dueDate || null,
|
||||
periodCovered: periodCovered ? `${periodCovered}-01` : null,
|
||||
vendorId: vendorId || null,
|
||||
payee: payee || null,
|
||||
billRef: billRef || null,
|
||||
description: description || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(action: ExpenseAction, payment?: ExpensePaymentRequest) {
|
||||
const body = buildBody()
|
||||
if (!body) return
|
||||
if (action === 'Pay') body.payment = payment
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await expensesApi.create(body, action)
|
||||
toast.add({ title: `Expense ${action === 'Draft' ? 'saved as draft' : action === 'Post' ? 'posted' : 'posted and paid'}`, type: 'success' })
|
||||
router.push(`/payments/expenses/${created.id}`)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save expense', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav className="text-sm text-muted-foreground mb-2">
|
||||
<span>Accounts</span> <span className="mx-1">/</span>
|
||||
<span className="hover:underline cursor-pointer" onClick={() => router.push('/payments/expenses')}>Expenses</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-foreground">New</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-foreground">New Expense</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Record a bill — pick its category and title, then save it as a draft, post it, or post and pay it in one step.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-2xl border p-6 space-y-5">
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={warehouseId} onValueChange={v => setWarehouseId(v ?? '__company__')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__company__">Company-wide</SelectItem>
|
||||
{whs.map(w => <SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Category <span className="text-destructive">*</span></FieldLabel>
|
||||
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ''); setTitleId('') }}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a category" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Title <span className="text-destructive">*</span></FieldLabel>
|
||||
{canManageCats && categoryId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddTitleOpen(true)}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} /> Add title
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={titleId} onValueChange={v => setTitleId(v ?? '')} disabled={!categoryId}>
|
||||
<SelectTrigger><SelectValue placeholder={categoryId ? 'Pick a title' : 'Pick a category first'} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{titles.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Amount <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Bill date <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input type="date" value={billDate} onChange={e => setBillDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Due date</FieldLabel>
|
||||
<Input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Vendor (optional)</FieldLabel>
|
||||
{canManageCats && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddVendorOpen(true)}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} /> Add vendor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={vendorId} onValueChange={v => setVendorId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="— none —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.map(v => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Payee</FieldLabel>
|
||||
<Input value={payee} onChange={e => setPayee(e.target.value)} placeholder="Name on the cheque / cash paid to" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Bill / account ref</FieldLabel>
|
||||
<Input value={billRef} onChange={e => setBillRef(e.target.value)} placeholder="Bill no, meter reading, …" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Period covered</FieldLabel>
|
||||
<Input type="month" value={periodCovered} onChange={e => setPeriodCovered(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => router.push('/payments/expenses')}>Cancel</Button>
|
||||
<Button variant="outline" disabled={submitting} onClick={() => submit('Draft')}>
|
||||
{submitting ? 'Saving…' : 'Save draft'}
|
||||
</Button>
|
||||
{canPost && (
|
||||
<Button variant="outline" disabled={submitting} onClick={() => submit('Post')}>
|
||||
Save & post
|
||||
</Button>
|
||||
)}
|
||||
{canPay && (
|
||||
<Button disabled={submitting} onClick={() => { if (buildBody()) setPayDialogOpen(true) }}>
|
||||
Save & pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PayExpenseDialog
|
||||
open={payDialogOpen}
|
||||
onClose={() => setPayDialogOpen(false)}
|
||||
amount={parseFloat(amount) || 0}
|
||||
onConfirm={async (payment) => { setPayDialogOpen(false); await submit('Pay', payment) }}
|
||||
/>
|
||||
|
||||
<AddTitleDialog
|
||||
open={addTitleOpen}
|
||||
onClose={() => setAddTitleOpen(false)}
|
||||
parentCategoryId={categoryId}
|
||||
onCreated={async (createdId) => { await loadCategories(); setTitleId(createdId) }}
|
||||
/>
|
||||
|
||||
<AddVendorDialog
|
||||
open={addVendorOpen}
|
||||
onClose={() => setAddVendorOpen(false)}
|
||||
onCreated={(v) => { setVendors(prev => [...prev, v]); setVendorId(v.id) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── inline sub-components kept here to avoid file explosion ──────────────────
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
|
||||
function AddTitleDialog({
|
||||
open, onClose, parentCategoryId, onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
parentCategoryId: string
|
||||
onCreated: (createdId: string) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const body: CreateExpenseCategoryRequest = { name: name.trim(), parentCategoryId }
|
||||
const created = await categoriesApi.create(body)
|
||||
toast.add({ title: 'Title added', type: 'success' })
|
||||
onCreated(created.id)
|
||||
setName('')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to add title', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Title</DialogTitle>
|
||||
<DialogDescription>Create a new title under the selected category.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>{busy ? 'Saving…' : 'Add'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function AddVendorDialog({
|
||||
open, onClose, onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (v: ExpenseVendorResponse) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [contact, setContact] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const body: CreateExpenseVendorRequest = { name: name.trim(), contactInfo: contact || null }
|
||||
const created = await vendorsApi.create(body)
|
||||
toast.add({ title: 'Vendor added', type: 'success' })
|
||||
onCreated(created)
|
||||
setName(''); setContact('')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to add vendor', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Vendor</DialogTitle>
|
||||
<DialogDescription>Vendors are shared across the Expenses module only.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Contact (optional)</FieldLabel>
|
||||
<Input value={contact} onChange={e => setContact(e.target.value)} placeholder="Phone / email" />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>{busy ? 'Saving…' : 'Add'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
bankAccounts as bankAccountsApi,
|
||||
chequeBooks as chequeBooksApi,
|
||||
getApiError,
|
||||
} from '@/lib/api/index'
|
||||
import {
|
||||
BankAccountType,
|
||||
ChequePageIssueStatus,
|
||||
type BankAccountResponse,
|
||||
type ChequePageResponse,
|
||||
} from '@/types/payments'
|
||||
import type { ExpensePaymentRequest } from '@/types/expenses'
|
||||
|
||||
type Method = 'Cash' | 'Cheque'
|
||||
|
||||
/**
|
||||
* Shared dialog for both "Save & pay" (on new expense) and "Pay" (on view).
|
||||
* Cash → pick a BankAccount (Petty Cash is a Cash account; one-click button up top).
|
||||
* Cheque → pick an Unused page from an Active cheque book.
|
||||
*/
|
||||
export function PayExpenseDialog({
|
||||
open, onClose, amount, onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
amount: number
|
||||
onConfirm: (payment: ExpensePaymentRequest) => void | Promise<void>
|
||||
}) {
|
||||
const [method, setMethod] = useState<Method>('Cash')
|
||||
const [bankAccountId, setBankAccountId] = useState('')
|
||||
const [chequePageId, setChequePageId] = useState('')
|
||||
const [accounts, setAccounts] = useState<BankAccountResponse[]>([])
|
||||
const [pages, setPages] = useState<ChequePageResponse[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setMethod('Cash'); setBankAccountId(''); setChequePageId('')
|
||||
bankAccountsApi.list()
|
||||
.then(list => setAccounts(list.filter(a => a.isActive !== false)))
|
||||
.catch(() => {})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || method !== 'Cheque') return
|
||||
chequeBooksApi.list({ status: 'Active' as never, page: 1, pageSize: 50 })
|
||||
.then(async res => {
|
||||
const expanded = await Promise.all(res.items.map(b => chequeBooksApi.get(b.chequeBookNo, true)))
|
||||
const unused: ChequePageResponse[] = []
|
||||
expanded.forEach(book => book.pages.filter(p => p.issueStatus === ChequePageIssueStatus.Unused).forEach(p => unused.push(p)))
|
||||
setPages(unused)
|
||||
})
|
||||
.catch(err => toast.add({ title: 'Failed to load cheque pages', description: getApiError(err), type: 'error' }))
|
||||
}, [open, method])
|
||||
|
||||
const defaultPettyCash = useMemo(
|
||||
() => accounts.find(a => a.accountType === BankAccountType.Cash && a.isDefault),
|
||||
[accounts],
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (method === 'Cash' && !bankAccountId) { toast.add({ title: 'Pick an account', type: 'error' }); return }
|
||||
if (method === 'Cheque' && !chequePageId) { toast.add({ title: 'Pick a cheque page', type: 'error' }); return }
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm({
|
||||
method,
|
||||
bankAccountId: method === 'Cash' ? bankAccountId : null,
|
||||
chequePageId: method === 'Cheque' ? chequePageId : null,
|
||||
})
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay Expense</DialogTitle>
|
||||
<DialogDescription>
|
||||
{amount > 0 ? `Settling Rs. ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}.` : 'Pick the payment source.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{defaultPettyCash && method === 'Cash' && !bankAccountId && (
|
||||
<Button
|
||||
type="button" variant="outline" className="w-full justify-start"
|
||||
onClick={() => setBankAccountId(defaultPettyCash.accountId)}
|
||||
>
|
||||
Use Petty Cash — {defaultPettyCash.accountName}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Method</FieldLabel>
|
||||
<Select value={method} onValueChange={v => setMethod((v ?? 'Cash') as Method)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Cash">Cash</SelectItem>
|
||||
<SelectItem value="Cheque">Cheque</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{method === 'Cash' && (
|
||||
<Field>
|
||||
<FieldLabel>Account</FieldLabel>
|
||||
<Select value={bankAccountId} onValueChange={v => setBankAccountId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a cash or bank account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts.map(a => (
|
||||
<SelectItem key={a.accountId} value={a.accountId}>
|
||||
{a.accountName}{a.bankName ? ` (${a.bankName})` : ''}
|
||||
{a.accountType === BankAccountType.Cash && a.isDefault ? ' — default' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{method === 'Cheque' && (
|
||||
<Field>
|
||||
<FieldLabel>Cheque Page</FieldLabel>
|
||||
<Select value={chequePageId} onValueChange={v => setChequePageId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick an unused cheque page" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{pages.map(p => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.chequeBookNo} / {p.chequeNo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy}>{busy ? 'Paying…' : 'Confirm payment'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NewExpenseClient } from '../_components/new-expense-client'
|
||||
|
||||
export default function NewExpensePage() {
|
||||
return <NewExpenseClient />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExpensesClient } from './_components/expenses-client'
|
||||
|
||||
export default function ExpensesPage() {
|
||||
return <ExpensesClient />
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
BankIcon,
|
||||
PackageMoving01Icon,
|
||||
Settings01Icon,
|
||||
ReceiptDollarIcon,
|
||||
Folder01Icon,
|
||||
Calendar03Icon,
|
||||
UserGroup03Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
|
||||
export interface NavItem {
|
||||
@@ -127,7 +131,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Accounts',
|
||||
title: 'Accounting',
|
||||
icon: BankIcon,
|
||||
items: [
|
||||
{ title: 'Analytics', href: '/payments/analytics', icon: BankIcon, description: 'Overview of bank accounts, cheque books and received cheques' },
|
||||
@@ -136,6 +140,16 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Expenses',
|
||||
icon: ReceiptDollarIcon,
|
||||
items: [
|
||||
{ title: 'Expenses', href: '/payments/expenses', icon: ReceiptDollarIcon, description: 'Record and track company expenses — salaries, utilities, rent and more', permission: 'expense.view' },
|
||||
{ title: 'Recurring Expenses', href: '/payments/expense-recurring', icon: Calendar03Icon, description: 'Templates that generate expenses on a schedule', permission: 'expense.view' },
|
||||
{ title: 'Expense Categories', href: '/payments/expense-categories', icon: Folder01Icon, description: 'Two-level category tree used to classify expenses', permission: 'expense.view' },
|
||||
{ title: 'Expense Vendors', href: '/payments/expense-vendors', icon: UserGroup03Icon, description: 'Vendor directory used by the Expenses module', permission: 'expense.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Returns & Claims',
|
||||
icon: ClipboardListIcon,
|
||||
|
||||
@@ -99,6 +99,19 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
'compliance.manage',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'expenses',
|
||||
label: 'Expenses',
|
||||
codes: [
|
||||
'expense.view',
|
||||
'expense.create',
|
||||
'expense.post',
|
||||
'expense.pay',
|
||||
'expense.void',
|
||||
'expense.manage_categories',
|
||||
'expense.manage_recurring',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'User Management',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
CreateExpenseCategoryRequest,
|
||||
CreateExpenseRequest,
|
||||
CreateExpenseVendorRequest,
|
||||
CreateRecurringExpenseRequest,
|
||||
ExpenseAction,
|
||||
ExpenseAttachmentResponse,
|
||||
ExpenseCategoryResponse,
|
||||
ExpensePaymentRequest,
|
||||
ExpenseResponse,
|
||||
ExpenseStatus,
|
||||
ExpenseVendorResponse,
|
||||
PagedExpenseVendorsResponse,
|
||||
PagedExpensesResponse,
|
||||
PagedRecurringExpensesResponse,
|
||||
RecurringExpenseResponse,
|
||||
RunDueResponse,
|
||||
UpdateExpenseCategoryRequest,
|
||||
UpdateExpenseDraftRequest,
|
||||
UpdateExpenseVendorRequest,
|
||||
UpdateRecurringExpenseRequest,
|
||||
} from '@/types/expenses'
|
||||
|
||||
// Categories
|
||||
|
||||
export const expenseCategories = {
|
||||
tree: () =>
|
||||
api.get<ExpenseCategoryResponse[]>('/expense-categories').then(r => r.data),
|
||||
|
||||
create: (body: CreateExpenseCategoryRequest) =>
|
||||
api.post<ExpenseCategoryResponse>('/expense-categories', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: UpdateExpenseCategoryRequest) =>
|
||||
api.put<ExpenseCategoryResponse>(`/expense-categories/${id}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// Vendors
|
||||
|
||||
export const expenseVendors = {
|
||||
list: (params?: { search?: string; page?: number; pageSize?: number }) =>
|
||||
api.get<PagedExpenseVendorsResponse>('/expense-vendors', { params }).then(r => r.data),
|
||||
|
||||
create: (body: CreateExpenseVendorRequest) =>
|
||||
api.post<ExpenseVendorResponse>('/expense-vendors', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: UpdateExpenseVendorRequest) =>
|
||||
api.put<ExpenseVendorResponse>(`/expense-vendors/${id}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// Expenses
|
||||
|
||||
export interface ExpenseListParams {
|
||||
status?: ExpenseStatus
|
||||
categoryId?: string
|
||||
warehouseId?: string
|
||||
from?: string
|
||||
to?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const expenses = {
|
||||
list: (params?: ExpenseListParams) =>
|
||||
api.get<PagedExpensesResponse>('/expenses', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<ExpenseResponse>(`/expenses/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: CreateExpenseRequest, action: ExpenseAction = 'Draft') =>
|
||||
api.post<ExpenseResponse>('/expenses', body, { params: { action } }).then(r => r.data),
|
||||
|
||||
updateDraft: (id: string, body: UpdateExpenseDraftRequest) =>
|
||||
api.put<ExpenseResponse>(`/expenses/${id}`, body).then(r => r.data),
|
||||
|
||||
post: (id: string) =>
|
||||
api.post<ExpenseResponse>(`/expenses/${id}/post`).then(r => r.data),
|
||||
|
||||
pay: (id: string, body: ExpensePaymentRequest) =>
|
||||
api.post<ExpenseResponse>(`/expenses/${id}/pay`, body).then(r => r.data),
|
||||
|
||||
void: (id: string, reason?: string) =>
|
||||
api.post<ExpenseResponse>(`/expenses/${id}/void`, { reason }).then(r => r.data),
|
||||
|
||||
uploadAttachment: (expenseId: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<ExpenseAttachmentResponse>(`/expenses/${expenseId}/attachments`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then(r => r.data)
|
||||
},
|
||||
|
||||
downloadAttachmentUrl: (attachmentId: string) =>
|
||||
`${api.defaults.baseURL}/expense-attachments/${attachmentId}/download`,
|
||||
|
||||
deleteAttachment: (attachmentId: string) =>
|
||||
api.delete(`/expense-attachments/${attachmentId}`),
|
||||
}
|
||||
|
||||
// Recurring
|
||||
|
||||
export const recurringExpenses = {
|
||||
list: (params?: { page?: number; pageSize?: number }) =>
|
||||
api.get<PagedRecurringExpensesResponse>('/recurring-expenses', { params }).then(r => r.data),
|
||||
|
||||
create: (body: CreateRecurringExpenseRequest) =>
|
||||
api.post<RecurringExpenseResponse>('/recurring-expenses', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: UpdateRecurringExpenseRequest) =>
|
||||
api.put<RecurringExpenseResponse>(`/recurring-expenses/${id}`, body).then(r => r.data),
|
||||
|
||||
toggle: (id: string, paused: boolean) =>
|
||||
api.post<RecurringExpenseResponse>(`/recurring-expenses/${id}/toggle`, { paused }).then(r => r.data),
|
||||
|
||||
runDue: () =>
|
||||
api.post<RunDueResponse>('/recurring-expenses/run-due').then(r => r.data),
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export { chequeBooks, chequePages } from './cheques'
|
||||
export { receivedCheques } from './received-cheques'
|
||||
export { supplierPayments } from './supplier-payments'
|
||||
export { notifications } from './notifications'
|
||||
export { expenseCategories, expenseVendors, expenses, recurringExpenses } from './expenses'
|
||||
export { getPaginatedItems } from './paginated'
|
||||
|
||||
export type { PageParams } from './master'
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
export type ExpenseStatus = 'Draft' | 'Posted' | 'Paid' | 'Void'
|
||||
export type ExpenseAction = 'Draft' | 'Post' | 'Pay'
|
||||
export type RecurringFrequency = 'Weekly' | 'Monthly' | 'Quarterly' | 'Yearly'
|
||||
|
||||
export interface ExpenseCategoryResponse {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
parentCategoryId: string | null
|
||||
isActive: boolean
|
||||
children: ExpenseCategoryResponse[]
|
||||
}
|
||||
|
||||
export interface CreateExpenseCategoryRequest {
|
||||
name: string
|
||||
code?: string | null
|
||||
parentCategoryId?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateExpenseCategoryRequest {
|
||||
name: string
|
||||
code?: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ExpenseVendorResponse {
|
||||
id: string
|
||||
name: string
|
||||
contactInfo: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface PagedExpenseVendorsResponse {
|
||||
items: ExpenseVendorResponse[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateExpenseVendorRequest {
|
||||
name: string
|
||||
contactInfo?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateExpenseVendorRequest {
|
||||
name: string
|
||||
contactInfo?: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ExpenseAttachmentResponse {
|
||||
id: string
|
||||
originalFileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ExpensePaymentRequest {
|
||||
method: 'Cash' | 'Cheque'
|
||||
bankAccountId?: string | null
|
||||
chequePageId?: string | null
|
||||
}
|
||||
|
||||
export interface ExpenseResponse {
|
||||
id: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
parentCategoryId: string | null
|
||||
parentCategoryName: string | null
|
||||
warehouseId: string | null
|
||||
warehouseName: string | null
|
||||
amount: number
|
||||
billDate: string
|
||||
dueDate: string | null
|
||||
periodCovered: string | null
|
||||
vendorId: string | null
|
||||
vendorName: string | null
|
||||
payee: string | null
|
||||
billRef: string | null
|
||||
description: string | null
|
||||
status: ExpenseStatus
|
||||
method: 'Cash' | 'Cheque' | 'Credit' | null
|
||||
bankAccountId: string | null
|
||||
chequePageId: string | null
|
||||
chequeNo: string | null
|
||||
paidAt: string | null
|
||||
recordedByUserId: string
|
||||
postedByUserId: string | null
|
||||
paidByUserId: string | null
|
||||
voidedByUserId: string | null
|
||||
voidReason: string | null
|
||||
recurringTemplateId: string | null
|
||||
attachments: ExpenseAttachmentResponse[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PagedExpensesResponse {
|
||||
items: ExpenseResponse[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateExpenseRequest {
|
||||
categoryId: string
|
||||
warehouseId?: string | null
|
||||
amount: number
|
||||
billDate: string
|
||||
dueDate?: string | null
|
||||
periodCovered?: string | null
|
||||
vendorId?: string | null
|
||||
payee?: string | null
|
||||
billRef?: string | null
|
||||
description?: string | null
|
||||
payment?: ExpensePaymentRequest
|
||||
}
|
||||
|
||||
export interface UpdateExpenseDraftRequest {
|
||||
categoryId: string
|
||||
warehouseId?: string | null
|
||||
amount: number
|
||||
billDate: string
|
||||
dueDate?: string | null
|
||||
periodCovered?: string | null
|
||||
vendorId?: string | null
|
||||
payee?: string | null
|
||||
billRef?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface RecurringExpenseResponse {
|
||||
id: string
|
||||
name: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
warehouseId: string | null
|
||||
warehouseName: string | null
|
||||
vendorId: string | null
|
||||
vendorName: string | null
|
||||
amount: number
|
||||
payee: string | null
|
||||
description: string | null
|
||||
frequency: RecurringFrequency
|
||||
dayOfMonth: number | null
|
||||
dayOfWeek: number | null
|
||||
startDate: string
|
||||
endDate: string | null
|
||||
nextRunDate: string
|
||||
autoPost: boolean
|
||||
isPaused: boolean
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PagedRecurringExpensesResponse {
|
||||
items: RecurringExpenseResponse[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateRecurringExpenseRequest {
|
||||
name: string
|
||||
categoryId: string
|
||||
warehouseId?: string | null
|
||||
vendorId?: string | null
|
||||
amount: number
|
||||
payee?: string | null
|
||||
description?: string | null
|
||||
frequency: RecurringFrequency
|
||||
dayOfMonth?: number | null
|
||||
dayOfWeek?: number | null
|
||||
startDate: string
|
||||
endDate?: string | null
|
||||
autoPost: boolean
|
||||
}
|
||||
|
||||
export type UpdateRecurringExpenseRequest = CreateRecurringExpenseRequest
|
||||
|
||||
export interface RunDueResponse {
|
||||
generated: number
|
||||
expenseIds: string[]
|
||||
}
|
||||
Reference in New Issue
Block a user