From a9a201cce101970b59c9716093de0dbf267279ed Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Sun, 20 Sep 2026 21:33:44 +0530 Subject: [PATCH] Update sales pages --- .../_components/loading-sheets-client.tsx | 82 +++++- .../app/(dashboard)/sales/[id]/page.tsx | 216 +++++++++++++++ .../sales/_components/sales-client.tsx | 166 ++++++++++-- .../_components/shop-po-create-sheet.tsx | 217 ---------------- .../sales/_components/shop-po-view-sheet.tsx | 106 -------- .../(dashboard)/sales/invoices/[id]/page.tsx | 245 ++++++++++++++++++ 6 files changed, 681 insertions(+), 351 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/sales/[id]/page.tsx delete mode 100644 distribution_fe/app/(dashboard)/sales/_components/shop-po-create-sheet.tsx delete mode 100644 distribution_fe/app/(dashboard)/sales/_components/shop-po-view-sheet.tsx create mode 100644 distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx diff --git a/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheets-client.tsx b/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheets-client.tsx index 25acbc0..b033786 100644 --- a/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheets-client.tsx +++ b/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheets-client.tsx @@ -8,9 +8,18 @@ import { DataTable } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { toast } from '@/components/ui/toast' -import { loadingSheets as loadingSheetsApi, getApiError, getPaginatedItems } from '@/lib/api/index' -import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api' +import { loadingSheets as loadingSheetsApi, rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { LoadingSheetResponse, LoadingSheetStatus, RideResponse } from '@/types/api' + +const ALL = '__all__' import { LoadingSheetViewSheet } from './loading-sheet-view-sheet' import { LoadingSheetCreateDialog } from './loading-sheet-create-dialog' @@ -36,10 +45,25 @@ export function LoadingSheetsClient() { const [viewOpen, setViewOpen] = useState(false) const [selectedSheet, setSelectedSheet] = useState(null) + const [ridesList, setRidesList] = useState([]) + const [rideId, setRideId] = useState('') + const [statusFilter, setStatusFilter] = useState('') + + useEffect(() => { + ridesApi.list({ pageSize: 200 }) + .then((res) => setRidesList(getPaginatedItems(res))) + .catch((err) => toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' })) + }, []) + const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) try { - const res = await loadingSheetsApi.list({ page: p, pageSize: ps }) + const res = await loadingSheetsApi.list({ + page: p, + pageSize: ps, + rideId: rideId || undefined, + status: statusFilter || undefined, + }) setData(getPaginatedItems(res)) setTotal(res.total) } catch (err) { @@ -47,12 +71,19 @@ export function LoadingSheetsClient() { } finally { setLoading(false) } - }, []) + }, [rideId, statusFilter]) useEffect(() => { fetchData(page, pageSize) }, [fetchData, page, pageSize]) + const hasFilters = !!rideId || !!statusFilter + + function clearFilters() { + setRideId('') + setStatusFilter('') + } + function handleView(row: LoadingSheetResponse) { setSelectedSheet(row) setViewOpen(true) @@ -159,6 +190,49 @@ export function LoadingSheetsClient() { +
+
+ + +
+ +
+ + +
+ + {hasFilters && ( + + )} +
+ = { + Pending: 'secondary', + PartiallyFulfilled: 'secondary', + Fulfilled: 'default', + Cancelled: 'destructive', +} + +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', + }) + : '—' + +function Stat({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ) +} + +export default function ShopPOViewPage() { + const router = useRouter() + const params = useParams<{ id: string }>() + const [po, setPo] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + setLoading(true) + shopPosApi + .get(params.id) + .then((res) => { + if (!cancelled) setPo(res) + }) + .catch((err) => { + toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' }) + router.push('/sales') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [params.id, router]) + + async function handleConfirm() { + if (!po) return + try { + const updated = await shopPosApi.confirm(po.id) + setPo(updated) + toast.add({ title: 'Shop PO confirmed', type: 'success' }) + } catch (err) { + toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' }) + } + } + + 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 ( +
+
+ +
+

Loading Shop PO…

+
+ ) + } + + if (!po) return null + + const activeItems = po.items.filter((i) => i.isActive) + + return ( +
+
+
+ +
+
+

+ Shop PO {truncId(po.id)} +

+ {po.status} +
+

{po.storeName}

+
+
+ +
+ {!po.confirmedAt && po.status !== 'Cancelled' && ( + + )} + {po.status !== 'Cancelled' && po.status !== 'Fulfilled' && ( + + )} +
+
+ +
+ router.push(`/sales/invoices/${po.invoiceId}`)} + > + {truncId(po.invoiceId)} + + ) : ( + 'Not raised yet' + ) + } + /> + + + +
+ +
+
+

