From c249c25731b91a60b09437ff2f3603d95e46d02f Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 03:16:21 +0000 Subject: [PATCH] refactor: update clearance review colors and styles for consistency - Changed status colors in ClearanceReviewSection from blue to green for approved and from accent to gray for pending. - Updated Loader color in DocumentClearanceDetailPage to green. - Adjusted Badge colors in DocumentClearanceDetailPage and DocumentClearanceListPage to reflect new color scheme. - Introduced DirectionIcon component for better visual representation of trade direction with tooltips. - Enhanced ResubmitDocuments component to improve document handling and display. - Added validation error alerts in ChangesRequestedView and ResubmitBookingModal. - Implemented ContractSignButton for bookings ready for signature. - Created useBookingDocumentSetting hook to fetch document settings based on company nationality. --- .../detail/ClearanceReviewSection.tsx | 32 +-- .../bookings/DocumentClearanceDetailPage.tsx | 26 ++- .../bookings/DocumentClearanceListPage.tsx | 76 ++++--- .../MyPortalPage/components/BookingRow.tsx | 9 + .../ChangesRequestedView.tsx | 27 ++- .../clearance/BookingActionButton.tsx | 11 +- .../bookings/contract/ContractSignButton.tsx | 59 ++++++ .../resubmit/ResubmitBookingModal.tsx | 16 +- .../bookings/resubmit/ResubmitDocuments.tsx | 192 ++++++++++-------- .../src/pages/bookings/resubmit/index.ts | 10 +- .../pages/bookings/resubmit/resubmitDocs.ts | 60 ++---- .../resubmit/useBookingDocumentSetting.ts | 35 ++++ .../bookings/resubmit/useResubmitFlow.ts | 132 ++++++++---- 13 files changed, 439 insertions(+), 246 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/contract/ContractSignButton.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index bb50168ad..d8c0577fd 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -44,9 +44,9 @@ const STATUS_META: Record< Freight.DocumentReviewStatus, { label: string; color: string } > = { - APPROVED: { label: "Approved", color: "edr-blue" }, + APPROVED: { label: "Approved", color: "edr-green" }, QUERIED: { label: "Queried", color: "red" }, - PENDING: { label: "Pending", color: "edr-accent" }, + PENDING: { label: "Pending", color: "gray" }, }; /** @@ -142,7 +142,7 @@ export function ClearanceReviewSection({ if (isLoading || !clearance) { return ( - + Loading clearance… ); @@ -165,15 +165,15 @@ export function ClearanceReviewSection({ - + - + )} @@ -219,13 +219,13 @@ export function ClearanceReviewSection({ icon={Upload} title="Customs output documents" subtitle="Upload the cleared/customs paperwork to hand back to the customer." - accent="edr-accent" + accent="edr-green" > {glDocs.map((doc) => ( - + {doc.label} {doc.required ? " *" : ""} @@ -239,7 +239,7 @@ export function ClearanceReviewSection({ href={doc.file.url} target="_blank" rel="noreferrer" - c="edr-accent" + c="edr-green" style={{ display: "flex" }} > @@ -261,7 +261,7 @@ export function ClearanceReviewSection({ {...props} size="compact-xs" variant="light" - color="edr-accent" + color="edr-green" leftSection={} > {outputFiles[doc.fileKey] ? "Selected" : "Upload"} @@ -275,7 +275,7 @@ export function ClearanceReviewSection({ + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx index 1481440c9..fd46cbe97 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx @@ -72,15 +72,13 @@ function ResubmitBookingModalBody({ )} - - - Your documents - - - Replace any document you need to update, then resubmit. - - - + + + {flow.validationError && ( + }> + {flow.validationError} + + )} {flow.mutations.some((m) => m.isError) && ( }> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx index 745c0767c..b6eaaac81 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx @@ -1,97 +1,123 @@ -import { ActionIcon, Box, Button, Group, Text } from "@mantine/core"; -import { Download, Upload, X } from "lucide-react"; -import { useRef } from "react"; +import { Box, Group, Loader, Stack, Text } from "@mantine/core"; +import { SmartFileInput } from "@edr/ui-common"; +import { CheckCircle2, Download, FileText } from "lucide-react"; -import { DocRow, IconSquare } from "../BookingDetailPage/components/Documents"; +import { IconSquare } from "../BookingDetailPage/components/Documents"; +import { labelForDocCode } from "./resubmitDocs"; import type { ResubmitFlowController } from "./useResubmitFlow"; /** - * Document list for resubmitting a CHANGES_REQUESTED booking. Renders exactly - * the files the customer originally submitted (`booking.files`, surfaced through - * the flow controller) and lets them replace any of them before resubmitting. + * Document section for resubmitting a CHANGES_REQUESTED booking. * - * No fixed required list and no "X of N" gate — the customer updates what they - * actually provided. + * Mirrors the new-booking document step: the fields come from the company's + * onboarding document setting (TIN, license, ID, passport, …) rendered via + * SmartFileInput. Documents already submitted on the booking are shown as an + * "on file" reference; the customer uploads here only to replace one, or to fill + * any required field that has nothing on file yet (those block resubmit). */ export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) { - const { rows, replacements, setReplacement } = flow; - const inputRefs = useRef>({}); + const { files, setting, settingLoading, documents, setDocuments, fieldErrors } = + flow; - if (rows.length === 0) { - return ( - - No documents were submitted on this booking. - - ); - } + // One reference row per distinct doc already on the booking (latest upload). + const onFile = dedupeLatestByCode(files); return ( - - {rows.map((row, i) => { - const replaced = replacements[row.key]; - return ( - - } - /> - { - inputRefs.current[row.key] = el; - }} - type="file" - accept=".pdf,.jpg,.jpeg,.png" - style={{ display: "none" }} - onChange={(e) => - setReplacement(row.key, e.target.files?.[0] ?? null) - } - /> - - - {replaced && ( - setReplacement(row.key, null)} - style={{ color: "#C0392B" }} - > - - - )} + + {onFile.length > 0 && ( + + + Already submitted + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + - - } + } + /> + + + ))} + + )} + + + + Update documents + + + Replace any document you need to change. Documents marked required must + be on file before you can resubmit. + + + {settingLoading ? ( + + + + ) : setting ? ( + - ); - })} - + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + ); } + +type BookingFile = ResubmitFlowController["files"][number]; + +/** Keep one row per code (the most recent upload, i.e. last in the array). */ +function dedupeLatestByCode(files: BookingFile[]): BookingFile[] { + const order: string[] = []; + const latest = new Map(); + for (const file of files) { + if (!latest.has(file.code)) order.push(file.code); + latest.set(file.code, file); + } + return order.map((code) => latest.get(code)!); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts index da5e67fcb..a6a5094bc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts @@ -1,13 +1,13 @@ export { PriceChangeModal } from "./PriceChangeModal"; export { ResubmitBookingModal } from "./ResubmitBookingModal"; export { ResubmitDocuments } from "./ResubmitDocuments"; +export { labelForDocCode, type BookingFile } from "./resubmitDocs"; export { - getResubmitDocRows, - labelForDocCode, - type BookingFile, - type ResubmitDocRow, -} from "./resubmitDocs"; + documentSettingCode, + useBookingDocumentSetting, +} from "./useBookingDocumentSetting"; export { useResubmitFlow, + type DocumentsValue, type ResubmitFlowController, } from "./useResubmitFlow"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts index f4096ddb9..d8b660413 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts @@ -5,24 +5,23 @@ import { REQUIRED_DOC_FIELDS } from "../BookingDetailPage/constants"; /** A single uploaded file on a booking. */ export type BookingFile = NonNullable[number]; -/** A document the customer can replace when resubmitting after changes. */ -export interface ResubmitDocRow { - /** Stable file code used as the multipart field name on update. */ - key: string; - /** Human-readable label shown in the row. */ - label: string; - /** The currently-submitted file for this row. */ - file: BookingFile; -} - -const LABEL_BY_CODE = new Map( - REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label]), -); +/** Human labels for known document codes (shipment + onboarding documents). */ +const LABEL_BY_CODE = new Map([ + ...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const), + // Company onboarding document codes (see file-upload-settings seeder). + ["tin_certificate", "TIN Certificate"], + ["commercial_license", "Commercial License"], + ["business_license", "Business License / Trade License"], + ["investment_license", "Investment License"], + ["national_id", "National ID"], + ["national_id_passport", "National ID / Passport"], + ["passport", "Passport"], +]); /** - * Turn a file `code` (e.g. "commercial_invoice", "custom_172..._0") into a - * human label. Known booking-document codes use their configured label; ad-hoc - * / unknown codes are title-cased from the code itself. + * Turn a file `code` (e.g. "tin_certificate", "commercial_invoice", + * "custom_172..._0") into a human label. Known codes use their configured label; + * ad-hoc / unknown codes are title-cased from the code itself. */ export function labelForDocCode(code: string): string { const known = LABEL_BY_CODE.get(code); @@ -32,32 +31,3 @@ export function labelForDocCode(code: string): string { .replace(/[_-]+/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } - -/** - * Documents to show when a customer is updating a booking that staff returned - * with `CHANGES_REQUESTED`. These are exactly the files the customer submitted - * during the booking process (`booking.files`) — not a fixed required list — so - * the customer updates what they actually provided and resubmits. - * - * The API appends a new file record on every (re)upload without removing the - * old one, so `booking.files` can hold several rows for the same `code`. We show - * one row per code using the most recent upload (the last occurrence in the - * array) while preserving the original first-seen order for stable rendering. - */ -export function getResubmitDocRows( - booking: Pick, -): ResubmitDocRow[] { - const files = booking.files ?? []; - - const order: string[] = []; - const latestByCode = new Map(); - for (const file of files) { - if (!latestByCode.has(file.code)) order.push(file.code); - latestByCode.set(file.code, file); // last write wins → most recent upload - } - - return order.map((code) => { - const file = latestByCode.get(code)!; - return { key: code, label: labelForDocCode(code), file }; - }); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts new file mode 100644 index 000000000..65774f97f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; + +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; + +/** Onboarding document setting code for the company's nationality. */ +export function documentSettingCode( + nationality: string | null | undefined, +): string { + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; +} + +/** + * Fetches the FileUploadSetting that describes the documents a booking requires + * (TIN, license, national ID, passport, …) — the same setting the new-booking + * document step uses, resolved from the company's nationality. + * + * Shared by the resubmit modal and the changes-requested detail view so both + * render an identical document section. + */ +export function useBookingDocumentSetting() { + const auth = useAuth(); + const nationality = auth.company?.company?.nationality as + | string + | null + | undefined; + + return useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode(nationality) }, + }), + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts index 5b2170c54..18d09595b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts @@ -1,50 +1,87 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { api } from "@/services/api"; import type { SubmitBookingResponse } from "@/services/bookings.service"; import type { Freight } from "@edr/types"; -import { getResubmitDocRows } from "./resubmitDocs"; +import { useBookingDocumentSetting } from "./useBookingDocumentSetting"; + +export type DocumentsValue = Record; + +/** True when a SmartFileInput value holds at least one file for a key. */ +function hasFile(value: File | File[] | null | undefined): boolean { + if (!value) return false; + return Array.isArray(value) ? value.length > 0 : true; +} /** * Drives the "update documents and resubmit" flow for a booking that staff - * returned with `CHANGES_REQUESTED`. The document set is the files the customer - * actually submitted (`booking.files`); they may replace any of them, then - * resubmit. Shared by the booking detail page and the home-page modal. + * returned with `CHANGES_REQUESTED`. + * + * The document section mirrors the new-booking step: it's driven by the + * company's onboarding document setting (TIN, license, ID, passport, …) via + * SmartFileInput. Fields already present on the booking are treated as on file; + * any required field with neither an existing file nor a freshly-picked one + * blocks resubmit. + * + * Shared by the booking detail page and the home-page modal. */ export function useResubmitFlow( booking: Freight.IBooking, opts?: { onResubmitted?: () => void }, ) { const queryClient = useQueryClient(); + const settingQuery = useBookingDocumentSetting(); - const rows = useMemo(() => getResubmitDocRows(booking), [booking]); - - // Replacement files keyed by document code; only changed docs are sent. - const [replacements, setReplacements] = useState>( - {}, + // The booking may arrive from the lightweight list endpoint, which omits + // `files`. Fetch the full record so the already-submitted documents (and the + // required-field check that depends on them) are accurate everywhere. + const filesAlreadyLoaded = booking.files !== undefined; + const detailQuery = useQuery( + api.bookings.get.queryOptions({ + input: { id: booking.id }, + enabled: !filesAlreadyLoaded, + }), ); + const detailed = detailQuery.data ?? booking; + const files = detailed.files ?? []; + + // Freshly-selected files keyed by fileKey (SmartFileInput value). + const [documents, setDocuments] = useState({}); const [priceChange, setPriceChange] = useState( null, ); + const [validationError, setValidationError] = useState(""); + // Only surface per-field "required" errors once the user has tried to submit. + const [showErrors, setShowErrors] = useState(false); - const hasReplacements = Object.values(replacements).some(Boolean); + // Codes already attached to the booking from the original submission. + const existingCodes = useMemo( + () => new Set(files.map((f) => f.code)), + [files], + ); + + const fields = settingQuery.data?.fields ?? []; + + // Required fields that have neither an existing file nor a newly-picked one. + const missingRequiredKeys = useMemo(() => { + return fields + .filter((f) => f.isRequired) + .filter( + (f) => !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]), + ) + .map((f) => f.fileKey); + }, [fields, existingCodes, documents]); + + const hasNewFiles = Object.values(documents).some(hasFile); const invalidateLists = () => queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); const updateMutation = useMutation({ - mutationFn: (files: Record) => + mutationFn: (files: DocumentsValue) => api.bookings.update.call({ id: booking.id, dto: {}, documents: files }), - onSuccess: () => { - setReplacements({}); - queryClient.invalidateQueries({ - queryKey: api.bookings.get.queryKey({ id: booking.id }), - }); - invalidateLists(); - opts?.onResubmitted?.(); - }, }); const submitMutation = useMutation({ @@ -74,22 +111,32 @@ export function useResubmitFlow( opts?.onResubmitted?.(); } - const setReplacement = (key: string, file: File | null) => - setReplacements((prev) => ({ ...prev, [key]: file })); - /** - * Upload any replaced documents (if any), then resubmit the booking for - * review. Replacing files is optional — staff may have asked for a non-doc - * change — so an empty replacement set still submits. + * Validate required documents, upload any newly-selected ones, then resubmit + * the booking for review. */ function resubmit() { - const changed: Record = {}; - for (const [key, file] of Object.entries(replacements)) { - if (file) changed[key] = file; + if (missingRequiredKeys.length > 0) { + setShowErrors(true); + setValidationError( + "Please attach all required documents before resubmitting.", + ); + return; } - if (Object.keys(changed).length > 0) { - updateMutation.mutate(changed, { - onSuccess: () => submitMutation.mutate(), + setShowErrors(false); + setValidationError(""); + + const files: DocumentsValue = {}; + for (const [key, value] of Object.entries(documents)) { + if (hasFile(value)) files[key] = value; + } + + if (Object.keys(files).length > 0) { + updateMutation.mutate(files, { + onSuccess: () => { + setDocuments({}); + submitMutation.mutate(); + }, }); } else { submitMutation.mutate(); @@ -97,15 +144,28 @@ export function useResubmitFlow( } const isBusy = + settingQuery.isLoading || updateMutation.isPending || submitMutation.isPending || confirmSubmitMutation.isPending; return { - rows, - replacements, - hasReplacements, - setReplacement, + booking: detailed, + /** Documents already attached to the booking from the original submission. */ + files, + setting: settingQuery.data, + settingLoading: settingQuery.isLoading || detailQuery.isLoading, + existingCodes, + documents, + setDocuments, + hasNewFiles, + missingRequiredKeys, + /** Per-field errors for SmartFileInput; only set after a failed submit. */ + fieldErrors: showErrors + ? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"])) + : {}, + canResubmit: missingRequiredKeys.length === 0, + validationError, resubmit, isBusy, priceChange,