mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 22:43:23 +00:00
386 lines
10 KiB
TypeScript
386 lines
10 KiB
TypeScript
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Paper,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Tooltip,
|
|
} from "@mui/material";
|
|
import {
|
|
formatUpliftMoney,
|
|
isGrantedAmount,
|
|
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
|
import {
|
|
UPLIFT_APPROVAL_HEADER_CELLS,
|
|
type UpliftApprovalTab,
|
|
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
|
|
import {
|
|
RejectedEmptyState,
|
|
RejectedRowCells,
|
|
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
|
|
import { Text } from "@/components/ui/text";
|
|
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
|
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
|
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
|
|
|
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
|
|
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
|
|
|
function workOrderLabel(row: UpliftQueueItem): string {
|
|
return row.woNumber || row.dispatchNumber || "—";
|
|
}
|
|
|
|
function WaitingCell({ row }: { row: UpliftQueueItem }) {
|
|
if (!row.requestedAt) {
|
|
return (
|
|
<Text variant="caption" tone="muted">
|
|
—
|
|
</Text>
|
|
);
|
|
}
|
|
const color = waitTimeColor(row.requestedAt);
|
|
const className = getWaitTimeTextClass(color);
|
|
return (
|
|
<Text variant="caption" className={className}>
|
|
{timeSince(row.requestedAt)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function AttachmentsCell({
|
|
row,
|
|
onOpenAttachment,
|
|
}: {
|
|
row: UpliftQueueItem;
|
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
|
}) {
|
|
if (row.evidenceDocumentId == null && !row.evidenceFileName) {
|
|
return (
|
|
<Text variant="caption" tone="muted">
|
|
—
|
|
</Text>
|
|
);
|
|
}
|
|
const extraCount =
|
|
typeof row.attachmentCount === "number" && row.attachmentCount > 1
|
|
? row.attachmentCount - 1
|
|
: 0;
|
|
return (
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
|
<Chip
|
|
size="small"
|
|
label={row.evidenceFileName || "Attachment"}
|
|
onClick={row.evidenceDocumentId != null ? () => onOpenAttachment(row) : undefined}
|
|
clickable={row.evidenceDocumentId != null}
|
|
/>
|
|
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function PendingRowActions({
|
|
row,
|
|
onApprove,
|
|
onReject,
|
|
isDecisionPending,
|
|
}: {
|
|
row: UpliftQueueItem;
|
|
onApprove: (row: UpliftQueueItem) => void;
|
|
onReject: (row: UpliftQueueItem) => void;
|
|
isDecisionPending: boolean;
|
|
}) {
|
|
const canApproveTier = useUpliftsCanApprove(row.requiredTier);
|
|
const canDecide = row.canDecide && canApproveTier.data === true;
|
|
const tooltip = canDecide ? "" : `Requires Tier ${row.requiredTier} role`;
|
|
return (
|
|
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
|
|
<Tooltip title={tooltip}>
|
|
<span>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
color="success"
|
|
disabled={!canDecide || isDecisionPending}
|
|
onClick={() => onApprove(row)}
|
|
>
|
|
Approve {formatUpliftMoney(upliftAmount(row))}
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip title={tooltip}>
|
|
<span>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
color="error"
|
|
disabled={!canDecide || isDecisionPending}
|
|
onClick={() => onReject(row)}
|
|
>
|
|
Reject
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function ApprovedRowActions({
|
|
row,
|
|
canRevoke,
|
|
onRevoke,
|
|
isDecisionPending,
|
|
}: {
|
|
row: UpliftQueueItem;
|
|
canRevoke: boolean;
|
|
onRevoke: (row: UpliftQueueItem) => void;
|
|
isDecisionPending: boolean;
|
|
}) {
|
|
const closed = row.workOrderClosed === true;
|
|
const tooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
|
|
return (
|
|
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
|
|
<Tooltip title={tooltip}>
|
|
<span>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="error"
|
|
disabled={!canRevoke || isDecisionPending || closed || row.workOrderId == null}
|
|
onClick={() => onRevoke(row)}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function UpliftApprovalRow({
|
|
row,
|
|
tab,
|
|
canRevoke,
|
|
onOpenDetail,
|
|
onApprove,
|
|
onReject,
|
|
onRevoke,
|
|
onOpenAttachment,
|
|
isDecisionPending,
|
|
}: {
|
|
row: UpliftQueueItem;
|
|
tab: UpliftApprovalTab;
|
|
canRevoke: boolean;
|
|
onOpenDetail: (row: UpliftQueueItem) => void;
|
|
onApprove: (row: UpliftQueueItem) => void;
|
|
onReject: (row: UpliftQueueItem) => void;
|
|
onRevoke: (row: UpliftQueueItem) => void;
|
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
|
isDecisionPending: boolean;
|
|
}) {
|
|
return (
|
|
<TableRow
|
|
hover
|
|
sx={{ cursor: "pointer" }}
|
|
onClick={() => onOpenDetail(row)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") onOpenDetail(row);
|
|
}}
|
|
tabIndex={0}
|
|
aria-label={`Open uplift details for ${workOrderLabel(row)}`}
|
|
>
|
|
<TableCell>
|
|
<Box>
|
|
<Text variant="body" className="font-medium">
|
|
{workOrderLabel(row)}
|
|
</Text>
|
|
{Boolean(row.site) && (
|
|
<Text variant="caption" tone="muted" className="block">
|
|
{row.site}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Box>
|
|
{Boolean(row.serviceName) && (
|
|
<Text variant="body" className="block">
|
|
{row.serviceName}
|
|
</Text>
|
|
)}
|
|
<Text variant="caption" tone="muted">
|
|
{row.vendorCompanyName || "—"}
|
|
</Text>
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Text variant="body" className="font-medium">
|
|
{formatUpliftMoney(upliftAmount(row))}
|
|
</Text>
|
|
</TableCell>
|
|
<TableCell>
|
|
{isGrantedAmount(row.approvedOnWoTotal) ? (
|
|
<Text variant="body">{formatUpliftMoney(row.approvedOnWoTotal)}</Text>
|
|
) : (
|
|
<Text variant="caption" tone="muted">
|
|
—
|
|
</Text>
|
|
)}
|
|
</TableCell>
|
|
<TableCell sx={{ maxWidth: 220 }}>
|
|
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
|
|
{row.vendorReason}
|
|
</Text>
|
|
</TableCell>
|
|
<TableCell>
|
|
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
|
</TableCell>
|
|
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
|
{tab === "pending" && (
|
|
<>
|
|
<TableCell>
|
|
<WaitingCell row={row} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<PendingRowActions
|
|
row={row}
|
|
onApprove={onApprove}
|
|
onReject={onReject}
|
|
isDecisionPending={isDecisionPending}
|
|
/>
|
|
</TableCell>
|
|
</>
|
|
)}
|
|
{tab === "approved" && (
|
|
<>
|
|
<TableCell>{row.decidedByName || "—"}</TableCell>
|
|
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
|
<TableCell>
|
|
<ApprovedRowActions
|
|
row={row}
|
|
canRevoke={canRevoke}
|
|
onRevoke={onRevoke}
|
|
isDecisionPending={isDecisionPending}
|
|
/>
|
|
</TableCell>
|
|
</>
|
|
)}
|
|
{tab === "rejected" && <RejectedRowCells row={row} />}
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
|
|
if (tab === "rejected") return <RejectedEmptyState />;
|
|
if (tab === "approved") {
|
|
return (
|
|
<Box>
|
|
<Text variant="description">No approved uplifts yet</Text>
|
|
<Text variant="caption" tone="muted">
|
|
Uplifts you approve are archived here for reference.
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
return (
|
|
<Text variant="description" tone="muted">
|
|
No uplifts waiting for approval.
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
// A failed queue is not an empty one: the page's error alert explains it, so the
|
|
// empty-state copy must not also claim the list has nothing in it.
|
|
function UpliftApprovalsEmptyRow({
|
|
tab,
|
|
colSpan,
|
|
hasError,
|
|
}: {
|
|
tab: UpliftApprovalTab;
|
|
colSpan: number;
|
|
hasError: boolean;
|
|
}) {
|
|
if (hasError) return null;
|
|
return (
|
|
<TableRow>
|
|
<TableCell colSpan={colSpan} align="center" className="py-10">
|
|
<UpliftApprovalsEmptyState tab={tab} />
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
export function UpliftApprovalsTable({
|
|
tab,
|
|
isLoading,
|
|
hasError,
|
|
rows,
|
|
canRevoke,
|
|
onOpenDetail,
|
|
onApprove,
|
|
onReject,
|
|
onRevoke,
|
|
onOpenAttachment,
|
|
isDecisionPending,
|
|
}: {
|
|
tab: UpliftApprovalTab;
|
|
isLoading: boolean;
|
|
hasError: boolean;
|
|
rows: UpliftQueueItem[];
|
|
canRevoke: boolean;
|
|
onOpenDetail: (row: UpliftQueueItem) => void;
|
|
onApprove: (row: UpliftQueueItem) => void;
|
|
onReject: (row: UpliftQueueItem) => void;
|
|
onRevoke: (row: UpliftQueueItem) => void;
|
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
|
isDecisionPending: boolean;
|
|
}) {
|
|
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
|
|
return (
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small" aria-label={`${tab} uplift requests`}>
|
|
<TableHead>
|
|
<TableRow>
|
|
{headerCells.map((label) => (
|
|
<TableCell key={label} className="font-semibold">
|
|
{label}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={headerCells.length} align="center" className="py-10">
|
|
<CircularProgress size={28} />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : rows.length === 0 ? (
|
|
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
|
|
) : (
|
|
rows.map((row) => (
|
|
<UpliftApprovalRow
|
|
key={String(row.id)}
|
|
row={row}
|
|
tab={tab}
|
|
canRevoke={canRevoke}
|
|
onOpenDetail={onOpenDetail}
|
|
onApprove={onApprove}
|
|
onReject={onReject}
|
|
onRevoke={onRevoke}
|
|
onOpenAttachment={onOpenAttachment}
|
|
isDecisionPending={isDecisionPending}
|
|
/>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
);
|
|
}
|