Items

+

+ {activeItems.length} item{activeItems.length === 1 ? '' : 's'} on this PO. +

+
+ +
+ + + + + + + + + + + + {activeItems.length === 0 ? ( + + + + ) : ( + activeItems.map((item) => ( + + + + + + + + )) + )} + + {activeItems.length > 0 && ( + + + + + + + )} +
ItemQuantityFulfilledUnit PriceLine Total
+ No items on this PO. +
{item.itemName}{item.quantity}{item.quantityFulfilled}{fmtAmount(item.unitPrice)}{fmtAmount(item.quantity * item.unitPrice)}
+ Total + + {fmtAmount(po.totalAmount)} +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx index bb02cd2..d5cc631 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' @@ -8,12 +9,26 @@ import { DataTable } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { toast } from '@/components/ui/toast' -import { shopPos as shopPosApi, getApiError, getPaginatedItems } from '@/lib/api/index' -import type { ShopPOResponse, ShopPOStatus } from '@/types/api' +import { + shopPos as shopPosApi, + users as usersApi, + routes as routesApi, + stores as storesApi, + getApiError, + getPaginatedItems, +} from '@/lib/api/index' +import type { ShopPOResponse, ShopPOStatus, UserResponse, RouteResponse, StoreResponse } from '@/types/api' -import { ShopPOViewSheet } from './shop-po-view-sheet' -import { ShopPOCreateSheet } from './shop-po-create-sheet' +const ALL = '__all__' +type ConfirmedFilter = 'all' | 'confirmed' | 'unconfirmed' const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) @@ -29,19 +44,47 @@ const PO_STATUS_VARIANT: Record([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [viewOpen, setViewOpen] = useState(false) - const [createOpen, setCreateOpen] = useState(false) - const [selectedPO, setSelectedPO] = useState(null) + + const [agentsList, setAgentsList] = useState([]) + const [routesList, setRoutesList] = useState([]) + const [storesList, setStoresList] = useState([]) + + const [agentId, setAgentId] = useState('') + const [routeId, setRouteId] = useState('') + const [shopId, setShopId] = useState('') + const [confirmedFilter, setConfirmedFilter] = useState('all') + + useEffect(() => { + Promise.all([ + usersApi.list({ pageSize: 200 }), + routesApi.list({ pageSize: 200 }), + storesApi.list({ pageSize: 200 }), + ]) + .then(([u, r, s]) => { + setAgentsList(getPaginatedItems(u)) + setRoutesList(getPaginatedItems(r)) + setStoresList(getPaginatedItems(s)) + }) + .catch((err) => toast.add({ title: 'Failed to load filter options', description: getApiError(err), type: 'error' })) + }, []) const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) try { - const res = await shopPosApi.list({ page: p, pageSize: ps }) + const res = await shopPosApi.list({ + page: p, + pageSize: ps, + agentId: agentId || undefined, + routeId: routeId || undefined, + shopId: shopId || undefined, + confirmed: confirmedFilter === 'all' ? undefined : confirmedFilter === 'confirmed', + }) setData(getPaginatedItems(res)) setTotal(res.total) } catch (err) { @@ -49,15 +92,23 @@ export function SalesClient() { } finally { setLoading(false) } - }, []) + }, [agentId, routeId, shopId, confirmedFilter]) useEffect(() => { fetchData(page, pageSize) }, [fetchData, page, pageSize]) + const hasFilters = !!agentId || !!routeId || !!shopId || confirmedFilter !== 'all' + + function clearFilters() { + setAgentId('') + setRouteId('') + setShopId('') + setConfirmedFilter('all') + } + function handleView(row: ShopPOResponse) { - setSelectedPO(row) - setViewOpen(true) + router.push(`/sales/${row.id}`) } async function handleConfirm(row: ShopPOResponse) { @@ -170,12 +221,91 @@ export function SalesClient() { Purchase orders raised by agents on behalf of stores

