Merge pull request #182 from Tria-plc/freight_feature/priority

Freight feature/priority
This commit is contained in:
marshal
2026-06-16 17:31:28 +03:00
committed by GitHub
36 changed files with 847 additions and 55 deletions

View File

@@ -28,6 +28,7 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
@@ -257,6 +258,7 @@ const App = () => {
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/new" element={<NewBookingPage />} />

View File

@@ -1,4 +1,5 @@
import { Badge } from "@mantine/core";
import { Badge, Group } from "@mantine/core";
import { Link2 } from "lucide-react";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
const statusColorMap: Record<string, string> = {
@@ -24,14 +25,26 @@ const statusColorMap: Record<string, string> = {
CONSOLIDATED: "indigo",
};
export function BookingStatusBadge({ status }: { status: string }) {
interface BookingStatusBadgeProps {
status: string;
/** When the booking is part of a consolidation, show a sibling badge. */
consolidated?: boolean;
/** Partner booking reference for the consolidated badge tooltip. */
partnerReference?: string | null;
}
export function BookingStatusBadge({
status,
consolidated,
partnerReference,
}: BookingStatusBadgeProps) {
const style = BOOKING_STATUS_STYLES[status] ?? {
label: status,
color: "gray",
};
const color = statusColorMap[status] ?? "gray";
return (
const statusBadge = (
<Badge
color={color}
variant="light"
@@ -51,4 +64,34 @@ export function BookingStatusBadge({ status }: { status: string }) {
{style.label}
</Badge>
);
if (!consolidated) return statusBadge;
return (
<Group gap={4} wrap="nowrap">
{statusBadge}
<Badge
color="indigo"
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
leftSection={<Link2 size={12} />}
title={
partnerReference
? `Consolidated with ${partnerReference}`
: "Part of a consolidation"
}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
whiteSpace: "nowrap",
}}
>
Consolidated
</Badge>
</Group>
);
}

View File

@@ -36,7 +36,11 @@ export function BookingDetailHeader({
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
<BookingStatusBadge
status={booking.status}
consolidated={Boolean(booking.consolidationPartnerId)}
partnerReference={booking.consolidationPartner?.reference}
/>
</Group>
<Group gap="xs">
<Building2 size={14} color="var(--mantine-color-gray-5)" />

View File

@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, List, Text } from "@mantine/core";
import { Link2 } from "lucide-react";
import { bookingsService } from "@/services/bookings.service";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
/**
* Shown on a booking parked in PENDING_CONSOLIDATION. Explains that the booking
* cannot be approved until a complementary booking fills the wagon, and lists
* the partial-wagon container lines that are waiting for a partner.
*/
export function ConsolidationWaitingBanner({ bookingId }: { bookingId: string }) {
const { data } = useQuery({
queryKey: [...QUERY_KEYS.BOOKINGS.byId(bookingId), "consolidation"],
queryFn: () => bookingsService.getConsolidationDetails(bookingId),
enabled: Boolean(bookingId),
});
return (
<Alert
color="yellow"
variant="light"
icon={<Link2 size={18} />}
title="Waiting for a consolidation partner"
>
<Text size="sm">
This booking cannot be approved until a matching booking fills the
wagon. It will return to the approval queue automatically once a partner
is found.
</Text>
{data?.wagonSlots?.length ? (
<List size="sm" mt="xs" spacing={2}>
{data.wagonSlots.map((slot) => (
<List.Item key={slot.containerTypeCode}>
{slot.slotsNeeded} more × {slot.containerTypeCode} (
{slot.containersPerWagon} per wagon; you have {slot.quantity})
</List.Item>
))}
</List>
) : null}
</Alert>
);
}

View File

@@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
import {
Bell,
ChevronDown,
FileSignature,
Languages,
LogOut,
MessageSquare,
@@ -10,6 +11,7 @@ import {
User,
} from "lucide-react";
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import type { PageMeta } from "./types";
import "./FreightDashboardHeader.css";
@@ -37,6 +39,7 @@ const FreightDashboardHeader = ({
theme,
onToggleTheme,
}: FreightDashboardHeaderProps) => {
const navigate = useNavigate();
const initials =
userInitials ??
(userName
@@ -200,10 +203,22 @@ const FreightDashboardHeader = ({
<Menu.Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => setIsUserMenuOpen(false)}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile");
}}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile#signature");
}}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"

View File

@@ -36,6 +36,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Dashboard summary and key metrics",
},
},
{
prefix: "/dashboard/profile",
meta: {
title: "My Profile",
subtitle: "Manage your account and signature",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {

View File

@@ -0,0 +1,144 @@
import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAuth } from "@/auth/useAuth";
import {
useMySignature,
useSaveSignature,
} from "@/hooks/useSavedSignature";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in user view and update the reusable signature stored on
* their profile. The same signature is offered for approval when signing a
* booking contract.
*/
export function MySignatureCard() {
const { user } = useAuth();
const { data: saved, isLoading } = useMySignature();
const saveMutation = useSaveSignature();
const [open, setOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const defaultName =
user?.name?.en || user?.username || user?.email || "";
const openDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName);
setSignatureData(null);
setOpen(true);
};
const save = () => {
if (!signatureData || !signerName.trim()) return;
saveMutation.mutate(
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
},
{ onSuccess: () => setOpen(false) },
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-4" />
My signature
</CardTitle>
<CardDescription>
This signature can be reused to sign booking contracts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex h-36 items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : saved?.signatureImageUrl ? (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save your signature</DialogTitle>
<DialogDescription>
Draw your signature below. It will be stored on your profile for
future contracts.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profileSignerName">Full name</Label>
<Input
id="profileSignerName"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="As shown on contracts"
/>
</div>
<ContractSignaturePad onChange={setSignatureData} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={
saveMutation.isPending || !signatureData || !signerName.trim()
}
onClick={save}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save signature"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -347,6 +347,10 @@ export function getBookingActions(
case "CHANGES_REQUESTED":
actions = [CANCEL_ACTION];
break;
case "PENDING_CONSOLIDATION":
// View-only while waiting for a consolidation partner; cancel still allowed.
actions = [CANCEL_ACTION];
break;
default:
actions = [];
}

View File

@@ -212,21 +212,25 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
},
PENDING_CONSOLIDATION: {
title: "Pending Consolidation",
description: "Waiting for consolidation partner.",
description: "Blocked — waiting for a consolidation partner before approval.",
color: "text-amber-600",
stage: 4,
stage: 0,
},
CONSOLIDATED: {
title: "Consolidated",
description: "Paired with another booking.",
color: "text-indigo-600",
stage: 4,
stage: 0,
},
};
export const BOOKING_LIST_TABS = [
{ key: "all", label: "All bookings", statuses: null as string[] | null },
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
{
key: "intake",
label: "Submitted",
statuses: ["SUBMITTED", "PENDING_CONSOLIDATION"],
},
{
key: "in_approval",
label: "In approval",
@@ -256,7 +260,7 @@ export const BOOKING_LIST_TABS = [
{
key: "operations",
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
statuses: ["PAID", "IN_TRANSIT"],
},
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
@@ -265,7 +269,15 @@ export const BOOKING_LIST_TABS = [
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
export const WORKFLOW_STAGES = [
{ label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
{
label: "Submission",
statuses: [
"DRAFT",
"SUBMITTED",
"CHANGES_REQUESTED",
"PENDING_CONSOLIDATION",
],
},
{
label: "Approval",
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
@@ -286,7 +298,7 @@ export const WORKFLOW_STAGES = [
},
{
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
statuses: ["PAID", "IN_TRANSIT"],
},
{ label: "Done", statuses: ["COMPLETED"] },
] as const;

View File

@@ -42,6 +42,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
trainScheduleId: booking.trainScheduleId ?? null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
createdAt: booking.createdAt,
};
}

View File

@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
signaturesService,
type SaveSignaturePayload,
} from "@/services/signatures.service";
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
export function useMySignature() {
return useQuery({
queryKey: SAVED_SIGNATURE_KEY,
queryFn: () => signaturesService.getMySignature(),
staleTime: 60_000,
});
}
export function useSaveSignature() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: SaveSignaturePayload) =>
signaturesService.saveMySignature(payload),
onSuccess: () => {
toast.success("Signature saved");
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
},
onError: () => toast.error("Failed to save signature"),
});
}

View File

@@ -40,6 +40,9 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = 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);
const { data, isLoading, isError } = useQuery({
queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
@@ -53,6 +56,12 @@ export default function BookingContractPage() {
? "STAFF"
: null;
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? 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;
const signMutation = useMutation({
mutationFn: (payload: SignContractPayload) =>
bookingsService.signContract(id!, payload),
@@ -88,16 +97,22 @@ export default function BookingContractPage() {
};
const openSign = () => {
setSignerName("");
// Prefill from the saved signature when available so the user only has to
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signRole || !signatureData || !signerName.trim()) return;
if (!signRole || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
role: signRole,
signatureImageBase64: signatureData,
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -183,7 +198,9 @@ export default function BookingContractPage() {
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
</DialogTitle>
<DialogDescription>
Sign to execute the contract for {data.reference}.
{usingSaved
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
: `Sign to execute the contract for ${data.reference}.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -196,7 +213,32 @@ export default function BookingContractPage() {
placeholder="As shown on the contract"
/>
</div>
<ContractSignaturePad onChange={setSignatureData} />
{usingSaved ? (
<div className="space-y-2">
<Label>Saved signature</Label>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={savedSignatureImage ?? undefined}
alt="Saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</div>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -205,13 +247,15 @@ export default function BookingContractPage() {
<Button
disabled={
signMutation.isPending ||
!signatureData ||
(!usingSaved && !signatureData) ||
!signerName.trim()
}
onClick={confirmSign}
>
{signMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : usingSaved ? (
"Approve & sign"
) : (
"Confirm signature"
)}

View File

@@ -17,6 +17,7 @@ import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import {
detailStyles,
BookingRequestHero,
@@ -128,6 +129,10 @@ export default function BookingRequestDetailPage() {
titleColor={statusMeta.color}
/>
{booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
<Grid gutter="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>

View File

@@ -227,7 +227,11 @@ export default function BookingRequestsPage() {
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<BookingStatusBadge status={row.original.status} />
<BookingStatusBadge
status={row.original.status}
consolidated={Boolean(row.original.consolidationPartnerId)}
partnerReference={row.original.consolidationPartnerReference}
/>
</div>
),
meta: {

View File

@@ -0,0 +1,11 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
export default function MyProfilePage() {
return (
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
<div id="signature">
<MySignatureCard />
</div>
</div>
);
}

View File

@@ -80,6 +80,26 @@ export interface ContractView {
signedAt: string;
signatureImageUrl?: string | null;
}>;
/** Current viewer's reusable saved signature, if they have one. */
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
} | null;
}
export interface ConsolidationWagonSlot {
containerTypeCode: string;
quantity: number;
containersPerWagon: number;
remainder: number;
slotsNeeded: number;
}
export interface ConsolidationDetails {
statusMessage: string;
wagonSlots: ConsolidationWagonSlot[];
partner: { id: string; reference: string } | null;
splitBilling: { bookingShare: number; partnerShare: number } | null;
}
export interface SignContractPayload {
@@ -193,6 +213,13 @@ export const bookingsService = {
return unwrap(response.data);
},
getConsolidationDetails: async (
id: string,
): Promise<ConsolidationDetails> => {
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
return unwrap(response.data) as ConsolidationDetails;
},
customerSign: (id: string, payload: SignContractPayload) =>
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
...payload,

View File

@@ -0,0 +1,32 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
const SIGNATURE_URL = "/me/signature";
export interface SavedSignature {
signerDisplayName: string;
signatureImageUrl?: string | null;
}
export interface SaveSignaturePayload {
signerDisplayName: string;
signatureImageBase64: string;
}
export const signaturesService = {
/** Returns the current user's reusable signature, or null if none saved. */
getMySignature: async (): Promise<SavedSignature | null> => {
const response = await client.get<SavedSignature | null>(SIGNATURE_URL);
return (unwrap(response.data) as SavedSignature | null) ?? null;
},
saveMySignature: async (
payload: SaveSignaturePayload,
): Promise<SavedSignature | null> => {
const response = await client.put<SavedSignature | null>(
SIGNATURE_URL,
payload,
);
return (unwrap(response.data) as SavedSignature | null) ?? null;
},
};

View File

@@ -101,6 +101,8 @@ export interface BookingDetail {
cargoTotalWeightVgm: number;
isHazardous: boolean;
allowConsolidation: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
priorityScore: number;
schedulingStatus?: string;
holdExpiresAt?: string | null;
@@ -157,5 +159,7 @@ export interface BookingListRow {
trainScheduleId?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
createdAt: string;
}