From 5e48873422b24978d0cf1a8ee986c071f5a991e0 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 3 Sep 2026 16:08:34 +0530 Subject: [PATCH] Update sales UI --- .../sales/_components/sale-sheet.tsx | 354 --------------- .../sales/_components/sale-view-sheet.tsx | 111 +++++ .../sales/_components/sales-client.tsx | 47 +- .../app/(dashboard)/sales/new/page.tsx | 412 ++++++++++++++++++ distribution_fe/lib/api/sales.ts | 3 + 5 files changed, 566 insertions(+), 361 deletions(-) delete mode 100644 distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx create mode 100644 distribution_fe/app/(dashboard)/sales/_components/sale-view-sheet.tsx create mode 100644 distribution_fe/app/(dashboard)/sales/new/page.tsx diff --git a/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx b/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx deleted file mode 100644 index c696c20..0000000 --- a/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx +++ /dev/null @@ -1,354 +0,0 @@ -'use client' - -import { useEffect, useMemo, useState } from 'react' -import { useFieldArray } from 'react-hook-form' -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 { Checkbox } from '@/components/ui/checkbox' -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 { - sales as salesApi, - stores as storesApi, - rides as ridesApi, - items as itemsApi, - getApiError, - getPaginatedItems, -} from '@/lib/api/index' -import type { ItemSummaryResponse, RideResponse, StoreResponse } from '@/types/api' - -const saleItemSchema = 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({ - storeId: z.string().min(1, 'Store is required'), - rideId: z.string(), - fulfillImmediately: z.boolean(), - items: z.array(saleItemSchema).min(1, 'At least one item is required'), -}) - -type FormValues = z.infer - -interface SaleSheetProps { - open: boolean - onClose: () => void - onSuccess: () => void -} - - -function createDefaultItem() { - return { - itemId: '', - quantity: 1, - unitPrice: 0, - } -} - -export function SaleSheet({ open, onClose, onSuccess }: SaleSheetProps) { - const [storesList, setStoresList] = useState([]) - const [ridesList, setRidesList] = useState([]) - const [itemsList, setItemsList] = useState([]) - - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { - storeId: '', - rideId: '', - fulfillImmediately: false, - items: [createDefaultItem()], - }, - }) - - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: 'items', - }) - - const watchedItems = form.watch('items') - const itemsById = useMemo( - () => new Map(itemsList.map((item) => [item.id, item])), - [itemsList] - ) - const totalAmount = useMemo( - () => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0), - [watchedItems] - ) - - useEffect(() => { - if (!open) return - - form.reset({ - storeId: '', - rideId: '', - fulfillImmediately: false, - items: [createDefaultItem()], - }) - - Promise.all([ - storesApi.list({ pageSize: 200 }), - ridesApi.list({ pageSize: 200, rideType: 'Sale' }), - itemsApi.list({ pageSize: 200 }), - ]) - .then(([stores, rides, items]) => { - setStoresList(getPaginatedItems(stores)) - setRidesList(getPaginatedItems(rides)) - setItemsList(getPaginatedItems(items)) - }) - .catch((err) => { - toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) - }) - }, [open]) - - async function onSubmit(values: FormValues) { - try { - await salesApi.create({ - storeId: values.storeId, - rideId: values.rideId || null, - fulfillImmediately: values.fulfillImmediately, - items: values.items, - }) - toast.add({ title: 'Sale created', type: 'success' }) - onSuccess() - onClose() - } catch (err) { - toast.add({ title: 'Failed to create sale', description: getApiError(err), type: 'error' }) - } - } - - return ( - !v && onClose()}> - - - Create Sale - Record a sale for a store with one or more items. - - -
-
- -
- ( - - )} - /> - - ( - - )} - /> -
- - ( - - )} - /> - -
-
-
-

Sale Summary

-

- Quantity and unit price are handled in the selected item's base UOM. -

-
-
-

Total

-

Rs. {totalAmount.toLocaleString()}

-
-
-
-
- -
-
-
-

Items

-

- Only this panel scrolls when multiple items are added. -

-
- -
- -
- {fields.map((fieldItem, index) => { - const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '') - - return ( -
- ( - - )} - /> - -
- ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - placeholder="1" - aria-invalid={invalid} - /> - )} - /> - - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - placeholder="0.00" - aria-invalid={invalid} - /> - )} - /> -
- -

