Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-25 06:17:38 +03:00
13 changed files with 439 additions and 246 deletions

View File

@@ -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 (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-blue" />
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
);
@@ -165,15 +165,15 @@ export function ClearanceReviewSection({
<Box>
<Progress
value={stats.pct}
color="edr-blue"
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-blue" label="Approved" value={stats.approved} />
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="edr-accent" label="Pending" value={stats.pending} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
@@ -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"
>
<Stack gap={10}>
{glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-accent-6)" />
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{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" }}
>
<Download size={15} />
@@ -261,7 +261,7 @@ export function ClearanceReviewSection({
{...props}
size="compact-xs"
variant="light"
color="edr-accent"
color="edr-green"
leftSection={<Upload size={13} />}
>
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
@@ -275,7 +275,7 @@ export function ClearanceReviewSection({
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-accent"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
@@ -301,7 +301,7 @@ export function ClearanceReviewSection({
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-blue" : "gray"}
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
@@ -314,7 +314,7 @@ export function ClearanceReviewSection({
</Text>
</Group>
<Button
color="edr-blue"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
@@ -391,7 +391,7 @@ function DocReviewCard({
status === "QUERIED"
? "var(--mantine-color-red-2)"
: status === "APPROVED"
? "var(--mantine-color-edr-blue-2)"
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)",
}}
>
@@ -399,7 +399,7 @@ function DocReviewCard({
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-blue" : "gray"}
color={hasFile ? "edr-green" : "gray"}
radius="md"
size={40}
>
@@ -471,7 +471,7 @@ function DocReviewCard({
</Button>
<Button
size="compact-sm"
color="edr-blue"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}

View File

@@ -64,7 +64,7 @@ export default function DocumentClearanceDetailPage() {
return (
<PageContainer>
<Group justify="center" py={80} gap={10}>
<Loader color="edr-blue" />
<Loader color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</PageContainer>
@@ -103,7 +103,7 @@ export default function DocumentClearanceDetailPage() {
clearance.allApproved ? (
<Badge
variant="light"
color="edr-blue"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
@@ -112,7 +112,7 @@ export default function DocumentClearanceDetailPage() {
) : (
<Badge
variant="light"
color="edr-accent"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
@@ -133,13 +133,17 @@ export default function DocumentClearanceDetailPage() {
{/* RIGHT — sticky progress gauge */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard icon={PackageCheck} title="Review progress" accent="blue">
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-blue" }]}
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
@@ -153,7 +157,7 @@ export default function DocumentClearanceDetailPage() {
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-blue"
color="edr-green"
label="Approved"
value={stats.approved}
/>
@@ -163,7 +167,7 @@ export default function DocumentClearanceDetailPage() {
value={stats.queried}
/>
<ProgressStat
color="edr-accent"
color="gray"
label="Pending"
value={stats.pending}
/>
@@ -199,7 +203,7 @@ function ClearanceHero({
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-blue" radius="md" size={52}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
@@ -210,7 +214,7 @@ function ClearanceHero({
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-blue" : "edr-accent"}
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
@@ -219,7 +223,7 @@ function ClearanceHero({
<Badge
size="sm"
variant="light"
color="edr-accent"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
@@ -248,7 +252,7 @@ function ClearanceHero({
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-blue" radius="xl" size="md" />
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>

View File

@@ -15,6 +15,7 @@ import {
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
ArrowRight,
@@ -101,8 +102,29 @@ function formatDate(iso?: string): string {
});
}
function directionColor(direction: string): string {
return direction === "IMPORT" ? "edr-blue" : "edr-accent";
/**
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
* for export — on a light background, matching the "awaiting review" badge
* styling. Keeps the cards within the white / light-gray / green palette and
* drops the text label in favour of a tooltip.
*/
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={28}
aria-label={label}
>
<Icon size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
);
}
export default function DocumentClearanceListPage() {
@@ -204,15 +226,8 @@ export default function DocumentClearanceListPage() {
{r.destinationLabel}
</Text>
</Group>
<Group gap={6}>
<Badge
size="xs"
variant="light"
radius="sm"
color={directionColor(r.tradeDirection)}
>
{r.tradeDirection}
</Badge>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{r.freightType}
</Badge>
@@ -235,7 +250,7 @@ export default function DocumentClearanceListPage() {
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: () => (
<Badge size="sm" variant="light" color="edr-blue" radius="sm">
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
),
@@ -296,13 +311,13 @@ export default function DocumentClearanceListPage() {
label: "Import",
value: tabCounts.import,
icon: Truck,
color: "edr-blue",
color: "edr-green",
},
{
label: "Export",
value: tabCounts.export,
icon: ShipWheel,
color: "edr-accent",
color: "gray",
},
]}
/>
@@ -528,7 +543,7 @@ function ClearanceCard({
</Group>
</Box>
</Group>
<Badge size="sm" variant="light" color="edr-blue" radius="sm">
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
</Group>
@@ -554,28 +569,23 @@ function ClearanceCard({
</Box>
<Group justify="space-between" mt="md" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<Badge
size="xs"
variant="light"
radius="sm"
color={directionColor(row.tradeDirection)}
>
{row.tradeDirection}
</Badge>
<Group gap={8} wrap="nowrap">
<DirectionIcon direction={row.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{row.freightType}
</Badge>
{row.hasCustoms ? (
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={11} />}
>
Customs
</Badge>
<Tooltip label="Customs clearance" withArrow>
<ThemeIcon
variant="light"
color="edr-green"
radius="md"
size={28}
aria-label="Customs clearance"
>
<ShieldCheck size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Group gap={4} wrap="nowrap">

View File

@@ -5,6 +5,10 @@ import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
interface BookingRowProps {
booking: any;
@@ -28,6 +32,9 @@ export const BookingRow = memo(function BookingRow({
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -90,6 +97,8 @@ export const BookingRow = memo(function BookingRow({
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" />
) : (

View File

@@ -1,6 +1,14 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
import {
Alert,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Send, XCircle } from "lucide-react";
import { AlertCircle, Send, XCircle } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -91,12 +99,23 @@ export function ChangesRequestedView({
<CardTitle>Your documents</CardTitle>
</Group>
<Text fz="12.5px" c="#6B7C8E" mb="sm">
These are the documents you submitted for this booking. Replace
any you need to update, then resubmit for review.
Update the documents for this booking, then resubmit for review.
Replace any that changed and attach any that are still required.
</Text>
<ResubmitDocuments flow={flow} />
{flow.validationError && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={16} />}
mt="md"
>
{flow.validationError}
</Alert>
)}
<Button
fullWidth
mt="lg"

View File

@@ -1,4 +1,4 @@
import { Button } from "@mantine/core";
import { Box, Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react";
@@ -51,7 +51,11 @@ export function BookingActionButton({
const label = action ? action.label : "Update & resubmit";
return (
<>
// Mantine modals portal to <body>, but React events still bubble through
// the React tree to this button's ancestors — including the clickable list
// row. Stop click propagation here so interacting with the modal never
// triggers the row's navigate-to-detail handler.
<Box component="span" onClick={(e) => e.stopPropagation()}>
<Button
size={size}
radius="md"
@@ -60,7 +64,6 @@ export function BookingActionButton({
color="edr-green"
leftSection={<Icon size={14} />}
onClick={(e) => {
// Don't let the surrounding row-click handler fire.
e.stopPropagation();
open();
}}
@@ -77,6 +80,6 @@ export function BookingActionButton({
) : (
<BookingActionModal booking={booking} opened={opened} onClose={close} />
)}
</>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import { Button } from "@mantine/core";
import { FileSignature } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
/**
* Statuses where the contract is ready for the customer to review and sign.
* These are the only states where {@link ContractSignButton} renders — once the
* customer has signed, the row falls back to its normal "View" action.
*/
const SIGNABLE_STATUSES = ["CONTRACT_READY", "APPROVED_PENDING_SIGNATURE"];
export function bookingIsSignable(
booking: Pick<Freight.IBooking, "status">,
): boolean {
return SIGNABLE_STATUSES.includes(booking.status as string);
}
interface ContractSignButtonProps {
booking: Freight.IBooking;
size?: "xs" | "sm";
}
/**
* Self-contained "View & Sign" trigger for a My Shipments row. Renders nothing
* unless the booking's contract is ready to be signed; otherwise shows a button
* that navigates to the full-page contract viewer ({@link BookingContractPage})
* where the signature flow lives.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function ContractSignButton({
booking,
size = "sm",
}: ContractSignButtonProps) {
const navigate = useNavigate();
if (!bookingIsSignable(booking)) return null;
return (
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
leftSection={<FileSignature size={14} />}
onClick={(e) => {
// Don't let the surrounding row-click handler fire.
e.stopPropagation();
navigate(`/bookings/${booking.id}/contract`);
}}
>
View &amp; sign
</Button>
);
}

View File

@@ -72,15 +72,13 @@ function ResubmitBookingModalBody({
</Alert>
)}
<Box>
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Your documents
</Text>
<Text fz="12px" c="dimmed" mb="xs">
Replace any document you need to update, then resubmit.
</Text>
<ResubmitDocuments flow={flow} />
</Box>
<ResubmitDocuments flow={flow} />
{flow.validationError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{flow.validationError}
</Alert>
)}
{flow.mutations.some((m) => m.isError) && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>

View File

@@ -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<Record<string, HTMLInputElement | null>>({});
const { files, setting, settingLoading, documents, setDocuments, fieldErrors } =
flow;
if (rows.length === 0) {
return (
<Text fz="13px" c="dimmed" py="sm">
No documents were submitted on this booking.
</Text>
);
}
// One reference row per distinct doc already on the booking (latest upload).
const onFile = dedupeLatestByCode(files);
return (
<Box>
{rows.map((row, i) => {
const replaced = replacements[row.key];
return (
<DocRow
key={row.key}
last={i === rows.length - 1}
title={row.label}
meta={replaced ? replaced.name : (row.file.name ?? "Submitted")}
status={replaced ? "ready" : "verified"}
action={
<>
<IconSquare
href={row.file.signedUrl ?? row.file.url}
icon={<Download size={16} />}
/>
<input
ref={(el) => {
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)
}
/>
<Group gap={6} wrap="nowrap">
<Button
variant="white"
radius={9}
leftSection={<Upload size={16} color="#334155" />}
onClick={() => inputRefs.current[row.key]?.click()}
styles={{
root: {
height: 34,
paddingInline: 13,
border: "1.5px solid #CBD5E1",
},
label: {
fontSize: 12.5,
fontWeight: 700,
color: "#334155",
},
}}
>
{replaced ? "Change" : "Replace"}
</Button>
{replaced && (
<ActionIcon
variant="default"
radius={8}
w={34}
h={34}
onClick={() => setReplacement(row.key, null)}
style={{ color: "#C0392B" }}
>
<X size={15} />
</ActionIcon>
)}
<Stack gap="lg">
{onFile.length > 0 && (
<Stack gap={8}>
<Text fz={13} fw={700} c="#10202F">
Already submitted
</Text>
{onFile.map((file) => (
<Group
key={file.id}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: "10px 14px" }}
>
<Box
style={{
flexShrink: 0,
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="13px" fw={600} c="#10202F" truncate>
{labelForDocCode(file.code)}
</Text>
<Text fz="12px" c="dimmed" truncate>
{file.name}
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c="#0A6F4D">
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c="#0A6F4D">
On file
</Text>
</Group>
</>
}
<IconSquare
href={file.signedUrl ?? file.url}
icon={<Download size={15} />}
/>
</Group>
</Group>
))}
</Stack>
)}
<Box>
<Text fz={13} fw={700} c="#10202F" mb="xs">
Update documents
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Replace any document you need to change. Documents marked required must
be on file before you can resubmit.
</Text>
{settingLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : setting ? (
<SmartFileInput
file={setting}
value={documents}
onChange={setDocuments}
errors={fieldErrors}
/>
);
})}
</Box>
) : (
<Text fz="13px" c="dimmed">
No document requirements are configured for your account. You can
resubmit using the documents already on file.
</Text>
)}
</Box>
</Stack>
);
}
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<string, BookingFile>();
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)!);
}

View File

@@ -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";

View File

@@ -5,24 +5,23 @@ import { REQUIRED_DOC_FIELDS } from "../BookingDetailPage/constants";
/** A single uploaded file on a booking. */
export type BookingFile = NonNullable<Freight.IBooking["files"]>[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<string, string>([
...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<Freight.IBooking, "files">,
): ResubmitDocRow[] {
const files = booking.files ?? [];
const order: string[] = [];
const latestByCode = new Map<string, BookingFile>();
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 };
});
}

View File

@@ -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) },
}),
);
}

View File

@@ -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<string, File | File[] | null>;
/** 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<Record<string, File | null>>(
{},
// 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<DocumentsValue>({});
const [priceChange, setPriceChange] = useState<SubmitBookingResponse | null>(
null,
);
const [validationError, setValidationError] = useState<string>("");
// 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<string, File | null>) =>
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<string, File | null> = {};
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,