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