mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +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,
|
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
</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} />}>
|
||||||
|
|||||||
@@ -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)!);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user