10.
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m39s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m1s

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:
Kalana B. Thilakarathna
2026-10-22 17:47:49 +05:30
parent 6e4bebb89b
commit c775146b64
52 changed files with 12003 additions and 53 deletions
@@ -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} />
}
@@ -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 />
}
+15 -1
View File
@@ -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,
+13
View File
@@ -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',
+117
View File
@@ -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),
}
+1
View File
@@ -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'
+184
View File
@@ -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[]
}