mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge pull request #962 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { AlertTriangle, Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
export interface BookingChangesRequestedAlertProps {
|
||||
bookingId: string;
|
||||
reference?: string | null;
|
||||
/** Operations' note — what has to change before this can go back to them. */
|
||||
note?: string | null;
|
||||
/** Shipment day the booking currently holds; the resubmit default. */
|
||||
scheduledDate?: string | null;
|
||||
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
|
||||
canResubmit: boolean;
|
||||
onResubmitted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations sent a GL-created booking back for changes.
|
||||
*
|
||||
* The customer cannot act on this — GL created the booking on their behalf — so
|
||||
* the note and the way out both live here, on the page GL works from. Resubmit
|
||||
* re-requests operation on the chosen shipment day; the server re-checks the day
|
||||
* has a departure that can carry the cargo and refuses with the reason if not.
|
||||
*/
|
||||
export function BookingChangesRequestedAlert({
|
||||
bookingId,
|
||||
reference,
|
||||
note,
|
||||
scheduledDate,
|
||||
canResubmit,
|
||||
onResubmitted,
|
||||
}: BookingChangesRequestedAlertProps) {
|
||||
const [day, setDay] = useState<Date | null>(
|
||||
scheduledDate ? new Date(scheduledDate) : null,
|
||||
);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const resubmit = async () => {
|
||||
if (!day) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await bookingsService.proceedToOperation(bookingId, day.toISOString());
|
||||
toast.success("Sent back to Operations for review");
|
||||
onResubmitted?.();
|
||||
} catch {
|
||||
// The http interceptor already toasts the server's own reason (no
|
||||
// departure that day, no wagon that can carry the cargo, export train
|
||||
// full…) — a second toast here would just duplicate it.
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title={`Operations returned booking ${reference ?? ""} for changes`.trim()}
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
{note ? (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
bg="red.0"
|
||||
style={{ borderColor: "var(--mantine-color-red-3)", width: "100%" }}
|
||||
>
|
||||
<Text size="xs" fw={700} c="red.9" tt="uppercase" mb={4}>
|
||||
What Operations asked for
|
||||
</Text>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{note}
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text size="sm">
|
||||
Operations returned this booking without a note — contact them for
|
||||
the detail before resubmitting.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text size="sm">
|
||||
This booking was created by GL Ethiopia, so the customer cannot fix it.
|
||||
Make the correction Operations asked for, then send it back for review.{" "}
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/bookings/${bookingId}/clearance`}
|
||||
inherit
|
||||
fw={600}
|
||||
c="red.8"
|
||||
>
|
||||
Open the booking →
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{canResubmit ? (
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<DateInput
|
||||
label="Shipment day"
|
||||
description="Keep the day or pick another with an open departure"
|
||||
value={day}
|
||||
onChange={(v) => setDay(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
size="sm"
|
||||
w={230}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={sending}
|
||||
disabled={!day}
|
||||
leftSection={<Send size={15} />}
|
||||
onClick={() => void resubmit()}
|
||||
>
|
||||
Resubmit to Operations
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingChangesRequestedAlert;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck, X } from "lucide-react";
|
||||
import { Check, Flame, ShieldCheck, X } from "lucide-react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -14,10 +14,22 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { HazardDeclarationPanel } from "./HazardDeclarationPanel";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canApproveContractStep } from "@/lib/permissions";
|
||||
import {
|
||||
canApproveContractStep,
|
||||
CONTRACT_APPROVAL_ROLE_LABELS,
|
||||
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
/** Chain roles that exist only because the contract carries dangerous goods. */
|
||||
const isHazardStep = (requiredRole: string): boolean =>
|
||||
requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
|
||||
|
||||
const roleLabel = (requiredRole: string): string =>
|
||||
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
@@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin");
|
||||
@@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({
|
||||
<Text size="sm" c="dimmed">
|
||||
You are about to approve the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{pendingStep?.requiredRole}
|
||||
{roleLabel(pendingStep?.requiredRole ?? "")}
|
||||
</Text>{" "}
|
||||
step for contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
@@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({
|
||||
</Text>
|
||||
. This action cannot be undone from this screen.
|
||||
</Text>
|
||||
{pendingStep && isHazardStep(pendingStep.requiredRole) && (
|
||||
<HazardDeclarationPanel contract={contract} />
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={closeApprove}>
|
||||
Cancel
|
||||
@@ -241,7 +256,7 @@ export function ContractApprovalStepsCard({
|
||||
{ value: "CUSTOMER", label: "Customer — must resubmit" },
|
||||
...returnableSteps.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
|
||||
label: `${roleLabel(s.requiredRole)} — step ${s.stepOrder} re-approves`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
@@ -254,7 +269,7 @@ export function ContractApprovalStepsCard({
|
||||
</Text>{" "}
|
||||
will go back to the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{targetStep?.requiredRole}
|
||||
{roleLabel(targetStep?.requiredRole ?? "")}
|
||||
</Text>{" "}
|
||||
step. That approver fixes the contract and approves again, and
|
||||
every later step re-approves in order. The customer is not
|
||||
@@ -264,7 +279,7 @@ export function ContractApprovalStepsCard({
|
||||
<Text size="sm" c="dimmed">
|
||||
Rejecting the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{rejectStepRow?.requiredRole}
|
||||
{roleLabel(rejectStepRow?.requiredRole ?? "")}
|
||||
</Text>{" "}
|
||||
step rejects contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
@@ -300,7 +315,7 @@ export function ContractApprovalStepsCard({
|
||||
onClick={runReject}
|
||||
>
|
||||
{sendBack
|
||||
? `Send back to ${targetStep?.requiredRole ?? "step"}`
|
||||
? `Send back to ${targetStep ? roleLabel(targetStep.requiredRole) : "step"}`
|
||||
: "Reject contract"}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -333,6 +348,7 @@ function StepRow({
|
||||
: isNext
|
||||
? "edr-green"
|
||||
: "gray";
|
||||
const hazard = isHazardStep(step.requiredRole);
|
||||
|
||||
return (
|
||||
<Group
|
||||
@@ -343,11 +359,19 @@ function StepRow({
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderLeft: isNext
|
||||
? "3px solid var(--freight-brand)"
|
||||
border: hazard
|
||||
? "1px solid #F3D5D0"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||
borderLeft: isNext
|
||||
? `3px solid ${hazard ? "#C0392B" : "var(--freight-brand)"}`
|
||||
: hazard
|
||||
? "1px solid #F3D5D0"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: hazard
|
||||
? "#FEF7F6"
|
||||
: isNext
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "white",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
@@ -371,9 +395,23 @@ function StepRow({
|
||||
{step.stepOrder}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{roleLabel(step.requiredRole)}
|
||||
</Text>
|
||||
{hazard && (
|
||||
<Badge
|
||||
color="red"
|
||||
variant="light"
|
||||
size="xs"
|
||||
radius="sm"
|
||||
leftSection={<Flame size={10} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Hazmat
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{step.note && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.note}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { Flame } from "lucide-react";
|
||||
import { hazardClassLabel, type Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* The contract's dangerous-goods declaration — the UN/ADR class and UN number
|
||||
* the customer declared alongside the hazard documents. Shown wherever a
|
||||
* hazardous contract is reviewed: the cargo-scope card and the two hazardous
|
||||
* approval confirmations, so no one signs off without seeing what is moving.
|
||||
*/
|
||||
export function HazardDeclarationPanel({
|
||||
contract,
|
||||
}: {
|
||||
contract: Pick<Freight.IContract, "hazardClass" | "unNumber">;
|
||||
}) {
|
||||
const classLabel = hazardClassLabel(contract.hazardClass);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid #F3D5D0",
|
||||
background: "#FEF7F6",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FBEAE7",
|
||||
color: "#C0392B",
|
||||
}}
|
||||
>
|
||||
<Flame size={16} />
|
||||
</Box>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={700}>
|
||||
Dangerous goods declaration
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Badge color="red" variant="light" radius="sm" size="sm">
|
||||
{classLabel ?? "Class not declared"}
|
||||
</Badge>
|
||||
<Badge color="red" variant="light" radius="sm" size="sm">
|
||||
{contract.unNumber ? `UN ${contract.unNumber}` : "UN number not declared"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Check the declaration against the uploaded hazard documents before
|
||||
approving.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -156,6 +156,8 @@ export const URL_CONSTANTS = {
|
||||
`/bookings/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||
`/bookings/${id}/clearance/export-release`,
|
||||
// Re-request operation after Operations sent the booking back for changes.
|
||||
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
|
||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||
},
|
||||
@@ -294,6 +296,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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -45,6 +46,8 @@ export const FREIGHT_PERMS = {
|
||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
hazardousApprovalOne: "edr_freight_app:contracts:hazardous_approval_one",
|
||||
hazardousApprovalTwo: "edr_freight_app:contracts:hazardous_approval_two",
|
||||
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||
signStaff: {
|
||||
bulk: "edr_freight_app:contracts:sign_staff:bulk",
|
||||
@@ -441,6 +444,22 @@ const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* The two hazardous-goods steps prepended to a hazardous contract's chain.
|
||||
* They are not position types — they authorize purely on their own permission,
|
||||
* exactly as the API's HAZARDOUS_APPROVAL_ROLE_PERMISSION does.
|
||||
*/
|
||||
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
|
||||
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
|
||||
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
|
||||
};
|
||||
|
||||
/** Display label for an approval step's role (hazardous steps get real names). */
|
||||
export const CONTRACT_APPROVAL_ROLE_LABELS: Record<string, string> = {
|
||||
HAZARDOUS_APPROVAL_ONE: "Hazardous review — first approver",
|
||||
HAZARDOUS_APPROVAL_TWO: "Hazardous review — second approver",
|
||||
};
|
||||
|
||||
/**
|
||||
* Can this user action a contract approval step requiring `requiredRole`?
|
||||
*
|
||||
@@ -463,6 +482,10 @@ export function canApproveContractStep(
|
||||
if (!user || !requiredRole) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
// Hazardous steps are permission-only — no position type stands in for them.
|
||||
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||
if (hazardousPermission) return hasPermission(user, hazardousPermission);
|
||||
|
||||
const positionTypes = getPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
@@ -477,6 +500,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
isDjiboutiGl,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
@@ -137,10 +138,15 @@ export default function ContractClearanceDetailPage() {
|
||||
// The GL-created booking expired unpaid — the slot is free again and GL
|
||||
// rebooks on the customer's behalf (customs bookings are never self-booked).
|
||||
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
|
||||
const canRebook =
|
||||
bookingExpired &&
|
||||
// Operations sent the GL-created booking back. GL owns customs bookings, so
|
||||
// the note and the resubmit belong here, not in the customer's portal.
|
||||
const bookingNeedsChanges =
|
||||
clearance?.linkedBookingStatus === "OPERATION_CHANGES_REQUESTED";
|
||||
const isGlBookingOwner =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner;
|
||||
const canRebook = bookingExpired && isGlBookingOwner;
|
||||
const rebookHref = linkedBookingId
|
||||
? `${bookingHref}?copyFrom=${linkedBookingId}`
|
||||
: bookingHref;
|
||||
@@ -294,6 +300,19 @@ export default function ContractClearanceDetailPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : bookingNeedsChanges && linkedBookingId ? (
|
||||
<BookingChangesRequestedAlert
|
||||
bookingId={linkedBookingId}
|
||||
reference={clearance.linkedBookingReference}
|
||||
note={clearance.linkedBookingReviewNote}
|
||||
scheduledDate={clearance.linkedBookingScheduledDate}
|
||||
canResubmit={canResubmitBooking}
|
||||
onResubmitted={() => {
|
||||
void refetch();
|
||||
void refetchContract();
|
||||
refetchBookingMilestonesIfLinked();
|
||||
}}
|
||||
/>
|
||||
) : bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
|
||||
@@ -135,9 +135,10 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
// The queue joins the company relation — show its name, never the raw uuid.
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
: (contract.company?.name ?? "—"),
|
||||
tradeDirection: contract.tradeDirection ?? "—",
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
|
||||
@@ -50,6 +50,7 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import {
|
||||
@@ -587,6 +588,11 @@ export default function ContractRequestDetailPage() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{contract.isHazardous ? (
|
||||
<Box mb="md">
|
||||
<HazardDeclarationPanel contract={contract} />
|
||||
</Box>
|
||||
) : null}
|
||||
{(contract.cargoScope ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No cargo scope lines.
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -903,7 +926,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"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
@@ -2534,6 +2533,13 @@ export const api = {
|
||||
bookingsService.reviewOperation(id, decision, { note }),
|
||||
),
|
||||
|
||||
proceedToOperation: endpoint<
|
||||
{ id: string; scheduledDate: string },
|
||||
BookingDetail
|
||||
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
|
||||
bookingsService.proceedToOperation(id, scheduledDate),
|
||||
),
|
||||
|
||||
generateContract: endpoint<{ id: string }, BookingDetail>(
|
||||
"bookings",
|
||||
"generateContract",
|
||||
|
||||
@@ -243,6 +243,14 @@ export const bookingsService = {
|
||||
...options,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Re-request operation on a booking Operations sent back for changes. The
|
||||
* customer path uses the same endpoint from the portal; GL needs it here
|
||||
* because a customs booking is GL's to fix, not the customer's.
|
||||
*/
|
||||
proceedToOperation: (id: string, scheduledDate: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
|
||||
|
||||
generateContract: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user