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, Freight.DocumentReviewStatus,
{ label: string; color: string } { label: string; color: string }
> = { > = {
APPROVED: { label: "Approved", color: "edr-blue" }, APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" }, 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) { if (isLoading || !clearance) {
return ( return (
<Group justify="center" py="xl" gap={10}> <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> <Text c="dimmed">Loading clearance</Text>
</Group> </Group>
); );
@@ -165,15 +165,15 @@ export function ClearanceReviewSection({
<Box> <Box>
<Progress <Progress
value={stats.pct} value={stats.pct}
color="edr-blue" color="edr-green"
radius="xl" radius="xl"
size="sm" size="sm"
mb={6} mb={6}
/> />
<Group gap="lg"> <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="red" label="Queried" value={stats.queried} />
<StatPill color="edr-accent" label="Pending" value={stats.pending} /> <StatPill color="gray" label="Pending" value={stats.pending} />
</Group> </Group>
</Box> </Box>
)} )}
@@ -219,13 +219,13 @@ export function ClearanceReviewSection({
icon={Upload} icon={Upload}
title="Customs output documents" title="Customs output documents"
subtitle="Upload the cleared/customs paperwork to hand back to the customer." subtitle="Upload the cleared/customs paperwork to hand back to the customer."
accent="edr-accent" accent="edr-green"
> >
<Stack gap={10}> <Stack gap={10}>
{glDocs.map((doc) => ( {glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap"> <Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> <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> <Text fz="13px" c="edr-text" truncate>
{doc.label} {doc.label}
{doc.required ? " *" : ""} {doc.required ? " *" : ""}
@@ -239,7 +239,7 @@ export function ClearanceReviewSection({
href={doc.file.url} href={doc.file.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
c="edr-accent" c="edr-green"
style={{ display: "flex" }} style={{ display: "flex" }}
> >
<Download size={15} /> <Download size={15} />
@@ -261,7 +261,7 @@ export function ClearanceReviewSection({
{...props} {...props}
size="compact-xs" size="compact-xs"
variant="light" variant="light"
color="edr-accent" color="edr-green"
leftSection={<Upload size={13} />} leftSection={<Upload size={13} />}
> >
{outputFiles[doc.fileKey] ? "Selected" : "Upload"} {outputFiles[doc.fileKey] ? "Selected" : "Upload"}
@@ -275,7 +275,7 @@ export function ClearanceReviewSection({
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button <Button
variant="light" variant="light"
color="edr-accent" color="edr-green"
radius="md" radius="md"
leftSection={<Upload size={15} />} leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0} disabled={Object.keys(outputFiles).length === 0}
@@ -301,7 +301,7 @@ export function ClearanceReviewSection({
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon <ThemeIcon
variant="light" variant="light"
color={clearance.allApproved ? "edr-blue" : "gray"} color={clearance.allApproved ? "edr-green" : "gray"}
radius="md" radius="md"
size={28} size={28}
> >
@@ -314,7 +314,7 @@ export function ClearanceReviewSection({
</Text> </Text>
</Group> </Group>
<Button <Button
color="edr-blue" color="edr-green"
radius="md" radius="md"
leftSection={<CheckCircle2 size={16} />} leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved} disabled={!clearance.allApproved}
@@ -391,7 +391,7 @@ function DocReviewCard({
status === "QUERIED" status === "QUERIED"
? "var(--mantine-color-red-2)" ? "var(--mantine-color-red-2)"
: status === "APPROVED" : status === "APPROVED"
? "var(--mantine-color-edr-blue-2)" ? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)", : "var(--mantine-color-edr-border-6)",
}} }}
> >
@@ -399,7 +399,7 @@ function DocReviewCard({
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon <ThemeIcon
variant="light" variant="light"
color={hasFile ? "edr-blue" : "gray"} color={hasFile ? "edr-green" : "gray"}
radius="md" radius="md"
size={40} size={40}
> >
@@ -471,7 +471,7 @@ function DocReviewCard({
</Button> </Button>
<Button <Button
size="compact-sm" size="compact-sm"
color="edr-blue" color="edr-green"
radius="md" radius="md"
leftSection={<CheckCircle2 size={14} />} leftSection={<CheckCircle2 size={14} />}
disabled={busy} disabled={busy}

View File

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

View File

@@ -15,6 +15,7 @@ import {
Text, Text,
TextInput, TextInput,
ThemeIcon, ThemeIcon,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import {
ArrowRight, 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() { export default function DocumentClearanceListPage() {
@@ -204,15 +226,8 @@ export default function DocumentClearanceListPage() {
{r.destinationLabel} {r.destinationLabel}
</Text> </Text>
</Group> </Group>
<Group gap={6}> <Group gap={8} align="center">
<Badge <DirectionIcon direction={r.tradeDirection} />
size="xs"
variant="light"
radius="sm"
color={directionColor(r.tradeDirection)}
>
{r.tradeDirection}
</Badge>
<Badge size="xs" variant="default" radius="sm"> <Badge size="xs" variant="default" radius="sm">
{r.freightType} {r.freightType}
</Badge> </Badge>
@@ -235,7 +250,7 @@ export default function DocumentClearanceListPage() {
id: "status", id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>, header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: () => ( cell: () => (
<Badge size="sm" variant="light" color="edr-blue" radius="sm"> <Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review Under review
</Badge> </Badge>
), ),
@@ -296,13 +311,13 @@ export default function DocumentClearanceListPage() {
label: "Import", label: "Import",
value: tabCounts.import, value: tabCounts.import,
icon: Truck, icon: Truck,
color: "edr-blue", color: "edr-green",
}, },
{ {
label: "Export", label: "Export",
value: tabCounts.export, value: tabCounts.export,
icon: ShipWheel, icon: ShipWheel,
color: "edr-accent", color: "gray",
}, },
]} ]}
/> />
@@ -528,7 +543,7 @@ function ClearanceCard({
</Group> </Group>
</Box> </Box>
</Group> </Group>
<Badge size="sm" variant="light" color="edr-blue" radius="sm"> <Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review Under review
</Badge> </Badge>
</Group> </Group>
@@ -554,28 +569,23 @@ function ClearanceCard({
</Box> </Box>
<Group justify="space-between" mt="md" wrap="nowrap"> <Group justify="space-between" mt="md" wrap="nowrap">
<Group gap={6} wrap="nowrap"> <Group gap={8} wrap="nowrap">
<Badge <DirectionIcon direction={row.tradeDirection} />
size="xs"
variant="light"
radius="sm"
color={directionColor(row.tradeDirection)}
>
{row.tradeDirection}
</Badge>
<Badge size="xs" variant="default" radius="sm"> <Badge size="xs" variant="default" radius="sm">
{row.freightType} {row.freightType}
</Badge> </Badge>
{row.hasCustoms ? ( {row.hasCustoms ? (
<Badge <Tooltip label="Customs clearance" withArrow>
size="xs" <ThemeIcon
variant="light" variant="light"
color="edr-green" color="edr-green"
radius="sm" radius="md"
leftSection={<ShieldCheck size={11} />} size={28}
> aria-label="Customs clearance"
Customs >
</Badge> <ShieldCheck size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
) : null} ) : null}
</Group> </Group>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">

View File

@@ -5,6 +5,10 @@ import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton"; import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction"; import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
interface BookingRowProps { interface BookingRowProps {
booking: any; booking: any;
@@ -28,6 +32,9 @@ export const BookingRow = memo(function BookingRow({
// Clearance/operation steps + changes-requested resubmit can be done in place // Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row. // via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking); 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 origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest = const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -90,6 +97,8 @@ export const BookingRow = memo(function BookingRow({
</Group> </Group>
{canPay ? ( {canPay ? (
<PayNowButton booking={booking} size="sm" /> <PayNowButton booking={booking} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : hasInlineAction ? ( ) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" /> <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 { useMutation, useQuery } from "@tanstack/react-query";
import { Send, XCircle } from "lucide-react"; import { AlertCircle, Send, XCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -91,12 +99,23 @@ export function ChangesRequestedView({
<CardTitle>Your documents</CardTitle> <CardTitle>Your documents</CardTitle>
</Group> </Group>
<Text fz="12.5px" c="#6B7C8E" mb="sm"> <Text fz="12.5px" c="#6B7C8E" mb="sm">
These are the documents you submitted for this booking. Replace Update the documents for this booking, then resubmit for review.
any you need to update, then resubmit for review. Replace any that changed and attach any that are still required.
</Text> </Text>
<ResubmitDocuments flow={flow} /> <ResubmitDocuments flow={flow} />
{flow.validationError && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={16} />}
mt="md"
>
{flow.validationError}
</Alert>
)}
<Button <Button
fullWidth fullWidth
mt="lg" 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 { useDisclosure } from "@mantine/hooks";
import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react"; import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react";
@@ -51,7 +51,11 @@ export function BookingActionButton({
const label = action ? action.label : "Update & resubmit"; const label = action ? action.label : "Update & resubmit";
return ( 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 <Button
size={size} size={size}
radius="md" radius="md"
@@ -60,7 +64,6 @@ export function BookingActionButton({
color="edr-green" color="edr-green"
leftSection={<Icon size={14} />} leftSection={<Icon size={14} />}
onClick={(e) => { onClick={(e) => {
// Don't let the surrounding row-click handler fire.
e.stopPropagation(); e.stopPropagation();
open(); open();
}} }}
@@ -77,6 +80,6 @@ export function BookingActionButton({
) : ( ) : (
<BookingActionModal booking={booking} opened={opened} onClose={close} /> <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> </Alert>
)} )}
<Box> <ResubmitDocuments flow={flow} />
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Your documents {flow.validationError && (
</Text> <Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
<Text fz="12px" c="dimmed" mb="xs"> {flow.validationError}
Replace any document you need to update, then resubmit. </Alert>
</Text> )}
<ResubmitDocuments flow={flow} />
</Box>
{flow.mutations.some((m) => m.isError) && ( {flow.mutations.some((m) => m.isError) && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}> <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 { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { Download, Upload, X } from "lucide-react"; import { SmartFileInput } from "@edr/ui-common";
import { useRef } from "react"; 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"; import type { ResubmitFlowController } from "./useResubmitFlow";
/** /**
* Document list for resubmitting a CHANGES_REQUESTED booking. Renders exactly * Document section for resubmitting a CHANGES_REQUESTED booking.
* the files the customer originally submitted (`booking.files`, surfaced through
* the flow controller) and lets them replace any of them before resubmitting.
* *
* No fixed required list and no "X of N" gate — the customer updates what they * Mirrors the new-booking document step: the fields come from the company's
* actually provided. * 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 }) { export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
const { rows, replacements, setReplacement } = flow; const { files, setting, settingLoading, documents, setDocuments, fieldErrors } =
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({}); flow;
if (rows.length === 0) { // One reference row per distinct doc already on the booking (latest upload).
return ( const onFile = dedupeLatestByCode(files);
<Text fz="13px" c="dimmed" py="sm">
No documents were submitted on this booking.
</Text>
);
}
return ( return (
<Box> <Stack gap="lg">
{rows.map((row, i) => { {onFile.length > 0 && (
const replaced = replacements[row.key]; <Stack gap={8}>
return ( <Text fz={13} fw={700} c="#10202F">
<DocRow Already submitted
key={row.key} </Text>
last={i === rows.length - 1} {onFile.map((file) => (
title={row.label} <Group
meta={replaced ? replaced.name : (row.file.name ?? "Submitted")} key={file.id}
status={replaced ? "ready" : "verified"} gap={12}
action={ align="center"
<> wrap="nowrap"
<IconSquare className="rounded-xl"
href={row.file.signedUrl ?? row.file.url} style={{ border: "1px solid #E6ECF2", padding: "10px 14px" }}
icon={<Download size={16} />} >
/> <Box
<input style={{
ref={(el) => { flexShrink: 0,
inputRefs.current[row.key] = el; width: 34,
}} height: 34,
type="file" display: "flex",
accept=".pdf,.jpg,.jpeg,.png" alignItems: "center",
style={{ display: "none" }} justifyContent: "center",
onChange={(e) => borderRadius: 9,
setReplacement(row.key, e.target.files?.[0] ?? null) backgroundColor: "#EAF1FB",
} color: "#2E5B96",
/> }}
<Group gap={6} wrap="nowrap"> >
<Button <FileText size={17} />
variant="white" </Box>
radius={9} <Box style={{ minWidth: 0, flex: 1 }}>
leftSection={<Upload size={16} color="#334155" />} <Text fz="13px" fw={600} c="#10202F" truncate>
onClick={() => inputRefs.current[row.key]?.click()} {labelForDocCode(file.code)}
styles={{ </Text>
root: { <Text fz="12px" c="dimmed" truncate>
height: 34, {file.name}
paddingInline: 13, </Text>
border: "1.5px solid #CBD5E1", </Box>
}, <Group gap={8} wrap="nowrap">
label: { <Group gap={5} c="#0A6F4D">
fontSize: 12.5, <CheckCircle2 size={14} />
fontWeight: 700, <Text fz="11.5px" fw={600} c="#0A6F4D">
color: "#334155", On file
}, </Text>
}}
>
{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>
)}
</Group> </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}
/> />
); ) : (
})} <Text fz="13px" c="dimmed">
</Box> 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 { PriceChangeModal } from "./PriceChangeModal";
export { ResubmitBookingModal } from "./ResubmitBookingModal"; export { ResubmitBookingModal } from "./ResubmitBookingModal";
export { ResubmitDocuments } from "./ResubmitDocuments"; export { ResubmitDocuments } from "./ResubmitDocuments";
export { labelForDocCode, type BookingFile } from "./resubmitDocs";
export { export {
getResubmitDocRows, documentSettingCode,
labelForDocCode, useBookingDocumentSetting,
type BookingFile, } from "./useBookingDocumentSetting";
type ResubmitDocRow,
} from "./resubmitDocs";
export { export {
useResubmitFlow, useResubmitFlow,
type DocumentsValue,
type ResubmitFlowController, type ResubmitFlowController,
} from "./useResubmitFlow"; } from "./useResubmitFlow";

View File

@@ -5,24 +5,23 @@ import { REQUIRED_DOC_FIELDS } from "../BookingDetailPage/constants";
/** A single uploaded file on a booking. */ /** A single uploaded file on a booking. */
export type BookingFile = NonNullable<Freight.IBooking["files"]>[number]; export type BookingFile = NonNullable<Freight.IBooking["files"]>[number];
/** A document the customer can replace when resubmitting after changes. */ /** Human labels for known document codes (shipment + onboarding documents). */
export interface ResubmitDocRow { const LABEL_BY_CODE = new Map<string, string>([
/** Stable file code used as the multipart field name on update. */ ...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
key: string; // Company onboarding document codes (see file-upload-settings seeder).
/** Human-readable label shown in the row. */ ["tin_certificate", "TIN Certificate"],
label: string; ["commercial_license", "Commercial License"],
/** The currently-submitted file for this row. */ ["business_license", "Business License / Trade License"],
file: BookingFile; ["investment_license", "Investment License"],
} ["national_id", "National ID"],
["national_id_passport", "National ID / Passport"],
const LABEL_BY_CODE = new Map( ["passport", "Passport"],
REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label]), ]);
);
/** /**
* Turn a file `code` (e.g. "commercial_invoice", "custom_172..._0") into a * Turn a file `code` (e.g. "tin_certificate", "commercial_invoice",
* human label. Known booking-document codes use their configured label; ad-hoc * "custom_172..._0") into a human label. Known codes use their configured label;
* / unknown codes are title-cased from the code itself. * ad-hoc / unknown codes are title-cased from the code itself.
*/ */
export function labelForDocCode(code: string): string { export function labelForDocCode(code: string): string {
const known = LABEL_BY_CODE.get(code); const known = LABEL_BY_CODE.get(code);
@@ -32,32 +31,3 @@ export function labelForDocCode(code: string): string {
.replace(/[_-]+/g, " ") .replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()); .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 { useMemo, useState } from "react";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service"; import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types"; 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 * Drives the "update documents and resubmit" flow for a booking that staff
* returned with `CHANGES_REQUESTED`. The document set is the files the customer * returned with `CHANGES_REQUESTED`.
* actually submitted (`booking.files`); they may replace any of them, then *
* resubmit. Shared by the booking detail page and the home-page modal. * 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( export function useResubmitFlow(
booking: Freight.IBooking, booking: Freight.IBooking,
opts?: { onResubmitted?: () => void }, opts?: { onResubmitted?: () => void },
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const settingQuery = useBookingDocumentSetting();
const rows = useMemo(() => getResubmitDocRows(booking), [booking]); // The booking may arrive from the lightweight list endpoint, which omits
// `files`. Fetch the full record so the already-submitted documents (and the
// Replacement files keyed by document code; only changed docs are sent. // required-field check that depends on them) are accurate everywhere.
const [replacements, setReplacements] = useState<Record<string, File | null>>( 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>( const [priceChange, setPriceChange] = useState<SubmitBookingResponse | null>(
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 = () => const invalidateLists = () =>
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (files: Record<string, File | null>) => mutationFn: (files: DocumentsValue) =>
api.bookings.update.call({ id: booking.id, dto: {}, documents: files }), 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({ const submitMutation = useMutation({
@@ -74,22 +111,32 @@ export function useResubmitFlow(
opts?.onResubmitted?.(); 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 * Validate required documents, upload any newly-selected ones, then resubmit
* review. Replacing files is optional — staff may have asked for a non-doc * the booking for review.
* change — so an empty replacement set still submits.
*/ */
function resubmit() { function resubmit() {
const changed: Record<string, File | null> = {}; if (missingRequiredKeys.length > 0) {
for (const [key, file] of Object.entries(replacements)) { setShowErrors(true);
if (file) changed[key] = file; setValidationError(
"Please attach all required documents before resubmitting.",
);
return;
} }
if (Object.keys(changed).length > 0) { setShowErrors(false);
updateMutation.mutate(changed, { setValidationError("");
onSuccess: () => submitMutation.mutate(),
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 { } else {
submitMutation.mutate(); submitMutation.mutate();
@@ -97,15 +144,28 @@ export function useResubmitFlow(
} }
const isBusy = const isBusy =
settingQuery.isLoading ||
updateMutation.isPending || updateMutation.isPending ||
submitMutation.isPending || submitMutation.isPending ||
confirmSubmitMutation.isPending; confirmSubmitMutation.isPending;
return { return {
rows, booking: detailed,
replacements, /** Documents already attached to the booking from the original submission. */
hasReplacements, files,
setReplacement, 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, resubmit,
isBusy, isBusy,
priceChange, priceChange,