shoc-frontend-new/src/app/(protected)/uplifts/_components/uplift-approvals-table.tsx

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