mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & sign
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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} />}>
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user