- +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {hasFilters && ( + + )} +
+ - - setCreateOpen(false)} - onSuccess={() => fetchData(page, pageSize)} - /> - - setViewOpen(false)} - po={selectedPO} - /> ) } diff --git a/distribution_fe/app/(dashboard)/sales/_components/shop-po-create-sheet.tsx b/distribution_fe/app/(dashboard)/sales/_components/shop-po-create-sheet.tsx deleted file mode 100644 index 0fdad1d..0000000 --- a/distribution_fe/app/(dashboard)/sales/_components/shop-po-create-sheet.tsx +++ /dev/null @@ -1,217 +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 { - 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 { shopPos as shopPosApi, stores as storesApi, items as itemsApi, getApiError, getPaginatedItems } from '@/lib/api/index' -import type { StoreResponse, ItemSummaryResponse } from '@/types/api' - -const itemLineSchema = z.object({ - itemId: z.string().min(1, 'Item required'), - quantity: z.number().int().min(1, 'Min 1'), - unitPrice: z.number().min(0, 'Price must be 0 or more'), -}) - -const schema = z.object({ - storeId: z.string().min(1, 'Store is required'), - items: z.array(itemLineSchema).min(1, 'At least one item is required'), -}) - -type FormValues = z.infer - -interface ShopPOCreateSheetProps { - open: boolean - onClose: () => void - onSuccess: () => void -} - -const emptyLine = { itemId: '', quantity: 1, unitPrice: 0 } - -export function ShopPOCreateSheet({ open, onClose, onSuccess }: ShopPOCreateSheetProps) { - const [storesList, setStoresList] = useState([]) - const [itemsList, setItemsList] = useState([]) - - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { storeId: '', items: [emptyLine] }, - }) - - const watchedItems = form.watch('items') - - useEffect(() => { - if (!open) return - form.reset({ storeId: '', items: [emptyLine] }) - Promise.all([ - storesApi.list({ pageSize: 200 }), - itemsApi.list({ pageSize: 200 }), - ]) - .then(([s, i]) => { - setStoresList(getPaginatedItems(s)) - setItemsList(getPaginatedItems(i)) - }) - .catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })) - }, [open]) - - function addLine() { - form.setValue('items', [...watchedItems, emptyLine]) - } - - function removeLine(idx: number) { - form.setValue('items', watchedItems.filter((_, i) => i !== idx)) - } - - async function onSubmit(values: FormValues) { - try { - await shopPosApi.create(values) - toast.add({ title: 'Shop PO created', type: 'success' }) - onSuccess() - onClose() - } catch (err) { - toast.add({ title: 'Failed to create Shop PO', description: getApiError(err), type: 'error' }) - } - } - - return ( - !v && onClose()}> - - - New Shop PO - Raise a purchase order on behalf of a store. - - -
- - ( - - )} - /> - - -
-
-

Line Items

- -
- -
- {watchedItems.map((_, idx) => ( -
- ( - - )} - /> - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> -
- -
-
- ))} -
-
- - - - - -
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/sales/_components/shop-po-view-sheet.tsx b/distribution_fe/app/(dashboard)/sales/_components/shop-po-view-sheet.tsx deleted file mode 100644 index 911233c..0000000 --- a/distribution_fe/app/(dashboard)/sales/_components/shop-po-view-sheet.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'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 { ShopPOResponse, ShopPOStatus } from '@/types/api' - -const PO_STATUS_VARIANT: Record = { - Pending: 'secondary', - PartiallyFulfilled: 'secondary', - Fulfilled: 'default', - Cancelled: 'destructive', -} - -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 ShopPOViewSheetProps { - open: boolean - onClose: () => void - po: ShopPOResponse | null -} - -function Row({ label, value }: { label: string; value: React.ReactNode }) { - return ( -
- {label} - {value} -
- ) -} - -export function ShopPOViewSheet({ open, onClose, po }: ShopPOViewSheetProps) { - if (!po) return null - - return ( - !v && onClose()}> - - - Shop PO Details - - -
- {truncId(po.id)}} /> - - {po.status}} /> - - {truncId(po.invoiceId)} : 'No invoice yet'} /> - - - -
- - - -
-

Line Items ({po.items.filter(i => i.isActive).length})

