feat: streamline clearance action handling and enhance document upload process

This commit is contained in:
Marshal
2026-06-28 15:38:35 +00:00
parent 2de1c2e7be
commit 7c744352d1
5 changed files with 592 additions and 48 deletions

View File

@@ -15,10 +15,9 @@ export interface ActionItem {
urgent?: boolean;
}
const CLEARANCE_UPLOAD_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
];
// Only AWAITING is a pending CUSTOMER action (initial upload or re-upload after a
// query). UNDER_REVIEW is waiting on staff, so it doesn't belong on the card.
const CLEARANCE_ACTION_STATUSES = ["AWAITING_CLEARANCE_DOCUMENTS"];
/**
* Derive the list of pending customer actions from the customer's contracts and
@@ -43,17 +42,14 @@ export function deriveActionItems(
});
continue;
}
if (CLEARANCE_UPLOAD_STATUSES.includes(c.status)) {
const queried = c.status === "AWAITING_CLEARANCE_DOCUMENTS";
if (CLEARANCE_ACTION_STATUSES.includes(c.status)) {
items.push({
id: `clearance-${c.id}`,
kind: "clearance",
reference: c.reference,
description: queried
? "Clearance document needs correction"
: "Upload clearance documents",
description: "Clearance documents needed",
targetId: c.id,
urgent: queried,
urgent: true,
});
continue;
}

View File

@@ -145,18 +145,26 @@ export function ActionNeededSection({ items }: ActionNeededSectionProps) {
</Group>
<Button
size="compact-sm"
variant="light"
variant={item.urgent ? "filled" : "light"}
color={meta.color}
radius="md"
leftSection={<FilePlus2 size={14} />}
leftSection={
item.kind === "clearance" ? (
<Upload size={14} />
) : (
<FilePlus2 size={14} />
)
}
>
{item.kind === "pay"
? "Pay"
? "Pay now"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
: "Resolve"}
: item.urgent
? "Upload documents"
: "Upload"}
</Button>
</Group>
);

View File

@@ -111,10 +111,21 @@ export function ContractClearancePanel({
},
});
const customerDocs = useMemo(
() =>
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
const customerDocs = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
// Surface queried documents (the ones needing correction) first.
const rank = (s: string | null) =>
s === "QUERIED" ? 0 : s === "APPROVED" ? 2 : 1;
return [...docs].sort(
(a, b) => rank(a.reviewStatus) - rank(b.reviewStatus),
);
}, [clearance]);
const queriedCount = useMemo(
() => customerDocs.filter((d) => d.reviewStatus === "QUERIED").length,
[customerDocs],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
@@ -167,6 +178,18 @@ export function ContractClearancePanel({
const body = (
<Stack gap={0}>
{queriedCount > 0 && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={18} />}
mb="md"
title={`${queriedCount} document${queriedCount > 1 ? "s" : ""} need correction`}
>
Re-upload the highlighted document{queriedCount > 1 ? "s" : ""} below to
continue. The reviewer's note explains what to fix.
</Alert>
)}
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{customsPath
@@ -201,7 +224,14 @@ export function ContractClearancePanel({
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: `1px solid ${BORDER}`, padding: 12 }}
style={{
border:
doc.reviewStatus === "QUERIED"
? "1px solid #F0B4B4"
: `1px solid ${BORDER}`,
background: doc.reviewStatus === "QUERIED" ? "#FDF4F4" : "#fff",
padding: 12,
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>

View File

@@ -100,7 +100,9 @@ function groupContractDocuments(files: ContractFile[]): DocGroup[] {
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
for (const f of files) {
// Signature images are baked into the contract PDF — don't list them here.
if (f.code === "contract") contract.push(f);
else if (f.code.startsWith("signature_")) continue;
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else clearance.push(f);
}
@@ -444,6 +446,20 @@ export default function ContractDetailPage() {
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Text>
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
<Button
mt="md"
color="edr-green"
radius="md"
size="sm"
leftSection={<Upload size={15} />}
onClick={clearanceModal.open}
>
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload documents"
: "Manage documents"}
</Button>
)}
</Paper>
)}
@@ -664,33 +680,75 @@ export default function ContractDetailPage() {
</Badge>
)}
</Group>
{files.length === 0 ? (
<Stack align="center" gap={10} py="xl">
<FileText size={26} color={MUTED} style={{ opacity: 0.5 }} />
{docGroups.length === 0 ? (
<Stack align="center" gap={10} py={48}>
<Box
style={{
width: 56,
height: 56,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#F1F5F9",
}}
>
<FileText size={26} color={MUTED} style={{ opacity: 0.6 }} />
</Box>
<Text fz={14} fw={600} style={{ color: INK }}>
No documents yet
</Text>
<Text fz={13} c="dimmed" ta="center" maw={420}>
No documents yet. The signed contract and any uploaded
clearance documents will appear here.
The signed contract and any uploaded clearance documents will
appear here.
</Text>
</Stack>
) : (
<Stack gap="lg">
{docGroups.map((group) => (
<Stack key={group.key} gap={8}>
<Group justify="space-between" align="center">
<Text fz={12} fw={700} c="dimmed" tt="uppercase">
{group.title}
</Text>
<Badge size="xs" variant="light" color="gray" radius="sm">
{group.files.length}
</Badge>
</Group>
<Stack gap={10}>
{group.files.map((file) => (
<DocFileRow key={file.id} file={file} onView={view} />
))}
<Stack gap="xl">
{docGroups.map((group) => {
const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
return (
<Stack key={group.key} gap={10}>
<Group gap={10} align="center">
<Box
style={{
width: 28,
height: 28,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: `${accent}14`,
color: accent,
}}
>
<Icon size={15} />
</Box>
<Text fz={13} fw={700} style={{ color: INK }}>
{group.title}
</Text>
<Badge
size="sm"
variant="light"
color="gray"
radius="sm"
>
{group.files.length}
</Badge>
</Group>
<Stack gap={10}>
{group.files.map((file) => (
<DocFileRow
key={file.id}
file={file}
onView={view}
/>
))}
</Stack>
</Stack>
</Stack>
))}
);
})}
</Stack>
)}
</Card>
@@ -813,6 +871,18 @@ const KEY_FACT_ACCENT: Record<string, string> = {
orange: "#C77F09",
};
// Per-section accent + icon for the Documents tab groups.
const DOC_GROUP_ACCENT: Record<string, string> = {
contract: GREEN,
profile: "#2B6CB0",
clearance: "#C77F09",
};
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
contract: FileSignature,
profile: FileText,
clearance: Upload,
};
/**
* A pill-style detail tab matching the backoffice booking-requests tabs: an
* icon, a label, and an always-visible count badge (shows 0 when empty).
@@ -897,6 +967,30 @@ function SectionLabel({
* stored filename and size as secondary text, and view / download actions that
* stream through the API by file id.
*/
/** Extension → a small colored type chip (PDF red, image green, etc.). */
function fileTypeChip(name: string, mimeType?: string | null): {
ext: string;
color: string;
} {
const dot = name.lastIndexOf(".");
let ext = dot >= 0 ? name.slice(dot + 1).toUpperCase() : "";
if (!ext && mimeType) ext = mimeType.split("/")[1]?.toUpperCase() ?? "FILE";
if (!ext) ext = "FILE";
const color =
ext === "PDF"
? "#D64545"
: ["PNG", "JPG", "JPEG", "GIF", "WEBP", "SVG"].includes(ext)
? "#2F9E6E"
: ["DOC", "DOCX"].includes(ext)
? "#2B6CB0"
: ["XLS", "XLSX", "CSV"].includes(ext)
? "#2F855A"
: ["MP4", "WEBM", "MOV"].includes(ext)
? "#7A40C8"
: "#6B7C8E";
return { ext: ext.slice(0, 4), color };
}
function DocFileRow({
file,
onView,
@@ -905,15 +999,45 @@ function DocFileRow({
onView: (f: ViewableFile) => void;
}) {
const kind = labelForDocCode(file.code);
const { ext, color } = fileTypeChip(file.name, file.mimeType);
const viewable = isViewable({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
});
return (
<Group
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
style={{
borderRadius: 14,
border: `1px solid ${BORDER}`,
background: "#fff",
transition: "border-color 120ms ease, box-shadow 120ms ease",
}}
className="hover:border-edr-green-3 hover:shadow-sm"
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color={MUTED} style={{ flexShrink: 0 }} />
<Box
style={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 10,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: `${color}14`,
color,
}}
>
<FileText size={16} />
<Text fz={8} fw={800} mt={1} style={{ letterSpacing: "0.04em" }}>
{ext}
</Text>
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={600} style={{ color: INK }} truncate>
{kind}
@@ -925,11 +1049,7 @@ function DocFileRow({
</Box>
</Group>
<Group gap={8} wrap="nowrap">
{isViewable({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
}) && (
{viewable && (
<Button
variant="light"
color="edr-green"