mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
changes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user