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,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 />
}