- {selectedItem - ? `Quantity is in ${selectedItem.baseUomName}. Unit price is for one ${selectedItem.baseUomName.toLowerCase()}.` - : 'Quantity uses the selected item base UOM. Unit price is for one base-UOM unit.'} -

- - {fields.length > 1 && ( -
- -
- )} -
- ) - })} -
-
-
- - - - - -
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/sales/_components/sale-view-sheet.tsx b/distribution_fe/app/(dashboard)/sales/_components/sale-view-sheet.tsx new file mode 100644 index 0000000..4f028ad --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/_components/sale-view-sheet.tsx @@ -0,0 +1,111 @@ +'use client' + +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Separator } from '@/components/ui/separator' +import type { SaleResponse, SaleStatus, PaymentStatus } from '@/types/api' + +const SALE_STATUS_VARIANT: Record = { + Pending: 'secondary', + Completed: 'default', + Cancelled: 'destructive', +} + +const PAYMENT_STATUS_VARIANT: Record = { + Pending: 'secondary', + PartiallyPaid: 'secondary', + Paid: 'default', +} + +const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` +const truncId = (id: string) => id.substring(0, 8) + +const fmtDateTime = (v: string | null) => + v + ? new Date(v).toLocaleString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', + }) + : '—' + +interface SaleViewSheetProps { + open: boolean + onClose: () => void + sale: SaleResponse | null +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ) +} + +export function SaleViewSheet({ open, onClose, sale }: SaleViewSheetProps) { + if (!sale) return null + + return ( + !v && onClose()}> + + + Sale Details + + +
+ {truncId(sale.id)}} /> + {truncId(sale.storeId)}} /> + {sale.status}} /> + {sale.paymentStatus}} /> + + + + + + +
+ + + +
+

Items ({sale.items.length})

+
+ + + + + + + + + + + {sale.items.length === 0 ? ( + + + + ) : ( + sale.items.map((item) => ( + + + + + + + )) + )} + +
ItemQuantityFulfilledUnit Price
+ No items on this sale. +
{truncId(item.itemId)}{item.quantity}{item.quantityFulfilled}{fmtAmount(item.unitPrice)}
+
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx index 370cf08..4f9c997 100644 --- a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx +++ b/distribution_fe/app/(dashboard)/sales/_components/sales-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' @@ -12,7 +13,7 @@ import { toast } from '@/components/ui/toast' import { sales as salesApi, getApiError, getPaginatedItems } from '@/lib/api/index' import type { SaleResponse, SaleStatus, PaymentStatus } from '@/types/api' -import { SaleSheet } from './sale-sheet' +import { SaleViewSheet } from './sale-view-sheet' const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) @@ -33,12 +34,14 @@ const PAYMENT_STATUS_VARIANT: Record([]) 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 [viewOpen, setViewOpen] = useState(false) + const [selectedSale, setSelectedSale] = useState(null) const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) @@ -57,6 +60,21 @@ export function SalesClient() { fetchData(page, pageSize) }, [fetchData, page, pageSize]) + function handleView(row: SaleResponse) { + setSelectedSale(row) + setViewOpen(true) + } + + async function handleCancel(row: SaleResponse) { + try { + await salesApi.cancel(row.id) + toast.add({ title: 'Sale cancelled', type: 'success' }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to cancel sale', description: getApiError(err), type: 'error' }) + } + } + const tableConfig = useMemo>(() => ({ rowKey: 'id', serverSide: true, @@ -122,6 +140,21 @@ export function SalesClient() { width: 140, }, ], + rowActions: { + actions: () => [ + { + action: 'view', + label: 'View sale', + onClick: handleView, + }, + { + action: 'cancel', + label: 'Cancel sale', + onClick: handleCancel, + visible: (target) => target.status !== 'Cancelled', + }, + ], + }, }), [pageSize]) return ( @@ -133,7 +166,7 @@ export function SalesClient() { Sales transactions recorded against stores

- @@ -150,10 +183,10 @@ export function SalesClient() { }} /> - setSheetOpen(false)} - onSuccess={() => fetchData(page, pageSize)} + setViewOpen(false)} + sale={selectedSale} /> ) diff --git a/distribution_fe/app/(dashboard)/sales/new/page.tsx b/distribution_fe/app/(dashboard)/sales/new/page.tsx new file mode 100644 index 0000000..0c61212 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/new/page.tsx @@ -0,0 +1,412 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { 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 { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { FieldGroup } from '@/components/ui/field' +import { Separator } from '@/components/ui/separator' +import { toast } from '@/components/ui/toast' +import { + sales as salesApi, + stores as storesApi, + rides as ridesApi, + items as itemsApi, + users as usersApi, + getApiError, + getPaginatedItems, +} from '@/lib/api/index' +import type { ItemSummaryResponse, RideResponse, StoreResponse, UserResponse } from '@/types/api' + +const saleItemSchema = 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({ + storeId: z.string().min(1, 'Store is required'), + saleDate: z.string().min(1, 'Sale date is required'), + createdByUserId: z.string().min(1, 'Created by is required'), + rideId: z.string(), + fulfillImmediately: z.boolean(), + items: z.array(saleItemSchema).min(1, 'At least one item is required'), +}) + +type FormValues = z.infer + +function createDefaultItem() { + return { itemId: '', quantity: 1, unitPrice: 0 } +} + +function todayIso() { + return new Date().toISOString().slice(0, 10) +} + +const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` + +export default function NewSalePage() { + const router = useRouter() + const [storesList, setStoresList] = useState([]) + const [ridesList, setRidesList] = useState([]) + const [itemsList, setItemsList] = useState([]) + const [usersList, setUsersList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + storeId: '', + saleDate: todayIso(), + createdByUserId: '', + rideId: '', + fulfillImmediately: false, + 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, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0), + [watchedItems] + ) + + useEffect(() => { + Promise.all([ + storesApi.list({ pageSize: 200 }), + ridesApi.list({ pageSize: 200, rideType: 'Sale' }), + itemsApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(([stores, rides, items, usersRes]) => { + setStoresList(getPaginatedItems(stores)) + setRidesList(getPaginatedItems(rides)) + setItemsList(getPaginatedItems(items)) + setUsersList(getPaginatedItems(usersRes)) + }) + .catch((err) => { + toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) + }) + }, []) + + async function onSubmit(values: FormValues) { + try { + await salesApi.create({ + storeId: values.storeId, + saleDate: values.saleDate, + rideId: values.rideId || null, + fulfillImmediately: values.fulfillImmediately, + items: values.items, + createdByUserId: values.createdByUserId, + }) + toast.add({ title: 'Sale created', type: 'success' }) + router.push('/sales') + } catch (err) { + toast.add({ title: 'Failed to create sale', description: getApiError(err), type: 'error' }) + } + } + + return ( +
+
+ +
+

Create Sale

+

Record a sale for a store with one or more items.

+
+
+ +
+
+

Sale Details

+ +
+ ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> +
+ + ( + + )} + /> +
+
+ +
+
+
+

Items

+

+ Quantity and unit price use the selected item's base UOM. +

+
+ +
+ +
+ + + + + + + + + + + + {fields.map((fieldItem, index) => { + const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '') + const lineTotal = (Number(watchedItems[index]?.quantity) || 0) * (Number(watchedItems[index]?.unitPrice) || 0) + + return ( + + + + + + + + + + + + ) + })} + +
ItemQuantityUnit PriceLine Total
+ ( + + )} + /> + {selectedItem && ( +

+ In {selectedItem.baseUomName} +

+ )} +
+ ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="1" + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="0.00" + aria-invalid={invalid} + /> + )} + /> + {fmtAmount(lineTotal)} + +
+
+
+ +
+
+

Sale Summary

+

+ {watchedItems.length} item{watchedItems.length === 1 ? '' : 's'} on this sale. +

+ + + +
+

Total

+

{fmtAmount(totalAmount)}

+
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/distribution_fe/lib/api/sales.ts b/distribution_fe/lib/api/sales.ts index f56ed78..f548b3d 100644 --- a/distribution_fe/lib/api/sales.ts +++ b/distribution_fe/lib/api/sales.ts @@ -22,9 +22,12 @@ export interface SaleItemBody { export interface SaleBody { storeId: string + saleDate: string rideId?: string | null fulfillImmediately?: boolean items: SaleItemBody[] + // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. + createdByUserId: string } export interface SaleListParams extends PageParams {