mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
add permissions and fix issues
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Flag,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={8} align="center">
|
||||
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
{count}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Yard-work modal for the track page's "Log pass" step.
|
||||
*
|
||||
* A train runs A→B→C→D and bookings board/alight at any stop, so logging the
|
||||
* pass at a yard is the moment its yard work happens: bookings destined here
|
||||
* flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
|
||||
* instant the pass is logged, and bookings boarding here become loadable —
|
||||
* the server only accepts a load while the train's latest checkpoint is this
|
||||
* yard. The modal therefore drives the sequence: log the pass first, then
|
||||
* load anything that boards here (including cargo the operator forgot — it
|
||||
* stays loadable until the next pass is logged).
|
||||
*/
|
||||
export function LogPassYardWorkModal({
|
||||
opened,
|
||||
onClose,
|
||||
scheduleId,
|
||||
station,
|
||||
isFinal,
|
||||
alreadyLogged,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
scheduleId: string;
|
||||
station: TrackStation | null;
|
||||
isFinal: boolean;
|
||||
/** True when opened for the current station (pass already logged). */
|
||||
alreadyLogged: boolean;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
||||
const logged = alreadyLogged || justLogged;
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
|
||||
|
||||
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
|
||||
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
|
||||
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
|
||||
|
||||
const doLogPass = () => {
|
||||
if (!station) return;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setJustLogged(true);
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — remaining bookings marked arrived, assets freed"
|
||||
: `Pass logged at ${station.label}`,
|
||||
description: isFinal
|
||||
? undefined
|
||||
: arrivals.some((r) => r.canUnload)
|
||||
? "Bookings arriving here have been marked arrived."
|
||||
: undefined,
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not log checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const doLoad = (row: YardWorkBookingRow) => {
|
||||
load.mutate(
|
||||
{ scheduleId, bookingId: row.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: `${row.reference ?? "Booking"} loaded`,
|
||||
description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not load booking",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const hasWork = boarders.length > 0 || arrivals.length > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||
<Text fw={700}>
|
||||
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
|
||||
</Text>
|
||||
{logged ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
{isFinal ? "Arrived" : "Pass logged"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{yardWorkQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : !hasWork ? (
|
||||
<Alert color="gray" variant="light" radius="md" icon={<MapPin size={16} />}>
|
||||
No bookings board or alight at this station.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Arriving here ─────────────────────────────────────────── */}
|
||||
{arrivals.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<SectionLabel
|
||||
icon={<Flag size={14} />}
|
||||
title="Arriving at this yard"
|
||||
count={arrivals.length}
|
||||
/>
|
||||
{!logged ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Logging the pass marks the loaded bookings below as Arrived
|
||||
(import/export) or Completed (intercity) automatically.
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{arrivals.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{arrivals.length > 0 && boarders.length > 0 ? <Divider /> : null}
|
||||
|
||||
{/* ── Boarding here ─────────────────────────────────────────── */}
|
||||
{boarders.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<SectionLabel
|
||||
icon={<TrainFront size={14} />}
|
||||
title="Boarding at this yard"
|
||||
count={boarders.length}
|
||||
/>
|
||||
{!logged && pendingBoarders.length > 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Log the pass first — the train must be at {station?.label} before
|
||||
cargo can be loaded.
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Loaded</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{boarders.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
GOV
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.loadedAt ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<CheckCircle2
|
||||
size={13}
|
||||
color="var(--mantine-color-edr-green-7)"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDate(row.loadedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Not loaded
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!row.loadedAt ? (
|
||||
<Tooltip
|
||||
label={
|
||||
!logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "Booking is not ready to load (payment pending)"
|
||||
: "Confirm cargo loaded onto the train"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!logged || !row.canLoad}
|
||||
loading={
|
||||
load.isPending && load.variables?.bookingId === row.id
|
||||
}
|
||||
onClick={() => doLoad(row)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{logged && pendingBoarders.length > 0
|
||||
? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.`
|
||||
: ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{!logged ? (
|
||||
<Button
|
||||
color={isFinal ? "teal" : "edr-green"}
|
||||
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
loading={recordCheckpoint.isPending}
|
||||
onClick={doLogPass}
|
||||
>
|
||||
{isFinal
|
||||
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||
: `Log pass at ${station?.label ?? "station"}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -166,9 +166,6 @@ export function ScheduleWorkspacePanel({
|
||||
const setLoading = useMutation(
|
||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||
);
|
||||
const confirmLoading = useMutation(
|
||||
api.trainScheduling.confirmLoading.mutationOptions(),
|
||||
);
|
||||
const moveSchedule = useMutation(
|
||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||
);
|
||||
@@ -301,25 +298,6 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Point the pool booking at the chosen same-day train, then put it on wagons.
|
||||
// If the wagon step fails (that train is short too) the booking stays paid &
|
||||
// unassigned in the pool — nothing is lost, staff just pick another train.
|
||||
@@ -460,53 +438,8 @@ 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}
|
||||
{/* Loading confirmation gate removed: bookings can board mid-corridor,
|
||||
so per-yard loading happens from the track page's log-pass flow. */}
|
||||
|
||||
{/* Two-panel board */}
|
||||
<Group align="stretch" gap="lg" grow wrap="wrap">
|
||||
|
||||
@@ -199,7 +199,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ export const FREIGHT_PERMS = {
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||
signStaff: "edr_freight_app:contracts:sign_staff",
|
||||
signStaff: {
|
||||
bulk: "edr_freight_app:contracts:sign_staff:bulk",
|
||||
container: "edr_freight_app:contracts:sign_staff:container",
|
||||
},
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
createBooking: "edr_freight_app:contracts:create_booking",
|
||||
@@ -57,7 +60,6 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
manage: "edr_freight_app:train_scheduling:manage",
|
||||
create: "edr_freight_app:train_scheduling:create",
|
||||
update: "edr_freight_app:train_scheduling:update",
|
||||
cancel: "edr_freight_app:train_scheduling:cancel",
|
||||
@@ -509,8 +511,18 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
|
||||
}
|
||||
|
||||
/** Any train-scheduling write action (create / update / cancel / reschedule). */
|
||||
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
|
||||
return (
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.cancel) ||
|
||||
hasPermission(user, FREIGHT_PERMS.trainScheduling.reschedule)
|
||||
);
|
||||
}
|
||||
|
||||
export function canCreateSchedule(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.create);
|
||||
}
|
||||
|
||||
export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||
@@ -546,12 +558,17 @@ export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||
}
|
||||
|
||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
||||
export type RuleEngineAction = "view" | "create" | "update" | "delete";
|
||||
|
||||
export function ruleEngineActionKey(
|
||||
slug: RuleEngineResourceSlug,
|
||||
action: RuleEngineAction,
|
||||
): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:${action}`;
|
||||
}
|
||||
|
||||
export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
|
||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||
return ruleEngineActionKey(slug, "view");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,10 +589,19 @@ export function canApproveRuleEngineChange(
|
||||
export function canAccessRuleEngineResource(
|
||||
user: AuthUser | null | undefined,
|
||||
slug: RuleEngineResourceSlug,
|
||||
mode: "view" | "manage",
|
||||
mode: RuleEngineAction,
|
||||
): boolean {
|
||||
const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug);
|
||||
return hasPermission(user, key);
|
||||
return hasPermission(user, ruleEngineActionKey(slug, mode));
|
||||
}
|
||||
|
||||
/** Holds any write action on the resource — for surfaces gated on "can edit at all". */
|
||||
export function canWriteRuleEngineResource(
|
||||
user: AuthUser | null | undefined,
|
||||
slug: RuleEngineResourceSlug,
|
||||
): boolean {
|
||||
return (["create", "update", "delete"] as const).some((a) =>
|
||||
canAccessRuleEngineResource(user, slug, a),
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessAnyRuleEngineView(
|
||||
|
||||
@@ -20,6 +20,8 @@ import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuc
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* Staff contract preview + sign. Staff must open and read the generated
|
||||
@@ -30,6 +32,7 @@ export default function ContractViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
@@ -116,6 +119,15 @@ export default function ContractViewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Counter-signature permission is split per freight type — a bulk signer must
|
||||
// not sign a container contract (API enforces the same on POST /contract/sign).
|
||||
const maySign = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.signStaff[
|
||||
data.freightType === "BULK" ? "bulk" : "container"
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box p={{ base: "md", md: "xl" }}>
|
||||
<Box maw={920} mx="auto">
|
||||
@@ -145,7 +157,7 @@ export default function ContractViewPage() {
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
{data.canSignStaff && (
|
||||
{data.canSignStaff && maySign && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -43,6 +43,12 @@ const FleetResourcePage = () => {
|
||||
const canCreate = canFleetAction(user, slug, "create");
|
||||
const canUpdate = canFleetAction(user, slug, "update");
|
||||
const canDelete = canFleetAction(user, slug, "delete");
|
||||
// Wagon transfer workspace: shown only to holders of a transfer capability
|
||||
// (raise a request, fulfill one, or see the cross-yard history).
|
||||
const canTransfer =
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferRequest) ||
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -411,15 +417,17 @@ const FleetResourcePage = () => {
|
||||
Yard Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
{canTransfer ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{canCreate ? (
|
||||
|
||||
@@ -114,7 +114,9 @@ const CargoTypesPage = () => {
|
||||
const config = getRuleEngineResource(CARGO_SLUG);
|
||||
|
||||
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||
const canCreate = canAccessRuleEngineResource(user, CARGO_SLUG, "create");
|
||||
const canUpdate = canAccessRuleEngineResource(user, CARGO_SLUG, "update");
|
||||
const canDelete = canAccessRuleEngineResource(user, CARGO_SLUG, "delete");
|
||||
|
||||
// One fetch of the whole (small) set — page-walked because the API caps
|
||||
// pageSize at 100; the tree, ancestry and each level are derived client-side
|
||||
@@ -128,7 +130,7 @@ const CargoTypesPage = () => {
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
@@ -291,7 +293,7 @@ const CargoTypesPage = () => {
|
||||
leftSection={<Search size={16} />}
|
||||
w={240}
|
||||
/>
|
||||
{canManage && (
|
||||
{canCreate && (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
@@ -335,7 +337,7 @@ const CargoTypesPage = () => {
|
||||
? "No cargo categories yet"
|
||||
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||
</Text>
|
||||
{!term && canManage && (
|
||||
{!term && canCreate && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
@@ -354,7 +356,8 @@ const CargoTypesPage = () => {
|
||||
node={node}
|
||||
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||
topBorder={i > 0}
|
||||
canManage={canManage}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||
onDelete={() => setDeleteTarget(node)}
|
||||
@@ -444,7 +447,8 @@ interface CargoRowProps {
|
||||
node: CargoNode;
|
||||
childCount: number;
|
||||
topBorder: boolean;
|
||||
canManage: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -454,7 +458,8 @@ function CargoRow({
|
||||
node,
|
||||
childCount,
|
||||
topBorder,
|
||||
canManage,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -529,19 +534,19 @@ function CargoRow({
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{canManage && (
|
||||
<>
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
{canUpdate && (
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Open" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||
|
||||
@@ -150,9 +150,20 @@ const RuleEngineResourcePage = () => {
|
||||
const canView = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
||||
);
|
||||
const canManage = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "manage"),
|
||||
// Per-action gates replace the retired coarse "manage": Add shows only with
|
||||
// create, row Edit with update, row Delete with delete.
|
||||
const canCreate = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "create"),
|
||||
);
|
||||
const canUpdate = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "update"),
|
||||
);
|
||||
const canDelete = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "delete"),
|
||||
);
|
||||
// Update-class controls (reorder, rate submit/approve, approval-rule decide)
|
||||
// all map to the update permission — the matching endpoints now require it.
|
||||
const canUpdateControls = canUpdate;
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
@@ -480,7 +491,7 @@ const RuleEngineResourcePage = () => {
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{config.orderConfig && canManage ? (
|
||||
{config.orderConfig && canUpdateControls ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
orderConfig={config.orderConfig}
|
||||
@@ -493,7 +504,7 @@ const RuleEngineResourcePage = () => {
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canManage}
|
||||
readOnly={!canUpdateControls}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
@@ -504,8 +515,8 @@ const RuleEngineResourcePage = () => {
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
@@ -514,7 +525,7 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
return base;
|
||||
}, [
|
||||
canManage,
|
||||
canUpdateControls,
|
||||
config,
|
||||
isRates,
|
||||
pendingByRateId,
|
||||
@@ -642,7 +653,7 @@ const RuleEngineResourcePage = () => {
|
||||
title={config.label}
|
||||
subtitle={config.subtitle}
|
||||
action={
|
||||
canManage && config.slug !== "container-types" ? (
|
||||
canCreate && config.slug !== "container-types" ? (
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
@@ -653,7 +664,7 @@ const RuleEngineResourcePage = () => {
|
||||
{isPriorityRules ? (
|
||||
<PriorityRuleApprovalsSection
|
||||
requests={priorityWorkflow.pending.data ?? []}
|
||||
canDecide={canManage}
|
||||
canDecide={canUpdateControls}
|
||||
approve={priorityWorkflow.approve}
|
||||
reject={priorityWorkflow.reject}
|
||||
/>
|
||||
@@ -742,7 +753,7 @@ const RuleEngineResourcePage = () => {
|
||||
showSearch={Boolean(config.supportsSearch)}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onManageOrder={
|
||||
canManage && config.orderConfig
|
||||
canUpdateControls && config.orderConfig
|
||||
? () => setOrderDialogOpen(true)
|
||||
: undefined
|
||||
}
|
||||
@@ -806,16 +817,16 @@ const RuleEngineResourcePage = () => {
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
readOnly={!canManage}
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
readOnly={!canUpdate && !canDelete}
|
||||
onEdit={canUpdate ? openEdit : undefined}
|
||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
Flag,
|
||||
MapPin,
|
||||
Navigation,
|
||||
PackageCheck,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -25,7 +28,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import type { TrackStation } from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -148,6 +153,18 @@ export default function TrainScheduleTrackPage() {
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||
}),
|
||||
);
|
||||
const [yardModal, setYardModal] = useState<{
|
||||
station: TrackStation;
|
||||
isFinal: boolean;
|
||||
alreadyLogged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
@@ -181,8 +198,26 @@ export default function TrainScheduleTrackPage() {
|
||||
const inTransit = track.status === "DISPATCHED";
|
||||
const arrived = track.status === "ARRIVED";
|
||||
|
||||
// Yard work at a station: boarders not yet loaded, and loaded bookings that
|
||||
// alight there. When either exists, logging the pass goes through the modal
|
||||
// so the operator sees (and can act on) both lists; empty yards log directly.
|
||||
const yardWorkFor = (station: TrackStation | undefined) =>
|
||||
yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||
const stationHasWork = (station: TrackStation | undefined) => {
|
||||
const yard = yardWorkFor(station);
|
||||
return Boolean(
|
||||
yard &&
|
||||
(yard.toLoad.some((r) => !r.loadedAt) || yard.toUnload.some((r) => r.canUnload)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
if (station && stationHasWork(station)) {
|
||||
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||
return;
|
||||
}
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
@@ -203,6 +238,15 @@ export default function TrainScheduleTrackPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// "Forgot to load" catch: while the train sits at the current station, any
|
||||
// boarder there that is still unloaded can be loaded until the next pass.
|
||||
const currentStationObj = track.stations.find(
|
||||
(s) => s.sequenceNo === track.currentSequenceNo,
|
||||
);
|
||||
const currentYard = canLog ? yardWorkFor(currentStationObj) : undefined;
|
||||
const forgottenBoarders =
|
||||
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Button
|
||||
@@ -416,6 +460,43 @@ export default function TrainScheduleTrackPage() {
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
loadable until the next pass is logged. */}
|
||||
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title={`${forgottenBoarders.length} booking${
|
||||
forgottenBoarders.length === 1 ? "" : "s"
|
||||
} at ${currentStationObj.label} not loaded yet`}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<Text size="sm">
|
||||
The train is at {currentStationObj.label} — cargo boarding here can
|
||||
still be loaded before the next station is logged.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() =>
|
||||
setYardModal({
|
||||
station: currentStationObj,
|
||||
isFinal:
|
||||
currentStationObj.sequenceNo ===
|
||||
track.stations[totalStations - 1]?.sequenceNo,
|
||||
alreadyLogged: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open yard work
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -505,6 +586,15 @@ export default function TrainScheduleTrackPage() {
|
||||
</Timeline>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<LogPassYardWorkModal
|
||||
opened={yardModal !== null}
|
||||
onClose={() => setYardModal(null)}
|
||||
scheduleId={scheduleId}
|
||||
station={yardModal?.station ?? null}
|
||||
isFinal={yardModal?.isFinal ?? false}
|
||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -398,11 +398,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
!b.loadedAt &&
|
||||
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
|
||||
).length;
|
||||
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
|
||||
// confirmed in the workspace — surface it as a blocker, not just a warning.
|
||||
const loadingBlocksDispatch =
|
||||
schedule.requiresLoadingConfirmation === true &&
|
||||
schedule.loadingConfirmed !== true;
|
||||
// No loading hard-block: bookings may board mid-corridor, so loading happens
|
||||
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||
const hasDispatchWarnings =
|
||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||
|
||||
@@ -1271,22 +1268,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
{loadingBlocksDispatch ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Loading not confirmed"
|
||||
>
|
||||
This import train cannot depart until loading is confirmed. Use{" "}
|
||||
<Text span fw={700}>
|
||||
Confirm loading
|
||||
</Text>{" "}
|
||||
in the Workspace tab first.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
@@ -1309,8 +1290,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Text span fw={700}>
|
||||
{unloadedCount}
|
||||
</Text>{" "}
|
||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
|
||||
unloaded
|
||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded
|
||||
yet — mid-route boarders load from the track page when the train
|
||||
reaches their yard
|
||||
</List.Item>
|
||||
) : null}
|
||||
{intercityNotLoadedCount > 0 ? (
|
||||
@@ -1350,7 +1332,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={dispatch.isPending}
|
||||
disabled={loadingBlocksDispatch}
|
||||
onClick={() => void runDispatch()}
|
||||
>
|
||||
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
||||
|
||||
@@ -55,6 +55,8 @@ import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateSchedule } from "@/lib/permissions";
|
||||
import type {
|
||||
FreightType,
|
||||
TrainScheduleListFilters,
|
||||
@@ -99,6 +101,8 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
export default function TrainScheduleV2ListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = canCreateSchedule(user);
|
||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -550,9 +554,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
title="Train Schedules"
|
||||
subtitle="Operational train scheduling with full allocation workflow."
|
||||
action={
|
||||
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
|
||||
New schedule
|
||||
</Button>
|
||||
canCreate ? (
|
||||
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
|
||||
New schedule
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -657,7 +657,16 @@ export const api = {
|
||||
"finalize-schedule",
|
||||
(id) => trainSchedulingService.finalizeSchedule(id),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
// Finalize only flips statuses (schedule DRAFT→SCHEDULED + each booking's
|
||||
// schedulingStatus) — it never touches allocation or loading. Refresh only
|
||||
// the schedule detail/list + booking views instead of the broad
|
||||
// train-scheduling ROOT, which refired the eligible-bookings / pool /
|
||||
// yard-work queries and made the booking lists visibly reload.
|
||||
() => [
|
||||
["train-scheduling", "schedule"],
|
||||
["train-scheduling", "schedules"],
|
||||
QUERY_KEYS.BOOKINGS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface ContractView {
|
||||
contractId: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
freightType: "BULK" | "CONTAINER";
|
||||
templateKey: string;
|
||||
title: string;
|
||||
html: string;
|
||||
|
||||
Reference in New Issue
Block a user