This commit is contained in:
Marshal
2026-08-20 18:06:13 +00:00
committed by Hagernesh
parent 76bfbe1b17
commit 2f894ba51b
8 changed files with 495 additions and 277 deletions

View File

@@ -1,10 +1,12 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Collapse,
FileButton,
Group,
Loader,
@@ -20,6 +22,7 @@ import {
import {
AlertCircle,
CheckCircle2,
ChevronDown,
Download,
Eye,
FileCheck2,
@@ -187,73 +190,102 @@ export function ClearanceReviewSection({
return (
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
extra={
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
<Paper radius={13} withBorder style={{ overflow: "hidden" }} p={0}>
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={15}
style={{ borderBottom: "1px solid #EFF3F7" }}
>
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="#0A8A5F" />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text">
Customer documents
</Text>
<Text fz={11.5} c="#93A4B5" truncate>
{stats.approved} of {stats.total} approved
{stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required
marked *
</Text>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this booking.
</Group>
<Group
gap={5}
wrap="nowrap"
px={8}
py={3}
style={{
flexShrink: 0,
borderRadius: 6,
background: approvalsLocked ? "#F4F7FA" : "#E7F5EF",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: 999,
background: approvalsLocked ? "#93A4B5" : "#0A8A5F",
}}
/>
<Text
fz={10.5}
fw={700}
style={{ color: approvalsLocked ? "#67788A" : "#0A8A5F" }}
>
{approvalsLocked ? "Uploads closed" : "Uploads open"}
</Text>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "APPROVED",
})
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
)}
</Stack>
</SectionCard>
</Group>
</Group>
{!hideSummary && stats.total > 0 && (
<Box px={18} py={12} style={{ borderBottom: "1px solid #EFF3F7" }}>
<Progress value={stats.pct} color="edr-green" radius="xl" size="sm" mb={8} />
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed" px={18} py={20}>
No customer documents are required for this booking.
</Text>
) : (
customerDocs.map((doc, i) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
first={i === 0}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
onApprove={() =>
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
)}
</Paper>
{clearance.outputCode && !phasedCustoms && (
<SectionCard
@@ -559,6 +591,23 @@ function StatPill({
);
}
/** Row tints straight from the design tokens. */
const ROW_TONE: Record<
Freight.DocumentReviewStatus,
{ bg: string; chipBg: string; fg: string }
> = {
APPROVED: { bg: "#FFFFFF", chipBg: "#E7F5EF", fg: "#0A8A5F" },
QUERIED: { bg: "#FBECEA", chipBg: "#FBECEA", fg: "#C0392B" },
PENDING: { bg: "#FFFFFF", chipBg: "#FCF2E2", fg: "#A76F08" },
};
/**
* One document as a compact 60px row that expands in place. Collapsed it shows
* name, file line, status chip and the review actions; expanded it reveals the
* per-document history timeline and the query note. Keeping the actions in the
* collapsed row means approving a stack of documents never needs a single
* expand.
*/
function DocReviewCard({
doc,
approvalsLocked,
@@ -572,6 +621,7 @@ function DocReviewCard({
onQuery,
onView,
busy,
first,
}: {
doc: Freight.ClearanceDocument;
approvalsLocked: boolean;
@@ -585,146 +635,177 @@ function DocReviewCard({
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
first: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const tone = ROW_TONE[status];
const hasFile = !!doc.file;
const isApproved = status === "APPROVED";
const history = doc.history ?? [];
// A queried document is the one the reviewer must act on, so it opens itself.
const [open, setOpen] = useState(status === "QUERIED");
const expandable = history.length > 0 || Boolean(doc.note);
// Opening the query form has to reveal the body it lives in.
const bodyOpen = open || queryOpen;
// The file line carries the same at-a-glance summary as the design: file
// name, who decided, when.
const last = history[history.length - 1];
const fileLine = hasFile
? [
doc.file!.name,
status === "APPROVED" && last ? `Approved by ${last.byName ?? "staff"}` : null,
status === "PENDING" ? "awaiting review" : null,
status === "QUERIED" ? doc.note : null,
last ? formatDateTime(last.at) : null,
]
.filter(Boolean)
.join(" · ")
: "Not uploaded by customer";
return (
<Paper
withBorder
radius="md"
p="md"
<Box
style={{
borderColor:
status === "QUERIED"
? "var(--mantine-color-red-2)"
: status === "APPROVED"
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)",
background: tone.bg,
borderTop: first ? undefined : "1px solid #EFF3F7",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-green" : "gray"}
radius="md"
size={40}
>
<FileText size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
</Box>
</Group>
<Group gap={12} wrap="nowrap" align="center" px={18} py={13}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 34,
height: 34,
borderRadius: 9,
background: tone.chipBg,
color: tone.fg,
}}
>
<FileText size={16} />
</Box>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile &&
isViewable({
name: doc.file!.name,
url: "",
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
onView,
)
}
>
View
</Button>
</Tooltip>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={12.5} fw={600} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz={11} c="#93A4B5" truncate>
{fileLine}
</Text>
</Box>
<Badge
variant="light"
radius="xl"
color={meta.color}
styles={{ root: { flexShrink: 0 } }}
>
{meta.label}
</Badge>
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
{hasFile && !readOnly && !isApproved && !approvalsLocked && (
<Button
size="compact-sm"
radius={7}
color="edr-green"
leftSection={<CheckCircle2 size={12} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
{hasFile && !readOnly && !queriesLocked && !queryOpen && (
<Button
size="compact-sm"
radius={7}
variant="default"
disabled={busy}
onClick={() => {
onToggleQuery(true);
setOpen(true);
}}
>
Query
</Button>
)}
{hasFile && isViewable({ name: doc.file!.name, url: "" }) && (
<Tooltip label="Preview document">
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(onView)
}
>
<Eye size={13} />
</ActionIcon>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(doc.file!.id, doc.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() => void downloadBookingFile(doc.file!.id, doc.file!.name)}
>
<Download size={15} />
</Box>
<Download size={13} />
</ActionIcon>
</Tooltip>
)}
{expandable && (
<Tooltip label={bodyOpen ? "Hide history" : "Show history"}>
<ActionIcon
variant="subtle"
color="gray"
radius={7}
size={29}
aria-expanded={bodyOpen}
aria-label={bodyOpen ? "Hide history" : "Show history"}
onClick={() => setOpen((o) => !o)}
>
<ChevronDown
size={14}
style={{
transition: "transform 150ms",
transform: bodyOpen ? "rotate(180deg)" : undefined,
}}
/>
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
{(doc.history?.length ?? 0) > 0 && (
<DocHistoryTimeline history={doc.history!} />
)}
<Collapse expanded={bodyOpen}>
<Box px={18} pb={14} pl={64}>
{status === "QUERIED" && doc.note ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
mb="sm"
>
<Text fz={12.5} c="red.9">
{doc.note}
</Text>
</Alert>
) : null}
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
>
<Text fz="12.5px" c="red.9">
{doc.note}
</Text>
</Alert>
)}
{history.length > 0 ? <DocHistoryTimeline history={history} /> : null}
{hasFile && !readOnly && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
{!queriesLocked && (
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
)}
{!isApproved && !approvalsLocked && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
{queryOpen && !readOnly ? (
<Box
mt="sm"
p="sm"
style={{
borderRadius: 12,
@@ -733,11 +814,8 @@ function DocReviewCard({
}}
>
<Group gap={6} mb={6}>
<MessageSquareWarning
size={14}
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
<MessageSquareWarning size={14} color="var(--mantine-color-red-7)" />
<Text fz={12.5} fw={700} c="red.8">
Describe the problem for the customer
</Text>
</Group>
@@ -775,9 +853,9 @@ function DocReviewCard({
</Button>
</Group>
</Box>
)}
) : null}
</Box>
)}
</Paper>
</Collapse>
</Box>
);
}