From 48f1685e4de0aea147ee535cdf416d30002d10f0 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 12 Jun 2026 11:56:22 -0300 Subject: [PATCH] feat: add uplifts queue module --- src/app/(protected)/uplifts/index.tsx | 312 ++++++++++++++++++ src/domain/uplifts/api/uplifts-api.ts | 63 ++++ src/domain/uplifts/mappers/uplift-mapper.ts | 43 +++ src/domain/uplifts/types/uplift.ts | 23 ++ .../uplifts/use-cases/use-uplift-actions.ts | 79 +++++ .../uplifts/use-cases/use-uplifts-queue.ts | 14 + 6 files changed, 534 insertions(+) create mode 100644 src/app/(protected)/uplifts/index.tsx create mode 100644 src/domain/uplifts/api/uplifts-api.ts create mode 100644 src/domain/uplifts/mappers/uplift-mapper.ts create mode 100644 src/domain/uplifts/types/uplift.ts create mode 100644 src/domain/uplifts/use-cases/use-uplift-actions.ts create mode 100644 src/domain/uplifts/use-cases/use-uplifts-queue.ts diff --git a/src/app/(protected)/uplifts/index.tsx b/src/app/(protected)/uplifts/index.tsx new file mode 100644 index 00000000..89fb736e --- /dev/null +++ b/src/app/(protected)/uplifts/index.tsx @@ -0,0 +1,312 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions"; +import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue"; +import { formatDateTime } from "@/lib/time-utils"; + +const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled", ""]; + +function formatMoney(value: number | null | undefined): string { + return `$${Number(value ?? 0).toFixed(2)}`; +} + +export default function UpliftQueuePage() { + const navigate = useNavigate(); + const [status, setStatus] = useState("Pending"); + const [tier, setTier] = useState(""); + const [page, setPage] = useState(0); + const [pageSize] = useState(25); + + const [upliftAction, setUpliftAction] = useState<{ + type: "approve" | "deny"; + item: UpliftQueueItem; + } | null>(null); + const [note, setNote] = useState(""); + + const listParams = useMemo( + () => ({ status, tier, page: page + 1, pageSize }), + [status, tier, page, pageSize], + ); + + const { data, isLoading, isFetching, error, refetch } = useUpliftsQueue(listParams); + const approveUplift = useApproveUplift(); + const denyUplift = useDenyUplift(); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + const handleConfirm = () => { + if (!upliftAction) return; + if (upliftAction.type === "approve") { + approveUplift.mutate( + { id: upliftAction.item.id, note: note || undefined }, + { + onSuccess: () => { + setUpliftAction(null); + setNote(""); + void refetch(); + }, + }, + ); + } else { + if (!note.trim()) return; + denyUplift.mutate( + { id: upliftAction.item.id, note: note.trim() }, + { + onSuccess: () => { + setUpliftAction(null); + setNote(""); + void refetch(); + }, + }, + ); + } + }; + + return ( + + + + + Uplift Queue + + + {totalCount} total + + {isFetching && !isLoading && } + + + + + Status + + + + Tier + + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load uplift queue"} + + )} + + + + + + Dispatch + PO + Vendor + Current NTE + Requested + Delta + Tier + Status + Requested At + Actions + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + + No uplift requests match these filters. + + + + ) : ( + rows.map((row) => ( + + {row.dispatchNumber} + {row.poNumber} + {row.vendorCompanyName} + {formatMoney(row.currentNTE)} + {formatMoney(row.requestedNTE)} + {formatMoney(row.delta)} + + + + + + + {row.requestedAt ? formatDateTime(row.requestedAt) : ""} + + + {row.workOrderId && ( + + )} + {row.status === "Pending" && ( + <> + + + + + + + + + + + + )} + + + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={pageSize} + rowsPerPageOptions={[25]} + /> + + setUpliftAction(null)}> + + {upliftAction?.type === "approve" + ? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?` + : "Deny Uplift"} + + + {upliftAction?.type === "approve" && ( + + {formatMoney(upliftAction.item.currentNTE)} →{" "} + {formatMoney(upliftAction.item.requestedNTE)} (+ + {formatMoney(upliftAction.item.delta)}) + + )} + setNote(e.target.value)} + /> + + + + + + +
+ ); +} diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts new file mode 100644 index 00000000..bdb3fdc3 --- /dev/null +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -0,0 +1,63 @@ +import { API_PATHS } from "@/api/api-paths"; +import { apiGet, apiPost } from "@/api/api"; +import { handleApiResponse } from "@/api/handle-api-response"; +import { mapUpliftQueueItems } from "@/domain/uplifts/mappers/uplift-mapper"; +import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; +import { mapUpliftRequests } from "@/domain/work-orders/mappers/work-order-mapper"; +import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; + +function buildQueueParams(params: UpliftsQueueParams): Record { + const searchParams: Record = { + page: params.page ?? 1, + pageSize: params.pageSize ?? 25, + }; + if (params.status) searchParams.status = params.status; + if (params.tier) searchParams.tier = params.tier; + return searchParams; +} + +export const upliftsApi = { + list: async (params: UpliftsQueueParams = {}): Promise => { + const data = await apiGet(API_PATHS.rest.uplifts, { + searchParams: buildQueueParams(params), + }); + const response = handleApiResponse>(data); + const items = Array.isArray(response?.items) + ? response.items + : Array.isArray((response as { data?: unknown[] })?.data) + ? (response as { data: unknown[] }).data + : []; + const total = + (typeof response?.total === "number" ? response.total : undefined) ?? + (typeof response?.totalCount === "number" ? response.totalCount : undefined) ?? + items.length; + + return { + items: mapUpliftQueueItems(items), + totalCount: total, + page: params.page ?? 1, + pageSize: params.pageSize ?? 25, + }; + }, + + listForDispatch: async (dispatchId: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.uplifts}/dispatch/${dispatchId}`); + const response = handleApiResponse(data); + const items = Array.isArray(response) + ? response + : typeof response === "object" && + response !== null && + Array.isArray((response as { data?: unknown[] }).data) + ? (response as { data: unknown[] }).data + : []; + return mapUpliftRequests(items); + }, + + approve: async (id: string | number, note?: string): Promise => { + await apiPost(`${API_PATHS.rest.uplifts}/${id}/approve`, { note }); + }, + + deny: async (id: string | number, note: string): Promise => { + await apiPost(`${API_PATHS.rest.uplifts}/${id}/deny`, { note }); + }, +}; diff --git a/src/domain/uplifts/mappers/uplift-mapper.ts b/src/domain/uplifts/mappers/uplift-mapper.ts new file mode 100644 index 00000000..132ab0c3 --- /dev/null +++ b/src/domain/uplifts/mappers/uplift-mapper.ts @@ -0,0 +1,43 @@ +import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift"; +import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + } + return ""; +} + +function readOptionalId( + record: Record, + ...keys: string[] +): string | number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" || typeof value === "number") return value; + } + return null; +} + +export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem { + const base = mapUpliftRequest(raw); + const item = asRecord(raw); + return { + ...base, + dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"), + poNumber: readString(item, "poNumber", "PONumber"), + vendorCompanyName: readString(item, "vendorCompanyName", "VendorCompanyName"), + workOrderId: readOptionalId(item, "workOrderId", "WorkOrderId"), + dispatchId: readOptionalId(item, "dispatchId", "DispatchId"), + }; +} + +export function mapUpliftQueueItems(rawItems: unknown[]): UpliftQueueItem[] { + return rawItems.map(mapUpliftQueueItem); +} diff --git a/src/domain/uplifts/types/uplift.ts b/src/domain/uplifts/types/uplift.ts new file mode 100644 index 00000000..8d6d3503 --- /dev/null +++ b/src/domain/uplifts/types/uplift.ts @@ -0,0 +1,23 @@ +import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; + +export interface UpliftQueueItem extends UpliftRequest { + dispatchNumber: string; + poNumber: string; + vendorCompanyName: string; + workOrderId: string | number | null; + dispatchId: string | number | null; +} + +export interface UpliftsQueueParams { + status?: string; + tier?: string; + page?: number; + pageSize?: number; +} + +export interface UpliftsQueueResult { + items: UpliftQueueItem[]; + totalCount: number; + page: number; + pageSize: number; +} diff --git a/src/domain/uplifts/use-cases/use-uplift-actions.ts b/src/domain/uplifts/use-cases/use-uplift-actions.ts new file mode 100644 index 00000000..071ce7e3 --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplift-actions.ts @@ -0,0 +1,79 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationResult, + type UseQueryResult, +} from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import type { UpliftRequest } from "@/domain/work-orders/types/work-order"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { requireQueryParam } from "@/lib/query/require-query-param"; + +export function useUpliftsForDispatch( + dispatchId: string | number | undefined, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.uplifts.forDispatch(dispatchId ?? ""), + queryFn: () => upliftsApi.listForDispatch(requireQueryParam(dispatchId, "Dispatch id")), + enabled: dispatchId !== undefined && dispatchId !== "", + }); +} + +function invalidateUpliftQueries( + queryClient: ReturnType, + dispatchId?: string | number, +) { + void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all }); + if (dispatchId !== undefined && dispatchId !== "") { + void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.vendorPos.detail(dispatchId) }); + } +} + +interface ApproveUpliftInput { + id: string | number; + note?: string; +} + +interface DenyUpliftInput { + id: string | number; + note: string; +} + +export function useApproveUplift( + dispatchId?: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, note }: { id: string | number; note?: string }) => + upliftsApi.approve(id, note), + onSuccess: () => { + invalidateUpliftQueries(queryClient, dispatchId); + toast.success("Uplift approved — NTE updated"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to approve uplift"); + }, + }); +} + +export function useDenyUplift( + dispatchId?: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, note }: { id: string | number; note: string }) => upliftsApi.deny(id, note), + onSuccess: () => { + invalidateUpliftQueries(queryClient, dispatchId); + toast.success("Uplift denied"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to deny uplift"); + }, + }); +} diff --git a/src/domain/uplifts/use-cases/use-uplifts-queue.ts b/src/domain/uplifts/use-cases/use-uplifts-queue.ts new file mode 100644 index 00000000..9ebcf4bd --- /dev/null +++ b/src/domain/uplifts/use-cases/use-uplifts-queue.ts @@ -0,0 +1,14 @@ +import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { upliftsApi } from "@/domain/uplifts/api/uplifts-api"; +import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useUpliftsQueue( + params: UpliftsQueueParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.uplifts.queue(params as Record), + queryFn: () => upliftsApi.list(params), + placeholderData: keepPreviousData, + }); +}