Add region code support
This commit is contained in:
@@ -4,7 +4,6 @@ import { useEffect } from 'react'
|
|||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
import { FormField } from '@/components/shared/form-field'
|
import { FormField } from '@/components/shared/form-field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,8 +19,8 @@ import { regions as regionsApi, getApiError } from '@/lib/api/index'
|
|||||||
import type { RegionResponse } from '@/types/api'
|
import type { RegionResponse } from '@/types/api'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
code: z.string().max(50, 'Code must be under 50 characters').optional(),
|
||||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'),
|
name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'),
|
||||||
isActive: z.boolean(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
type FormValues = z.infer<typeof schema>
|
||||||
@@ -30,30 +29,39 @@ interface RegionSheetProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
region: RegionResponse | null
|
region: RegionResponse | null
|
||||||
|
readOnly?: boolean
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetProps) {
|
export function RegionSheet({ open, onClose, region, readOnly = false, onSuccess }: RegionSheetProps) {
|
||||||
const isEdit = !!region
|
const isEdit = !!region
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { name: '', isActive: true },
|
defaultValues: { code: '', name: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
form.reset(region ? { name: region.name, isActive: region.isActive } : { name: '', isActive: true })
|
form.reset(region ? { code: region.code, name: region.name } : { code: '', name: '' })
|
||||||
|
if (!region) {
|
||||||
|
// Suggest the next code; the user can overwrite it, or clear it to let the server generate one.
|
||||||
|
regionsApi.nextCode()
|
||||||
|
.then((res) => {
|
||||||
|
if (!form.getValues('code')) form.setValue('code', res.code)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [open, region])
|
}, [open, region])
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await regionsApi.update(region!.id, values)
|
await regionsApi.update(region!.id, { name: values.name, code: values.code?.trim() || undefined })
|
||||||
toast.add({ title: 'Region updated', type: 'success' })
|
toast.add({ title: 'Region updated', type: 'success' })
|
||||||
} else {
|
} else {
|
||||||
await regionsApi.create(values)
|
await regionsApi.create({ name: values.name, code: values.code?.trim() || undefined })
|
||||||
toast.add({ title: 'Region created', type: 'success' })
|
toast.add({ title: 'Region created', type: 'success' })
|
||||||
}
|
}
|
||||||
onSuccess()
|
onSuccess()
|
||||||
@@ -67,14 +75,37 @@ export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetPro
|
|||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
|
<DialogTitle>{readOnly ? 'View Region' : isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEdit ? 'Update the region details below.' : 'Add a new geographic region to the distribution network.'}
|
{readOnly ? 'Region details.' : isEdit ? 'Update the region details below.' : 'Add a new geographic region to the distribution network.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
label="Region Code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={(field.value as string) ?? ''}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. REG-0001"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isEdit && !readOnly && (
|
||||||
|
<p className="-mt-3 text-xs text-muted-foreground">
|
||||||
|
Suggested automatically — edit it, or clear it to auto-generate on save.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -89,36 +120,20 @@ export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetPro
|
|||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
placeholder="e.g. Western Province"
|
placeholder="e.g. Western Province"
|
||||||
|
disabled={readOnly}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="isActive"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isActive"
|
|
||||||
checked={field.value as boolean}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
{readOnly ? 'Close' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
{!readOnly && (<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Region'}
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Region'}
|
||||||
</Button>
|
</Button>)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DataTable } from '@/components/shared/data-table'
|
|||||||
import type { TableConfig } from '@/components/shared/data-table'
|
import type { TableConfig } from '@/components/shared/data-table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { RegionResponse } from '@/types/api'
|
import type { RegionResponse } from '@/types/api'
|
||||||
@@ -23,6 +24,9 @@ export function RegionsClient() {
|
|||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
||||||
|
const [viewOnly, setViewOnly] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState<RegionResponse | null>(null)
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false)
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -43,24 +47,34 @@ export function RegionsClient() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
|
setViewOnly(false)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(region: RegionResponse) {
|
function openEdit(region: RegionResponse) {
|
||||||
setEditing(region)
|
setEditing(region)
|
||||||
|
setViewOnly(false)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(region: RegionResponse) {
|
function openView(region: RegionResponse) {
|
||||||
|
setEditing(region)
|
||||||
|
setViewOnly(true)
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleting) return
|
||||||
|
setDeleteBusy(true)
|
||||||
try {
|
try {
|
||||||
await regionsApi.update(region.id, { isActive: !region.isActive })
|
await regionsApi.remove(deleting.id)
|
||||||
toast.add({
|
toast.add({ title: 'Region deleted', type: 'success' })
|
||||||
title: region.isActive ? 'Region deactivated' : 'Region activated',
|
setDeleting(null)
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
fetchData(page, pageSize)
|
fetchData(page, pageSize)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to update region', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to delete region', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +85,13 @@ export function RegionsClient() {
|
|||||||
emptyMessage: 'No regions yet. Add one to get started.',
|
emptyMessage: 'No regions yet. Add one to get started.',
|
||||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||||
columns: [
|
columns: [
|
||||||
|
{
|
||||||
|
key: 'code',
|
||||||
|
title: 'Code',
|
||||||
|
fields: [{ key: 'code', primary: true }],
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
title: 'Region',
|
title: 'Region',
|
||||||
@@ -103,6 +124,11 @@ export function RegionsClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
label: 'View region',
|
||||||
|
onClick: openView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit region',
|
label: 'Edit region',
|
||||||
@@ -110,19 +136,12 @@ export function RegionsClient() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'deactivate',
|
action: 'deactivate',
|
||||||
label: 'Deactivate region',
|
label: 'Delete region',
|
||||||
onClick: toggleActive,
|
onClick: (region: RegionResponse) => setDeleting(region),
|
||||||
visible: (target) => target.isActive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: 'activate',
|
|
||||||
label: 'Activate region',
|
|
||||||
onClick: toggleActive,
|
|
||||||
visible: (target) => !target.isActive,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}), [pageSize, openEdit, toggleActive])
|
}), [pageSize, openEdit, openView])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,8 +173,28 @@ export function RegionsClient() {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
region={editing}
|
region={editing}
|
||||||
|
readOnly={viewOnly}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={() => fetchData(page, pageSize)}
|
||||||
/>
|
/>
|
||||||
|
<Dialog open={!!deleting} onOpenChange={(v) => !v && !deleteBusy && setDeleting(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete region?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{deleting ? `${deleting.code} – ${deleting.name} will be removed from the regions list.` : null}
|
||||||
|
{' '}This action cannot be undone from this screen.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDeleting(null)} disabled={deleteBusy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="destructive" onClick={confirmDelete} disabled={deleteBusy}>
|
||||||
|
{deleteBusy ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface PageParams {
|
|||||||
|
|
||||||
export interface RegionBody {
|
export interface RegionBody {
|
||||||
name: string
|
name: string
|
||||||
|
code?: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +92,9 @@ export const regions = {
|
|||||||
list: (params?: PageParams) =>
|
list: (params?: PageParams) =>
|
||||||
api.get<PaginatedResponse<RegionResponse>>('/regions', { params }).then(r => r.data),
|
api.get<PaginatedResponse<RegionResponse>>('/regions', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
nextCode: () =>
|
||||||
|
api.get<{ code: string }>('/regions/next-code').then(r => r.data),
|
||||||
|
|
||||||
get: (id: string) =>
|
get: (id: string) =>
|
||||||
api.get<RegionResponse>(`/regions/${id}`).then(r => r.data),
|
api.get<RegionResponse>(`/regions/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface PermissionResponse {
|
|||||||
|
|
||||||
export interface RegionResponse {
|
export interface RegionResponse {
|
||||||
id: string
|
id: string
|
||||||
|
code: string
|
||||||
name: string
|
name: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
Reference in New Issue
Block a user