diff --git a/Distribution/Features/LoadingEvent/LoadingSheetController.cs b/Distribution/Features/LoadingEvent/LoadingSheetController.cs index 96fc079..a028b50 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetController.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetController.cs @@ -31,10 +31,13 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer [HttpPost] public async Task Create(CreateLoadingSheetRequest request, CancellationToken ct) { - var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware + // (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. + var userId = currentUser.UserId ?? request.PopulatedByUserId; + if (userId is null) return BadRequest(new { error = "PopulatedByUserId is required while auth is unavailable" }); try { - var result = await service.CreateAsync(request, userId, ct); + var result = await service.CreateAsync(request, userId.Value, ct); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } catch (KeyNotFoundException) { return NotFound(); } @@ -54,12 +57,15 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpPost("{id:guid}/confirm")] - public async Task Confirm(Guid id, CancellationToken ct) + public async Task Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct) { - var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware + // (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. + var userId = currentUser.UserId ?? request.ConfirmedByUserId; + if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" }); try { - var result = await service.ConfirmAsync(id, userId, ct); + var result = await service.ConfirmAsync(id, userId.Value, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } diff --git a/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs b/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs index cbd9a23..cf4b28b 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs @@ -25,7 +25,9 @@ public record LoadingSheetItemResponse( public record LoadingSheetOrderResponse(Guid ShopPOId); -public record CreateLoadingSheetRequest(Guid FK_RideId); +public record CreateLoadingSheetRequest(Guid FK_RideId, Guid? PopulatedByUserId = null); public record SetItemsRequest(List Items); public record SetItemInput(Guid FK_ItemId, Guid FK_WarehouseId, decimal Quantity, Guid? FK_ShopPOItemId = null); + +public record ConfirmLoadingSheetRequest(Guid? ConfirmedByUserId = null); diff --git a/Distribution/Features/LoadingEvent/LoadingSheetService.cs b/Distribution/Features/LoadingEvent/LoadingSheetService.cs index 07507cd..a5f4ad6 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetService.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetService.cs @@ -64,26 +64,47 @@ public class LoadingSheetService( if (sheet.Status != LoadingSheetStatus.Planned) throw new InvalidOperationException("Items can only be changed while the sheet is Planned"); - var shopPOItemIds = request.Items - .Where(i => i.FK_ShopPOItemId.HasValue) - .Select(i => i.FK_ShopPOItemId!.Value) - .ToList(); - var shopPOIds = shopPOItemIds.Count > 0 - ? await repo.GetShopPOIdsByItemIdsAsync(shopPOItemIds) - : new List(); - + // Upsert by (item, warehouse, shop PO item): a matching row gets its quantity + // updated in place, a new combination is added. Rows for combinations not present + // in this request are left untouched, so a later save can top up the same PO + // item's remaining quantity from a different warehouse without losing what was + // already planned from the first one. await uow.BeginAsync(ct); try { - repo.RemoveItems(sheet.Items); - await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem + var newItems = new List(); + foreach (var input in request.Items) { - FK_LoadingSheetId = sheet.Id, - FK_ItemId = i.FK_ItemId, - FK_WarehouseId = i.FK_WarehouseId, - FK_ShopPOItemId = i.FK_ShopPOItemId, - QuantityDispatched = i.Quantity - })); + var existing = sheet.Items.FirstOrDefault(i => + i.FK_ItemId == input.FK_ItemId && + i.FK_WarehouseId == input.FK_WarehouseId && + i.FK_ShopPOItemId == input.FK_ShopPOItemId); + + if (existing is not null) + existing.QuantityDispatched = input.Quantity; + else + newItems.Add(new LoadingSheetItem + { + FK_LoadingSheetId = sheet.Id, + FK_ItemId = input.FK_ItemId, + FK_WarehouseId = input.FK_WarehouseId, + FK_ShopPOItemId = input.FK_ShopPOItemId, + QuantityDispatched = input.Quantity + }); + } + if (newItems.Count > 0) + await repo.AddItemsAsync(newItems); + + var shopPOItemIds = sheet.Items.Select(i => i.FK_ShopPOItemId) + .Concat(newItems.Select(i => i.FK_ShopPOItemId)) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .Distinct() + .ToList(); + var shopPOIds = shopPOItemIds.Count > 0 + ? await repo.GetShopPOIdsByItemIdsAsync(shopPOItemIds) + : new List(); + await repo.ReplaceOrdersAsync(sheet.Id, shopPOIds); await uow.CommitAsync(ct); } diff --git a/Distribution/Features/ShopPO/ShopPOController.cs b/Distribution/Features/ShopPO/ShopPOController.cs index af13e30..f8f5fe9 100644 --- a/Distribution/Features/ShopPO/ShopPOController.cs +++ b/Distribution/Features/ShopPO/ShopPOController.cs @@ -33,10 +33,13 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current [HttpPost] public async Task Create(CreateShopPORequest request, CancellationToken ct) { - var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware + // (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. + var userId = currentUser.UserId ?? request.RaisedByUserId; + if (userId is null) return BadRequest(new { error = "RaisedByUserId is required while auth is unavailable" }); try { - var result = await service.CreateAsync(request, userId, ct); + var result = await service.CreateAsync(request, userId.Value, ct); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } @@ -55,12 +58,15 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current } [HttpPost("{id:guid}/confirm")] - public async Task Confirm(Guid id, CancellationToken ct) + public async Task Confirm(Guid id, ConfirmShopPORequest request, CancellationToken ct) { - var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware + // (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. + var userId = currentUser.UserId ?? request.ConfirmedByUserId; + if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" }); try { - var result = await service.ConfirmAsync(id, userId, ct); + var result = await service.ConfirmAsync(id, userId.Value, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } @@ -68,12 +74,15 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current } [HttpPost("{id:guid}/confirm-and-create-invoice")] - public async Task ConfirmAndCreateInvoice(Guid id, CancellationToken ct) + public async Task ConfirmAndCreateInvoice(Guid id, ConfirmShopPORequest request, CancellationToken ct) { - var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + // TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware + // (testbug.md #28) — fall back to the explicitly supplied user until that's fixed. + var userId = currentUser.UserId ?? request.ConfirmedByUserId; + if (userId is null) return BadRequest(new { error = "ConfirmedByUserId is required while auth is unavailable" }); try { - var result = await service.ConfirmAndCreateInvoiceAsync(id, userId, ct); + var result = await service.ConfirmAndCreateInvoiceAsync(id, userId.Value, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } diff --git a/Distribution/Features/ShopPO/ShopPODTOs.cs b/Distribution/Features/ShopPO/ShopPODTOs.cs index 2eb2604..5d50e6d 100644 --- a/Distribution/Features/ShopPO/ShopPODTOs.cs +++ b/Distribution/Features/ShopPO/ShopPODTOs.cs @@ -2,9 +2,10 @@ namespace Distribution.Features.ShopPO; -public record CreateShopPORequest(Guid StoreId, List Items); +public record CreateShopPORequest(Guid StoreId, List Items, Guid? RaisedByUserId = null); public record ShopPOItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null); public record UpdateShopPOItemsRequest(List Items); +public record ConfirmShopPORequest(Guid? ConfirmedByUserId = null); public record ShopPOItemResponse(Guid Id, Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice, bool IsActive); diff --git a/distribution_fe/app/(dashboard)/operations/loading/_components/confirm-loading-sheet-dialog.tsx b/distribution_fe/app/(dashboard)/operations/loading/_components/confirm-loading-sheet-dialog.tsx new file mode 100644 index 0000000..3a5ad36 --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/loading/_components/confirm-loading-sheet-dialog.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +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 { loadingSheets as loadingSheetsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { LoadingSheetResponse, UserResponse } from '@/types/api' + +const schema = z.object({ + confirmedByUserId: z.string().min(1, 'Confirmed by is required'), +}) + +type FormValues = z.infer + +interface ConfirmLoadingSheetDialogProps { + open: boolean + onClose: () => void + onSuccess: () => void + sheet: LoadingSheetResponse | null +} + +export function ConfirmLoadingSheetDialog({ open, onClose, onSuccess, sheet }: ConfirmLoadingSheetDialogProps) { + const [usersList, setUsersList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { confirmedByUserId: '' }, + }) + + useEffect(() => { + if (!open) return + form.reset({ confirmedByUserId: '' }) + 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 (!sheet) return null + + async function onSubmit(values: FormValues) { + try { + await loadingSheetsApi.confirm(sheet!.id, values.confirmedByUserId) + toast.add({ title: 'Loading sheet confirmed — stock committed to vehicle', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to confirm loading sheet', description: getApiError(err), type: 'error' }) + } + } + + return ( + { if (!v) onClose() }}> + + + Confirm Loading Sheet + {sheet.items.length} item(s) will be committed to the vehicle's stock. + + +
+ + ( + + )} + /> + + + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheet-items-dialog.tsx b/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheet-items-dialog.tsx index 5d9b929..5b1b20a 100644 --- a/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheet-items-dialog.tsx +++ b/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheet-items-dialog.tsx @@ -88,6 +88,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa useEffect(() => { if (!open) return setRouteId('') + setPos([]) + setLines({}) routesApi.list({ regionId: regionId || undefined, pageSize: 200 }) .then((res) => setRoutesList(getPaginatedItems(res))) .catch((err) => toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })) @@ -95,15 +97,20 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa useEffect(() => { if (!open) return + setLines({}) + if (!routeId) { + setPos([]) + return + } setLoading(true) - shopPosApi.list({ regionId: regionId || undefined, routeId: routeId || undefined, confirmed: true, pageSize: 200 }) + shopPosApi.list({ regionId: regionId || undefined, routeId, confirmed: true, pageSize: 200 }) .then((res) => { const items = getPaginatedItems(res).filter((po) => po.status === 'Pending' || po.status === 'PartiallyFulfilled') setPos(items) }) .catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' })) .finally(() => setLoading(false)) - }, [open, regionId, routeId]) + }, [open, routeId]) const rows = useMemo(() => { const result: PickedLine[] = [] @@ -138,6 +145,10 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa async function handleSubmit() { if (!sheet) return + if (!routeId) { + toast.add({ title: 'Select a route first', type: 'error' }) + return + } if (!warehouseId) { toast.add({ title: 'Select a warehouse first', type: 'error' }) return @@ -179,8 +190,8 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa Set Loading Sheet Items - Pick items from confirmed Shop POs to load onto the vehicle for this ride. Narrow the list - by region/route first. + Select a route first. Confirmed Shop POs for that route will then be available to pick + items from. @@ -201,13 +212,12 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
- - setRouteId(v ?? '')}> - + - All routes {routesList.map((r) => ( {r.routeName} ))} @@ -242,10 +252,12 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa - {loading ? ( + {!routeId ? ( + Select a route to see its confirmed Shop POs. + ) : loading ? ( Loading… ) : rows.length === 0 ? ( - No eligible Shop PO items found for this filter. + No eligible Shop PO items found for this route. ) : ( rows.map((row) => { const line = lines[row.shopPOItemId] ?? row 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 b58ff27..e510d08 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 @@ -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' @@ -22,8 +23,8 @@ import type { LoadingSheetResponse, LoadingSheetStatus, RideResponse, RegionResp const ALL = '__all__' import { LoadingSheetViewSheet } from './loading-sheet-view-sheet' -import { LoadingSheetCreateDialog } from './loading-sheet-create-dialog' import { LoadingSheetItemsDialog } from './loading-sheet-items-dialog' +import { ConfirmLoadingSheetDialog } from './confirm-loading-sheet-dialog' const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) @@ -36,15 +37,16 @@ const LS_STATUS_VARIANT: Record([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [createOpen, setCreateOpen] = useState(false) const [viewOpen, setViewOpen] = useState(false) const [itemsOpen, setItemsOpen] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) const [selectedSheet, setSelectedSheet] = useState(null) const [ridesList, setRidesList] = useState([]) @@ -116,14 +118,9 @@ export function LoadingSheetsClient() { setItemsOpen(true) } - async function handleConfirm(row: LoadingSheetResponse) { - try { - await loadingSheetsApi.confirm(row.id) - toast.add({ title: 'Loading sheet confirmed — stock committed to vehicle', type: 'success' }) - fetchData(page, pageSize) - } catch (err) { - toast.add({ title: 'Failed to confirm loading sheet', description: getApiError(err), type: 'error' }) - } + function handleConfirm(row: LoadingSheetResponse) { + setSelectedSheet(row) + setConfirmOpen(true) } async function handleGenerateTasks(row: LoadingSheetResponse) { @@ -233,7 +230,7 @@ export function LoadingSheetsClient() { Items loaded onto a vehicle before a delivery run

- @@ -329,12 +326,6 @@ export function LoadingSheetsClient() { }} /> - setCreateOpen(false)} - onSuccess={() => fetchData(page, pageSize)} - /> - setViewOpen(false)} @@ -347,6 +338,13 @@ export function LoadingSheetsClient() { onSuccess={() => fetchData(page, pageSize)} sheet={selectedSheet} /> + + setConfirmOpen(false)} + onSuccess={() => fetchData(page, pageSize)} + sheet={selectedSheet} + /> ) } diff --git a/distribution_fe/app/(dashboard)/operations/loading/new/page.tsx b/distribution_fe/app/(dashboard)/operations/loading/new/page.tsx new file mode 100644 index 0000000..38dff7b --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/loading/new/page.tsx @@ -0,0 +1,420 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon } from '@hugeicons/core-free-icons' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +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 { + shopPos as shopPosApi, + regions as regionsApi, + routes as routesApi, + stores as storesApi, + warehouses as warehousesApi, + rides as ridesApi, + users as usersApi, + loadingSheets as loadingSheetsApi, + getApiError, + getPaginatedItems, +} from '@/lib/api/index' +import type { + ShopPOResponse, + RegionResponse, + RouteResponse, + StoreResponse, + WarehouseResponse, + RideResponse, + UserResponse, +} from '@/types/api' +import type { LoadingSheetItemInput } from '@/lib/api/operations' + +const ALL = '__all__' + +interface PickedLine { + shopPOItemId: string + itemId: string + itemName: string + storeName: string + orderedQty: number + remainingQty: number + checked: boolean + quantity: number +} + +export default function NewLoadingSheetPage() { + const router = useRouter() + + const [regionsList, setRegionsList] = useState([]) + const [routesList, setRoutesList] = useState([]) + const [storesList, setStoresList] = useState([]) + const [warehousesList, setWarehousesList] = useState([]) + const [ridesList, setRidesList] = useState([]) + const [usersList, setUsersList] = useState([]) + + const [regionId, setRegionId] = useState('') + const [routeId, setRouteId] = useState('') + const [storeId, setStoreId] = useState('') + const [warehouseId, setWarehouseId] = useState('') + const [rideId, setRideId] = useState('') + const [populatedByUserId, setPopulatedByUserId] = useState('') + + const [pos, setPos] = useState([]) + const [loadingPos, setLoadingPos] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [lines, setLines] = useState>({}) + + useEffect(() => { + Promise.all([ + regionsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + ridesApi.list({ status: 'Open', pageSize: 200 }), + usersApi.list({ pageSize: 200 }), + ]) + .then(([regionsRes, warehousesRes, ridesRes, usersRes]) => { + setRegionsList(getPaginatedItems(regionsRes)) + setWarehousesList(getPaginatedItems(warehousesRes)) + setRidesList(getPaginatedItems(ridesRes)) + setUsersList(getPaginatedItems(usersRes)) + }) + .catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })) + }, []) + + useEffect(() => { + setRouteId('') + setStoreId('') + setPos([]) + setLines({}) + routesApi.list({ regionId: regionId || undefined, pageSize: 200 }) + .then((res) => setRoutesList(getPaginatedItems(res))) + .catch((err) => toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })) + }, [regionId]) + + useEffect(() => { + setStoreId('') + setLines({}) + if (!routeId) { + setStoresList([]) + setPos([]) + return + } + storesApi.list({ routeId, pageSize: 200 }) + .then((res) => setStoresList(getPaginatedItems(res))) + .catch((err) => toast.add({ title: 'Failed to load shops', description: getApiError(err), type: 'error' })) + + setLoadingPos(true) + shopPosApi.list({ routeId, confirmed: true, pageSize: 200 }) + .then((res) => { + const items = getPaginatedItems(res).filter((po) => po.status === 'Pending' || po.status === 'PartiallyFulfilled') + setPos(items) + }) + .catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' })) + .finally(() => setLoadingPos(false)) + }, [routeId]) + + const rows = useMemo(() => { + const result: PickedLine[] = [] + for (const po of pos) { + if (storeId && po.storeId !== storeId) continue + for (const item of po.items) { + const remaining = item.quantity - item.quantityFulfilled + if (remaining <= 0) continue + result.push( + lines[item.id] ?? { + shopPOItemId: item.id, + itemId: item.itemId, + itemName: item.itemName, + storeName: po.storeName, + orderedQty: item.quantity, + remainingQty: remaining, + checked: false, + quantity: remaining, + } + ) + } + } + return result + }, [pos, storeId, lines]) + + const selectedRows = useMemo( + () => rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false)), + [rows, lines] + ) + const totalQuantity = useMemo( + () => selectedRows.reduce((sum, r) => sum + (Number(lines[r.shopPOItemId]?.quantity) || 0), 0), + [selectedRows, lines] + ) + + function toggleLine(line: PickedLine, checked: boolean) { + setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } })) + } + + function setLineQuantity(line: PickedLine, quantity: number) { + setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } })) + } + + async function handleSubmit() { + if (!routeId) { + toast.add({ title: 'Select a route first', type: 'error' }) + return + } + if (!warehouseId) { + toast.add({ title: 'Select a warehouse', type: 'error' }) + return + } + if (selectedRows.length === 0) { + toast.add({ title: 'Select at least one item', type: 'error' }) + return + } + if (!rideId) { + toast.add({ title: 'Select a ride to load the vehicle', type: 'error' }) + return + } + if (!populatedByUserId) { + toast.add({ title: 'Select who is populating this sheet', type: 'error' }) + return + } + + const items: LoadingSheetItemInput[] = selectedRows.map((r) => { + const line = lines[r.shopPOItemId] + return { + fK_ItemId: line.itemId, + fK_WarehouseId: warehouseId, + quantity: line.quantity, + fK_ShopPOItemId: line.shopPOItemId, + } + }) + + setSubmitting(true) + try { + const sheet = await loadingSheetsApi.create({ fK_RideId: rideId, populatedByUserId }) + await loadingSheetsApi.updateItems(sheet.id, items) + toast.add({ title: 'Loading sheet created', type: 'success' }) + router.push('/operations/loading') + } catch (err) { + toast.add({ title: 'Failed to create loading sheet', description: getApiError(err), type: 'error' }) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ +
+

New Loading Sheet

+

+ Select a route first. Confirmed Shop POs for that route's shops will then be + available to pick items from. Pick the ride to load last. +

+
+
+ +
+

Route & Warehouse

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+

Items

+

+ Pick items from confirmed Shop POs for the selected route/shop. +

+
+ +
+ + + + + + + + + + + + {!routeId ? ( + + ) : loadingPos ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((row) => { + const line = lines[row.shopPOItemId] ?? row + return ( + + + + + + + + ) + }) + )} + +
ShopItemOrderedQty to load
Select a route to see its confirmed Shop POs.
Loading…
No eligible Shop PO items found for this route.
+ toggleLine(row, v === true)} + /> + {row.storeName}{row.itemName}{row.remainingQty} + setLineQuantity(row, Number(e.target.value))} + /> +
+
+
+ +
+

Ride & Assignment

+ +
+
+ + +
+ +
+ + + Temporary — required until auth resolves the logged-in user automatically. +
+
+
+
+ +
+
+

Loading Sheet Summary

+

+ {selectedRows.length} item{selectedRows.length === 1 ? '' : 's'} selected. +

+ + + +
+

Total Quantity

+

{totalQuantity}

+
+ +
+ + +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/[id]/page.tsx b/distribution_fe/app/(dashboard)/sales/[id]/page.tsx index ba49bae..1476f8e 100644 --- a/distribution_fe/app/(dashboard)/sales/[id]/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/[id]/page.tsx @@ -10,6 +10,8 @@ import { toast } from '@/components/ui/toast' import { shopPos as shopPosApi, getApiError } from '@/lib/api/index' import type { ShopPOResponse, ShopPOStatus } from '@/types/api' +import { ConfirmShopPODialog } from '../_components/confirm-shop-po-dialog' + const PO_STATUS_VARIANT: Record = { Pending: 'secondary', PartiallyFulfilled: 'secondary', @@ -41,6 +43,7 @@ export default function ShopPOViewPage() { const params = useParams<{ id: string }>() const [po, setPo] = useState(null) const [loading, setLoading] = useState(true) + const [confirmOpen, setConfirmOpen] = useState(false) useEffect(() => { let cancelled = false @@ -62,17 +65,6 @@ export default function ShopPOViewPage() { } }, [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 { @@ -123,7 +115,7 @@ export default function ShopPOViewPage() {
{!po.confirmedAt && po.status !== 'Cancelled' && ( - )} @@ -211,6 +203,15 @@ export default function ShopPOViewPage() {
+ + setConfirmOpen(false)} + onSuccess={() => { + shopPosApi.get(po.id).then(setPo) + }} + po={po} + /> ) } diff --git a/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheet-create-dialog.tsx b/distribution_fe/app/(dashboard)/sales/_components/confirm-shop-po-dialog.tsx similarity index 51% rename from distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheet-create-dialog.tsx rename to distribution_fe/app/(dashboard)/sales/_components/confirm-shop-po-dialog.tsx index b0031bc..3a9face 100644 --- a/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheet-create-dialog.tsx +++ b/distribution_fe/app/(dashboard)/sales/_components/confirm-shop-po-dialog.tsx @@ -14,83 +14,83 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent, - DialogHeader, - DialogTitle, DialogDescription, DialogFooter, + DialogHeader, + DialogTitle, } from '@/components/ui/dialog' import { FieldGroup } from '@/components/ui/field' import { toast } from '@/components/ui/toast' -import { loadingSheets as loadingSheetsApi, rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index' -import type { RideResponse } from '@/types/api' +import { shopPos as shopPosApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { ShopPOResponse, UserResponse } from '@/types/api' const schema = z.object({ - fK_RideId: z.string().min(1, 'Ride is required'), + confirmedByUserId: z.string().min(1, 'Confirmed by is required'), }) type FormValues = z.infer -interface LoadingSheetCreateDialogProps { +interface ConfirmShopPODialogProps { open: boolean onClose: () => void onSuccess: () => void + po: ShopPOResponse | null } -export function LoadingSheetCreateDialog({ open, onClose, onSuccess }: LoadingSheetCreateDialogProps) { - const [ridesList, setRidesList] = useState([]) +export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmShopPODialogProps) { + const [usersList, setUsersList] = useState([]) const form = useForm({ resolver: zodResolver(schema), - defaultValues: { fK_RideId: '' }, + defaultValues: { confirmedByUserId: '' }, }) useEffect(() => { if (!open) return - form.reset({ fK_RideId: '' }) - ridesApi.list({ status: 'Open', pageSize: 200 }) - .then((res) => setRidesList(getPaginatedItems(res))) - .catch((err) => toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' })) + form.reset({ confirmedByUserId: '' }) + 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 { - await loadingSheetsApi.create(values) - toast.add({ title: 'Loading sheet created', type: 'success' }) + await shopPosApi.confirm(po!.id, values.confirmedByUserId) + toast.add({ title: 'Shop PO confirmed', type: 'success' }) onSuccess() onClose() } catch (err) { - toast.add({ title: 'Failed to create loading sheet', description: getApiError(err), type: 'error' }) + toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' }) } } return ( - !v && onClose()}> - + { if (!v) onClose() }}> + - New Loading Sheet - - Create a loading sheet for an open ride. Only one planned sheet per ride is allowed. - + Confirm Shop PO + {po.storeName} — Rs. {po.totalAmount.toLocaleString()}
( @@ -99,11 +99,9 @@ export function LoadingSheetCreateDialog({ open, onClose, onSuccess }: LoadingSh - +
diff --git a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx index c248a5c..c2f5e99 100644 --- a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx +++ b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx @@ -27,6 +27,8 @@ import { } from '@/lib/api/index' import type { ShopPOResponse, ShopPOStatus, UserResponse, RouteResponse, StoreResponse } from '@/types/api' +import { ConfirmShopPODialog } from './confirm-shop-po-dialog' + const ALL = '__all__' type ConfirmedFilter = 'all' | 'confirmed' | 'unconfirmed' @@ -60,6 +62,9 @@ export function SalesClient() { const [shopId, setShopId] = useState('') const [confirmedFilter, setConfirmedFilter] = useState('all') + const [confirmOpen, setConfirmOpen] = useState(false) + const [selectedPO, setSelectedPO] = useState(null) + useEffect(() => { Promise.all([ usersApi.list({ pageSize: 200 }), @@ -111,14 +116,9 @@ export function SalesClient() { router.push(`/sales/${row.id}`) } - async function handleConfirm(row: ShopPOResponse) { - try { - await shopPosApi.confirm(row.id) - toast.add({ title: 'Shop PO confirmed', type: 'success' }) - fetchData(page, pageSize) - } catch (err) { - toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' }) - } + function handleConfirm(row: ShopPOResponse) { + setSelectedPO(row) + setConfirmOpen(true) } async function handleCancel(row: ShopPOResponse) { @@ -316,6 +316,13 @@ export function SalesClient() { setPageSize(p.pageSize) }} /> + + setConfirmOpen(false)} + onSuccess={() => fetchData(page, pageSize)} + po={selectedPO} + /> ) } diff --git a/distribution_fe/app/(dashboard)/sales/new/page.tsx b/distribution_fe/app/(dashboard)/sales/new/page.tsx index d384969..0e56632 100644 --- a/distribution_fe/app/(dashboard)/sales/new/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/new/page.tsx @@ -24,10 +24,11 @@ import { stores as storesApi, items as itemsApi, priceLists as priceListsApi, + users as usersApi, getApiError, getPaginatedItems, } from '@/lib/api/index' -import type { ItemSummaryResponse, StoreResponse } from '@/types/api' +import type { ItemSummaryResponse, StoreResponse, UserResponse } from '@/types/api' const lineItemSchema = z.object({ itemId: z.string().min(1, 'Item is required'), @@ -37,6 +38,7 @@ const lineItemSchema = z.object({ const schema = z.object({ storeId: z.string().min(1, 'Store is required'), + raisedByUserId: z.string().min(1, 'Raised by is required'), items: z.array(lineItemSchema).min(1, 'At least one item is required'), }) @@ -52,12 +54,13 @@ export default function NewShopPOPage() { const router = useRouter() const [storesList, setStoresList] = useState([]) const [itemsList, setItemsList] = useState([]) + const [usersList, setUsersList] = useState([]) const [priceMinByIndex, setPriceMinByIndex] = useState>({}) const [priceRetailByIndex, setPriceRetailByIndex] = useState>({}) const form = useForm({ resolver: zodResolver(schema), - defaultValues: { storeId: '', items: [createDefaultItem()] }, + defaultValues: { storeId: '', raisedByUserId: '', items: [createDefaultItem()] }, }) const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' }) @@ -107,10 +110,12 @@ export default function NewShopPOPage() { Promise.all([ storesApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), + usersApi.list({ pageSize: 200 }), ]) - .then(([stores, items]) => { + .then(([stores, items, users]) => { setStoresList(getPaginatedItems(stores)) setItemsList(getPaginatedItems(items)) + setUsersList(getPaginatedItems(users)) }) .catch((err) => { toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) @@ -121,6 +126,7 @@ export default function NewShopPOPage() { try { await shopPosApi.create({ storeId: values.storeId, + raisedByUserId: values.raisedByUserId, items: values.items, }) toast.add({ title: 'Shop PO created', type: 'success' }) @@ -147,27 +153,52 @@ export default function NewShopPOPage() {

Order Details

- ( - - )} - /> +
+ ( + + )} + /> + + ( + + )} + /> +
diff --git a/distribution_fe/lib/api/operations.ts b/distribution_fe/lib/api/operations.ts index a9efa70..9d7e672 100644 --- a/distribution_fe/lib/api/operations.ts +++ b/distribution_fe/lib/api/operations.ts @@ -54,6 +54,7 @@ export const rides = { export interface LoadingSheetCreateBody { fK_RideId: string + populatedByUserId?: string | null } export interface LoadingSheetItemInput { @@ -83,8 +84,8 @@ export const loadingSheets = { updateItems: (id: string, items: LoadingSheetItemInput[]) => api.put(`/loading-sheets/${id}/items`, { items }).then(r => r.data), - confirm: (id: string) => - api.post(`/loading-sheets/${id}/confirm`).then(r => r.data), + confirm: (id: string, confirmedByUserId?: string | null) => + api.post(`/loading-sheets/${id}/confirm`, { confirmedByUserId }).then(r => r.data), } // ─── Delivery Tasks ─────────────────────────────────────────────────────────── diff --git a/distribution_fe/lib/api/sales.ts b/distribution_fe/lib/api/sales.ts index e47d1c8..b635114 100644 --- a/distribution_fe/lib/api/sales.ts +++ b/distribution_fe/lib/api/sales.ts @@ -23,6 +23,7 @@ export interface ShopPOItemBody { export interface CreateShopPOBody { storeId: string items: ShopPOItemBody[] + raisedByUserId?: string | null } export interface UpdateShopPOItemsBody { @@ -51,11 +52,11 @@ export const shopPos = { updateItems: (id: string, body: UpdateShopPOItemsBody) => api.put(`/shop-pos/${id}/items`, body).then(r => r.data), - confirm: (id: string) => - api.post(`/shop-pos/${id}/confirm`).then(r => r.data), + confirm: (id: string, confirmedByUserId?: string | null) => + api.post(`/shop-pos/${id}/confirm`, { confirmedByUserId }).then(r => r.data), - confirmAndCreateInvoice: (id: string) => - api.post(`/shop-pos/${id}/confirm-and-create-invoice`).then(r => r.data), + confirmAndCreateInvoice: (id: string, confirmedByUserId?: string | null) => + api.post(`/shop-pos/${id}/confirm-and-create-invoice`, { confirmedByUserId }).then(r => r.data), cancel: (id: string) => api.post(`/shop-pos/${id}/cancel`).then(r => r.data),