Add FlatPaymentController with RESTful payment endpoints

Introduced FlatPaymentController under Distribution.Features.Payment, providing GET and POST endpoints at /api/payments. Supports listing, retrieving, and creating payments with filtering and error handling. Business logic is delegated to PaymentService.
This commit is contained in:
Kalana B. Thilakarathna
2026-08-26 20:18:58 +05:30
parent 83d3272784
commit dc7e7a70fc
27 changed files with 539 additions and 38 deletions
@@ -70,7 +70,7 @@ export function CategoriesClient() {
width: 160,
cellRender: (row) => (
<span className="text-sm text-muted-foreground">
{row.subCategories.length} sub-categories
{(row.subCategories ?? []).length} sub-categories
</span>
),
},
@@ -1,6 +1,8 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, PencilEdit01Icon, Delete01Icon, Tick01Icon, Cancel01Icon } from '@hugeicons/core-free-icons'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
@@ -17,7 +19,7 @@ import {
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { categories as categoriesApi, getApiError } from '@/lib/api/index'
import type { CategoryResponse } from '@/types/api'
import type { CategoryResponse, SubCategoryResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
@@ -36,6 +38,13 @@ interface CategorySheetProps {
export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) {
const isEdit = !!category
const [subCategories, setSubCategories] = useState<SubCategoryResponse[]>([])
const [isAdding, setIsAdding] = useState(false)
const [addingName, setAddingName] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [busyId, setBusyId] = useState<string | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', isActive: true },
@@ -48,6 +57,11 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
? { name: category.name, isActive: category.isActive }
: { name: '', isActive: true }
)
setSubCategories(category?.subCategories ?? [])
setIsAdding(false)
setAddingName('')
setEditingId(null)
setEditingName('')
}
}, [open, category])
@@ -67,6 +81,52 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
}
}
async function handleAddSubCategory() {
const name = addingName.trim()
if (!name) return
setBusyId('new')
try {
const created = await categoriesApi.createSubCategory(category!.id, { name, isActive: true })
setSubCategories(prev => [...prev, created as SubCategoryResponse])
setAddingName('')
setIsAdding(false)
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to add sub-category', description: getApiError(err), type: 'error' })
} finally {
setBusyId(null)
}
}
async function handleSaveEdit(subId: string) {
const name = editingName.trim()
if (!name) return
setBusyId(subId)
try {
await categoriesApi.updateSubCategory(category!.id, subId, { name })
setSubCategories(prev => prev.map(s => s.id === subId ? { ...s, name } : s))
setEditingId(null)
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to update sub-category', description: getApiError(err), type: 'error' })
} finally {
setBusyId(null)
}
}
async function handleDelete(subId: string) {
setBusyId(subId)
try {
await categoriesApi.deleteSubCategory(subId)
setSubCategories(prev => prev.filter(s => s.id !== subId))
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to delete sub-category', description: getApiError(err), type: 'error' })
} finally {
setBusyId(null)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
@@ -116,6 +176,127 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
/>
</FieldGroup>
{isEdit && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Sub-categories</span>
{!isAdding && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
>
<HugeiconsIcon icon={Add01Icon} size={14} />
Add
</Button>
)}
</div>
<div className="flex flex-col gap-0.5 rounded-md border bg-muted/30 px-1 py-1">
{subCategories.length === 0 && !isAdding && (
<p className="px-2 py-2 text-sm text-muted-foreground">No sub-categories yet.</p>
)}
{subCategories.map((sub) => (
<div key={sub.id} className="flex items-center gap-1 rounded px-2 py-1 hover:bg-muted/60">
{editingId === sub.id ? (
<>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); handleSaveEdit(sub.id) }
if (e.key === 'Escape') setEditingId(null)
}}
className="h-7 text-sm"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
disabled={busyId === sub.id}
onClick={() => handleSaveEdit(sub.id)}
>
<HugeiconsIcon icon={Tick01Icon} size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setEditingId(null)}
>
<HugeiconsIcon icon={Cancel01Icon} size={14} />
</Button>
</>
) : (
<>
<span className="flex-1 text-sm">{sub.name}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => { setEditingId(sub.id); setEditingName(sub.name) }}
>
<HugeiconsIcon icon={PencilEdit01Icon} size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive hover:text-destructive"
disabled={busyId === sub.id}
onClick={() => handleDelete(sub.id)}
>
<HugeiconsIcon icon={Delete01Icon} size={14} />
</Button>
</>
)}
</div>
))}
{isAdding && (
<div className="flex items-center gap-1 rounded px-2 py-1">
<Input
value={addingName}
onChange={(e) => setAddingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); handleAddSubCategory() }
if (e.key === 'Escape') { setIsAdding(false); setAddingName('') }
}}
placeholder="Sub-category name"
className="h-7 text-sm"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
disabled={busyId === 'new'}
onClick={handleAddSubCategory}
>
<HugeiconsIcon icon={Tick01Icon} size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => { setIsAdding(false); setAddingName('') }}
>
<HugeiconsIcon icon={Cancel01Icon} size={14} />
</Button>
</div>
)}
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel