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:
+1
-1
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
+183
-2
@@ -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
|
||||
|
||||
@@ -95,3 +95,4 @@ Ordered by **data dependency** — each module can only work once the modules ab
|
||||
- Pages live in `app/(dashboard)/<module>/page.tsx`.
|
||||
- Shared UI goes in `components/shared/`, module-specific UI stays co-located with the page.
|
||||
- Permissions are checked via `useAuth()` — never hard-code role names in pages.
|
||||
|
||||
|
||||
@@ -75,10 +75,13 @@ export const categories = {
|
||||
api.put<CategoryResponse>(`/categories/${id}`, body).then(r => r.data),
|
||||
|
||||
createSubCategory: (categoryId: string, body: SubCategoryBody) =>
|
||||
api.post(`/categories/${categoryId}/sub-categories`, body).then(r => r.data),
|
||||
api.post(`/categories/${categoryId}/subcategories`, body).then(r => r.data),
|
||||
|
||||
updateSubCategory: (categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
|
||||
api.put(`/categories/${categoryId}/sub-categories/${subId}`, body).then(r => r.data),
|
||||
updateSubCategory: (_categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
|
||||
api.put(`/subcategories/${subId}`, body).then(r => r.data),
|
||||
|
||||
deleteSubCategory: (subId: string) =>
|
||||
api.delete(`/subcategories/${subId}`),
|
||||
}
|
||||
|
||||
// ─── Brands ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user