diff --git a/distribution_fe/app/(dashboard)/sales/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/sales/[id]/edit/page.tsx new file mode 100644 index 0000000..fd9fb9c --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/[id]/edit/page.tsx @@ -0,0 +1,279 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useFieldArray, useWatch } from 'react-hook-form' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon, Add01Icon, Delete02Icon } 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 { Separator } from '@/components/ui/separator' +import { toast } from '@/components/ui/toast' +import { + shopPos as shopPosApi, + items as itemsApi, + priceLists as priceListsApi, + getApiError, + getPaginatedItems, +} from '@/lib/api/index' +import type { ItemSummaryResponse, ShopPOResponse } from '@/types/api' + +const lineItemSchema = z.object({ + itemId: z.string().min(1, 'Item is required'), + quantity: z.number().min(1, 'Quantity must be at least 1'), + unitPrice: z.number().min(0.01, 'Unit price must be greater than 0'), +}) + +const schema = z.object({ + items: z.array(lineItemSchema).min(1, 'At least one item is required'), +}) + +type FormValues = z.infer + +function createDefaultItem() { + return { itemId: '', quantity: 1, unitPrice: 0 } +} + +const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` + +export default function EditShopPOPage() { + const router = useRouter() + const { id } = useParams<{ id: string }>() + const [po, setPo] = useState(null) + const [itemsList, setItemsList] = useState([]) + const [loading, setLoading] = useState(true) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { items: [createDefaultItem()] }, + }) + + const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' }) + const watchedItems = useWatch({ control: form.control, name: 'items' }) + + const itemsById = useMemo( + () => new Map(itemsList.map((item) => [item.id, item])), + [itemsList] + ) + + const totalAmount = useMemo( + () => watchedItems.reduce((sum, line) => sum + (Number(line.quantity) || 0) * (Number(line.unitPrice) || 0), 0), + [watchedItems] + ) + + useEffect(() => { + Promise.all([shopPosApi.get(id), itemsApi.list({ pageSize: 200 })]) + .then(([poRes, items]) => { + setPo(poRes) + setItemsList(getPaginatedItems(items)) + form.reset({ + items: poRes.items + .filter((i) => i.isActive) + .map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice })), + }) + }) + .catch((err) => { + toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' }) + }) + .finally(() => setLoading(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + + async function resolveAndSetPrice(index: number, itemId: string) { + const today = new Date().toISOString().slice(0, 10) + if (po) { + try { + const resolution = await priceListsApi.resolve({ itemId, storeId: po.storeId, saleDate: today }) + form.setValue(`items.${index}.unitPrice`, resolution.price, { shouldValidate: true }) + return + } catch { + // No resolvable price for this store — fall back to item default. + } + } + const item = itemsById.get(itemId) + if (item) form.setValue(`items.${index}.unitPrice`, item.salePrice, { shouldValidate: true }) + } + + async function onSubmit(values: FormValues) { + try { + await shopPosApi.updateItems(id, { items: values.items }) + toast.add({ title: 'Shop PO updated', type: 'success' }) + router.push('/sales') + } catch (err) { + toast.add({ title: 'Failed to update Shop PO', description: getApiError(err), type: 'error' }) + } + } + + if (loading) return

Loading...

+ if (!po) return

Shop PO not found.

+ + const locked = !!po.confirmedAt || po.status === 'Cancelled' + + return ( +
+
+ +
+

Edit Shop PO

+

+ {po.storeName} — items can only be changed before the PO is confirmed. +

+
+
+ + {locked && ( +

+ This Shop PO is {po.confirmedAt ? 'confirmed' : 'cancelled'} and can no longer be edited. +

+ )} + +
+
+
+

Items

+ +
+ +
+ + + + + + + + + + + + {fields.map((fieldItem, index) => { + const lineTotal = + (Number(watchedItems[index]?.quantity) || 0) * + (Number(watchedItems[index]?.unitPrice) || 0) + + return ( + + + + + + + + + + + + ) + })} + +
ItemQuantityUnit PriceLine Total
+ ( + + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> + {fmtAmount(lineTotal)} + +
+
+
+ +
+
+

Order Summary

+ +
+

Total

+

{fmtAmount(totalAmount)}

+
+
+ + +
+
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/[id]/page.tsx b/distribution_fe/app/(dashboard)/sales/[id]/page.tsx index 1476f8e..9c24006 100644 --- a/distribution_fe/app/(dashboard)/sales/[id]/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/[id]/page.tsx @@ -11,6 +11,7 @@ import { shopPos as shopPosApi, getApiError } from '@/lib/api/index' import type { ShopPOResponse, ShopPOStatus } from '@/types/api' import { ConfirmShopPODialog } from '../_components/confirm-shop-po-dialog' +import { CancelShopPODialog } from '../_components/cancel-shop-po-dialog' const PO_STATUS_VARIANT: Record = { Pending: 'secondary', @@ -44,6 +45,7 @@ export default function ShopPOViewPage() { const [po, setPo] = useState(null) const [loading, setLoading] = useState(true) const [confirmOpen, setConfirmOpen] = useState(false) + const [cancelOpen, setCancelOpen] = useState(false) useEffect(() => { let cancelled = false @@ -65,17 +67,6 @@ export default function ShopPOViewPage() { } }, [params.id, router]) - async function handleCancel() { - if (!po) return - try { - const updated = await shopPosApi.cancel(po.id) - setPo(updated) - toast.add({ title: 'Shop PO cancelled', type: 'success' }) - } catch (err) { - toast.add({ title: 'Failed to cancel Shop PO', description: getApiError(err), type: 'error' }) - } - } - if (loading) { return (
@@ -120,7 +111,7 @@ export default function ShopPOViewPage() { )} {po.status !== 'Cancelled' && po.status !== 'Fulfilled' && ( - )} @@ -212,6 +203,12 @@ export default function ShopPOViewPage() { }} po={po} /> + setCancelOpen(false)} + onSuccess={setPo} + po={po} + />
) } diff --git a/distribution_fe/app/(dashboard)/sales/_components/cancel-shop-po-dialog.tsx b/distribution_fe/app/(dashboard)/sales/_components/cancel-shop-po-dialog.tsx new file mode 100644 index 0000000..64de530 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/_components/cancel-shop-po-dialog.tsx @@ -0,0 +1,131 @@ +'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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { shopPos as shopPosApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { ShopPOResponse, UserResponse } from '@/types/api' + +const schema = z.object({ + cancelledByUserId: z.string().min(1, 'Cancelled by is required'), + reason: z.string().trim().min(1, 'Reason is required'), +}) + +type FormValues = z.infer + +interface CancelShopPODialogProps { + open: boolean + onClose: () => void + onSuccess: (updated: ShopPOResponse) => void + po: ShopPOResponse | null +} + +export function CancelShopPODialog({ open, onClose, onSuccess, po }: CancelShopPODialogProps) { + const [usersList, setUsersList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { cancelledByUserId: '', reason: '' }, + }) + + useEffect(() => { + if (!open) return + form.reset({ cancelledByUserId: '', reason: '' }) + usersApi.list({ pageSize: 200 }) + .then((res) => setUsersList(getPaginatedItems(res))) + .catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })) + }, [open]) + + if (!po) return null + + async function onSubmit(values: FormValues) { + try { + const updated = await shopPosApi.cancel(po!.id, values.reason, values.cancelledByUserId) + toast.add({ title: 'Shop PO cancelled', type: 'success' }) + onSuccess(updated) + onClose() + } catch (err) { + toast.add({ title: 'Failed to cancel Shop PO', description: getApiError(err), type: 'error' }) + } + } + + return ( + { if (!v) onClose() }}> + + + Cancel Shop PO + {po.storeName} — Rs. {po.totalAmount.toLocaleString()} + + +
+ + ( + + )} + /> + + ( + + )} + /> + + + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx index c2f5e99..6443765 100644 --- a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx +++ b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx @@ -28,6 +28,7 @@ import { import type { ShopPOResponse, ShopPOStatus, UserResponse, RouteResponse, StoreResponse } from '@/types/api' import { ConfirmShopPODialog } from './confirm-shop-po-dialog' +import { CancelShopPODialog } from './cancel-shop-po-dialog' const ALL = '__all__' type ConfirmedFilter = 'all' | 'confirmed' | 'unconfirmed' @@ -64,6 +65,8 @@ export function SalesClient() { const [confirmOpen, setConfirmOpen] = useState(false) const [selectedPO, setSelectedPO] = useState(null) + const [cancelOpen, setCancelOpen] = useState(false) + const [cancelPO, setCancelPO] = useState(null) useEffect(() => { Promise.all([ @@ -116,19 +119,18 @@ export function SalesClient() { router.push(`/sales/${row.id}`) } + function handleEdit(row: ShopPOResponse) { + router.push(`/sales/${row.id}/edit`) + } + function handleConfirm(row: ShopPOResponse) { setSelectedPO(row) setConfirmOpen(true) } - async function handleCancel(row: ShopPOResponse) { - try { - await shopPosApi.cancel(row.id) - toast.add({ title: 'Shop PO cancelled', type: 'success' }) - fetchData(page, pageSize) - } catch (err) { - toast.add({ title: 'Failed to cancel Shop PO', description: getApiError(err), type: 'error' }) - } + function handleCancel(row: ShopPOResponse) { + setCancelPO(row) + setCancelOpen(true) } const tableConfig = useMemo>(() => ({ @@ -172,16 +174,56 @@ export function SalesClient() { ), }, { - key: 'confirmed', - title: 'Confirmed', - fields: ['confirmedAt'], - width: 120, + key: 'createdBy', + title: 'Created By', + fields: ['raisedByName'], + minWidth: 160, cellRender: (row) => ( - - {row.confirmedAt ? fmtDate(row.confirmedAt) : 'Unconfirmed'} - +
+ {row.raisedByName ?? '—'} + {row.raisedByRole && ( + {row.raisedByRole} + )} +
), }, + { + key: 'confirmedBy', + title: 'Confirmed By', + fields: ['confirmedByName'], + minWidth: 160, + cellRender: (row) => + row.confirmedByName ? ( +
+ {row.confirmedByName} + {row.confirmedByRole && ( + {row.confirmedByRole} + )} +
+ ) : ( + — + ), + }, + { + key: 'cancelled', + title: 'Cancelled By', + fields: ['cancelledByName'], + minWidth: 200, + cellRender: (row) => + row.cancelledByName ? ( +
+ {row.cancelledByName} + {row.cancelledByRole && ( + {row.cancelledByRole} + )} + {row.cancelReason && ( + Reason: {row.cancelReason} + )} +
+ ) : ( + — + ), + }, { key: 'createdAt', title: 'Created', @@ -196,6 +238,12 @@ export function SalesClient() { label: 'View PO', onClick: handleView, }, + { + action: 'edit', + label: 'Edit PO', + onClick: handleEdit, + visible: (target) => !target.confirmedAt && target.status !== 'Cancelled', + }, { action: 'confirm', label: 'Confirm PO', @@ -323,6 +371,12 @@ export function SalesClient() { onSuccess={() => fetchData(page, pageSize)} po={selectedPO} /> + setCancelOpen(false)} + onSuccess={() => fetchData(page, pageSize)} + po={cancelPO} + /> ) } diff --git a/distribution_fe/app/(dashboard)/sales/new/page.tsx b/distribution_fe/app/(dashboard)/sales/new/page.tsx index 0e56632..b0f15d3 100644 --- a/distribution_fe/app/(dashboard)/sales/new/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/new/page.tsx @@ -210,10 +210,16 @@ export default function NewShopPOPage() { Quantity and unit price use the selected item's base UOM.

- +
+ + +