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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,97 +1,123 @@
import { ActionIcon, Box, Button, Group, Text } from "@mantine/core";
import { Download, Upload, X } from "lucide-react";
import { useRef } from "react";
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, Download, FileText } from "lucide-react";
import { DocRow, IconSquare } from "../BookingDetailPage/components/Documents";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { labelForDocCode } from "./resubmitDocs";
import type { ResubmitFlowController } from "./useResubmitFlow";
/**
* Document list for resubmitting a CHANGES_REQUESTED booking. Renders exactly
* the files the customer originally submitted (`booking.files`, surfaced through
* the flow controller) and lets them replace any of them before resubmitting.
* Document section for resubmitting a CHANGES_REQUESTED booking.
*
* No fixed required list and no "X of N" gate — the customer updates what they
* actually provided.
* Mirrors the new-booking document step: the fields come from the company's
* onboarding document setting (TIN, license, ID, passport, …) rendered via
* SmartFileInput. Documents already submitted on the booking are shown as an
* "on file" reference; the customer uploads here only to replace one, or to fill
* any required field that has nothing on file yet (those block resubmit).
*/
export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
const { rows, replacements, setReplacement } = flow;
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const { files, setting, settingLoading, documents, setDocuments, fieldErrors } =
flow;
if (rows.length === 0) {
return (
<Text fz="13px" c="dimmed" py="sm">
No documents were submitted on this booking.
</Text>
);
}
// One reference row per distinct doc already on the booking (latest upload).
const onFile = dedupeLatestByCode(files);
return (
<Box>
{rows.map((row, i) => {
const replaced = replacements[row.key];
return (
<DocRow
key={row.key}
last={i === rows.length - 1}
title={row.label}
meta={replaced ? replaced.name : (row.file.name ?? "Submitted")}
status={replaced ? "ready" : "verified"}
action={
<>
<IconSquare
href={row.file.signedUrl ?? row.file.url}
icon={<Download size={16} />}
/>
<input
ref={(el) => {
inputRefs.current[row.key] = el;
}}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
style={{ display: "none" }}
onChange={(e) =>
setReplacement(row.key, e.target.files?.[0] ?? null)
}
/>
<Group gap={6} wrap="nowrap">
<Button
variant="white"
radius={9}
leftSection={<Upload size={16} color="#334155" />}
onClick={() => inputRefs.current[row.key]?.click()}
styles={{
root: {
height: 34,
paddingInline: 13,
border: "1.5px solid #CBD5E1",
},
label: {
fontSize: 12.5,
fontWeight: 700,
color: "#334155",
},
}}
>
{replaced ? "Change" : "Replace"}
</Button>
{replaced && (
<ActionIcon
variant="default"
radius={8}
w={34}
h={34}
onClick={() => setReplacement(row.key, null)}
style={{ color: "#C0392B" }}
>
<X size={15} />
</ActionIcon>
)}
<Stack gap="lg">
{onFile.length > 0 && (
<Stack gap={8}>
<Text fz={13} fw={700} c="#10202F">
Already submitted
</Text>
{onFile.map((file) => (
<Group
key={file.id}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: "10px 14px" }}
>
<Box
style={{
flexShrink: 0,
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="13px" fw={600} c="#10202F" truncate>
{labelForDocCode(file.code)}
</Text>
<Text fz="12px" c="dimmed" truncate>
{file.name}
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c="#0A6F4D">
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c="#0A6F4D">
On file
</Text>
</Group>
</>
}
<IconSquare
href={file.signedUrl ?? file.url}
icon={<Download size={15} />}
/>
</Group>
</Group>
))}
</Stack>
)}
<Box>
<Text fz={13} fw={700} c="#10202F" mb="xs">
Update documents
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Replace any document you need to change. Documents marked required must
be on file before you can resubmit.
</Text>
{settingLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : setting ? (
<SmartFileInput
file={setting}
value={documents}
onChange={setDocuments}
errors={fieldErrors}
/>
);
})}
</Box>
) : (
<Text fz="13px" c="dimmed">
No document requirements are configured for your account. You can
resubmit using the documents already on file.
</Text>
)}
</Box>
</Stack>
);
}
type BookingFile = ResubmitFlowController["files"][number];
/** Keep one row per code (the most recent upload, i.e. last in the array). */
function dedupeLatestByCode(files: BookingFile[]): BookingFile[] {
const order: string[] = [];
const latest = new Map<string, BookingFile>();
for (const file of files) {
if (!latest.has(file.code)) order.push(file.code);
latest.set(file.code, file);
}
return order.map((code) => latest.get(code)!);
}