-
- - - - - - - - - - - - {po.items.filter(i => i.isActive).length === 0 ? ( - - - - ) : ( - po.items.filter(i => i.isActive).map((item) => ( - - - - - - - - )) - )} - -
ItemQtyFulfilledUnit PriceLine Total
- No items on this PO. -
{item.itemName}{item.quantity}{item.quantityFulfilled}{fmtAmount(item.unitPrice)}{fmtAmount(item.quantity * item.unitPrice)}
-
-
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx b/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx new file mode 100644 index 0000000..a8df311 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon, Add01Icon } from '@hugeicons/core-free-icons' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index' +import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api' + +import { PaymentSheet } from '../../payments/_components/payment-sheet' +import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet' + +const INVOICE_STATUS_VARIANT: Record = { + Draft: 'secondary', + Printed: 'outline', + Finalized: 'default', +} + +const PAYMENT_STATUS_VARIANT: Record = { + Pending: 'destructive', + PartiallyPaid: 'secondary', + Paid: 'default', +} + +const PAYMENT_METHOD_VARIANT: Record = { + Cash: 'default', + Cheque: 'outline', + Credit: 'secondary', +} + +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', + }) + : '—' + +function Stat({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ) +} + +export default function InvoiceViewPage() { + const router = useRouter() + const params = useParams<{ id: string }>() + const [invoice, setInvoice] = useState(null) + const [payments, setPayments] = useState([]) + const [loading, setLoading] = useState(true) + const [paymentSheetOpen, setPaymentSheetOpen] = useState(false) + const [viewPayment, setViewPayment] = useState(null) + + const fetchInvoice = useCallback(async () => { + const inv = await invoicesApi.get(params.id) + setInvoice(inv) + return inv + }, [params.id]) + + const fetchPayments = useCallback(async () => { + const list = await paymentsApi.listByInvoice(params.id) + setPayments(list) + }, [params.id]) + + useEffect(() => { + let cancelled = false + setLoading(true) + Promise.all([fetchInvoice(), fetchPayments()]) + .catch((err) => { + toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' }) + router.push('/sales') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [fetchInvoice, fetchPayments, router]) + + async function handlePrint() { + if (!invoice) return + try { + const updated = await invoicesApi.print(invoice.id) + setInvoice(updated) + toast.add({ title: 'Invoice printed', type: 'success' }) + } catch (err) { + toast.add({ title: 'Failed to print invoice', description: getApiError(err), type: 'error' }) + } + } + + async function handleFinalize() { + if (!invoice) return + try { + const updated = await invoicesApi.finalize(invoice.id) + setInvoice(updated) + toast.add({ title: 'Invoice finalized', type: 'success' }) + } catch (err) { + toast.add({ title: 'Failed to finalize invoice', description: getApiError(err), type: 'error' }) + } + } + + if (loading) { + return ( +
+
+ +
+

Loading invoice…

+
+ ) + } + + if (!invoice) return null + + const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0) + const balance = invoice.totalAmount - paidAmount + + return ( +
+
+
+ +
+
+

+ Invoice {truncId(invoice.id)} +

+ {invoice.status} + {invoice.paymentStatus} +
+

{invoice.storeName}

+
+
+ +
+ {invoice.status === 'Draft' && ( + + )} + {invoice.status === 'Printed' && ( + + )} + {invoice.paymentStatus !== 'Paid' && ( + + )} +
+
+ +
+ + + + + + +
+ +
+
+

Payments

+

+ {payments.length} payment{payments.length === 1 ? '' : 's'} recorded against this invoice. +

+
+ +
+ + + + + + + + + + + + {payments.length === 0 ? ( + + + + ) : ( + payments.map((payment) => ( + + + + + + + + )) + )} + +
Payment IDAmountMethodCollected At
+ No payments recorded yet. +
{truncId(payment.id)}{fmtAmount(payment.amount)} + {payment.method} + {fmtDateTime(payment.collectedAt)} + +
+
+
+ + setPaymentSheetOpen(false)} + onSuccess={() => { + fetchPayments() + fetchInvoice() + }} + invoices={[invoice]} + /> + + setViewPayment(null)} + payment={viewPayment} + invoiceName={`${invoice.storeName} — ${truncId(invoice.id)}`} + /> +
+ ) +} -- 2.52.0