Enhance booking and signature functionalities

- Updated MySignaturePage title to Signature
This commit is contained in:
Marshal
2026-08-01 22:03:18 +00:00
parent ea9df9abbe
commit 53d8655dc3
26 changed files with 1050 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
@@ -13,6 +14,7 @@ export interface BookingCargoCardProps {
/** Cargo specs + container manifest table. */
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
const containers = booking.bookingContainers ?? [];
const { tons, items } = cargoTonsAndItems(booking);
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
@@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
/>
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
<MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />}
<MetricTile
label="Hazardous"
value={booking.isHazardous ? "Yes" : "No"}

View File

@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { SectionCard } from "./SectionCard";
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
@@ -38,7 +40,14 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
{
icon: Weight,
label: "VGM Weight",
value: (() => {
const { tons, items } = cargoTonsAndItems(booking);
return items != null ? `${tons} tons (${items} items)` : `${tons} tons`;
})(),
},
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
];

View File

@@ -22,6 +22,7 @@ import {
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
@@ -52,7 +53,7 @@ export function BookingRequestHero({
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
return (
<Paper
@@ -162,7 +163,7 @@ export function BookingRequestHero({
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint="VGM total"
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
accent="blue"
/>
<HeroTile

View File

@@ -79,7 +79,7 @@ export function MySignatureCard() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-4" />
My signature
Signature &amp; Stamp
</CardTitle>
<CardDescription>
This signature can be reused to sign booking contracts.

View File

@@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type {
ContainerPlacement,
FreightType,
@@ -416,7 +417,7 @@ export function AllocateBookingWizard({
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
const holdCountdown = formatCountdown(booking.holdExpiresAt);
const containerComplete =
@@ -945,7 +946,13 @@ export function AllocateBookingWizard({
})}`}
hint={booking.paymentStatus}
/>
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
<StatTile
onDark
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
/>
<StatTile
onDark
icon={ContainerIcon}

View File

@@ -68,11 +68,27 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
);
}
function NeedCells({ need }: { need: IntercityCapacity | null }) {
function NeedCells({ row }: { row: IntercityBookingRow }) {
const { need, wagonBreakdown } = row;
if (!need) return <Table.Td colSpan={3}></Table.Td>;
return (
<>
<Table.Td>{fmt(need.wagons)}</Table.Td>
<Table.Td>
{wagonBreakdown?.length ? (
// Which wagon TYPES this train gives up, not just how many wagons —
// a break-bulk booking's count depends on each type's capacity and
// its configured items-per-wagon fit, so it differs per train.
<Stack gap={2}>
{wagonBreakdown.map((entry) => (
<Text key={entry.wagonTypeId} size="sm">
{entry.wagons} × {entry.code}
</Text>
))}
</Stack>
) : (
fmt(need.wagons)
)}
</Table.Td>
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
</>
@@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<NeedCells need={row.need} />
<NeedCells row={row} />
<Table.Td>
{row.fits ? (
<Badge size="sm" variant="light" color="teal">

View File

@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
@@ -40,6 +41,9 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Company stamp: prefilled from the profile, or uploaded here when none is
// saved yet.
const [stampData, setStampData] = useState<string | null>(null);
// When the user has a saved signature we offer it for approval first; they
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
@@ -55,6 +59,7 @@ export default function BookingContractPage() {
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const savedStampImage = savedSignature?.stampImageUrl ?? null;
// Show the approval view only while a saved signature exists and the user
// hasn't opted to draw a new one.
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
@@ -98,6 +103,8 @@ export default function BookingContractPage() {
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(savedStampImage);
setDrawNew(false);
setSignOpen(true);
};
@@ -106,10 +113,12 @@ export default function BookingContractPage() {
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
// The API rejects a STAFF signature without a stamp.
if (!image || !stampData) return;
signMutation.mutate({
role: "STAFF",
signatureImageBase64: image,
stampImageBase64: stampData,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -234,6 +243,15 @@ export default function BookingContractPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description={
savedStampImage
? "Your saved company stamp — replace it for this contract if needed."
: "Required. Attach your official company stamp or seal."
}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -243,6 +261,7 @@ export default function BookingContractPage() {
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!stampData ||
!signerName.trim()
}
onClick={confirmSign}

View File

@@ -92,6 +92,7 @@ export interface ContractView {
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
} | null;
}
@@ -113,6 +114,8 @@ export interface ConsolidationDetails {
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -171,6 +171,8 @@ export interface BookingDetail {
/** What the containers carry / bulk commodity label — entered at booking time. */
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
bulkTotalWeightTons?: number | null;
isHazardous: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;

View File

@@ -959,6 +959,14 @@ export interface IntercityCapacity {
lengthMeters: number | null;
}
/** One wagon type this train must give up, and how many of it. */
export interface IntercityWagonBreakdownEntry {
wagonTypeId: string;
/** Wagon-type code as marshalled, e.g. "N35" / "PW2". */
code: string;
wagons: number;
}
export interface IntercityBookingRow {
id: string;
reference: string | null;
@@ -973,6 +981,12 @@ export interface IntercityBookingRow {
weightTons: number;
paymentDeadline: string | null;
need: IntercityCapacity | null;
/**
* `need.wagons` split across the wagon types THIS schedule stocks — the same
* booking reads differently on a train of 60T wagons than on one of 40T.
* Empty when the stock or the cargo type's wagon list is unresolved.
*/
wagonBreakdown?: IntercityWagonBreakdownEntry[];
}
export interface IntercityCandidateRow extends IntercityBookingRow {

View File

@@ -0,0 +1,18 @@
/**
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
* VGM column showed a 20-item / 100T booking as "20 tons".
*/
export function cargoTonsAndItems(booking: {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
}): { tons: number; items: number | null } {
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
if (booking.freightType === "BULK" && bulkTons > 0) {
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
}
return { tons: vgm, items: null };
}