shoc-frontend-new/src/app/(protected)/uplifts/index.tsx
2026-08-11 08:57:38 -03:00

454 lines
14 KiB
TypeScript

import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
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 { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { UPLIFT_STATUSES } from "@/domain/uplifts/types/uplift";
import {
useApproveUplift,
useRejectUplift,
useRequestChangesUplift,
} from "@/domain/uplifts/use-cases/use-uplift-actions";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { formatDateTime } from "@/lib/time-utils";
import { toast } from "react-toastify";
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
type UpliftActionType = "approve" | "reject" | "request-changes";
type UpliftDecision = { type: UpliftActionType; item: UpliftQueueItem };
function formatMoney(value: number | null | undefined): string {
return `$${Number(value ?? 0).toFixed(2)}`;
}
function formatBytes(value: number | null | undefined): string {
if (value == null) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function UpliftQueueHeader({
totalCount,
isFetching,
isLoading,
}: {
totalCount: number;
isFetching: boolean;
isLoading: boolean;
}) {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Uplift Queue
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
);
}
function UpliftQueueFilters({
status,
tier,
onStatusChange,
onTierChange,
}: {
status: string;
tier: string;
onStatusChange: (value: string) => void;
onTierChange: (value: string) => void;
}) {
return (
<Stack direction="row" spacing={2}>
<FormControl size="small" className="min-w-[140px]">
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
{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) => onTierChange(e.target.value)}>
<MenuItem value="">All</MenuItem>
<MenuItem value="1">Tier 1</MenuItem>
<MenuItem value="2">Tier 2</MenuItem>
</Select>
</FormControl>
</Stack>
);
}
function UpliftQueueRow({
row,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
row: UpliftQueueItem;
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
}) {
const decisionable = row.status === "Pending";
return (
<TableRow hover>
<TableCell>{row.dispatchNumber}</TableCell>
<TableCell>{row.poNumber}</TableCell>
<TableCell>{row.vendorCompanyName}</TableCell>
<TableCell>{formatMoney(row.currentNTE)}</TableCell>
<TableCell>{formatMoney(row.requestedNTE)}</TableCell>
<TableCell>
<Chip size="small" label={`Tier ${row.requiredTier}`} />
</TableCell>
<TableCell>
<Chip size="small" label={row.status} />
</TableCell>
<TableCell>
{row.evidenceDocumentId != null ? (
<Stack direction="column" spacing={0.5}>
<Typography variant="caption">{row.evidenceFileName}</Typography>
{Boolean(row.evidenceContentType) && (
<Typography variant="caption" color="text.secondary">
{row.evidenceContentType} · {formatBytes(row.evidenceSizeBytes)}
</Typography>
)}
<Button size="small" variant="text" onClick={() => void onDownloadEvidence(row)}>
Download
</Button>
</Stack>
) : (
<Typography variant="caption" color="text.secondary">
—
</Typography>
)}
</TableCell>
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell>
<Stack direction="row" spacing={1}>
{Boolean(row.workOrderId) && (
<Button size="small" variant="outlined" onClick={() => onViewWorkOrder(row)}>
View
</Button>
)}
{decisionable && (
<>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="contained"
color="success"
disabled={!row.canDecide}
onClick={() => onDecide("approve", row)}
>
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={() => onDecide("reject", row)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={!row.canDecide ? `Requires Tier ${row.requiredTier} role` : ""}>
<span>
<Button
size="small"
variant="outlined"
disabled={!row.canDecide}
onClick={() => onDecide("request-changes", row)}
>
Request Changes
</Button>
</span>
</Tooltip>
</>
)}
</Stack>
</TableCell>
</TableRow>
);
}
function UpliftQueueTable({
isLoading,
rows,
onViewWorkOrder,
onDownloadEvidence,
onDecide,
}: {
isLoading: boolean;
rows: UpliftQueueItem[];
onViewWorkOrder: (row: UpliftQueueItem) => void;
onDownloadEvidence: (row: UpliftQueueItem) => void;
onDecide: (type: UpliftActionType, row: UpliftQueueItem) => void;
}) {
return (
<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">Tier</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Evidence</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 sx={{ color: "text.secondary" }}>
No uplift requests match these filters.
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<UpliftQueueRow
key={String(row.id)}
row={row}
onViewWorkOrder={onViewWorkOrder}
onDownloadEvidence={onDownloadEvidence}
onDecide={onDecide}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
);
}
function UpliftDecisionDialog({
action,
note,
noteRequired,
decisionPending,
onNoteChange,
onClose,
onConfirm,
}: {
action: UpliftDecision | null;
note: string;
noteRequired: boolean;
decisionPending: boolean;
onNoteChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open={Boolean(action)} onClose={onClose}>
<DialogTitle>
{action?.type === "approve"
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
: action?.type === "reject"
? "Reject Uplift"
: "Request Changes"}
</DialogTitle>
<DialogContent>
{action?.type === "approve" && (
<Typography variant="body2" className="mt-1 mb-3">
{formatMoney(action.item.currentNTE)} → {formatMoney(action.item.requestedNTE)}
</Typography>
)}
<TextField
fullWidth
multiline
minRows={2}
className="mt-2"
label={noteRequired ? "Note (required)" : "Optional note"}
value={note}
required={noteRequired}
onChange={(e) => onNoteChange(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
color={action?.type === "approve" ? "success" : "primary"}
disabled={noteRequired ? !note.trim() || decisionPending : decisionPending}
onClick={onConfirm}
>
Confirm
</Button>
</DialogActions>
</Dialog>
);
}
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<UpliftDecision | 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 rejectUplift = useRejectUplift();
const requestChangesUplift = useRequestChangesUplift();
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
const decisionPending =
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
const handleStatusChange = (value: string) => {
setStatus(value);
setPage(0);
};
const handleTierChange = (value: string) => {
setTier(value);
setPage(0);
};
const closeDialog = () => {
setUpliftAction(null);
setNote("");
};
const handleConfirm = () => {
if (!upliftAction) return;
if (noteRequired && !note.trim()) return;
const id = upliftAction.item.id;
const onDone = () => {
closeDialog();
void refetch();
};
if (upliftAction.type === "approve") {
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
} else if (upliftAction.type === "reject") {
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
} else {
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
}
};
const downloadEvidence = async (item: UpliftQueueItem) => {
try {
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Unable to download evidence.");
}
};
const handleDecide = (type: UpliftActionType, item: UpliftQueueItem) => {
setUpliftAction({ type, item });
setNote("");
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<UpliftQueueHeader totalCount={totalCount} isFetching={isFetching} isLoading={isLoading} />
<UpliftQueueFilters
status={status}
tier={tier}
onStatusChange={handleStatusChange}
onTierChange={handleTierChange}
/>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert>
)}
<UpliftQueueTable
isLoading={isLoading}
rows={rows}
onViewWorkOrder={(r) => navigate(`/workorders/${r.workOrderId}`)}
onDownloadEvidence={(r) => void downloadEvidence(r)}
onDecide={handleDecide}
/>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[25]}
/>
<UpliftDecisionDialog
action={upliftAction}
note={note}
noteRequired={noteRequired}
decisionPending={decisionPending}
onNoteChange={setNote}
onClose={closeDialog}
onConfirm={handleConfirm}
/>
</Box>
);
}