View File

@@ -1,13 +1,13 @@
export { PriceChangeModal } from "./PriceChangeModal";
export { ResubmitBookingModal } from "./ResubmitBookingModal";
export { ResubmitDocuments } from "./ResubmitDocuments";
export { labelForDocCode, type BookingFile } from "./resubmitDocs";
export {
getResubmitDocRows,
labelForDocCode,
type BookingFile,
type ResubmitDocRow,
} from "./resubmitDocs";
documentSettingCode,
useBookingDocumentSetting,
} from "./useBookingDocumentSetting";
export {
useResubmitFlow,
type DocumentsValue,
type ResubmitFlowController,
} from "./useResubmitFlow";

View File

@@ -5,24 +5,23 @@ import { REQUIRED_DOC_FIELDS } from "../BookingDetailPage/constants";
/** A single uploaded file on a booking. */
export type BookingFile = NonNullable<Freight.IBooking["files"]>[number];
/** A document the customer can replace when resubmitting after changes. */
export interface ResubmitDocRow {
/** Stable file code used as the multipart field name on update. */
key: string;
/** Human-readable label shown in the row. */
label: string;
/** The currently-submitted file for this row. */
file: BookingFile;
}
const LABEL_BY_CODE = new Map(
REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label]),
);
/** Human labels for known document codes (shipment + onboarding documents). */
const LABEL_BY_CODE = new Map<string, string>([
...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
// Company onboarding document codes (see file-upload-settings seeder).
["tin_certificate", "TIN Certificate"],
["commercial_license", "Commercial License"],
["business_license", "Business License / Trade License"],
["investment_license", "Investment License"],
["national_id", "National ID"],
["national_id_passport", "National ID / Passport"],
["passport", "Passport"],
]);
/**
* Turn a file `code` (e.g. "commercial_invoice", "custom_172..._0") into a
* human label. Known booking-document codes use their configured label; ad-hoc
* / unknown codes are title-cased from the code itself.
* Turn a file `code` (e.g. "tin_certificate", "commercial_invoice",
* "custom_172..._0") into a human label. Known codes use their configured label;
* ad-hoc / unknown codes are title-cased from the code itself.
*/
export function labelForDocCode(code: string): string {
const known = LABEL_BY_CODE.get(code);
@@ -32,32 +31,3 @@ export function labelForDocCode(code: string): string {
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Documents to show when a customer is updating a booking that staff returned
* with `CHANGES_REQUESTED`. These are exactly the files the customer submitted
* during the booking process (`booking.files`) — not a fixed required list — so
* the customer updates what they actually provided and resubmits.
*
* The API appends a new file record on every (re)upload without removing the
* old one, so `booking.files` can hold several rows for the same `code`. We show
* one row per code using the most recent upload (the last occurrence in the
* array) while preserving the original first-seen order for stable rendering.
*/
export function getResubmitDocRows(
booking: Pick<Freight.IBooking, "files">,
): ResubmitDocRow[] {
const files = booking.files ?? [];
const order: string[] = [];
const latestByCode = new Map<string, BookingFile>();
for (const file of files) {
if (!latestByCode.has(file.code)) order.push(file.code);
latestByCode.set(file.code, file); // last write wins → most recent upload
}
return order.map((code) => {
const file = latestByCode.get(code)!;
return { key: code, label: labelForDocCode(code), file };
});
}

View File

@@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
/** Onboarding document setting code for the company's nationality. */
export function documentSettingCode(
nationality: string | null | undefined,
): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* Fetches the FileUploadSetting that describes the documents a booking requires
* (TIN, license, national ID, passport, …) — the same setting the new-booking
* document step uses, resolved from the company's nationality.
*
* Shared by the resubmit modal and the changes-requested detail view so both
* render an identical document section.
*/
export function useBookingDocumentSetting() {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
return useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
}

View File

@@ -1,50 +1,87 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { getResubmitDocRows } from "./resubmitDocs";
import { useBookingDocumentSetting } from "./useBookingDocumentSetting";
export type DocumentsValue = Record<string, File | File[] | null>;
/** True when a SmartFileInput value holds at least one file for a key. */
function hasFile(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
/**
* Drives the "update documents and resubmit" flow for a booking that staff
* returned with `CHANGES_REQUESTED`. The document set is the files the customer
* actually submitted (`booking.files`); they may replace any of them, then
* resubmit. Shared by the booking detail page and the home-page modal.
* returned with `CHANGES_REQUESTED`.
*
* The document section mirrors the new-booking step: it's driven by the
* company's onboarding document setting (TIN, license, ID, passport, …) via
* SmartFileInput. Fields already present on the booking are treated as on file;
* any required field with neither an existing file nor a freshly-picked one
* blocks resubmit.
*
* Shared by the booking detail page and the home-page modal.
*/
export function useResubmitFlow(
booking: Freight.IBooking,
opts?: { onResubmitted?: () => void },
) {
const queryClient = useQueryClient();
const settingQuery = useBookingDocumentSetting();
const rows = useMemo(() => getResubmitDocRows(booking), [booking]);
// Replacement files keyed by document code; only changed docs are sent.
const [replacements, setReplacements] = useState<Record<string, File | null>>(
{},
// The booking may arrive from the lightweight list endpoint, which omits
// `files`. Fetch the full record so the already-submitted documents (and the
// required-field check that depends on them) are accurate everywhere.
const filesAlreadyLoaded = booking.files !== undefined;
const detailQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: booking.id },
enabled: !filesAlreadyLoaded,
}),
);
const detailed = detailQuery.data ?? booking;
const files = detailed.files ?? [];
// Freshly-selected files keyed by fileKey (SmartFileInput value).
const [documents, setDocuments] = useState<DocumentsValue>({});
const [priceChange, setPriceChange] = useState<SubmitBookingResponse | null>(
null,
);
const [validationError, setValidationError] = useState<string>("");
// Only surface per-field "required" errors once the user has tried to submit.
const [showErrors, setShowErrors] = useState(false);
const hasReplacements = Object.values(replacements).some(Boolean);
// Codes already attached to the booking from the original submission.
const existingCodes = useMemo(
() => new Set(files.map((f) => f.code)),
[files],
);
const fields = settingQuery.data?.fields ?? [];
// Required fields that have neither an existing file nor a newly-picked one.
const missingRequiredKeys = useMemo(() => {
return fields
.filter((f) => f.isRequired)
.filter(
(f) => !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]),
)
.map((f) => f.fileKey);
}, [fields, existingCodes, documents]);
const hasNewFiles = Object.values(documents).some(hasFile);
const invalidateLists = () =>
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
const updateMutation = useMutation({
mutationFn: (files: Record<string, File | null>) =>
mutationFn: (files: DocumentsValue) =>
api.bookings.update.call({ id: booking.id, dto: {}, documents: files }),
onSuccess: () => {
setReplacements({});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
invalidateLists();
opts?.onResubmitted?.();
},
});
const submitMutation = useMutation({
@@ -74,22 +111,32 @@ export function useResubmitFlow(
opts?.onResubmitted?.();
}
const setReplacement = (key: string, file: File | null) =>
setReplacements((prev) => ({ ...prev, [key]: file }));
/**
* Upload any replaced documents (if any), then resubmit the booking for
* review. Replacing files is optional — staff may have asked for a non-doc
* change — so an empty replacement set still submits.
* Validate required documents, upload any newly-selected ones, then resubmit
* the booking for review.
*/
function resubmit() {
const changed: Record<string, File | null> = {};
for (const [key, file] of Object.entries(replacements)) {
if (file) changed[key] = file;
if (missingRequiredKeys.length > 0) {
setShowErrors(true);
setValidationError(
"Please attach all required documents before resubmitting.",
);
return;
}
if (Object.keys(changed).length > 0) {
updateMutation.mutate(changed, {
onSuccess: () => submitMutation.mutate(),
setShowErrors(false);
setValidationError("");
const files: DocumentsValue = {};
for (const [key, value] of Object.entries(documents)) {
if (hasFile(value)) files[key] = value;
}
if (Object.keys(files).length > 0) {
updateMutation.mutate(files, {
onSuccess: () => {
setDocuments({});
submitMutation.mutate();
},
});
} else {
submitMutation.mutate();
@@ -97,15 +144,28 @@ export function useResubmitFlow(
}
const isBusy =
settingQuery.isLoading ||
updateMutation.isPending ||
submitMutation.isPending ||
confirmSubmitMutation.isPending;
return {
rows,
replacements,
hasReplacements,
setReplacement,
booking: detailed,
/** Documents already attached to the booking from the original submission. */
files,
setting: settingQuery.data,
settingLoading: settingQuery.isLoading || detailQuery.isLoading,
existingCodes,
documents,
setDocuments,
hasNewFiles,
missingRequiredKeys,
/** Per-field errors for SmartFileInput; only set after a failed submit. */
fieldErrors: showErrors
? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"]))
: {},
canResubmit: missingRequiredKeys.length === 0,
validationError,
resubmit,
isBusy,
priceChange,