diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts
index dddd6540e..cb37ac9a3 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts
@@ -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;
}
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx
index 064d518a1..fe7a09253 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx
@@ -145,18 +145,26 @@ export function ActionNeededSection({ items }: ActionNeededSectionProps) {
}
+ leftSection={
+ item.kind === "clearance" ? (
+
+ ) : (
+
+ )
+ }
>
{item.kind === "pay"
- ? "Pay"
+ ? "Pay now"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
- : "Resolve"}
+ : item.urgent
+ ? "Upload documents"
+ : "Upload"}
);
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx
index 508ce1c29..42a0f799e 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx
@@ -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 = (
+ {queriedCount > 0 && (
+ }
+ 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.
+
+ )}
{isReady ? (
} mb="md">
{customsPath
@@ -201,7 +224,14 @@ export function ContractClearancePanel({
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
index e5fb8ca93..433711ddc 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
@@ -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."}
+ {contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
+ }
+ onClick={clearanceModal.open}
+ >
+ {contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
+ ? "Upload documents"
+ : "Manage documents"}
+
+ )}
)}
@@ -664,33 +680,75 @@ export default function ContractDetailPage() {
)}
- {files.length === 0 ? (
-
-
+ {docGroups.length === 0 ? (
+
+
+
+
+
+ No documents yet
+
- 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.
) : (
-
- {docGroups.map((group) => (
-
-
-
- {group.title}
-
-
- {group.files.length}
-
-
-
- {group.files.map((file) => (
-
- ))}
+
+ {docGroups.map((group) => {
+ const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
+ const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
+ return (
+
+
+
+
+
+
+ {group.title}
+
+
+ {group.files.length}
+
+
+
+ {group.files.map((file) => (
+
+ ))}
+
-
- ))}
+ );
+ })}
)}
@@ -813,6 +871,18 @@ const KEY_FACT_ACCENT: Record = {
orange: "#C77F09",
};
+// Per-section accent + icon for the Documents tab groups.
+const DOC_GROUP_ACCENT: Record = {
+ contract: GREEN,
+ profile: "#2B6CB0",
+ clearance: "#C77F09",
+};
+const DOC_GROUP_ICON: Record = {
+ 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 (
-
+
+
+
+ {ext}
+
+
{kind}
@@ -925,11 +1049,7 @@ function DocFileRow({
- {isViewable({
- name: file.name,
- url: fileViewUrl(file.id),
- mimeType: file.mimeType,
- }) && (
+ {viewable && (