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.
+
+ )}
+
+
+
+ )
+}
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' && (
-
)
}
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 (
+
+ )
+}
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.
- append(createDefaultItem())}>
-
- Add Item
-
+
+ router.push('/catalogue/items')}>
+
+ Add Product
+
+ append(createDefaultItem())}>
+
+ Add Item
+
+