mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -53,8 +53,9 @@ import { bookingsService } from "@/services/bookings.service";
|
||||
/**
|
||||
* Export customs flow, ordered per the stakeholder process:
|
||||
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
|
||||
* → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ)
|
||||
* → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm.
|
||||
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
|
||||
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
|
||||
* → final invoice (DJ) + customer slip + GL confirm.
|
||||
*/
|
||||
export function computeExportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
@@ -77,8 +78,8 @@ export function computeExportActiveStep(
|
||||
}
|
||||
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
|
||||
if (!clearance.train?.arrivedAt) return 6;
|
||||
if (!clearance.gatepassGranted) return 7;
|
||||
if (!clearance.t1Closed) return 8;
|
||||
if (!clearance.t1Closed) return 7;
|
||||
if (!clearance.gatepassGranted) return 8;
|
||||
if (clearance.finalInvoice?.status !== "PAID") return 9;
|
||||
return 10;
|
||||
}
|
||||
@@ -395,17 +396,9 @@ export function ExportClearanceStepper({
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Gate pass"
|
||||
description="Secured on the train schedule after arrival"
|
||||
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
||||
>
|
||||
<GatepassStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Accept T1"
|
||||
description="GL Djibouti closes the transport document"
|
||||
description="GL Djibouti closes once the train arrives"
|
||||
icon={clearance.t1Closed ? <CheckCircle2 size={14} /> : <PackageCheck size={14} />}
|
||||
>
|
||||
<AcceptT1Step
|
||||
@@ -420,6 +413,14 @@ export function ExportClearanceStepper({
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Gate pass"
|
||||
description="Secured on the train schedule after arrival"
|
||||
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
||||
>
|
||||
<GatepassStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Final invoice & payment"
|
||||
description="GL Djibouti invoices after offload; customer pays"
|
||||
@@ -554,6 +555,7 @@ function AcceptT1Step({
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const files = exportTransitFilesFromWorkflow(workflowFiles);
|
||||
const arrived = Boolean(clearance.train?.arrivedAt);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -582,9 +584,9 @@ function AcceptT1Step({
|
||||
pendingLabel={
|
||||
!transportIssued
|
||||
? "Waiting for the transport document."
|
||||
: clearance.gatepassGranted
|
||||
? "Gate pass granted — GL Djibouti accepts (closes) the T1."
|
||||
: "Available after the gate pass is granted."
|
||||
: arrived
|
||||
? "Train arrived — GL Djibouti accepts (closes) the T1."
|
||||
: "Available once the train arrives at Djibouti."
|
||||
}
|
||||
doneLabel=""
|
||||
/>
|
||||
@@ -592,7 +594,7 @@ function AcceptT1Step({
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!transportIssued || !clearance.gatepassGranted}
|
||||
disabled={!transportIssued || !arrived}
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -120,7 +120,7 @@ export function GlClearanceUploadModal({
|
||||
/>
|
||||
) : (
|
||||
<DateInput
|
||||
label="Vessel departure date (optional)"
|
||||
label="Vessel arrival date (optional)"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
size="sm"
|
||||
|
||||
@@ -98,6 +98,7 @@ function computeImportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
bookingCreated: boolean,
|
||||
bookingMilestones: MilestoneRow[],
|
||||
t1Uploaded: boolean,
|
||||
): number {
|
||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||
@@ -119,16 +120,24 @@ function computeImportActiveStep(
|
||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||
if (!bookingCreated) return 7;
|
||||
if (!clearance.gatepassGranted) return 8;
|
||||
if (!clearance.t1?.closed) return 9;
|
||||
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10;
|
||||
if (!t1Uploaded && !clearance.t1?.closed) return 9;
|
||||
if (!clearance.t1?.closed) return 10;
|
||||
// Risk is "assigned" when the booking milestone says so OR the clearance view
|
||||
// already carries a riskLevel. The ET page derives its bookingMilestones from a
|
||||
// separately-fetched booking id that can lag or mismatch the booking carrying
|
||||
// the milestone — `clearance.riskLevel` is server truth and matches the badge.
|
||||
const riskAssigned =
|
||||
Boolean(clearance.riskLevel) ||
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||
if (!riskAssigned) return 11;
|
||||
// Additional duty round is optional — resolved once skipped or paid.
|
||||
const secondDutyResolved =
|
||||
clearance.secondDuty?.skipped ||
|
||||
clearance.secondDuty?.paid ||
|
||||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
||||
if (!secondDutyResolved) return 11;
|
||||
if (!clearance.importReleaseGranted) return 12;
|
||||
return 13;
|
||||
if (!secondDutyResolved) return 12;
|
||||
if (!clearance.importReleaseGranted) return 13;
|
||||
return 14;
|
||||
}
|
||||
|
||||
function t1FilesFromWorkflow(
|
||||
@@ -226,12 +235,25 @@ export function PhasedClearanceActionPanel({
|
||||
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
|
||||
const actionBookingId =
|
||||
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
|
||||
const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0;
|
||||
// Risk is assigned when the clearance view carries a riskLevel (server truth,
|
||||
// drives the badge) OR the fetched booking milestone confirms it. Kept in sync
|
||||
// with computeImportActiveStep so the stepper never freezes on a page whose
|
||||
// bookingMilestones lag/mismatch the booking that holds the milestone.
|
||||
const riskAssigned =
|
||||
Boolean(clearance.riskLevel) ||
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||
const activeStep = useMemo(
|
||||
() =>
|
||||
isImport
|
||||
? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones)
|
||||
? computeImportActiveStep(
|
||||
clearance,
|
||||
effectiveBookingCreated,
|
||||
bookingMilestones,
|
||||
t1Uploaded,
|
||||
)
|
||||
: 0,
|
||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones],
|
||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
|
||||
);
|
||||
|
||||
if (isImport) {
|
||||
@@ -544,27 +566,50 @@ export function PhasedClearanceActionPanel({
|
||||
|
||||
<Stepper.Step
|
||||
label="T1 transport documents"
|
||||
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
|
||||
description="GL Djibouti uploads after the gate pass is secured"
|
||||
icon={
|
||||
clearance.t1?.closed ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||
t1Uploaded || clearance.t1?.closed ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<Truck size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ImportT1Section
|
||||
<ImportT1UploadStep
|
||||
t1={clearance.t1 ?? null}
|
||||
gatepassGranted={Boolean(clearance.gatepassGranted)}
|
||||
workflowFiles={workflowFiles}
|
||||
canDjAct={showDj && canDj}
|
||||
canEtAct={showEt && canEt}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Close T1"
|
||||
description="GL Ethiopia closes once the train arrives"
|
||||
icon={
|
||||
clearance.t1?.closed ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<PackageCheck size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ImportT1CloseStep
|
||||
t1={clearance.t1 ?? null}
|
||||
t1Uploaded={t1Uploaded}
|
||||
canEtAct={showEt && canEt}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Customs risk"
|
||||
description="GL Ethiopia assigns Green / Yellow / Red"
|
||||
icon={
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? (
|
||||
riskAssigned ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<ShieldAlert size={14} />
|
||||
@@ -575,7 +620,7 @@ export function PhasedClearanceActionPanel({
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showEt && canEt}
|
||||
done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
|
||||
done={riskAssigned}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
@@ -589,7 +634,7 @@ export function PhasedClearanceActionPanel({
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showEt && canEt}
|
||||
riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
|
||||
riskAssigned={riskAssigned}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
@@ -645,26 +690,29 @@ export function PhasedClearanceActionPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportT1Section({
|
||||
/**
|
||||
* GL Djibouti uploads the T1 transport documents (multi-file) once the gate
|
||||
* pass is secured on the train schedule; locked on departure or T1 close.
|
||||
*/
|
||||
function ImportT1UploadStep({
|
||||
t1,
|
||||
gatepassGranted,
|
||||
workflowFiles = [],
|
||||
canDjAct,
|
||||
canEtAct,
|
||||
onChanged,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
t1: Freight.ClearanceT1State | null;
|
||||
gatepassGranted: boolean;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
canDjAct: boolean;
|
||||
canEtAct: boolean;
|
||||
onChanged?: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const uploaded = t1FilesFromWorkflow(workflowFiles);
|
||||
const replaceMode = uploaded.length > 0;
|
||||
@@ -680,8 +728,8 @@ function ImportT1Section({
|
||||
}
|
||||
|
||||
const departed = Boolean(t1.trainDepartedAt);
|
||||
const arrived = Boolean(t1.trainArrivedAt);
|
||||
const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
|
||||
const canUpload =
|
||||
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -707,7 +755,7 @@ function ImportT1Section({
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel="T1 accepted and closed by GL Ethiopia — documents are final."
|
||||
doneLabel="T1 documents are final — closed by GL Ethiopia."
|
||||
/>
|
||||
) : !t1.wagonAllocated ? (
|
||||
<StepStatus
|
||||
@@ -715,6 +763,12 @@ function ImportT1Section({
|
||||
pendingLabel="Waiting for operations to allocate wagons."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : !gatepassGranted ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : departed ? (
|
||||
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
|
||||
The train has departed — T1 documents are locked and can no longer be changed.
|
||||
@@ -725,6 +779,8 @@ function ImportT1Section({
|
||||
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : uploaded.length > 0 && !canUpload ? (
|
||||
<StepStatus done pendingLabel="" doneLabel="T1 documents uploaded." />
|
||||
) : null}
|
||||
|
||||
{canUpload ? (
|
||||
@@ -771,40 +827,103 @@ function ImportT1Section({
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
{canEtAct && !t1.closed ? (
|
||||
arrived ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
The train has arrived — review the T1 documents and close (accept) them.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={closing}
|
||||
disabled={uploaded.length === 0}
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
onClick={async () => {
|
||||
setClosing(true);
|
||||
try {
|
||||
await contractsService.closeT1(t1.bookingId);
|
||||
toast.success("T1 closed");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept & close T1
|
||||
</Button>
|
||||
</Stack>
|
||||
) : departed ? (
|
||||
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
|
||||
Train en route — T1 can be closed once it arrives in Ethiopia.
|
||||
</Alert>
|
||||
) : null
|
||||
) : null}
|
||||
/**
|
||||
* GL Ethiopia closes (accepts) the T1 set with one click once the train has
|
||||
* arrived. Separate step from the GL Djibouti upload.
|
||||
*/
|
||||
function ImportT1CloseStep({
|
||||
t1,
|
||||
t1Uploaded,
|
||||
canEtAct,
|
||||
onChanged,
|
||||
}: {
|
||||
t1: Freight.ClearanceT1State | null;
|
||||
t1Uploaded: boolean;
|
||||
canEtAct: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
if (!t1) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Available once the shipment booking is created."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (t1.closed) {
|
||||
return (
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel={`T1 accepted and closed by GL Ethiopia${
|
||||
t1.closedAt ? ` · ${new Date(t1.closedAt).toLocaleString()}` : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!t1Uploaded) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!t1.trainArrivedAt) {
|
||||
return (
|
||||
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
|
||||
{t1.trainDepartedAt
|
||||
? "Train en route — T1 can be closed once it arrives in Ethiopia."
|
||||
: "T1 can be closed once the train arrives in Ethiopia."}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canEtAct) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Train arrived — waiting for GL Ethiopia to close the T1."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
The train has arrived — review the T1 documents and close (accept) them.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={closing}
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
onClick={async () => {
|
||||
setClosing(true);
|
||||
try {
|
||||
await contractsService.closeT1(t1.bookingId);
|
||||
toast.success("T1 closed");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept & close T1
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { isAxiosError } from "axios";
|
||||
import { Clock, Info, Moon, Sun } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import DurationField from "@/components/trainScheduling/DurationField";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
|
||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
importWindowLeadDays: 3,
|
||||
};
|
||||
|
||||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||
function hourLabel(hour: number): string {
|
||||
const period = hour < 12 ? "AM" : "PM";
|
||||
const h12 = hour % 12 === 0 ? 12 : hour % 12;
|
||||
return `${h12}:00 ${period}`;
|
||||
}
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
|
||||
value: String(h),
|
||||
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
|
||||
}));
|
||||
|
||||
interface FormState {
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number | "";
|
||||
docReviewMinutes: number | "";
|
||||
paymentWindowMinutes: number | "";
|
||||
importWindowLeadDays: number | "";
|
||||
}
|
||||
|
||||
function parseError(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface BookingWindowSettingsModalProps {
|
||||
scheduleId: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful save (e.g. to refetch a list). */
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-schedule booking-window settings editor. Prefills from the schedule's own
|
||||
* rule snapshot, lets staff tune the daily desk hours / durations for just that
|
||||
* train, and saves an override. Only editable before the window opens.
|
||||
*/
|
||||
export default function BookingWindowSettingsModal({
|
||||
scheduleId,
|
||||
opened,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: BookingWindowSettingsModalProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
...api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
}),
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
});
|
||||
const schedule = detailQuery.data;
|
||||
|
||||
const save = useMutation(
|
||||
api.trainScheduling.updateScheduleWindowRule.mutationOptions(),
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
|
||||
// Seed the form from the schedule's snapshot once it loads (or when reopened).
|
||||
useEffect(() => {
|
||||
if (!opened || !schedule) return;
|
||||
const r = schedule.windowRule;
|
||||
setForm({
|
||||
windowOpenHour: r?.windowOpenHour ?? DEFAULTS.windowOpenHour,
|
||||
windowCloseHour: r?.windowCloseHour ?? DEFAULTS.windowCloseHour,
|
||||
windowDurationHours: r?.windowDurationHours ?? DEFAULTS.windowDurationHours,
|
||||
docReviewMinutes: r?.docReviewMinutes ?? DEFAULTS.docReviewMinutes,
|
||||
paymentWindowMinutes:
|
||||
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
|
||||
importWindowLeadDays:
|
||||
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
|
||||
});
|
||||
}, [opened, schedule]);
|
||||
|
||||
const isExport = schedule?.direction === "EXPORT";
|
||||
const canEdit = schedule?.windowPhase === "PRE_WINDOW";
|
||||
const is24h =
|
||||
form != null && form.windowOpenHour === form.windowCloseHour;
|
||||
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning),
|
||||
// not an error — the engine wraps it across midnight.
|
||||
const isOvernight =
|
||||
form != null && form.windowCloseHour < form.windowOpenHour;
|
||||
|
||||
const reopenSummary = useMemo(() => {
|
||||
if (!form) return "";
|
||||
const doc = Number(form.docReviewMinutes) || 0;
|
||||
const pay = Number(form.paymentWindowMinutes) || 0;
|
||||
const total = doc + pay;
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
|
||||
return parts.length ? parts.join(" ") : "0m";
|
||||
}, [form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!scheduleId || !form) return;
|
||||
// Numeric fields must hold real values.
|
||||
const duration = Number(form.windowDurationHours);
|
||||
const doc = Number(form.docReviewMinutes);
|
||||
const pay = Number(form.paymentWindowMinutes);
|
||||
const lead = Number(form.importWindowLeadDays);
|
||||
if (
|
||||
form.windowDurationHours === "" ||
|
||||
form.docReviewMinutes === "" ||
|
||||
form.paymentWindowMinutes === "" ||
|
||||
form.importWindowLeadDays === "" ||
|
||||
!Number.isFinite(duration) ||
|
||||
!Number.isFinite(doc) ||
|
||||
!Number.isFinite(pay) ||
|
||||
!Number.isFinite(lead)
|
||||
) {
|
||||
toast({
|
||||
title: "Fill every field before saving",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload: UpdateScheduleWindowRulePayload = {
|
||||
windowOpenHour: form.windowOpenHour,
|
||||
windowCloseHour: form.windowCloseHour,
|
||||
windowDurationHours: duration,
|
||||
docReviewMinutes: doc,
|
||||
paymentWindowMinutes: pay,
|
||||
importWindowLeadDays: lead,
|
||||
};
|
||||
|
||||
try {
|
||||
await save.mutateAsync({ id: scheduleId, payload });
|
||||
toast({ title: "Booking window settings updated" });
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update booking window"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Clock size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} lh={1.2}>
|
||||
Booking window settings
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{schedule?.route?.name ?? "This schedule only"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{detailQuery.isLoading || !form ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : !canEdit ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
icon={<Info size={16} />}
|
||||
title="Window already open"
|
||||
>
|
||||
Booking window settings can only be changed before the window opens.
|
||||
This schedule is currently{" "}
|
||||
<b>{String(schedule?.windowPhase ?? "not window-managed")}</b>.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{isExport ? (
|
||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||
Export schedules use a single FCFS lead window — the daily desk
|
||||
hours below don't apply, only the lead time does.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* ── Daily desk hours ─────────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="center" mb={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
Daily desk hours (EAT)
|
||||
</Text>
|
||||
{is24h ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Moon size={12} />}
|
||||
>
|
||||
24-hour desk
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Sun size={12} />}
|
||||
>
|
||||
{hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Opens"
|
||||
data={HOUR_OPTIONS}
|
||||
value={String(form.windowOpenHour)}
|
||||
onChange={(v) =>
|
||||
v != null &&
|
||||
setForm((f) => f && { ...f, windowOpenHour: Number(v) })
|
||||
}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<Select
|
||||
label="Closes"
|
||||
data={HOUR_OPTIONS}
|
||||
value={String(form.windowCloseHour)}
|
||||
onChange={(v) =>
|
||||
v != null &&
|
||||
setForm((f) => f && { ...f, windowCloseHour: Number(v) })
|
||||
}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
disabled={isExport}
|
||||
/>
|
||||
</Group>
|
||||
{isOvernight && !is24h ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Overnight desk — opens {form.windowOpenHour}:00 and runs past
|
||||
midnight, closing {form.windowCloseHour}:00 the next morning.
|
||||
</Text>
|
||||
) : null}
|
||||
<Switch
|
||||
mt="sm"
|
||||
size="sm"
|
||||
color="grape"
|
||||
label="Run 24 hours a day (never pause overnight)"
|
||||
checked={is24h}
|
||||
disabled={isExport}
|
||||
onChange={(e) => {
|
||||
const checked = e.currentTarget.checked;
|
||||
setForm((f) => {
|
||||
if (!f) return f;
|
||||
// On → close == open (24h desk). Off → restore a normal ~9h
|
||||
// day, always kept ≥ open hour so it never lands invalid.
|
||||
const close = checked
|
||||
? f.windowOpenHour
|
||||
: Math.min(23, f.windowOpenHour + 9);
|
||||
return { ...f, windowCloseHour: close };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!isExport ? (
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
A not-yet-full train pauses at the close hour and resumes the next
|
||||
morning at the open hour, every day until it fills or departs.
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Cycle timing ─────────────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
Cycle timing
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<DurationField
|
||||
label="Window duration"
|
||||
description="How long each booking cycle stays open before it closes for review"
|
||||
value={form.windowDurationHours}
|
||||
nativeUnit="hours"
|
||||
onChange={(v) =>
|
||||
setForm((f) => f && { ...f, windowDurationHours: v })
|
||||
}
|
||||
min={0.0166}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<Group grow align="flex-start">
|
||||
<DurationField
|
||||
label="Document review"
|
||||
description="Staff time to accept documents after the window closes"
|
||||
value={form.docReviewMinutes}
|
||||
nativeUnit="minutes"
|
||||
onChange={(v) =>
|
||||
setForm((f) => f && { ...f, docReviewMinutes: v })
|
||||
}
|
||||
min={0}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<DurationField
|
||||
label="Payment window"
|
||||
description="Time a selected customer has to pay"
|
||||
value={form.paymentWindowMinutes}
|
||||
nativeUnit="minutes"
|
||||
onChange={(v) =>
|
||||
setForm((f) => f && { ...f, paymentWindowMinutes: v })
|
||||
}
|
||||
min={1}
|
||||
disabled={isExport}
|
||||
/>
|
||||
</Group>
|
||||
{!isExport ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Reopen gap after each cycle = document review + payment ={" "}
|
||||
<b>{reopenSummary}</b>.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||||
<NumberInput
|
||||
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
|
||||
description={
|
||||
isExport
|
||||
? "How many days before departure export booking opens"
|
||||
: "How many days before departure the booking window starts"
|
||||
}
|
||||
value={form.importWindowLeadDays}
|
||||
onChange={(v) =>
|
||||
setForm(
|
||||
(f) =>
|
||||
f && {
|
||||
...f,
|
||||
importWindowLeadDays: v === "" ? "" : Number(v),
|
||||
},
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
clampBehavior="none"
|
||||
allowDecimal={false}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={save.isPending}>
|
||||
Save settings
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { isAxiosError } from "axios";
|
||||
import { CalendarClock, Info } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
function parseError(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** ISO → the `YYYY-MM-DDTHH:mm` value a datetime-local input expects (local time). */
|
||||
function toLocalInputValue(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
export interface EditScheduleDateModalProps {
|
||||
scheduleId: string | null;
|
||||
currentDate: string | null;
|
||||
routeName?: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful save (e.g. to refetch a list). */
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule a train's departure date. Only shown for schedules whose booking
|
||||
* window has not opened yet; the API rejects a date inside the booking lead
|
||||
* window (import/intercity lead in days, export in hours).
|
||||
*/
|
||||
export default function EditScheduleDateModal({
|
||||
scheduleId,
|
||||
currentDate,
|
||||
routeName,
|
||||
opened,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditScheduleDateModalProps) {
|
||||
const { toast } = useToast();
|
||||
const save = useMutation(
|
||||
api.trainScheduling.updateScheduleDate.mutationOptions(),
|
||||
);
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValue(toLocalInputValue(currentDate));
|
||||
}, [opened, currentDate]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!scheduleId || !value) {
|
||||
toast({ title: "Pick a departure date", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await save.mutateAsync({
|
||||
id: scheduleId,
|
||||
scheduleDate: new Date(value).toISOString(),
|
||||
});
|
||||
toast({ title: "Departure date updated" });
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update departure date"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<CalendarClock size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} lh={1.2}>
|
||||
Edit departure date
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{routeName ?? "This schedule only"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||
The date can only be changed before the booking window opens, and must
|
||||
still leave room for the booking lead window before departure.
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
CheckCircle2,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Repeat,
|
||||
PackageX,
|
||||
// Repeat, // used by the hidden Move (reassign) button
|
||||
Train,
|
||||
Weight,
|
||||
X,
|
||||
@@ -152,6 +153,12 @@ export function ScheduleWorkspacePanel({
|
||||
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
|
||||
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const setLoading = useMutation(
|
||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||
);
|
||||
const confirmLoading = useMutation(
|
||||
api.trainScheduling.confirmLoading.mutationOptions(),
|
||||
);
|
||||
const moveSchedule = useMutation(
|
||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||
);
|
||||
@@ -237,6 +244,50 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
const toggleLoaded = (
|
||||
bookingId: string,
|
||||
ref: string,
|
||||
next: "LOADED" | "UNLOADED",
|
||||
) => {
|
||||
setLoading
|
||||
.mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next })
|
||||
.then(() => {
|
||||
toast({
|
||||
title:
|
||||
next === "LOADED"
|
||||
? `${ref} marked loaded`
|
||||
: `${ref} marked unloaded`,
|
||||
});
|
||||
onChanged();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not update loading status",
|
||||
description: apiErrorMessage(error, "Please try again."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const doConfirmLoading = () => {
|
||||
confirmLoading
|
||||
.mutateAsync({ id: schedule.id })
|
||||
.then(() => {
|
||||
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
|
||||
onChanged();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not confirm loading",
|
||||
description: apiErrorMessage(
|
||||
error,
|
||||
"Grant the Djibouti gatepass first, then confirm loading.",
|
||||
),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const doMove = () => {
|
||||
if (!moveBookingId || !moveTarget) return;
|
||||
moveSchedule
|
||||
@@ -268,7 +319,7 @@ export function ScheduleWorkspacePanel({
|
||||
<div>
|
||||
<Text fw={700}>Allocation workspace</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Manually add ready-to-pay bookings, remove, or reassign them
|
||||
Manually add paid, unassigned bookings, remove, or reassign them
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -347,17 +398,65 @@ export function ScheduleWorkspacePanel({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Loading confirmation — required before dispatch for import-Djibouti
|
||||
trains; shown for every direction so staff have one place to confirm. */}
|
||||
{canManage ? (
|
||||
<Group
|
||||
gap={10}
|
||||
p="sm"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-yellow-0)",
|
||||
border: `1px solid ${
|
||||
schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-yellow-3)"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||
{schedule.loadingConfirmed ? (
|
||||
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
|
||||
) : (
|
||||
<PackageCheck size={18} color="#B7791F" />
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.loadingConfirmed
|
||||
? "Loading confirmed — cleared to dispatch"
|
||||
: "Confirm loading before dispatching this train"}
|
||||
</Text>
|
||||
</Group>
|
||||
{!schedule.loadingConfirmed ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={confirmLoading.isPending}
|
||||
onClick={doConfirmLoading}
|
||||
>
|
||||
Confirm loading
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{/* Two-panel board */}
|
||||
<Group align="stretch" gap="lg" grow wrap="wrap">
|
||||
{/* Pool */}
|
||||
<PanelColumn
|
||||
title="Ready to pay"
|
||||
hint="Accepted · this route & day"
|
||||
title="Paid · unassigned"
|
||||
hint="Paid · this route & day · not on a train"
|
||||
count={pool.length}
|
||||
accent="#F2A516"
|
||||
loading={poolQuery.isLoading}
|
||||
emptyIcon={Inbox}
|
||||
emptyText="No ready-to-pay bookings waiting for this train."
|
||||
emptyText="No paid, unassigned bookings waiting for this train."
|
||||
>
|
||||
{pool.map((b) => (
|
||||
<BookingCard
|
||||
@@ -402,9 +501,53 @@ export function ScheduleWorkspacePanel({
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
status={b.status}
|
||||
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
||||
right={
|
||||
canManage ? (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{b.wagonAssigned ? (
|
||||
<Tooltip
|
||||
label={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "Mark cargo unloaded from wagon"
|
||||
: "Mark cargo loaded onto wagon"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "light"
|
||||
: "filled"
|
||||
}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED" ? (
|
||||
<PackageX size={13} />
|
||||
) : (
|
||||
<PackageCheck size={13} />
|
||||
)
|
||||
}
|
||||
loading={setLoading.isPending}
|
||||
onClick={() =>
|
||||
toggleLoaded(
|
||||
b.id,
|
||||
b.reference ?? b.id.slice(0, 8),
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "UNLOADED"
|
||||
: "LOADED",
|
||||
)
|
||||
}
|
||||
>
|
||||
{(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "Unload"
|
||||
: "Load"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{/* Reassign-to-another-train — hidden for now.
|
||||
<Tooltip label="Reassign to another train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -420,6 +563,7 @@ export function ScheduleWorkspacePanel({
|
||||
Move
|
||||
</Button>
|
||||
</Tooltip>
|
||||
*/}
|
||||
<Tooltip label="Remove from this train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -565,12 +709,14 @@ function BookingCard({
|
||||
customer,
|
||||
weightTons,
|
||||
status,
|
||||
loadingStatus,
|
||||
right,
|
||||
}: {
|
||||
reference: string;
|
||||
customer?: string | null;
|
||||
weightTons?: number | null;
|
||||
status?: string | null;
|
||||
loadingStatus?: "LOADED" | "UNLOADED";
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -596,6 +742,16 @@ function BookingCard({
|
||||
{reference}
|
||||
</Text>
|
||||
{status ? <BookingStatusBadge status={status} /> : null}
|
||||
{loadingStatus ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={loadingStatus === "LOADED" ? "filled" : "light"}
|
||||
color={loadingStatus === "LOADED" ? "edr-green" : "gray"}
|
||||
>
|
||||
{loadingStatus === "LOADED" ? "Loaded" : "Unloaded"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={10} align="center" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
|
||||
Reference in New Issue
Block a user