remove PriceListSheet component and integrate new price list creation page
This commit is contained in:
@@ -0,0 +1,224 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } 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'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { priceLists as priceListsApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { PriceListResponse, RegionResponse } from '@/types/api'
|
||||||
|
import { PriceListItemsSection } from '../../_components/price-list-items-section'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
regionId: z.string().optional(),
|
||||||
|
effectiveFrom: z.string().min(1, 'Required'),
|
||||||
|
effectiveTo: z.string().min(1, 'Required'),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export default function EditPriceListPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const [priceList, setPriceList] = useState<PriceListResponse | null>(null)
|
||||||
|
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [itemsSectionKey, setItemsSectionKey] = useState(0)
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: '', regionId: '', effectiveFrom: '', effectiveTo: '', isActive: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
priceListsApi.get(params.id),
|
||||||
|
regionsApi.list({ pageSize: 100 }),
|
||||||
|
])
|
||||||
|
.then(([pl, regionsRes]) => {
|
||||||
|
setPriceList(pl)
|
||||||
|
setRegionsList(getPaginatedItems(regionsRes))
|
||||||
|
form.reset({
|
||||||
|
name: pl.name,
|
||||||
|
regionId: pl.regionId ?? '',
|
||||||
|
effectiveFrom: pl.effectiveFrom.split('T')[0],
|
||||||
|
effectiveTo: pl.effectiveTo.split('T')[0],
|
||||||
|
isActive: pl.isActive,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load price list', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [params.id])
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
try {
|
||||||
|
const updated = await priceListsApi.update(params.id, {
|
||||||
|
name: values.name,
|
||||||
|
regionId: values.regionId || null,
|
||||||
|
effectiveFrom: values.effectiveFrom,
|
||||||
|
effectiveTo: values.effectiveTo,
|
||||||
|
isActive: values.isActive,
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Price list updated', type: 'success' })
|
||||||
|
setPriceList(updated)
|
||||||
|
setItemsSectionKey((k) => k + 1)
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to update price list', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!priceList) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Price list not found.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/pricing/price-lists')}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Edit Price List</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{priceList.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-foreground">Price List Details</h3>
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Standard Q1 2025"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="regionId"
|
||||||
|
label="Region"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All Regions" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">All Regions</SelectItem>
|
||||||
|
{regionsList.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="effectiveFrom"
|
||||||
|
label="Effective From"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="effectiveFrom"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
type="date"
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="effectiveTo"
|
||||||
|
label="Effective To"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="effectiveTo"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
type="date"
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.push('/pricing/price-lists')}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<PriceListItemsSection key={itemsSectionKey} priceListId={params.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { priceLists as priceListsApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { PriceListResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const fmtDate = (v: string) =>
|
||||||
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
||||||
|
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium text-right">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewPriceListPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const [priceList, setPriceList] = useState<PriceListResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
priceListsApi.get(params.id)
|
||||||
|
.then(setPriceList)
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load price list', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [params.id])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!priceList) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Price list not found.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/pricing/price-lists')}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">{priceList.name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Price List Details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => router.push(`/pricing/price-lists/${priceList.id}/edit`)}>
|
||||||
|
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<Row label="Name" value={priceList.name} />
|
||||||
|
<Row label="Region" value={priceList.regionName ?? 'All Regions'} />
|
||||||
|
<Row label="Status" value={<Badge variant={priceList.isActive ? 'default' : 'secondary'}>{priceList.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
||||||
|
<Row label="Effective From" value={fmtDate(priceList.effectiveFrom)} />
|
||||||
|
<Row label="Effective To" value={fmtDate(priceList.effectiveTo)} />
|
||||||
|
<Row label="Created" value={fmtDate(priceList.createdAt)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-foreground">Items</h3>
|
||||||
|
<Separator className="mb-4" />
|
||||||
|
{priceList.items.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No items priced on this list yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Item</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Price</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Min Price</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Retail Price</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Agency %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{priceList.items.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{item.itemName}
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">{fmtAmount(item.price)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.minPrice != null ? fmtAmount(item.minPrice) : '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.retailPrice != null ? fmtAmount(item.retailPrice) : '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.agencyMarginPercent != null ? `${item.agencyMarginPercent}%` : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+334
@@ -0,0 +1,334 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { Add01Icon, Delete02Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { priceLists as priceListsApi, items as itemsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { PriceListResponse, PriceListItemResponse, ItemSummaryResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||||
|
|
||||||
|
interface PriceListItemsSectionProps {
|
||||||
|
priceListId: string
|
||||||
|
onChanged?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemFormState {
|
||||||
|
price: number
|
||||||
|
minPrice: string
|
||||||
|
retailPrice: string
|
||||||
|
agencyMarginPercent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(): ItemFormState {
|
||||||
|
return { price: 0, minPrice: '', retailPrice: '', agencyMarginPercent: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceListItemsSection({ priceListId, onChanged }: PriceListItemsSectionProps) {
|
||||||
|
const [items, setItems] = useState<PriceListItemResponse[]>([])
|
||||||
|
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||||
|
const [isActive, setIsActive] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editingSku, setEditingSku] = useState<string | null>(null)
|
||||||
|
const [editForm, setEditForm] = useState<ItemFormState>(emptyForm())
|
||||||
|
|
||||||
|
const [newItemId, setNewItemId] = useState('')
|
||||||
|
const [newForm, setNewForm] = useState<ItemFormState>(emptyForm())
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const pricedSkus = useMemo(() => new Set(items.map((i) => i.sku)), [items])
|
||||||
|
const availableItems = useMemo(
|
||||||
|
() => itemsList.filter((i) => !pricedSkus.has(i.sku)),
|
||||||
|
[itemsList, pricedSkus]
|
||||||
|
)
|
||||||
|
|
||||||
|
function loadDetail() {
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([
|
||||||
|
priceListsApi.get(priceListId),
|
||||||
|
itemsApi.list({ pageSize: 500 }),
|
||||||
|
])
|
||||||
|
.then(([pl, itemsRes]) => {
|
||||||
|
setItems(pl.items)
|
||||||
|
setIsActive(pl.isActive)
|
||||||
|
setItemsList(getPaginatedItems(itemsRes))
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load price list items', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDetail()
|
||||||
|
}, [priceListId])
|
||||||
|
|
||||||
|
function refresh(pl: PriceListResponse) {
|
||||||
|
setItems(pl.items)
|
||||||
|
setIsActive(pl.isActive)
|
||||||
|
onChanged?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!newItemId) {
|
||||||
|
toast.add({ title: 'Select an item first', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newForm.price <= 0) {
|
||||||
|
toast.add({ title: 'Price must be greater than 0', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = itemsList.find((i) => i.id === newItemId)
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const pl = await priceListsApi.upsertItem(priceListId, item.sku, {
|
||||||
|
price: newForm.price,
|
||||||
|
minPrice: newForm.minPrice ? Number(newForm.minPrice) : null,
|
||||||
|
retailPrice: newForm.retailPrice ? Number(newForm.retailPrice) : null,
|
||||||
|
agencyMarginPercent: newForm.agencyMarginPercent ? Number(newForm.agencyMarginPercent) : null,
|
||||||
|
})
|
||||||
|
refresh(pl)
|
||||||
|
setNewItemId('')
|
||||||
|
setNewForm(emptyForm())
|
||||||
|
toast.add({ title: 'Item priced', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to price item', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(item: PriceListItemResponse) {
|
||||||
|
setEditingSku(item.sku)
|
||||||
|
setEditForm({
|
||||||
|
price: item.price,
|
||||||
|
minPrice: item.minPrice != null ? String(item.minPrice) : '',
|
||||||
|
retailPrice: item.retailPrice != null ? String(item.retailPrice) : '',
|
||||||
|
agencyMarginPercent: item.agencyMarginPercent != null ? String(item.agencyMarginPercent) : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(sku: string) {
|
||||||
|
if (editForm.price <= 0) {
|
||||||
|
toast.add({ title: 'Price must be greater than 0', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const pl = await priceListsApi.upsertItem(priceListId, sku, {
|
||||||
|
price: editForm.price,
|
||||||
|
minPrice: editForm.minPrice ? Number(editForm.minPrice) : null,
|
||||||
|
retailPrice: editForm.retailPrice ? Number(editForm.retailPrice) : null,
|
||||||
|
agencyMarginPercent: editForm.agencyMarginPercent ? Number(editForm.agencyMarginPercent) : null,
|
||||||
|
})
|
||||||
|
refresh(pl)
|
||||||
|
setEditingSku(null)
|
||||||
|
toast.add({ title: 'Item price updated', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to update item price', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(sku: string) {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const pl = await priceListsApi.removeItem(priceListId, sku)
|
||||||
|
refresh(pl)
|
||||||
|
toast.add({ title: 'Item removed from price list', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to remove item', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">Items</p>
|
||||||
|
|
||||||
|
{!loading && !isActive && (
|
||||||
|
<p className="rounded-xl border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||||
|
This price list is Inactive — reactivate it above (check "Active" and Save) before adding or changing item prices.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading items…</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-right font-medium">Price</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-right font-medium">Min Price</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-right font-medium">Retail Price</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-right font-medium">Agency %</th>
|
||||||
|
<th className="w-20 px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{item.itemName}
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
|
||||||
|
</td>
|
||||||
|
{editingSku === item.sku ? (
|
||||||
|
<>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step="0.01"
|
||||||
|
value={editForm.price}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, price: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="None"
|
||||||
|
value={editForm.minPrice}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, minPrice: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="None"
|
||||||
|
value={editForm.retailPrice}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, retailPrice: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="None"
|
||||||
|
value={editForm.agencyMarginPercent}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, agencyMarginPercent: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button type="button" size="sm" disabled={saving} onClick={() => handleSaveEdit(item.sku)}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={() => setEditingSku(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-3 py-2 text-right">{fmtAmount(item.price)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.minPrice != null ? fmtAmount(item.minPrice) : '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.retailPrice != null ? fmtAmount(item.retailPrice) : '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.agencyMarginPercent != null ? `${item.agencyMarginPercent}%` : '—'}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={!isActive} onClick={() => startEdit(item)}>
|
||||||
|
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={saving || !isActive} onClick={() => handleRemove(item.sku)}>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={14} strokeWidth={2} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<tr className="border-t bg-muted/20">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Select value={newItemId} onValueChange={(v) => setNewItemId(v ?? '')} disabled={!isActive}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Add item..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-80 min-w-[320px]">
|
||||||
|
{availableItems.map((i) => (
|
||||||
|
<SelectItem key={i.id} value={i.id}>{i.name} — {i.sku}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Price"
|
||||||
|
value={newForm.price || ''}
|
||||||
|
onChange={(e) => setNewForm((f) => ({ ...f, price: Number(e.target.value) }))}
|
||||||
|
disabled={!isActive}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="None"
|
||||||
|
value={newForm.minPrice}
|
||||||
|
onChange={(e) => setNewForm((f) => ({ ...f, minPrice: e.target.value }))}
|
||||||
|
disabled={!isActive}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="None"
|
||||||
|
value={newForm.retailPrice}
|
||||||
|
onChange={(e) => setNewForm((f) => ({ ...f, retailPrice: e.target.value }))}
|
||||||
|
disabled={!isActive}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="None"
|
||||||
|
value={newForm.agencyMarginPercent}
|
||||||
|
onChange={(e) => setNewForm((f) => ({ ...f, agencyMarginPercent: e.target.value }))}
|
||||||
|
disabled={!isActive}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Button type="button" size="sm" disabled={saving || !isActive} onClick={handleAdd}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
|
||||||
import { FormField } from '@/components/shared/form-field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { priceLists as priceListsApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
|
||||||
import type { PriceListResponse, RegionResponse } from '@/types/api'
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
regionId: z.string().optional(),
|
|
||||||
effectiveFrom: z.string().min(1, 'Required'),
|
|
||||||
effectiveTo: z.string().min(1, 'Required'),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
interface PriceListSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
priceList: PriceListResponse | null
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PriceListSheet({ open, onClose, priceList, onSuccess }: PriceListSheetProps) {
|
|
||||||
const isEdit = !!priceList
|
|
||||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: { name: '', regionId: '', effectiveFrom: '', effectiveTo: '', isActive: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
form.reset(
|
|
||||||
priceList
|
|
||||||
? {
|
|
||||||
name: priceList.name,
|
|
||||||
regionId: priceList.regionId ?? '',
|
|
||||||
effectiveFrom: priceList.effectiveFrom.split('T')[0],
|
|
||||||
effectiveTo: priceList.effectiveTo.split('T')[0],
|
|
||||||
isActive: priceList.isActive,
|
|
||||||
}
|
|
||||||
: { name: '', regionId: '', effectiveFrom: '', effectiveTo: '', isActive: true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [open, priceList])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
regionsApi.list({ pageSize: 100 }).then((res) => setRegionsList(getPaginatedItems(res))).catch(() => {})
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
name: values.name,
|
|
||||||
regionId: values.regionId || null,
|
|
||||||
effectiveFrom: values.effectiveFrom,
|
|
||||||
effectiveTo: values.effectiveTo,
|
|
||||||
isActive: values.isActive,
|
|
||||||
items: [],
|
|
||||||
}
|
|
||||||
if (isEdit) {
|
|
||||||
await priceListsApi.update(priceList!.id, body)
|
|
||||||
toast.add({ title: 'Price list updated', type: 'success' })
|
|
||||||
} else {
|
|
||||||
await priceListsApi.create(body)
|
|
||||||
toast.add({ title: 'Price list created', type: 'success' })
|
|
||||||
}
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{isEdit ? 'Edit Price List' : 'Add Price List'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit ? 'Update the price list details below.' : 'Create a new price list.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
|
||||||
<FieldGroup>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
label="Name"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="e.g. Standard Q1 2025"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="regionId"
|
|
||||||
label="Region"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
value={field.value as string}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="All Regions" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">All Regions</SelectItem>
|
|
||||||
{regionsList.map((r) => (
|
|
||||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="effectiveFrom"
|
|
||||||
label="Effective From"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="effectiveFrom"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
type="date"
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="effectiveTo"
|
|
||||||
label="Effective To"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="effectiveTo"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
type="date"
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Price List'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+30
-34
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
@@ -9,30 +10,23 @@ 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 { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { priceLists as priceListsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { priceLists as priceListsApi, getApiError } from '@/lib/api/index'
|
||||||
import type { PriceListResponse } from '@/types/api'
|
import type { PriceListResponse } from '@/types/api'
|
||||||
|
|
||||||
import { PriceListSheet } from './price-list-sheet'
|
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
const fmtDate = (v: string) =>
|
||||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
||||||
export function PriceListsClient() {
|
export function PriceListsClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<PriceListResponse[]>([])
|
const [data, setData] = useState<PriceListResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const fetchData = useCallback(async () => {
|
||||||
const [editing, setEditing] = useState<PriceListResponse | null>(null)
|
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await priceListsApi.list({ page: p, pageSize: ps })
|
const items = await priceListsApi.list()
|
||||||
setData(getPaginatedItems(res))
|
setData(items)
|
||||||
setTotal(res.total)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to load price lists', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load price lists', description: getApiError(err), type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,22 +35,21 @@ export function PriceListsClient() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(page, pageSize)
|
fetchData()
|
||||||
}, [fetchData, page, pageSize])
|
}, [fetchData])
|
||||||
|
|
||||||
function openCreate() {
|
async function handleDelete(priceList: PriceListResponse) {
|
||||||
setEditing(null)
|
try {
|
||||||
setSheetOpen(true)
|
await priceListsApi.remove(priceList.id)
|
||||||
}
|
toast.add({ title: 'Price list deactivated', type: 'success' })
|
||||||
|
fetchData()
|
||||||
function openEdit(priceList: PriceListResponse) {
|
} catch (err) {
|
||||||
setEditing(priceList)
|
toast.add({ title: 'Failed to deactivate price list', description: getApiError(err), type: 'error' })
|
||||||
setSheetOpen(true)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableConfig = useMemo<TableConfig<PriceListResponse>>(() => ({
|
const tableConfig = useMemo<TableConfig<PriceListResponse>>(() => ({
|
||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
serverSide: true,
|
|
||||||
stickyHeader: true,
|
stickyHeader: true,
|
||||||
emptyMessage: 'No price lists yet. Add one to get started.',
|
emptyMessage: 'No price lists yet. Add one to get started.',
|
||||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||||
@@ -104,10 +97,21 @@ export function PriceListsClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
label: 'View price list',
|
||||||
|
onClick: (row: PriceListResponse) => router.push(`/pricing/price-lists/${row.id}/view`),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit price list',
|
label: 'Edit price list',
|
||||||
onClick: openEdit,
|
onClick: (row: PriceListResponse) => router.push(`/pricing/price-lists/${row.id}/edit`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'cancel',
|
||||||
|
label: 'Delete price list',
|
||||||
|
onClick: handleDelete,
|
||||||
|
visible: (target) => target.isActive,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -122,7 +126,7 @@ export function PriceListsClient() {
|
|||||||
Item prices scoped by region and active date range
|
Item prices scoped by region and active date range
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={() => router.push('/pricing/price-lists/new')}>
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
Add Price List
|
Add Price List
|
||||||
</Button>
|
</Button>
|
||||||
@@ -132,19 +136,11 @@ export function PriceListsClient() {
|
|||||||
config={tableConfig}
|
config={tableConfig}
|
||||||
data={data}
|
data={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
totalCount={total}
|
totalCount={data.length}
|
||||||
onServerSideChange={({ page: p }) => {
|
onServerSideChange={({ page: p }) => {
|
||||||
setPage(p.page)
|
|
||||||
setPageSize(p.pageSize)
|
setPageSize(p.pageSize)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PriceListSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onClose={() => setSheetOpen(false)}
|
|
||||||
priceList={editing}
|
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } 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'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { priceLists as priceListsApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { RegionResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
regionId: z.string().optional(),
|
||||||
|
effectiveFrom: z.string().min(1, 'Required'),
|
||||||
|
effectiveTo: z.string().min(1, 'Required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export default function NewPriceListPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: '', regionId: '', effectiveFrom: '', effectiveTo: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
regionsApi.list({ pageSize: 100 }).then((res) => setRegionsList(getPaginatedItems(res))).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
try {
|
||||||
|
const created = await priceListsApi.create({
|
||||||
|
name: values.name,
|
||||||
|
regionId: values.regionId || null,
|
||||||
|
effectiveFrom: values.effectiveFrom,
|
||||||
|
effectiveTo: values.effectiveTo,
|
||||||
|
isActive: true,
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Price list created — now add items', type: 'success' })
|
||||||
|
router.push(`/pricing/price-lists/${created.id}/edit`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to create price list', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/pricing/price-lists')}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Add Price List</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Create a price list scoped to a region and date range.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-foreground">Price List Details</h3>
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Standard Q1 2025"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="regionId"
|
||||||
|
label="Region"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All Regions" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">All Regions</SelectItem>
|
||||||
|
{regionsList.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="effectiveFrom"
|
||||||
|
label="Effective From"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="effectiveFrom"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
type="date"
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="effectiveTo"
|
||||||
|
label="Effective To"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="effectiveTo"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
type="date"
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.push('/pricing/price-lists')}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Creating…' : 'Create & Add Items'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import type { SchemeResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const fmtDate = (v: string) =>
|
||||||
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
||||||
|
interface SchemeViewSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
scheme: SchemeResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium text-right">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) {
|
||||||
|
if (!scheme) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Scheme Details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Row label="Name" value={scheme.name} />
|
||||||
|
<Row label="Rule Type" value={<Badge>{scheme.ruleType}</Badge>} />
|
||||||
|
<Row label="Region" value={scheme.regionName ?? 'All Regions'} />
|
||||||
|
<Row label="Eligible Segment" value={scheme.eligibleSegment ?? 'All Segments'} />
|
||||||
|
<Row label="Status" value={<Badge variant={scheme.isActive ? 'default' : 'secondary'}>{scheme.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
||||||
|
<Row label="Date Range" value={`${fmtDate(scheme.dateRangeFrom)} → ${fmtDate(scheme.dateRangeTo)}`} />
|
||||||
|
<Row label="Agent Limit Exempt" value={scheme.agentLimitExempt ? 'Yes' : 'No'} />
|
||||||
|
{scheme.baselineExpectedSales > 0 && (
|
||||||
|
<Row label="Baseline Expected Sales" value={`Rs. ${scheme.baselineExpectedSales.toLocaleString()}`} />
|
||||||
|
)}
|
||||||
|
{scheme.ruleConfig && <Row label="Rule Config" value={<span className="font-mono text-xs">{scheme.ruleConfig}</span>} />}
|
||||||
|
{scheme.triggerItemName && (
|
||||||
|
<Row label="Trigger" value={`${scheme.triggerQty} × ${scheme.triggerItemName}`} />
|
||||||
|
)}
|
||||||
|
{scheme.freeItemName && (
|
||||||
|
<Row label="Free Goods" value={`${scheme.freeQty} × ${scheme.freeItemName}`} />
|
||||||
|
)}
|
||||||
|
<Row label="Created" value={fmtDate(scheme.createdAt)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scheme.bundleComponents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-foreground">Bundle Components</p>
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Item</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Qty</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{scheme.bundleComponents.map((c) => (
|
||||||
|
<tr key={c.itemId} className="border-t">
|
||||||
|
<td className="px-3 py-2">{c.itemName} <span className="ml-1 text-xs text-muted-foreground">({c.sku})</span></td>
|
||||||
|
<td className="px-3 py-2 text-right">{c.qty}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ 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 { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { schemes as schemesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||||
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
||||||
|
|
||||||
import { SchemeSheet } from './scheme-sheet'
|
import { SchemeSheet } from './scheme-sheet'
|
||||||
|
import { SchemeViewSheet } from './scheme-view-sheet'
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
const fmtDate = (v: string) =>
|
||||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
@@ -27,19 +28,18 @@ const RULE_TYPE_VARIANT: Record<SchemeRuleType, 'default' | 'secondary'> = {
|
|||||||
export function SchemesClient() {
|
export function SchemesClient() {
|
||||||
const [data, setData] = useState<SchemeResponse[]>([])
|
const [data, setData] = useState<SchemeResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<SchemeResponse | null>(null)
|
const [editing, setEditing] = useState<SchemeResponse | null>(null)
|
||||||
|
const [viewing, setViewing] = useState<SchemeResponse | null>(null)
|
||||||
|
const [viewOpen, setViewOpen] = useState(false)
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await schemesApi.list({ page: p, pageSize: ps })
|
const items = await schemesApi.list()
|
||||||
setData(getPaginatedItems(res))
|
setData(items)
|
||||||
setTotal(res.total)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to load schemes', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load schemes', description: getApiError(err), type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,8 +48,8 @@ export function SchemesClient() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(page, pageSize)
|
fetchData()
|
||||||
}, [fetchData, page, pageSize])
|
}, [fetchData])
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -61,9 +61,23 @@ export function SchemesClient() {
|
|||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openView(scheme: SchemeResponse) {
|
||||||
|
setViewing(scheme)
|
||||||
|
setViewOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(scheme: SchemeResponse) {
|
||||||
|
try {
|
||||||
|
await schemesApi.remove(scheme.id)
|
||||||
|
toast.add({ title: 'Scheme deactivated', type: 'success' })
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to deactivate scheme', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tableConfig = useMemo<TableConfig<SchemeResponse>>(() => ({
|
const tableConfig = useMemo<TableConfig<SchemeResponse>>(() => ({
|
||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
serverSide: true,
|
|
||||||
stickyHeader: true,
|
stickyHeader: true,
|
||||||
emptyMessage: 'No schemes yet. Add one to get started.',
|
emptyMessage: 'No schemes yet. Add one to get started.',
|
||||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||||
@@ -118,11 +132,22 @@ export function SchemesClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
label: 'View scheme',
|
||||||
|
onClick: openView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit scheme',
|
label: 'Edit scheme',
|
||||||
onClick: openEdit,
|
onClick: openEdit,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
action: 'cancel',
|
||||||
|
label: 'Delete scheme',
|
||||||
|
onClick: handleDelete,
|
||||||
|
visible: (target) => target.isActive,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}), [pageSize])
|
}), [pageSize])
|
||||||
@@ -146,9 +171,8 @@ export function SchemesClient() {
|
|||||||
config={tableConfig}
|
config={tableConfig}
|
||||||
data={data}
|
data={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
totalCount={total}
|
totalCount={data.length}
|
||||||
onServerSideChange={({ page: p }) => {
|
onServerSideChange={({ page: p }) => {
|
||||||
setPage(p.page)
|
|
||||||
setPageSize(p.pageSize)
|
setPageSize(p.pageSize)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -157,7 +181,13 @@ export function SchemesClient() {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
scheme={editing}
|
scheme={editing}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={fetchData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SchemeViewSheet
|
||||||
|
open={viewOpen}
|
||||||
|
onClose={() => setViewOpen(false)}
|
||||||
|
scheme={viewing}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ export default function NewPurchaseOrderPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
suppliersApi.list({ pageSize: 200 }),
|
suppliersApi.list(),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list({ pageSize: 200 }),
|
||||||
])
|
])
|
||||||
.then(([suppliersRes, itemsRes, usersRes]) => {
|
.then(([suppliersRes, itemsRes, usersRes]) => {
|
||||||
setSuppliersList(getPaginatedItems(suppliersRes))
|
setSuppliersList(suppliersRes)
|
||||||
setItemsList(getPaginatedItems(itemsRes))
|
setItemsList(getPaginatedItems(itemsRes))
|
||||||
setUsersList(getPaginatedItems(usersRes))
|
setUsersList(getPaginatedItems(usersRes))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
rides as ridesApi,
|
rides as ridesApi,
|
||||||
items as itemsApi,
|
items as itemsApi,
|
||||||
users as usersApi,
|
users as usersApi,
|
||||||
|
priceLists as priceListsApi,
|
||||||
getApiError,
|
getApiError,
|
||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
@@ -83,6 +84,8 @@ export default function NewSalePage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const watchedItems = useWatch({ control: form.control, name: 'items' })
|
const watchedItems = useWatch({ control: form.control, name: 'items' })
|
||||||
|
const watchedStoreId = useWatch({ control: form.control, name: 'storeId' })
|
||||||
|
const watchedSaleDate = useWatch({ control: form.control, name: 'saleDate' })
|
||||||
const itemsById = useMemo(
|
const itemsById = useMemo(
|
||||||
() => new Map(itemsList.map((item) => [item.id, item])),
|
() => new Map(itemsList.map((item) => [item.id, item])),
|
||||||
[itemsList]
|
[itemsList]
|
||||||
@@ -91,6 +94,38 @@ export default function NewSalePage() {
|
|||||||
() => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0),
|
() => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0),
|
||||||
[watchedItems]
|
[watchedItems]
|
||||||
)
|
)
|
||||||
|
const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({})
|
||||||
|
const [priceRetailByIndex, setPriceRetailByIndex] = useState<Record<number, number | null>>({})
|
||||||
|
|
||||||
|
async function resolveAndSetPrice(index: number, itemId: string) {
|
||||||
|
const item = itemsById.get(itemId)
|
||||||
|
if (watchedStoreId && watchedSaleDate) {
|
||||||
|
try {
|
||||||
|
const resolution = await priceListsApi.resolve({ itemId, storeId: watchedStoreId, saleDate: watchedSaleDate })
|
||||||
|
form.setValue(`items.${index}.unitPrice`, resolution.price, { shouldValidate: true })
|
||||||
|
setPriceMinByIndex((prev) => ({ ...prev, [index]: resolution.minPrice }))
|
||||||
|
setPriceRetailByIndex((prev) => ({ ...prev, [index]: resolution.retailPrice }))
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// No resolvable price for this store/date (e.g. store/item not found) — fall back below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item) {
|
||||||
|
form.setValue(`items.${index}.unitPrice`, item.salePrice, { shouldValidate: true })
|
||||||
|
setPriceMinByIndex((prev) => ({ ...prev, [index]: null }))
|
||||||
|
setPriceRetailByIndex((prev) => ({ ...prev, [index]: null }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-resolve every already-selected line's price whenever the store or sale date changes,
|
||||||
|
// since the applicable price list is scoped by the store's region and the sale date.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watchedStoreId || !watchedSaleDate) return
|
||||||
|
watchedItems.forEach((line, index) => {
|
||||||
|
if (line.itemId) resolveAndSetPrice(index, line.itemId)
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [watchedStoreId, watchedSaleDate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -291,10 +326,7 @@ export default function NewSalePage() {
|
|||||||
value={field.value as string}
|
value={field.value as string}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
field.onChange(value)
|
field.onChange(value)
|
||||||
const nextItem = value ? itemsById.get(value) : undefined
|
if (value) resolveAndSetPrice(index, value)
|
||||||
if (nextItem) {
|
|
||||||
form.setValue(`items.${index}.unitPrice`, nextItem.salePrice, { shouldValidate: true })
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -359,6 +391,16 @@ export default function NewSalePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{priceMinByIndex[index] != null && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Negotiable down to {fmtAmount(priceMinByIndex[index]!)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{priceRetailByIndex[index] != null && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Retail price: {fmtAmount(priceRetailByIndex[index]!)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-3 py-2 text-right font-medium">{fmtAmount(lineTotal)}</td>
|
<td className="px-3 py-2 text-right font-medium">{fmtAmount(lineTotal)}</td>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import type { SupplierResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const fmtDate = (v: string) =>
|
||||||
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
||||||
|
interface SupplierViewSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
supplier: SupplierResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium text-right">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierViewSheet({ open, onClose, supplier }: SupplierViewSheetProps) {
|
||||||
|
if (!supplier) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Supplier Details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Row label="Name" value={supplier.name} />
|
||||||
|
<Row label="Contact Info" value={supplier.contactInfo ?? '—'} />
|
||||||
|
<Row label="Status" value={<Badge variant={supplier.isActive ? 'default' : 'secondary'}>{supplier.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
||||||
|
<Row label="Performance Score" value={supplier.performanceScore != null ? supplier.performanceScore.toFixed(2) : '—'} />
|
||||||
|
<Row label="Created" value={fmtDate(supplier.createdAt)} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,27 +9,27 @@ 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 { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { suppliers as suppliersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { suppliers as suppliersApi, getApiError } from '@/lib/api/index'
|
||||||
import type { SupplierResponse } from '@/types/api'
|
import type { SupplierResponse } from '@/types/api'
|
||||||
|
|
||||||
import { SupplierSheet } from './supplier-sheet'
|
import { SupplierSheet } from './supplier-sheet'
|
||||||
|
import { SupplierViewSheet } from './supplier-view-sheet'
|
||||||
|
|
||||||
export function SuppliersClient() {
|
export function SuppliersClient() {
|
||||||
const [data, setData] = useState<SupplierResponse[]>([])
|
const [data, setData] = useState<SupplierResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<SupplierResponse | null>(null)
|
const [editing, setEditing] = useState<SupplierResponse | null>(null)
|
||||||
|
const [viewing, setViewing] = useState<SupplierResponse | null>(null)
|
||||||
|
const [viewOpen, setViewOpen] = useState(false)
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await suppliersApi.list({ page: p, pageSize: ps })
|
const items = await suppliersApi.list()
|
||||||
setData(getPaginatedItems(res))
|
setData(items)
|
||||||
setTotal(res.total)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to load suppliers', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load suppliers', description: getApiError(err), type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,8 +38,8 @@ export function SuppliersClient() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(page, pageSize)
|
fetchData()
|
||||||
}, [fetchData, page, pageSize])
|
}, [fetchData])
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
@@ -51,9 +51,23 @@ export function SuppliersClient() {
|
|||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openView(supplier: SupplierResponse) {
|
||||||
|
setViewing(supplier)
|
||||||
|
setViewOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(supplier: SupplierResponse) {
|
||||||
|
try {
|
||||||
|
await suppliersApi.remove(supplier.id)
|
||||||
|
toast.add({ title: 'Supplier deactivated', type: 'success' })
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to deactivate supplier', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tableConfig = useMemo<TableConfig<SupplierResponse>>(() => ({
|
const tableConfig = useMemo<TableConfig<SupplierResponse>>(() => ({
|
||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
serverSide: true,
|
|
||||||
stickyHeader: true,
|
stickyHeader: true,
|
||||||
emptyMessage: 'No suppliers yet. Add one to get started.',
|
emptyMessage: 'No suppliers yet. Add one to get started.',
|
||||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||||
@@ -101,11 +115,22 @@ export function SuppliersClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
label: 'View supplier',
|
||||||
|
onClick: openView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit supplier',
|
label: 'Edit supplier',
|
||||||
onClick: openEdit,
|
onClick: openEdit,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
action: 'cancel',
|
||||||
|
label: 'Delete supplier',
|
||||||
|
onClick: handleDelete,
|
||||||
|
visible: (target) => target.isActive,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}), [pageSize])
|
}), [pageSize])
|
||||||
@@ -129,9 +154,8 @@ export function SuppliersClient() {
|
|||||||
config={tableConfig}
|
config={tableConfig}
|
||||||
data={data}
|
data={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
totalCount={total}
|
totalCount={data.length}
|
||||||
onServerSideChange={({ page: p }) => {
|
onServerSideChange={({ page: p }) => {
|
||||||
setPage(p.page)
|
|
||||||
setPageSize(p.pageSize)
|
setPageSize(p.pageSize)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -140,7 +164,13 @@ export function SuppliersClient() {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
supplier={editing}
|
supplier={editing}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={fetchData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SupplierViewSheet
|
||||||
|
open={viewOpen}
|
||||||
|
onClose={() => setViewOpen(false)}
|
||||||
|
supplier={viewing}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user