mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 08:52:04 +00:00
454 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|