diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/[id]/edit/page.tsx new file mode 100644 index 0000000..98a2911 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/[id]/edit/page.tsx @@ -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 + +export default function EditPriceListPage() { + const router = useRouter() + const params = useParams<{ id: string }>() + const [priceList, setPriceList] = useState(null) + const [regionsList, setRegionsList] = useState([]) + const [loading, setLoading] = useState(true) + const [itemsSectionKey, setItemsSectionKey] = useState(0) + + const form = useForm({ + 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

Loading…

+ } + + if (!priceList) { + return

Price list not found.

+ } + + return ( +
+
+ +
+

Edit Price List

+

{priceList.name}

+
+
+ +
+
+

Price List Details

+ +
+ ( + + )} + /> + + ( + + )} + /> + +
+ ( + + )} + /> + + ( + + )} + /> +
+
+ + ( +
+ + +
+ )} + /> +
+
+ +
+ + +
+
+ +
+ +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/[id]/view/page.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/[id]/view/page.tsx new file mode 100644 index 0000000..f3b9b4a --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/[id]/view/page.tsx @@ -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 ( +
+ {label} + {value} +
+ ) +} + +export default function ViewPriceListPage() { + const router = useRouter() + const params = useParams<{ id: string }>() + const [priceList, setPriceList] = useState(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

Loading…

+ } + + if (!priceList) { + return

Price list not found.

+ } + + return ( +
+
+
+ +
+

{priceList.name}

+

Price List Details

+
+
+ +
+ +
+ + + {priceList.isActive ? 'Active' : 'Inactive'}} /> + + + +
+ +
+

Items

+ + {priceList.items.length === 0 ? ( +

No items priced on this list yet.

+ ) : ( +
+ + + + + + + + + + + + {priceList.items.map((item) => ( + + + + + + + + ))} + +
ItemPriceMin PriceRetail PriceAgency %
+ {item.itemName} + ({item.sku}) + {fmtAmount(item.price)}{item.minPrice != null ? fmtAmount(item.minPrice) : '—'}{item.retailPrice != null ? fmtAmount(item.retailPrice) : '—'}{item.agencyMarginPercent != null ? `${item.agencyMarginPercent}%` : '—'}
+
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-items-section.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-items-section.tsx new file mode 100644 index 0000000..b19e4d0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-items-section.tsx @@ -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([]) + const [itemsList, setItemsList] = useState([]) + const [isActive, setIsActive] = useState(true) + const [loading, setLoading] = useState(true) + const [editingSku, setEditingSku] = useState(null) + const [editForm, setEditForm] = useState(emptyForm()) + + const [newItemId, setNewItemId] = useState('') + const [newForm, setNewForm] = useState(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 ( +
+

Items

+ + {!loading && !isActive && ( +

+ This price list is Inactive — reactivate it above (check "Active" and Save) before adding or changing item prices. +

+ )} + + {loading ? ( +

Loading items…

+ ) : ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + {editingSku === item.sku ? ( + <> + + + + + + + ) : ( + <> + + + + + + + )} + + ))} + + + + + + + + + + +
ItemPriceMin PriceRetail PriceAgency %
+ {item.itemName} + ({item.sku}) + + setEditForm((f) => ({ ...f, price: Number(e.target.value) }))} + /> + + setEditForm((f) => ({ ...f, minPrice: e.target.value }))} + /> + + setEditForm((f) => ({ ...f, retailPrice: e.target.value }))} + /> + + setEditForm((f) => ({ ...f, agencyMarginPercent: e.target.value }))} + /> + +
+ + +
+
{fmtAmount(item.price)}{item.minPrice != null ? fmtAmount(item.minPrice) : '—'}{item.retailPrice != null ? fmtAmount(item.retailPrice) : '—'}{item.agencyMarginPercent != null ? `${item.agencyMarginPercent}%` : '—'} +
+ + +
+
+ + + setNewForm((f) => ({ ...f, price: Number(e.target.value) }))} + disabled={!isActive} + /> + + setNewForm((f) => ({ ...f, minPrice: e.target.value }))} + disabled={!isActive} + /> + + setNewForm((f) => ({ ...f, retailPrice: e.target.value }))} + disabled={!isActive} + /> + + setNewForm((f) => ({ ...f, agencyMarginPercent: e.target.value }))} + disabled={!isActive} + /> + + +
+
+ )} +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-sheet.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-sheet.tsx deleted file mode 100644 index 9ec5f14..0000000 --- a/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-sheet.tsx +++ /dev/null @@ -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 - -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([]) - - const form = useForm({ - 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 ( - !v && onClose()}> - - - {isEdit ? 'Edit Price List' : 'Add Price List'} - - {isEdit ? 'Update the price list details below.' : 'Create a new price list.'} - - - -
- - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( -
- - -
- )} - /> -
- - - - - -
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-lists-client.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-lists-client.tsx index b464396..989bfbe 100644 --- a/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-lists-client.tsx +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-lists-client.tsx @@ -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([]) 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(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>(() => ({ 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

- @@ -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) }} /> - - setSheetOpen(false)} - priceList={editing} - onSuccess={() => fetchData(page, pageSize)} - /> ) } diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/new/page.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/new/page.tsx new file mode 100644 index 0000000..15047b4 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/new/page.tsx @@ -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 + +export default function NewPriceListPage() { + const router = useRouter() + const [regionsList, setRegionsList] = useState([]) + + const form = useForm({ + 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 ( +
+
+ +
+

Add Price List

+

Create a price list scoped to a region and date range.

+
+
+ +
+
+

Price List Details

+ +
+ ( + + )} + /> + + ( + + )} + /> + +
+ ( + + )} + /> + + ( + + )} + /> +
+
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-view-sheet.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-view-sheet.tsx new file mode 100644 index 0000000..3ad58fa --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-view-sheet.tsx @@ -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 ( +
+ {label} + {value} +
+ ) +} + +export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) { + if (!scheme) return null + + return ( + !v && onClose()}> + + + Scheme Details + + +
+ + {scheme.ruleType}} /> + + + {scheme.isActive ? 'Active' : 'Inactive'}} /> + + + {scheme.baselineExpectedSales > 0 && ( + + )} + {scheme.ruleConfig && {scheme.ruleConfig}} />} + {scheme.triggerItemName && ( + + )} + {scheme.freeItemName && ( + + )} + +
+ + {scheme.bundleComponents.length > 0 && ( +
+

Bundle Components

+
+ + + + + + + + + {scheme.bundleComponents.map((c) => ( + + + + + ))} + +
ItemQty
{c.itemName} ({c.sku}){c.qty}
+
+
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx index 01a4b48..e8e2b2a 100644 --- a/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx +++ b/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx @@ -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 = { export function SchemesClient() { const [data, setData] = useState([]) 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(null) + const [viewing, setViewing] = useState(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>(() => ({ 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} + /> + + setViewOpen(false)} + scheme={viewing} /> ) diff --git a/distribution_fe/app/(dashboard)/procurement/orders/new/page.tsx b/distribution_fe/app/(dashboard)/procurement/orders/new/page.tsx index b97233b..d41995b 100644 --- a/distribution_fe/app/(dashboard)/procurement/orders/new/page.tsx +++ b/distribution_fe/app/(dashboard)/procurement/orders/new/page.tsx @@ -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)) }) diff --git a/distribution_fe/app/(dashboard)/sales/new/page.tsx b/distribution_fe/app/(dashboard)/sales/new/page.tsx index 0c61212..6d04845 100644 --- a/distribution_fe/app/(dashboard)/sales/new/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/new/page.tsx @@ -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>({}) + const [priceRetailByIndex, setPriceRetailByIndex] = useState>({}) + + 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) }} > @@ -359,6 +391,16 @@ export default function NewSalePage() { /> )} /> + {priceMinByIndex[index] != null && ( +

+ Negotiable down to {fmtAmount(priceMinByIndex[index]!)} +

+ )} + {priceRetailByIndex[index] != null && ( +

+ Retail price: {fmtAmount(priceRetailByIndex[index]!)} +

+ )} {fmtAmount(lineTotal)} diff --git a/distribution_fe/app/(dashboard)/suppliers/_components/supplier-view-sheet.tsx b/distribution_fe/app/(dashboard)/suppliers/_components/supplier-view-sheet.tsx new file mode 100644 index 0000000..5661962 --- /dev/null +++ b/distribution_fe/app/(dashboard)/suppliers/_components/supplier-view-sheet.tsx @@ -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 ( +
+ {label} + {value} +
+ ) +} + +export function SupplierViewSheet({ open, onClose, supplier }: SupplierViewSheetProps) { + if (!supplier) return null + + return ( + !v && onClose()}> + + + Supplier Details + + +
+ + + {supplier.isActive ? 'Active' : 'Inactive'}} /> + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/suppliers/_components/suppliers-client.tsx b/distribution_fe/app/(dashboard)/suppliers/_components/suppliers-client.tsx index a35aab0..cd03f3f 100644 --- a/distribution_fe/app/(dashboard)/suppliers/_components/suppliers-client.tsx +++ b/distribution_fe/app/(dashboard)/suppliers/_components/suppliers-client.tsx @@ -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([]) 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(null) + const [viewing, setViewing] = useState(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>(() => ({ 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} + /> + + setViewOpen(false)} + supplier={viewing} /> )