Dev #23
@@ -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'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
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 { PriceListSheet } from './price-list-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
|
||||
export function PriceListsClient() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<PriceListResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<PriceListResponse | null>(null)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await priceListsApi.list({ page: p, pageSize: ps })
|
||||
setData(getPaginatedItems(res))
|
||||
setTotal(res.total)
|
||||
const items = await priceListsApi.list()
|
||||
setData(items)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load price lists', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
@@ -41,22 +35,21 @@ export function PriceListsClient() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(page, pageSize)
|
||||
}, [fetchData, page, pageSize])
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(priceList: PriceListResponse) {
|
||||
setEditing(priceList)
|
||||
setSheetOpen(true)
|
||||
async function handleDelete(priceList: PriceListResponse) {
|
||||
try {
|
||||
await priceListsApi.remove(priceList.id)
|
||||
toast.add({ title: 'Price list deactivated', type: 'success' })
|
||||
fetchData()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to deactivate price list', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const tableConfig = useMemo<TableConfig<PriceListResponse>>(() => ({
|
||||
rowKey: 'id',
|
||||
serverSide: true,
|
||||
stickyHeader: true,
|
||||
emptyMessage: 'No price lists yet. Add one to get started.',
|
||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||
@@ -104,10 +97,21 @@ export function PriceListsClient() {
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'view',
|
||||
label: 'View price list',
|
||||
onClick: (row: PriceListResponse) => router.push(`/pricing/price-lists/${row.id}/view`),
|
||||
},
|
||||
{
|
||||
action: 'edit',
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Button onClick={() => router.push('/pricing/price-lists/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Price List
|
||||
</Button>
|
||||
@@ -132,19 +136,11 @@ export function PriceListsClient() {
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
totalCount={data.length}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
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 { Button } from '@/components/ui/button'
|
||||
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 { SchemeSheet } from './scheme-sheet'
|
||||
import { SchemeViewSheet } from './scheme-view-sheet'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
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() {
|
||||
const [data, setData] = useState<SchemeResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
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)
|
||||
try {
|
||||
const res = await schemesApi.list({ page: p, pageSize: ps })
|
||||
setData(getPaginatedItems(res))
|
||||
setTotal(res.total)
|
||||
const items = await schemesApi.list()
|
||||
setData(items)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load schemes', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
@@ -48,8 +48,8 @@ export function SchemesClient() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(page, pageSize)
|
||||
}, [fetchData, page, pageSize])
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
@@ -61,9 +61,23 @@ export function SchemesClient() {
|
||||
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>>(() => ({
|
||||
rowKey: 'id',
|
||||
serverSide: true,
|
||||
stickyHeader: true,
|
||||
emptyMessage: 'No schemes yet. Add one to get started.',
|
||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||
@@ -118,11 +132,22 @@ export function SchemesClient() {
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'view',
|
||||
label: 'View scheme',
|
||||
onClick: openView,
|
||||
},
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit scheme',
|
||||
onClick: openEdit,
|
||||
},
|
||||
{
|
||||
action: 'cancel',
|
||||
label: 'Delete scheme',
|
||||
onClick: handleDelete,
|
||||
visible: (target) => target.isActive,
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize])
|
||||
@@ -146,9 +171,8 @@ export function SchemesClient() {
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
totalCount={data.length}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
@@ -157,7 +181,13 @@ export function SchemesClient() {
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
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(() => {
|
||||
Promise.all([
|
||||
suppliersApi.list({ pageSize: 200 }),
|
||||
suppliersApi.list(),
|
||||
itemsApi.list({ pageSize: 200 }),
|
||||
usersApi.list({ pageSize: 200 }),
|
||||
])
|
||||
.then(([suppliersRes, itemsRes, usersRes]) => {
|
||||
setSuppliersList(getPaginatedItems(suppliersRes))
|
||||
setSuppliersList(suppliersRes)
|
||||
setItemsList(getPaginatedItems(itemsRes))
|
||||
setUsersList(getPaginatedItems(usersRes))
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
rides as ridesApi,
|
||||
items as itemsApi,
|
||||
users as usersApi,
|
||||
priceLists as priceListsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
@@ -83,6 +84,8 @@ export default function NewSalePage() {
|
||||
})
|
||||
|
||||
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(
|
||||
() => new Map(itemsList.map((item) => [item.id, item])),
|
||||
[itemsList]
|
||||
@@ -91,6 +94,38 @@ export default function NewSalePage() {
|
||||
() => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0),
|
||||
[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(() => {
|
||||
Promise.all([
|
||||
@@ -291,10 +326,7 @@ export default function NewSalePage() {
|
||||
value={field.value as string}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
const nextItem = value ? itemsById.get(value) : undefined
|
||||
if (nextItem) {
|
||||
form.setValue(`items.${index}.unitPrice`, nextItem.salePrice, { shouldValidate: true })
|
||||
}
|
||||
if (value) resolveAndSetPrice(index, value)
|
||||
}}
|
||||
>
|
||||
<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 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 { Button } from '@/components/ui/button'
|
||||
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 { SupplierSheet } from './supplier-sheet'
|
||||
import { SupplierViewSheet } from './supplier-view-sheet'
|
||||
|
||||
export function SuppliersClient() {
|
||||
const [data, setData] = useState<SupplierResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
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)
|
||||
try {
|
||||
const res = await suppliersApi.list({ page: p, pageSize: ps })
|
||||
setData(getPaginatedItems(res))
|
||||
setTotal(res.total)
|
||||
const items = await suppliersApi.list()
|
||||
setData(items)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load suppliers', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
@@ -38,8 +38,8 @@ export function SuppliersClient() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(page, pageSize)
|
||||
}, [fetchData, page, pageSize])
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
@@ -51,9 +51,23 @@ export function SuppliersClient() {
|
||||
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>>(() => ({
|
||||
rowKey: 'id',
|
||||
serverSide: true,
|
||||
stickyHeader: true,
|
||||
emptyMessage: 'No suppliers yet. Add one to get started.',
|
||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||
@@ -101,11 +115,22 @@ export function SuppliersClient() {
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'view',
|
||||
label: 'View supplier',
|
||||
onClick: openView,
|
||||
},
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit supplier',
|
||||
onClick: openEdit,
|
||||
},
|
||||
{
|
||||
action: 'cancel',
|
||||
label: 'Delete supplier',
|
||||
onClick: handleDelete,
|
||||
visible: (target) => target.isActive,
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize])
|
||||
@@ -129,9 +154,8 @@ export function SuppliersClient() {
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
totalCount={data.length}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
@@ -140,7 +164,13 @@ export function SuppliersClient() {
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
supplier={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
<SupplierViewSheet
|
||||
open={viewOpen}
|
||||
onClose={() => setViewOpen(false)}
|
||||
supplier={viewing}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user