feat: add uplifts queue module

This commit is contained in:
Arthur Bassi 2026-06-12 11:56:22 -03:00
parent 1c474914f2
commit 48f1685e4d
6 changed files with 534 additions and 0 deletions

View file

@ -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 (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
justifyContent="space-between"
>
<Stack direction="row" spacing={2} alignItems="baseline">
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>
{s || "All"}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="min-w-[120px]">
<InputLabel>Tier</InputLabel>
<Select
label="Tier"
value={tier}
onChange={(e) => {
setTier(e.target.value);
setPage(0);
}}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Dispatch</TableCell>
<TableCell className="font-semibold">PO</TableCell>
<TableCell className="font-semibold">Vendor</TableCell>
<TableCell className="font-semibold">Current NTE</TableCell>
<TableCell className="font-semibold">Requested</TableCell>
<TableCell className="font-semibold">Delta</TableCell>
<TableCell className="font-semibold">Tier</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Requested At</TableCell>
<TableCell className="font-semibold">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" className="py-10">
<Typography color="text.secondary">
No uplift requests match these filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>{formatMoney(row.delta)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{row.workOrderId && (
<Button
size="small"
variant="outlined"
onClick={() => navigate(`/workorders/${row.workOrderId}`)}
>
View
</Button>
)}
{row.status === "Pending" && (
<>
<Tooltip
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => {
setUpliftAction({ type: "approve", item: row });
setNote("");
}}
>
Approve
</Button>
</span>
</Tooltip>
<Tooltip
title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}
>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!row.canDecide}
onClick={() => {
setUpliftAction({ type: "deny", item: row });
setNote("");
}}
>
Deny
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
<DialogTitle>
{upliftAction?.type === "approve"
? `Approve uplift to ${formatMoney(upliftAction.item.requestedNTE)}?`
: "Deny Uplift"}
</DialogTitle>
<DialogContent>
{upliftAction?.type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
{formatMoney(upliftAction.item.currentNTE)} →{" "}
{formatMoney(upliftAction.item.requestedNTE)} (+
{formatMoney(upliftAction.item.delta)})
</Typography>
)}
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
<Button
variant="contained"
color={upliftAction?.type === "approve" ? "success" : "error"}
disabled={
upliftAction?.type === "deny"
? !note.trim() || denyUplift.isPending
: approveUplift.isPending
}
onClick={handleConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -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<string, string | number> {
const searchParams: Record<string, string | number> = {
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<UpliftsQueueResult> => {
const data = await apiGet<unknown>(API_PATHS.rest.uplifts, {
searchParams: buildQueueParams(params),
});
const response = handleApiResponse<Record<string, unknown>>(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<UpliftRequest[]> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.uplifts}/dispatch/${dispatchId}`);
const response = handleApiResponse<unknown>(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<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/approve`, { note });
},
deny: async (id: string | number, note: string): Promise<void> => {
await apiPost<unknown>(`${API_PATHS.rest.uplifts}/${id}/deny`, { note });
},
};

View file

@ -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<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...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<string, unknown>,
...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);
}

View file

@ -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;
}

View file

@ -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<UpliftRequest[], Error> {
return useQuery({
queryKey: queryKeys.uplifts.forDispatch(dispatchId ?? ""),
queryFn: () => upliftsApi.listForDispatch(requireQueryParam(dispatchId, "Dispatch id")),
enabled: dispatchId !== undefined && dispatchId !== "",
});
}
function invalidateUpliftQueries(
queryClient: ReturnType<typeof useQueryClient>,
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<void, Error, ApproveUpliftInput> {
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<void, Error, DenyUpliftInput> {
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");
},
});
}

View file

@ -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<UpliftsQueueResult, Error> {
return useQuery({
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
queryFn: () => upliftsApi.list(params),
placeholderData: keepPreviousData,
});
}