10.
Implementation A partner, not just software HexDive Technologies' local team handles setup, data migration, training and ongoing support — in Sinhala, Tamil and English. [confirm] 1 Discovery Map your network, products and processes. 2 Configuration Set up depots, pricing, schemes, routes and users. 3 Migration & training Move your data and train every team. 4 Go-live & support Dedicated account manager and local support desk.
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user