add company stamp upload functionality for contract signing

- Introduced StampUpload component for uploading company stamp images.
- Integrated stamp upload in contract signing modal, supporting PNG and JPG formats.
- Implemented validation for file type and size (max 5 MB).
- Added visual feedback for drag-and-drop functionality.
- Updated contract-related pages to handle duplicate contract alerts and pricing notices.
- Enhanced contract expiry management with a nightly sweep service.
- Added unit tests for new features and updated existing tests for contract handling.
This commit is contained in:
Marshal
2026-07-25 17:14:58 +00:00
parent 54b5882355
commit fde5e6de4b
68 changed files with 1858 additions and 289 deletions

View File

@@ -9,7 +9,7 @@ import {
Group,
Loader,
Modal,
Select,
// Select, // ponytail: unused now the validity dropdown below is commented out
Stack,
Text,
Textarea,
@@ -25,6 +25,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import { DateInput } from "@mantine/dates";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
@@ -75,8 +76,10 @@ export function ContractDocumentEditorModal({
onClose,
contractId,
mode,
validityOptions = [],
validityLoading = false,
// ponytail: validityOptions/validityLoading fed the now-commented dropdown
// above — caller still passes them, left unread here for a quick revert.
// validityOptions = [],
// validityLoading = false,
accepting = false,
saving = false,
onAccept,
@@ -93,7 +96,12 @@ export function ContractDocumentEditorModal({
const [documentTitle, setDocumentTitle] = useState("");
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
const [articles, setArticles] = useState<EditableArticle[]>([]);
const [validityDays, setValidityDays] = useState<string | null>(null);
// const [validityDays, setValidityDays] = useState<string | null>(null);
// ponytail: client keeps flip-flopping the validity requirement — swapped
// the validity dropdown for explicit start/end dates, kept above commented
// instead of deleted so it's a one-line revert if they flip back.
const [validityStart, setValidityStart] = useState<Date | null>(null);
const [validityEnd, setValidityEnd] = useState<Date | null>(null);
// Seed the editor from the loaded draft whenever the dialog (re)opens.
useEffect(() => {
@@ -110,11 +118,11 @@ export function ContractDocumentEditorModal({
}, [opened, draft]);
// Default validity to the first configured option (accept mode).
useEffect(() => {
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [mode, validityDays, validityOptions]);
// useEffect(() => {
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
// setValidityDays(validityOptions[0].value);
// }
// }, [mode, validityDays, validityOptions]);
// Editing rights belong to the approver whose turn it is, so the server
// decides per-caller — the client cannot derive this from the contract alone.
@@ -169,8 +177,13 @@ export function ContractDocumentEditorModal({
const submit = () => {
const snapshot = buildSnapshot();
if (mode === "accept") {
const days = Number(validityDays);
if (!days) return;
// const days = Number(validityDays);
// if (!days) return;
if (!validityStart || !validityEnd) return;
const days = Math.ceil(
(validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000),
);
if (days <= 0) return;
onAccept?.(days, snapshot);
} else {
onSaveEdit?.(snapshot);
@@ -181,7 +194,8 @@ export function ContractDocumentEditorModal({
const canSubmit =
hasArticles &&
!locked &&
(mode === "edit" || Boolean(validityDays)) &&
// (mode === "edit" || Boolean(validityDays)) &&
(mode === "edit" || Boolean(validityStart && validityEnd)) &&
!submitting;
return (
@@ -375,7 +389,10 @@ export function ContractDocumentEditorModal({
{mode === "accept" && (
<>
{validityOptions.length > 0 ? (
{/* ponytail: client keeps changing this requirement — swapped
the validity-period dropdown for explicit start/end dates,
left the old block commented instead of deleted. */}
{/* {validityOptions.length > 0 ? (
<Select
label="Contract validity"
placeholder="Select a validity period"
@@ -391,7 +408,25 @@ export function ContractDocumentEditorModal({
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under Dropdown Settings."}
</Text>
)}
)} */}
<Group grow align="flex-start">
<DateInput
label="Start date"
placeholder="Contract validity start"
value={validityStart}
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
maxDate={validityEnd ?? undefined}
clearable
/>
<DateInput
label="End date"
placeholder="Contract validity end"
value={validityEnd}
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
minDate={validityStart ?? undefined}
clearable
/>
</Group>
</>
)}

View File

@@ -0,0 +1,171 @@
import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { RefreshCw, Stamp, X } from "lucide-react";
const MAX_STAMP_MB = 5;
export interface StampUploadProps {
/** Stamp image as a data URL, or null when none is attached yet. */
value: string | null;
onChange: (dataUrl: string | null) => void;
label?: string;
description?: string;
}
/**
* Company stamp/seal attachment for the contract signing modal. Reads the
* picked image straight into a data URL because the signing endpoint takes
* base64 in JSON (same transport as the drawn signature), not multipart.
*/
export function StampUpload({
value,
onChange,
label = "Company stamp",
description = "Attach your official company stamp or seal.",
}: StampUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const readFile = (file: File | undefined | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("The stamp must be an image file (PNG or JPG).");
return;
}
if (file.size > MAX_STAMP_MB * 1024 * 1024) {
setError(`The stamp image must be under ${MAX_STAMP_MB} MB.`);
return;
}
const reader = new FileReader();
reader.onload = () => {
setError(null);
setFileName(file.name);
onChange(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => setError("Could not read that file. Try another.");
reader.readAsDataURL(file);
};
const openPicker = () => inputRef.current?.click();
const clear = () => {
setFileName(null);
setError(null);
onChange(null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(e) => readFile(e.currentTarget.files?.[0])}
/>
{value ? (
<Paper withBorder radius="md" p="sm">
<Group gap="md" wrap="nowrap" align="center">
<Box
style={{
background:
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
borderRadius: 8,
flexShrink: 0,
padding: 6,
}}
>
<Image
src={value}
alt="Company stamp"
fit="contain"
h={92}
w={92}
/>
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{fileName ?? "Stamp attached"}
</Text>
<Text size="xs" c="dimmed">
This stamp is applied next to your signature on the contract.
</Text>
<Group gap="xs" mt={2}>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<RefreshCw size={13} />}
onClick={openPicker}
>
Replace
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<X size={13} />}
onClick={clear}
>
Remove
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<Paper
withBorder
radius="md"
p="lg"
onClick={openPicker}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
readFile(e.dataTransfer.files?.[0]);
}}
style={{
borderColor: dragging
? "var(--mantine-color-edr-green-6)"
: undefined,
borderStyle: "dashed",
backgroundColor: dragging
? "var(--mantine-color-edr-green-0)"
: undefined,
cursor: "pointer",
}}
>
<Stack gap={6} align="center">
<Stamp size={26} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
Upload company stamp
</Text>
<Text size="xs" c="dimmed" ta="center">
{description} Drop an image here or click to browse PNG or JPG,
up to {MAX_STAMP_MB} MB.
</Text>
</Stack>
</Paper>
)}
{error && (
<Text size="xs" c="red.7">
{error}
</Text>
)}
</Stack>
);
}

View File

@@ -172,7 +172,7 @@ export function computeGlShipmentTotal(
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
const tons = q.bulkQuantity;
if (tons > 0) {
lines.push({
@@ -199,7 +199,7 @@ export function computeGlShipmentTotal(
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton") {
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
qty = q.bulkQuantity;
} else if (cl.unit === "flat") {
qty = 1;

View File

@@ -23,6 +23,7 @@ import {
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import type { PageMeta } from "./types";
@@ -114,8 +115,12 @@ const FreightDashboardHeader = ({
</Group>
</Group>
{/* Right: actions + avatar */}
{/* Right: actions + avatar. The doc-review alarm leads the group — it
only renders in the last half of a review phase that still has
undecided requests, so it never competes for space otherwise. */}
<Group gap={10} wrap="nowrap" align="center">
<DocReviewAlertButton />
<Tooltip label="Language" withArrow openDelay={300}>
<UnstyledButton className={ISLAND} aria-label="Language">
<Languages size={17} strokeWidth={1.8} />

View File

@@ -294,6 +294,7 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/run-allocation`,
DOC_REVIEW_COMPLETE: (id: string) =>
`/train-scheduling/schedules/${id}/doc-review-complete`,
DOC_REVIEW_ALERT: "/train-scheduling/doc-review-alert",
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>

View File

@@ -0,0 +1,125 @@
import { Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canSeeDocReviewAlert } from "@/lib/permissions";
import { api } from "@/services/api";
/**
* Booking-request statuses that count as "nobody decided yet". These are the
* exact statuses the window engine expires when document review ends
* (findUnacceptedForRouteDay), so the deep-linked list shows precisely the
* requests the countdown is warning about.
*/
const UNDECIDED_STATUSES = [
"OPERATION_REQUESTED",
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
].join(",");
const pendingRequestsHref = (tradeDirection: string) =>
`/dashboard/booking-requests?statuses=${UNDECIDED_STATUSES}&tradeDirection=${tradeDirection}`;
/** mm:ss (or h:mm:ss past an hour), fixed width so the pill never jitters. */
function formatRemaining(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const mm = String(minutes).padStart(2, "0");
const ss = String(seconds).padStart(2, "0");
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}
/**
* Header alarm for the document-review deadline. Appears only once the review
* phase is half spent AND requests are still undecided — everything still
* pending when the clock runs out is expired automatically, so this is the last
* call to accept or reject. Clicking opens the booking requests already
* filtered to those undecided import requests.
*/
export default function DocReviewAlertButton() {
const navigate = useNavigate();
const { user } = useAuth();
const { data: alert } = useQuery({
...api.trainScheduling.docReviewAlert.queryOptions(),
// Dedicated permission: only the position types granted it are alarmed.
enabled: canSeeDocReviewAlert(user),
// The window engine ticks every 10s; a minute is close enough for a header
// chip — the countdown itself runs locally.
refetchInterval: 60_000,
});
const deadlineMs = alert ? new Date(alert.docReviewEndsAt).getTime() : 0;
const [remaining, setRemaining] = useState(() => deadlineMs - Date.now());
useEffect(() => {
if (!deadlineMs) return;
const tick = () => setRemaining(deadlineMs - Date.now());
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [deadlineMs]);
if (!alert) return null;
// Half the review phase has to be gone before staff are alarmed — a 30-minute
// review warns with 15 minutes left.
const halfMs = (Math.max(alert.docReviewMinutes, 1) * 60_000) / 2;
if (remaining <= 0 || remaining > halfMs) return null;
const requestLabel = alert.pendingCount === 1 ? "request" : "requests";
return (
<Tooltip
withArrow
openDelay={200}
multiline
w={260}
label={`Document review ends in ${formatRemaining(remaining)}. ${alert.pendingCount} ${alert.tradeDirection.toLowerCase()} booking ${requestLabel} ${alert.pendingCount === 1 ? "is" : "are"} still neither accepted nor rejected and will expire automatically. Click to review them.`}
>
<UnstyledButton
onClick={() => navigate(pendingRequestsHref(alert.tradeDirection))}
aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`}
className="group flex h-9 shrink-0 items-center gap-2 rounded-full border border-red-600/60 bg-red-600 pl-2.5 pr-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] transition-transform hover:scale-[1.02] hover:bg-red-700"
>
{/* Live dot: a ping ring behind a solid core, so the pill reads as
active without animating the whole chip. */}
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-white opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-white" />
</span>
<AlertTriangle size={15} strokeWidth={2.2} className="shrink-0" />
<Text
size="xs"
fw={700}
visibleFrom="sm"
className="whitespace-nowrap text-white!"
>
{alert.pendingCount} undecided
</Text>
<Text
size="xs"
fw={700}
className="text-white!"
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "0.02em" }}
>
{formatRemaining(remaining)}
</Text>
<ChevronRight
size={14}
strokeWidth={2.2}
className="shrink-0 opacity-80 transition-transform group-hover:translate-x-0.5"
/>
</UnstyledButton>
</Tooltip>
);
}

View File

@@ -119,7 +119,13 @@ export const useCargoLeafOptions = (enabled = true) =>
const code = String(row.code ?? "").trim();
const label =
name && code ? `${name} (${code})` : name || code || String(row.id);
return { label, value: String(row.id) };
// The commodity's unit of measure rides along so the rate form can
// offer per-item units for counted (break-bulk) commodities.
return {
label,
value: String(row.id),
unitOfMeasure: String(row.unitOfMeasure ?? ""),
};
});
},
});

View File

@@ -26,6 +26,7 @@ export const FREIGHT_PERMS = {
reviewDocuments: "edr_freight_app:bookings:review_documents",
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -477,6 +478,17 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
/**
* Sees the header countdown warning that document review is about to end with
* requests still undecided. Its own permission — granted per position type, so
* only the desks that act on those requests get alarmed.
*/
export function canSeeDocReviewAlert(
user: AuthUser | null | undefined,
): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.docReviewAlert);
}
export function canAccessContracts(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.contracts.view);
}

View File

@@ -27,8 +27,8 @@ import {
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
@@ -123,14 +123,25 @@ function formatDate(value: string | null | undefined): string {
export default function BookingRequestsPage() {
const navigate = useNavigate();
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests it
// is counting down for. Read once as the initial state so staff can then
// change the filters like any other visit.
const [searchParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking kind is a filter now — one list holds both kinds (null = "all").
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const paramStatuses = searchParams.get("statuses") ?? "";
const paramDirection = searchParams.get("tradeDirection");
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
paramStatuses.split(",").filter(Boolean),
);
const [directionFilter, setDirectionFilter] = useState<string | null>(
paramDirection,
);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
@@ -150,6 +161,15 @@ export default function BookingRequestsPage() {
}, 400);
}, []);
// Follow the URL when a deep link arrives while the page is already open
// (clicking the header alarm from this very list). Same-value writes are
// dropped so a manual filter change is never undone.
useEffect(() => {
const next = paramStatuses.split(",").filter(Boolean);
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
setDirectionFilter(paramDirection);
}, [paramStatuses, paramDirection]);
const filter: BookingListFilter = useMemo(() => {
return {
page: pagination.pageIndex + 1,

View File

@@ -18,7 +18,9 @@ import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { extractApiError } from "@/utils/result";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -38,6 +40,7 @@ export default function ContractViewPage() {
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
@@ -56,6 +59,7 @@ export default function ContractViewPage() {
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
stampImageBase64: stampData ?? "",
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -66,7 +70,12 @@ export default function ContractViewPage() {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
},
onError: () => toast.error("Failed to sign contract"),
// Surface the server's reason verbatim — the missing-customer-stamp gate
// and the status guards all explain themselves in the message.
onError: (err) =>
toast.error(
extractApiError(err).message ?? "Failed to sign contract",
),
});
const handlePrint = () => iframeRef.current?.contentWindow?.print();
@@ -89,12 +98,13 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setStampData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim()) return;
if (!signerName.trim() || !stampData) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
@@ -231,6 +241,14 @@ export default function ContractViewPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
label="EDR company stamp"
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -241,7 +259,8 @@ export default function ContractViewPage() {
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>

View File

@@ -1180,22 +1180,16 @@ export function LocomotivesCrudPage() {
// of the weight tolerance.
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
]}
emptyValues={{
code: '',
name: '',
locomotiveType: 'DIESEL',
status: 'AVAILABLE',
maxPullWeightTons: 0,
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: '',
overageToleranceMeters: '',
powerKw: '',
tractionForceKn: '',
maxSpeedKmh: '',
overageToleranceTons: 93,
overageToleranceMeters: 10,
}}
/>
);

View File

@@ -233,22 +233,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
// of the weight tolerance.
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 2500,
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: "",
overageToleranceMeters: "",
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
overageToleranceTons: 93,
overageToleranceMeters: 10,
},
},
{

View File

@@ -59,6 +59,7 @@ import {
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
rateUnitOptions,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
@@ -344,6 +345,20 @@ const RuleEngineResourcePage = () => {
options: cargoLeafOptions ?? [],
};
}
// Rate units follow the picked commodity: a per-item (break-bulk) cargo
// is priced per item where a weighed one is priced per ton.
if (field.name === "rateUnit" && field.optionsFromValues) {
return {
...field,
optionsFromValues: (values: Record<string, unknown>) =>
rateUnitOptions(
values,
(cargoLeafOptions ?? []).find(
(o) => o.value === String(values.cargoTypeId ?? ""),
)?.unitOfMeasure ?? "",
),
};
}
if (field.name === "rateId") {
return {
...field,

View File

@@ -207,13 +207,27 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
/**
* Valid weighting units for a rate shape — mirrors the API's
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
* bill per container, bulk per ton, overweight always per excess ton, etc. A
* rate scoped to a break-bulk commodity (unit of measure = PER_ITEM) offers
* PER_ITEM wherever a weighed one offers PER_TON. Kept in sync with
* apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/
const allowedRateUnits = (
appliesTo: string,
trigger: string,
cargoKind = "",
cargoUnitOfMeasure = "",
): string[] => {
const units = unitsForShape(appliesTo, trigger, cargoKind);
return cargoUnitOfMeasure === "PER_ITEM"
? units.map((u) => (u === "PER_TON" ? "PER_ITEM" : u))
: units;
};
const unitsForShape = (
appliesTo: string,
trigger: string,
cargoKind = "",
): string[] => {
if (appliesTo === "OTHER") {
switch (trigger) {
@@ -259,7 +273,15 @@ const allowedRateUnits = (
}
};
const rateUnitOptions = (values: Record<string, unknown>) => {
/**
* Unit choices for the rate form. `cargoUnitOfMeasure` is how the bulk
* commodity picked in the form is counted (PER_TON / PER_ITEM) — injected by
* RuleEngineResourcePage, which is the layer that has the cargo type list.
*/
export const rateUnitOptions = (
values: Record<string, unknown>,
cargoUnitOfMeasure = "",
) => {
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
@@ -267,6 +289,7 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
appliesTo,
trigger,
String(values.cargoKind ?? ""),
cargoUnitOfMeasure,
).map(unitOption);
};
@@ -889,7 +912,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: rateUnitOptions,
description: "Weighting basis — options depend on what the rate applies to.",
description:
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
},
],

View File

@@ -57,6 +57,7 @@ import type {
EligibleContainerBookingsResponse,
FreightType,
ImportLoadingBookingsResponse,
DocReviewAlert,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
@@ -276,6 +277,13 @@ export const api = {
() => ["train-scheduling", "all-booking-windows"],
),
docReviewAlert: endpoint<void, DocReviewAlert | null>(
"train-scheduling",
"doc-review-alert",
() => trainSchedulingService.getDocReviewAlert(),
() => ["train-scheduling", "doc-review-alert"],
),
batchBoardDetail: endpoint<
{ scheduleId: string },
BatchBoardScheduleDetail
@@ -1660,15 +1668,6 @@ export const api = {
() => [["wagons"]],
),
reorder: endpoint<{ trainId: string; wagonIds: string[] }, Wagon[]>(
"wagons",
"reorder",
({ trainId, wagonIds }) =>
wagonService.reorder(trainId, wagonIds).then((r) => r.data),
undefined,
() => [["wagons"]],
),
create: endpoint<Partial<Wagon>, Wagon>(
"wagons",
"create",

View File

@@ -109,6 +109,7 @@ export interface ContractView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
}>;
savedSignature?: {
signerDisplayName: string;
@@ -119,6 +120,8 @@ export interface ContractView {
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; required to sign a contract. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -35,9 +35,6 @@ export interface Locomotive {
overageToleranceTons?: number | null;
/** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */
overageToleranceMeters?: number | null;
powerKw?: number | null;
tractionForceKn?: number | null;
maxSpeedKmh?: number | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -12,6 +12,7 @@ import type {
BookingLoadResult,
BookingUnloadResult,
CompositionRemovalEntry,
DocReviewAlert,
UnassignedBookingsResponse,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
@@ -709,6 +710,15 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getDocReviewAlert: async (): Promise<DocReviewAlert | null> => {
const response = await client.get<DocReviewAlert | null>(
URL_CONSTANTS.TRAIN_SCHEDULING.DOC_REVIEW_ALERT,
);
// "No alert" comes back as null — which Nest sends as an empty body, so
// coerce anything falsy to null (react-query rejects undefined).
return unwrap(response.data) || null;
},
updateGlobalRules: async (
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
): Promise<TrainSchedulingGlobalRules> => {

View File

@@ -88,8 +88,6 @@ export const wagonService = {
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
reorder: (trainId: string, wagonIds: string[]) =>
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),

View File

@@ -287,6 +287,25 @@ export interface BatchBoardBooking {
* An announced booking window on any lane (import cycle or export FCFS), for
* staff dashboards. Mirrors the customer portal's MyBookingWindow.
*/
/**
* The nearest document-review deadline that still has booking requests nobody
* accepted or rejected. Everything still pending when it lapses is expired by
* the window engine, so the header counts down to it.
*/
export interface DocReviewAlert {
scheduleId: string;
originYardId: string;
destinationYardId: string;
/** EAT booking day, YYYY-MM-DD. */
day: string;
/** IMPORT (the usual) or DOMESTIC — the direction the at-risk requests belong to. */
tradeDirection: string;
docReviewEndsAt: string;
/** Full length of the review phase — warn past its halfway mark. */
docReviewMinutes: number;
pendingCount: number;
}
export interface StaffBookingWindow {
scheduleId: string;
reference: string | null;