Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-23 11:51:04 +00:00
217 changed files with 17041 additions and 1071 deletions

View File

@@ -271,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: FREIGHT_PERMS.fleet.view,
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
},
// {
@@ -295,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Vehicles",
@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Contract validity",
href: "/dashboard/configuration/contract-validity-periods",
permission: FREIGHT_PERMS.config.contractValidity.view,
},
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
@@ -586,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
// part of their job (create/rebook under a cleared contract, then view that
// booking's clearance), but those routes live outside the clearance prefix —
// without this allowlist the single-prefix lock bounces them out of their own
// workflow. Matched against location.pathname (no query string).
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
];
const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean =>
@@ -721,7 +726,11 @@ const DashboardShell = () => {
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
}, [location.pathname, sidebarSections]);
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
if (
glClearanceHome &&
!location.pathname.startsWith(glClearanceHome) &&
!GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
) {
return <Navigate to={glClearanceHome} replace />;
}
@@ -1104,7 +1113,7 @@ const App = () => {
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
<RoutesPage />
</RequirePermission>
}
@@ -1112,7 +1121,7 @@ const App = () => {
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1120,7 +1129,7 @@ const App = () => {
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1128,7 +1137,7 @@ const App = () => {
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainDetailPage />
</RequirePermission>
}
@@ -1136,7 +1145,7 @@ const App = () => {
<Route
path="train-builder"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderListPage />
</RequirePermission>
}
@@ -1144,7 +1153,7 @@ const App = () => {
<Route
path="train-builder/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderDetailPage />
</RequirePermission>
}
@@ -1152,7 +1161,7 @@ const App = () => {
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1160,7 +1169,7 @@ const App = () => {
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1168,7 +1177,7 @@ const App = () => {
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1254,7 +1263,7 @@ const App = () => {
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
<RoutesPage />
</RequirePermission>
}
@@ -1342,7 +1351,7 @@ const App = () => {
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1350,7 +1359,7 @@ const App = () => {
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1358,7 +1367,7 @@ const App = () => {
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainDetailPage />
</RequirePermission>
}
@@ -1366,7 +1375,7 @@ const App = () => {
<Route
path="train-builder"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderListPage />
</RequirePermission>
}
@@ -1374,7 +1383,7 @@ const App = () => {
<Route
path="train-builder/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderDetailPage />
</RequirePermission>
}
@@ -1382,7 +1391,7 @@ const App = () => {
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1390,7 +1399,7 @@ const App = () => {
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1398,7 +1407,7 @@ const App = () => {
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage />
</RequirePermission>
}
@@ -1465,14 +1474,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
{/* <Route
path="configuration/contract-validity-periods"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<ContractValidityPeriodsPage />
</RequirePermission>
}
/>
/> */}
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route
path="configuration/cargo-types/:id"

View File

@@ -14,6 +14,8 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
onReviewClearance,
}: ContractActionsToolbarProps) {
const navigate = useNavigate();
const { user } = useAuth();
const { status } = contract;
// Intake permissions are split per freight type: an accept:bulk holder must
// not see the accept button on a container contract (API enforces the same).
const arm = contract.freightType === "BULK" ? "bulk" : "container";
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
const mayRequestChanges = hasPermission(
user,
FREIGHT_PERMS.contracts.requestChanges[arm],
);
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [previewOpen, setPreviewOpen] = useState(false);
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
);
}
const canAccept = status === "SUBMITTED";
const canAccept =
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
// The document stays editable for the whole approval chain, but only by the
// approver whose turn it is. The server resolves that against the caller's
// position type; the client cannot derive it.
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
{canAccept && (
<>
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
{mayAccept && (
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
)}
{mayRequestChanges && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
)}
{mayReject && (
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
)}
</>
)}

View File

@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { canApproveContractStep } from "@/lib/permissions";
type Mutations = ReturnType<typeof useContractMutations>;
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
key={step.id}
step={step}
isNext={actionable && nextPending?.id === step.id}
// Buttons show only to the step's actual approver (matching
// position type): a chief step never offers Approve/Reject to a
// marketing officer. Everyone still sees the "next" highlight.
canAct={canApproveContractStep(user, step.requiredRole)}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
function StepRow({
step,
isNext,
canAct,
isPending,
onApprove,
onReject,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
canAct: boolean;
isPending: boolean;
onApprove: () => void;
onReject: () => void;
@@ -372,8 +381,11 @@ function StepRow({
)}
</Box>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{isNext && step.status === "PENDING" && (
{/* One element type per row: action buttons on the active step (they
already imply "pending & actionable"), a status badge otherwise.
Mixing compact buttons + a badge here made them read as misaligned. */}
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
{isNext && canAct && step.status === "PENDING" ? (
<>
<Button
size="compact-sm"
@@ -395,16 +407,17 @@ function StepRow({
Reject
</Button>
</>
) : (
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
)}
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
</Group>
</Group>
);

View File

@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulk, setBulk] = useState<BulkDraft>({
cargoWeightTons: "",
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
);
};
setPrefilled(true);
// Rebook carries the expired booking's cargo description forward.
if (copyFromBooking.cargoFreeText) {
setCargoDescription(copyFromBooking.cargoFreeText);
}
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
const lineErrors = useMemo<LineErrors[]>(() => {
if (!isContainer || !contract) return [];
return containerLines.map((line) => {
// A line can be 0 (the contract covers both sizes; a booking may only need
// one) but the booking as a whole needs at least one container — anchor
// that error on the first line's quantity so it renders in the field.
const totalQty = containerLines.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
return containerLines.map((line, idx) => {
const errs: LineErrors = {};
const qty = Number(line.quantity || 0);
if (line.quantity.trim() === "") {
errs.quantity = "Quantity is required.";
} else if (Number.isNaN(qty) || qty < 1) {
errs.quantity = "At least 1.";
} else if (line.units.length < qty) {
} else if (Number.isNaN(qty) || qty < 0) {
errs.quantity = "Enter 0 or more.";
} else if (idx === 0 && totalQty < 1) {
errs.quantity = "Book at least one container (either size).";
} else if (qty >= 1 && line.units.length < qty) {
errs.units = `Enter details for all ${qty} container(s).`;
}
if (contract.isHazardous) {
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
const routeError =
multiRoute && !contractRouteId ? "Select a route." : undefined;
const cargoDescriptionError =
isContainer && !cargoDescription.trim()
? "Describe the cargo carried in the containers."
: undefined;
const cargoValid = isContainer
? lineErrors.every(
(e) =>
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
) &&
unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
)
) &&
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
};
if (isContainer) {
// What the containers carry — captured per booking, not on the contract.
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
payload.containers = containerLines
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
)}
{remainderNotice}
<ContractCapacityNotice contractId={contract.id} isContainer />
<Textarea
label="Cargo description *"
description="What do the containers carry on this shipment?"
placeholder="e.g. Electronics, garments, machinery spare parts…"
value={cargoDescription}
onChange={(e) => setCargoDescription(e.currentTarget.value)}
error={showErrors ? cargoDescriptionError : undefined}
radius={10}
autosize
minRows={2}
maxRows={4}
styles={fieldStyles}
/>
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">
This contract has no container sizes in scope.
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={1}
min={0}
value={line.quantity}
error={
showErrors

View File

@@ -19,8 +19,9 @@ export interface FleetCardGridProps {
pageCount: number;
totalCount: number;
onPaginationChange: OnChangeFn<PaginationState>;
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
/** Omit to hide the action (caller lacks the update/delete permission). */
onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
}
const FleetCardGrid = ({

View File

@@ -8,8 +8,9 @@ import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetRecordActionsProps {
record: FleetRecord;
config: FleetResourceConfig;
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
/** Omit to hide the action (caller lacks the update/delete permission). */
onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void;
onViewDetail?: (record: FleetRecord) => void;
@@ -42,6 +43,17 @@ const FleetRecordActions = ({
navigate(config.detailPath.replace(":id", String(record.id)));
};
if (
!onEdit &&
!onRemove &&
!showDetail &&
!showViewDetail &&
!showHistory &&
!(isVehicle && onAssignDriver)
) {
return null;
}
if (layout === "compact") {
return (
<Menu position="bottom-end" withinPortal shadow="md">
@@ -61,12 +73,14 @@ const FleetRecordActions = ({
Assign Driver
</MenuItem>
) : null}
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
{onEdit ? (
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
) : null}
{showViewDetail ? (
<MenuItem
onClick={() => onViewDetail?.(record)}
@@ -91,13 +105,15 @@ const FleetRecordActions = ({
View details
</MenuItem>
) : null}
<MenuItem
color="red"
onClick={() => onRemove(record)}
leftSection={<Trash2 size={14} strokeWidth={2} />}
>
{removeLabel}
</MenuItem>
{onRemove ? (
<MenuItem
color="red"
onClick={() => onRemove(record)}
leftSection={<Trash2 size={14} strokeWidth={2} />}
>
{removeLabel}
</MenuItem>
) : null}
</Menu.Dropdown>
</Menu>
);
@@ -121,12 +137,14 @@ const FleetRecordActions = ({
Assign Driver
</MenuItem>
) : null}
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
{onEdit ? (
<MenuItem
onClick={() => onEdit(record)}
leftSection={<Edit2 size={14} strokeWidth={2} />}
>
Edit
</MenuItem>
) : null}
{showViewDetail ? (
<MenuItem
onClick={() => onViewDetail?.(record)}
@@ -143,13 +161,15 @@ const FleetRecordActions = ({
View details
</MenuItem>
) : null}
<MenuItem
color="red"
onClick={() => onRemove(record)}
leftSection={<Trash2 size={14} strokeWidth={2} />}
>
{removeLabel}
</MenuItem>
{onRemove ? (
<MenuItem
color="red"
onClick={() => onRemove(record)}
leftSection={<Trash2 size={14} strokeWidth={2} />}
>
{removeLabel}
</MenuItem>
) : null}
</Menu.Dropdown>
</Menu>
);

View File

@@ -287,6 +287,8 @@ export type SegmentStripBooking = {
destinationYardId?: string | null;
tradeDirection?: string | null;
wagonsRequired?: number | null;
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
weightTons?: number | null;
};
/**
@@ -300,10 +302,13 @@ export function SegmentOccupancyStrip({
stops,
bookings,
maxWagons,
maxGrossTons,
}: {
stops: Array<{ yardId: string; label: string }>;
bookings: SegmentStripBooking[];
maxWagons?: number | null;
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
maxGrossTons?: number | null;
}) {
if (stops.length < 2) return null;
const lastIdx = stops.length - 1;
@@ -312,6 +317,7 @@ export function SegmentOccupancyStrip({
const segments = stops.slice(0, -1).map((stop, edge) => {
let cargo = 0;
let intercity = 0;
let grossTons = 0;
for (const b of bookings) {
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const to =
@@ -322,8 +328,15 @@ export function SegmentOccupancyStrip({
const wagons = Number(b.wagonsRequired) || 1;
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
else cargo += wagons;
grossTons += Number(b.weightTons) || 0;
}
return { from: stop, to: stops[edge + 1], cargo, intercity };
return {
from: stop,
to: stops[edge + 1],
cargo,
intercity,
grossTons: Math.round(grossTons * 10) / 10,
};
});
const cap = Number(maxWagons) || null;
@@ -393,6 +406,22 @@ export function SegmentOccupancyStrip({
</Text>
) : null}
</Text>
{seg.grossTons > 0 ? (
<Text
size="xs"
ta="center"
fw={600}
c={
maxGrossTons != null && seg.grossTons > maxGrossTons
? "red.7"
: "dimmed"
}
style={{ whiteSpace: "nowrap" }}
>
{seg.grossTons}
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
</Text>
) : null}
</Stack>
{i === segments.length - 1 ? (
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>

View File

@@ -205,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
driverName: truckPrefill.driverName ?? '',
driverLicense: truckPrefill.driverLicense ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
@@ -218,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label: `Customer · ${t.plateNumber}${t.driverName}`,
trailerPlate: '',
driverName: t.driverName,
driverLicense: '',
driverPhone: '',
truckType: t.truckType,
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
@@ -231,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? `${t.driverName}` : ''}`,
trailerPlate: t.trailerPlateNumber ?? '',
driverName: t.driverName ?? '',
driverLicense: t.driverLicense ?? '',
driverPhone: t.driverPhone ?? '',
truckType: t.truckType ?? '',
containerNumbers: splitContainerNumbers(t.containerNumber),
@@ -281,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
// The freight order's truck details are the customer's / fleet's record — the
// gate may FILL blanks (walk-in license, phone) but never edit shown values.
const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate);
const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense);
const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone);
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
/** Load a truck into the form: its saved block if any, else its assignment. */
@@ -293,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
setTruckPlateNumber(plate);
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
setDriverName(block?.driverName || option?.driverName || '');
setDriverLicense(block?.driverLicense || '');
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
setTruckType(block?.truckType || option?.truckType || '');
const loaded = block
@@ -611,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
readOnly={isTrailerLocked}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group>
<Group grow align="flex-start">

View File

@@ -199,6 +199,7 @@ export const QUERY_KEYS = {
MAINTENANCE: {
ROOT: ["maintenance"] as const,
dueBoard: () => ["maintenance", "due-board"] as const,
schedules: (vehicleId?: string) =>
["maintenance", "schedules", vehicleId ?? "all"] as const,
upcoming: (vehicleId?: string) =>
@@ -207,6 +208,8 @@ export const QUERY_KEYS = {
["maintenance", "history", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) =>
["maintenance", "stats", vehicleId ?? "all"] as const,
intervals: (vehicleId?: string) =>
["maintenance", "intervals", vehicleId ?? "all"] as const,
},
FINANCIAL_REPORTS: {

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { AuthUser } from "@/auth/types";
import { canApproveContractStep } from "./permissions";
const withPositionType = (typeKey: string): AuthUser => ({
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
});
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
const withPermission = (permKey: string): AuthUser => ({
permissionKeys: [permKey],
});
describe("canApproveContractStep", () => {
it("shows to the matching position type only", () => {
const chief = withPositionType("-marketing-chief");
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
// a marketing officer must NOT see the chief step's buttons
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
});
it("lets super/org admins action any step", () => {
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
true,
);
expect(
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
).toBe(true);
});
it("resolves legacy chain roles via their position-type aliases", () => {
const director = withPositionType("operation-director");
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
expect(canApproveContractStep(director, "CEO")).toBe(false);
});
it("honours the role's own legacy approve permission", () => {
const staff = withPermission(
"edr_freight_app:contracts:approve_director",
);
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
});
it("does NOT show to holders of an unrelated approve permission", () => {
// the dropped blanket fallback: a line-staff approver is not a chief
const lineStaff = withPermission(
"edr_freight_app:contracts:approve_line_staff",
);
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
});
it("returns false without a user or role", () => {
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
});
});

View File

@@ -29,9 +29,19 @@ export const FREIGHT_PERMS = {
},
contracts: {
view: "edr_freight_app:contracts:view",
staffAccept: "edr_freight_app:contracts:staff_accept",
requestChanges: "edr_freight_app:contracts:request_changes",
reject: "edr_freight_app:contracts:reject",
// Intake actions are split per freight type — mirror of the API registry.
staffAccept: {
bulk: "edr_freight_app:contracts:staff_accept:bulk",
container: "edr_freight_app:contracts:staff_accept:container",
},
requestChanges: {
bulk: "edr_freight_app:contracts:request_changes:bulk",
container: "edr_freight_app:contracts:request_changes:container",
},
reject: {
bulk: "edr_freight_app:contracts:reject:bulk",
container: "edr_freight_app:contracts:reject:container",
},
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
approveDirector: "edr_freight_app:contracts:approve_director",
approveCeo: "edr_freight_app:contracts:approve_ceo",
@@ -240,12 +250,6 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
config: {
contractValidity: {
view: "edr_freight_app:config:contract_validity:view",
manage: "edr_freight_app:config:contract_validity:manage",
},
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
@@ -420,6 +424,53 @@ export function hasPermission(
return getPermissionKeys(user).includes(key);
}
// Legacy chain roles predate position types; map each to the position types
// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
// button visibility matches what the approve/reject endpoint will accept.
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
DIRECTOR: ["director", "operation-director"],
CEO: ["chief", "deputy"],
};
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
/**
* Can this user action a contract approval step requiring `requiredRole`?
*
* `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
* chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
* to show Approve/Reject only to the step's actual approver — a chief step
* shows only to a chief, a marketing-officer step only to that officer.
*
* Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
* also lets through anyone holding any contract-approve permission (a fallback
* for delegates whose token omits the position type). That blanket is what made
* every approver see the button, so it is dropped here: the visibility rule is
* admin OR the matching position type (direct / legacy alias) OR the role's own
* legacy approve permission. The server still guards the mutation.
*/
export function canApproveContractStep(
user: AuthUser | null | undefined,
requiredRole: string | null | undefined,
): boolean {
if (!user || !requiredRole) return false;
if (isFreightApprovalAdmin(user)) return true;
const positionTypes = getPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
return Boolean(legacyPermission && hasPermission(user, legacyPermission));
}
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
@@ -466,6 +517,31 @@ export function canViewFleet(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.fleet.view);
}
export type FleetCrudResource =
| "locomotives"
| "wagons"
| "trains"
| "routes"
| "containers"
| "cargoes"
| "vehicles"
| "drivers";
/**
* Per-resource fleet CRUD check. The legacy coarse fleet:manage key still
* grants every action (mirrors the API's one-of guard fallback).
*/
export function canFleetAction(
user: AuthUser | null | undefined,
resource: FleetCrudResource,
action: "create" | "update" | "delete",
): boolean {
return (
hasPermission(user, FREIGHT_PERMS[resource][action]) ||
hasPermission(user, FREIGHT_PERMS.fleet.manage)
);
}
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.admin);
}

View File

@@ -6,7 +6,6 @@ import {
LayoutGrid,
Milestone,
Package,
ShieldCheck,
} from "lucide-react";
import {
Container,
@@ -36,7 +35,6 @@ import {
BookingCompanyCard,
BookingContractSummaryCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
BookingDocumentsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
@@ -129,13 +127,9 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
// bookings are handled in the Global Logistics clearance queue instead.
const showClearanceTab =
!booking.customsClearingEnabled &&
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
booking.status,
);
// Clearance review + finalize now lives solely on the Operations "Clearance
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
// no clearance tab is embedded here anymore.
// A general contract drives an "Orders" tab: each drawdown order spawns a
// child booking that staff manage (clearance/approval) independently.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
@@ -143,13 +137,11 @@ export default function BookingRequestDetailPage() {
// customs-workflow, invoice or notice files — so the tab bar always renders.
const requestedTab = searchParams.get("tab");
const activeTab =
requestedTab === "clearance" && showClearanceTab
? "clearance"
: requestedTab === "orders" && isGeneralContract
? "orders"
: requestedTab === "documents"
? "documents"
: "overview";
requestedTab === "orders" && isGeneralContract
? "orders"
: requestedTab === "documents"
? "documents"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -209,14 +201,6 @@ export default function BookingRequestDetailPage() {
Orders
</Tabs.Tab>
)}
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Customer clearance
</Tabs.Tab>
)}
<Tabs.Tab
value="documents"
leftSection={<FolderOpen size={16} />}
@@ -236,14 +220,6 @@ export default function BookingRequestDetailPage() {
/>
</Tabs.Panel>
)}
{showClearanceTab && (
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
)}
<Tabs.Panel value="documents">
<BookingDocumentsPanel bookingId={booking.id} />
</Tabs.Panel>

View File

@@ -138,9 +138,14 @@ export default function ClearanceDocumentsPage() {
const generalQuery = useQuery({
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
queryFn: () =>
// Per-booking self-clearance instances are drawdowns under GENERAL
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
// is one-time) with contractKind=GENERAL, so filtering on
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
// =false + the three per-booking clearance statuses already isolate
// exactly this worklist — the same set the old booking-request tab showed.
bookingsService.list({
statuses: bookingStatuses,
bookingType: "GENERAL_CONTRACT",
customsClearingEnabled: "false",
page,
pageSize: PAGE_SIZE,

View File

@@ -93,6 +93,11 @@ export default function ContractClearanceDetailPage() {
}, [clearance]);
const reference = contract?.reference ?? "Clearance";
// Path A (non-customs) → Operations reviews & finalizes; Path B (customs) → GL.
// This page serves BOTH hubs (Ops "Clearance Documents" + GL "Document
// Clearance"), so the reviewer is decided by the contract, not the hub — a
// hardcoded value routes non-customs finalize to the GL endpoint and 409s.
const selfClear = !contract?.customsClearingEnabled;
const phasedCustoms =
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
const docsPhaseComplete =
@@ -338,7 +343,7 @@ export default function ContractClearanceDetailPage() {
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
selfClear={selfClear}
readOnly={reviewReadOnly}
approvalsLocked={phasedCustoms && docReviewLocked}
queriesLocked={queriesLocked}

View File

@@ -19,7 +19,6 @@ import {
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
Users,
} from "lucide-react";
@@ -51,7 +50,6 @@ 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 { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import {
@@ -74,15 +72,14 @@ import {
import type { CustomerDocument } from "@/types/customer";
import type { Freight } from "@edr/types";
// Clearance phase — staff can still ACT (approve / query / finalize).
// Clearance phase — actionable (docs approve / query / finalize on the hub).
const CLEARANCE_ACTIVE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
// see which documents were approved, by whom, and when.
// Clearance is done — its documents are still worth loading (read-only record).
const CLEARANCE_DONE_STATUSES = [
"ACTIVE_SHIPMENT_IN_PROGRESS",
"FULLY_EXECUTED",
@@ -91,7 +88,9 @@ const CLEARANCE_DONE_STATUSES = [
"EXPIRED",
];
// Show the Clearance Review tab in either phase (active or done).
// Contract is in (or past) its clearance phase — load the clearance view so the
// Documents tab can show customs workflow files, and surface the "Review
// clearance" deep-link to the Operations hub.
const CLEARANCE_REVIEW_STATUSES = [
...CLEARANCE_ACTIVE_STATUSES,
...CLEARANCE_DONE_STATUSES,
@@ -151,13 +150,13 @@ export default function ContractRequestDetailPage() {
}
};
const showClearanceTabQuery = Boolean(
const hasClearancePhase = Boolean(
contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status),
);
const { data: clearanceView } = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
queryFn: () => contractsService.getClearance(id!),
enabled: Boolean(id) && showClearanceTabQuery,
enabled: Boolean(id) && hasClearancePhase,
});
// Customer profile documents (national ID, TIN, import/business license) for
@@ -270,18 +269,10 @@ export default function ContractRequestDetailPage() {
contract.status === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const phasedCustoms =
contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
const docsPhaseComplete =
clearanceView?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
) ?? false;
const clearanceApprovalsLocked = phasedCustoms && docsPhaseComplete;
// Once clearance is finalized the tab is informational only — no approve/query.
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
const selfClear = !contract.customsClearingEnabled;
// Clearance review + finalize now lives solely on the Operations "Clearance
// Documents" hub. The Staff-actions "Review clearance" button deep-links there
// while the contract is in a clearance-review status — no embedded tab here.
const inClearanceReview = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const files = contract.files ?? [];
const contractPdf = files.find((f) => f.code === "contract");
// Signature files (code `signature_<role>`) are baked into the contract PDF —
@@ -303,9 +294,7 @@ export default function ContractRequestDetailPage() {
? "documents"
: requestedTab === "customer"
? "customer"
: requestedTab === "clearance" && showClearanceTab
? "clearance"
: "details";
: "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
@@ -485,39 +474,13 @@ export default function ContractRequestDetailPage() {
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
Customer
</Tabs.Tab>
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Clearance Review
</Tabs.Tab>
)}
</Tabs.List>
</Tabs>
<Grid gap="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{currentTab === "clearance" ? (
<Stack gap="lg">
<ContractClearanceReviewSection
contractId={id!}
selfClear={selfClear}
readOnly={clearanceReadOnly}
phasedCustoms={phasedCustoms}
approvalsLocked={clearanceApprovalsLocked}
onChanged={() => refetch()}
/>
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
<ClearanceWorkflowFilesPanel
files={clearanceView!.workflowFiles!}
onView={view}
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
/>
) : null}
</Stack>
) : currentTab === "documents" ? (
{currentTab === "documents" ? (
<Stack gap="lg">
<ContractDocumentsCard
files={contractDocuments}
@@ -727,7 +690,12 @@ export default function ContractRequestDetailPage() {
contract={contract}
mutations={mutations}
onReviewClearance={
showClearanceTab ? () => setTab("clearance") : undefined
inClearanceReview
? () =>
navigate(
`/dashboard/contracts/clearance-documents/${contract.id}`,
)
: undefined
}
/>
{showApprovalCard && (

View File

@@ -3,6 +3,8 @@ import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -37,6 +39,10 @@ const FleetResourcePage = () => {
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
const config = getFleetResource(slug);
const { toast } = useToast();
const { user } = useAuth();
const canCreate = canFleetAction(user, slug, "create");
const canUpdate = canFleetAction(user, slug, "update");
const canDelete = canFleetAction(user, slug, "delete");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
@@ -278,12 +284,16 @@ const FleetResourcePage = () => {
record={row.original}
config={config}
layout="compact"
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
onAssignDriver={setAssigningDriver}
onEdit={
canUpdate
? (record) => {
setEditing(record);
setFormOpen(true);
}
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
onHistory={setHistoryTarget}
/>
</div>
@@ -291,7 +301,7 @@ const FleetResourcePage = () => {
});
return base;
}, [config, dynamicOptions.yards]);
}, [config, dynamicOptions.yards, canUpdate, canDelete]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -390,15 +400,17 @@ const FleetResourcePage = () => {
<Group gap="sm">
{slug === "wagons" ? (
<>
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
{canUpdate ? (
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
) : null}
<Button
variant="light"
color="grape"
@@ -410,12 +422,14 @@ const FleetResourcePage = () => {
</Button>
</>
) : null}
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
{config.addLabel}
</Button>
{canCreate ? (
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
{config.addLabel}
</Button>
) : null}
</Group>
</Group>
@@ -529,11 +543,15 @@ const FleetResourcePage = () => {
pageCount={pageCount}
totalCount={filteredRows.length}
onPaginationChange={setPagination}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
onEdit={
canUpdate
? (record) => {
setEditing(record);
setFormOpen(true);
}
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
/>
)}
</Stack>

View File

@@ -14,8 +14,10 @@ import {
Text,
Title,
Container,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { Plus } from 'lucide-react';
import { CheckCircle2, Plus, Trash2 } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
@@ -26,6 +28,7 @@ interface MaintenanceSchedule {
id: string;
vehicleId: string;
maintenanceType: string;
serviceItem?: string | null;
description: string;
scheduledDate: string;
completedDate?: string;
@@ -35,8 +38,36 @@ interface MaintenanceSchedule {
serviceProvider?: string;
}
interface DueBoardRow {
scheduleId: string;
vehicleId: string;
plateNumber: string;
maintenanceType: string;
serviceItem: string | null;
description: string;
scheduledDate: string;
nextDueDate: string | null;
nextDueKm: number | null;
currentKm: number | null;
kmRemaining: number | null;
daysRemaining: number | null;
overdue: boolean;
}
interface MaintenanceInterval {
id: string;
vehicleId: string;
maintenanceType: string;
serviceItem: string | null;
intervalKm: number | null;
intervalDays: number | null;
description: string | null;
isActive: boolean;
}
const emptyForm = {
maintenanceType: 'PREVENTIVE',
serviceItem: '',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
estimatedCost: 0,
@@ -44,12 +75,34 @@ const emptyForm = {
notes: '',
};
const emptyIntervalForm = {
maintenanceType: 'PREVENTIVE',
serviceItem: '',
intervalKm: '' as number | '',
intervalDays: '' as number | '',
description: '',
};
export function MaintenancePage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [openScheduleModal, setOpenScheduleModal] = useState(false);
const [formData, setFormData] = useState(emptyForm);
const [intervalForm, setIntervalForm] = useState(emptyIntervalForm);
const [completeTarget, setCompleteTarget] = useState<MaintenanceSchedule | null>(null);
const [completeOdometer, setCompleteOdometer] = useState<number | ''>('');
const [completeCost, setCompleteCost] = useState<number | ''>('');
// Maintenance is driven by time AND km, not a picked-then-scheduled action —
// this is the fleet-wide board of what's actually due, by date or mileage.
const { data: dueBoard, isLoading: dueLoading } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.dueBoard(),
queryFn: async () => {
const res = await api.get('/maintenance/due-board');
return (res.data || []) as DueBoardRow[];
},
});
const { data: vehiclesData } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
@@ -69,7 +122,32 @@ export function MaintenancePage() {
enabled: !!selectedVehicle,
});
const { data: intervals } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''),
queryFn: async () => {
if (!selectedVehicle) return [];
const res = await api.get(`/maintenance/intervals/${selectedVehicle}`);
return (res.data || []) as MaintenanceInterval[];
},
enabled: !!selectedVehicle,
});
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : [];
const invalidateVehicle = () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT });
};
const onError = (err: unknown) => {
toast({
title: 'Error',
description:
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'Failed',
variant: 'destructive',
});
};
const scheduleMutation = useMutation({
mutationFn: async () => {
@@ -77,24 +155,79 @@ export function MaintenancePage() {
const res = await api.post('/maintenance/schedules', {
vehicleId: selectedVehicle,
...formData,
serviceItem: formData.serviceItem.trim() || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: 'Maintenance scheduled' });
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
});
invalidateVehicle();
setOpenScheduleModal(false);
setFormData(emptyForm);
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
onError,
});
// Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling
// of the next service when a maintenance completes with an odometer reading.
const intervalMutation = useMutation({
mutationFn: async () => {
if (!selectedVehicle) return;
const res = await api.post('/maintenance/intervals', {
vehicleId: selectedVehicle,
maintenanceType: intervalForm.maintenanceType,
serviceItem: intervalForm.serviceItem.trim() || undefined,
intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm),
intervalDays:
intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays),
description: intervalForm.description.trim() || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: 'Interval saved' });
invalidateVehicle();
setIntervalForm(emptyIntervalForm);
},
onError,
});
const deactivateIntervalMutation = useMutation({
mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`),
onSuccess: () => {
toast({ title: 'Interval deactivated' });
invalidateVehicle();
},
onError,
});
// Completion with odometer: the reading is what advances KM-based
// scheduling — the API auto-creates the next SCHEDULED item from it.
const completeMutation = useMutation({
mutationFn: async () => {
if (!completeTarget) return;
const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, {
status: 'COMPLETED',
completedDate: new Date().toISOString(),
odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer),
actualCost: completeCost === '' ? undefined : Number(completeCost),
});
return res.data;
},
onSuccess: () => {
toast({
title: 'Maintenance completed',
description:
completeOdometer === ''
? 'No odometer recorded — next service was NOT auto-scheduled.'
: 'Next service auto-scheduled from the recorded odometer.',
});
invalidateVehicle();
setCompleteTarget(null);
setCompleteOdometer('');
setCompleteCost('');
},
onError,
});
const vehicleOptions =
@@ -132,6 +265,64 @@ export function MaintenancePage() {
</Group>
<Stack gap="md">
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Due Board by date and driven km</Text>
</Card.Section>
<Card.Section p="md">
{dueLoading ? (
<Text>Loading</Text>
) : dueBoard && dueBoard.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Service Item</Table.Th>
<Table.Th>Next Due Date</Table.Th>
<Table.Th>Next Due Km</Table.Th>
<Table.Th>Current Km</Table.Th>
<Table.Th>Remaining</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{dueBoard.map((row) => (
<Table.Tr
key={row.scheduleId}
onClick={() => setSelectedVehicle(row.vehicleId)}
style={{ cursor: 'pointer' }}
>
<Table.Td>{row.plateNumber}</Table.Td>
<Table.Td>{row.maintenanceType}</Table.Td>
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
<Table.Td>
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
</Table.Td>
<Table.Td>{row.nextDueKm ?? '—'}</Table.Td>
<Table.Td>{row.currentKm ?? '—'}</Table.Td>
<Table.Td>
{row.kmRemaining != null
? `${row.kmRemaining} km`
: row.daysRemaining != null
? `${row.daysRemaining} d`
: '—'}
</Table.Td>
<Table.Td>
<Badge color={row.overdue ? 'edr-red' : 'edr-blue'}>
{row.overdue ? 'OVERDUE' : 'SCHEDULED'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">Nothing scheduled fleet-wide</Text>
)}
</Card.Section>
</Card>
<Card withBorder padding="md">
<Select
label="Select Vehicle"
@@ -150,50 +341,179 @@ export function MaintenancePage() {
</Text>
</Card>
) : (
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : upcomingList.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{upcomingList.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>
{m.estimatedCost != null
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
: '—'}
</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
<>
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Service Intervals drives auto-scheduling</Text>
<Text size="xs" c="dimmed">
e.g. oil change every 10,000 km. On completion with an odometer reading, the
next service is scheduled automatically at reading + interval.
</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="sm">
{intervalList.length > 0 && (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Service Item</Table.Th>
<Table.Th>Every (km)</Table.Th>
<Table.Th>Every (days)</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{intervalList.map((i) => (
<Table.Tr key={i.id}>
<Table.Td>{i.maintenanceType}</Table.Td>
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
<Table.Td>{i.description ?? '—'}</Table.Td>
<Table.Td>
<Tooltip label="Deactivate — stops auto-scheduling">
<ActionIcon
variant="subtle"
color="red"
onClick={() => deactivateIntervalMutation.mutate(i.id)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group align="flex-end" gap="sm" wrap="wrap">
<Select
label="Type"
w={150}
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
value={intervalForm.maintenanceType}
onChange={(v) =>
setIntervalForm({ ...intervalForm, maintenanceType: v || 'PREVENTIVE' })
}
/>
<TextInput
label="Service item"
placeholder="e.g. oil change"
w={170}
value={intervalForm.serviceItem}
onChange={(e) =>
setIntervalForm({ ...intervalForm, serviceItem: e.currentTarget.value })
}
/>
<NumberInput
label="Every (km)"
min={0}
w={130}
value={intervalForm.intervalKm}
onChange={(v) =>
setIntervalForm({ ...intervalForm, intervalKm: v === '' ? '' : Number(v) })
}
/>
<NumberInput
label="Every (days)"
min={0}
w={130}
value={intervalForm.intervalDays}
onChange={(v) =>
setIntervalForm({
...intervalForm,
intervalDays: v === '' ? '' : Number(v),
})
}
/>
<TextInput
label="Description"
placeholder="Oil and filter change"
style={{ flex: 1, minWidth: 160 }}
value={intervalForm.description}
onChange={(e) =>
setIntervalForm({ ...intervalForm, description: e.currentTarget.value })
}
/>
<Button
color="edr-green"
leftSection={<Plus size={14} />}
loading={intervalMutation.isPending}
disabled={
intervalForm.intervalKm === '' && intervalForm.intervalDays === ''
}
onClick={() => intervalMutation.mutate()}
>
Save interval
</Button>
</Group>
</Stack>
</Card.Section>
</Card>
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : upcomingList.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Service Item</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
</Table.Thead>
<Table.Tbody>
{upcomingList.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>
{m.estimatedCost != null
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
: '—'}
</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
<Table.Td>
{(m.status === 'SCHEDULED' || m.status === 'IN_PROGRESS') && (
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<CheckCircle2 size={13} />}
onClick={() => setCompleteTarget(m)}
>
Complete
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
</>
)}
</Stack>
@@ -210,6 +530,12 @@ export function MaintenancePage() {
value={formData.maintenanceType}
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
/>
<TextInput
label="Service item"
placeholder="e.g. oil change — links this schedule to its interval"
value={formData.serviceItem}
onChange={(e) => setFormData({ ...formData, serviceItem: e.currentTarget.value })}
/>
<TextInput
label="Description"
placeholder="What needs to be done?"
@@ -254,6 +580,49 @@ export function MaintenancePage() {
</Group>
</Stack>
</Modal>
<Modal
opened={completeTarget != null}
onClose={() => setCompleteTarget(null)}
title={`Complete maintenance${completeTarget?.serviceItem ? `${completeTarget.serviceItem}` : ''}`}
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Record the odometer at completion the next service is auto-scheduled at reading +
interval (e.g. completed at 50,000 km with a 10,000 km interval next due at 60,000
km).
</Text>
<NumberInput
label="Odometer reading (km)"
placeholder="e.g. 50000"
min={0}
required
value={completeOdometer}
onChange={(v) => setCompleteOdometer(v === '' ? '' : Number(v))}
/>
<NumberInput
label="Actual cost (ETB)"
min={0}
value={completeCost}
onChange={(v) => setCompleteCost(v === '' ? '' : Number(v))}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setCompleteTarget(null)}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<CheckCircle2 size={15} />}
loading={completeMutation.isPending}
disabled={completeOdometer === ''}
onClick={() => completeMutation.mutate()}
>
Complete & schedule next
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -36,6 +36,8 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
@@ -160,6 +162,10 @@ export default function RoutesPage() {
const { viewMode, setViewMode } = useFleetViewMode("routes");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { toast } = useToast();
const { user } = useAuth();
const canCreate = canFleetAction(user, "routes", "create");
const canUpdate = canFleetAction(user, "routes", "update");
const canDelete = canFleetAction(user, "routes", "delete");
const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions());
@@ -441,26 +447,30 @@ export default function RoutesPage() {
<Eye size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
<Edit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Mark stop working">
<ActionIcon
variant="subtle"
color="red"
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
onClick={() => handleDeactivate(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
{canUpdate ? (
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
<Edit size={16} />
</ActionIcon>
</Tooltip>
) : null}
{canDelete ? (
<Tooltip label="Mark stop working">
<ActionIcon
variant="subtle"
color="red"
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
onClick={() => handleDeactivate(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
),
},
];
}, [deactivateMutation.isPending]);
}, [deactivateMutation.isPending, canUpdate, canDelete]);
return (
<PageContainer>
@@ -468,9 +478,11 @@ export default function RoutesPage() {
title="Routes"
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
action={
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
Add route
</Button>
canCreate ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
Add route
</Button>
) : undefined
}
/>
@@ -555,9 +567,11 @@ export default function RoutesPage() {
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
View
</Button>
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
Edit
</Button>
{canUpdate ? (
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
Edit
</Button>
) : null}
</Group>
</Stack>
</Card>

View File

@@ -13,6 +13,7 @@ import {
Loader,
Modal,
Stack,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
let country: string | undefined;
if (appliesTo === "INTERCITY") {
country = "Ethiopia";
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
} else if (
appliesTo === "CONTAINER" ||
appliesTo === "BULK" ||
// Customs clearance + empty-container return are sold per direction +
// route, so their yard dropdowns narrow exactly like base freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set
// rather than defaulting to one and letting it read as a real choice.
@@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
// Category tabs (rates page): the active tab's filters go to the backend.
const [activeTab, setActiveTab] = useState<string>(
config?.listTabs?.[0]?.key ?? "",
);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
sortOrder: "ASC" as const,
}
: {}),
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
}),
[
config?.orderConfig,
config?.supportsSearch,
config?.listTabs,
activeTab,
search,
pagination.pageIndex,
pagination.pageSize,
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
}, [config?.slug, setPagination]);
setActiveTab(config?.listTabs?.[0]?.key ?? "");
}, [config?.slug, config?.listTabs, setPagination]);
const { data, isLoading, isError, error } = useRuleEngineList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
@@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => {
<Card p={0}>
<Stack gap={0}>
{config.listTabs && (
<Tabs
value={activeTab}
onChange={(v) => {
setActiveTab(v ?? config.listTabs![0].key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
px="md"
pt="sm"
>
<Tabs.List>
{config.listTabs.map((tab) => (
<Tabs.Tab key={tab.key} value={tab.key}>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
)}
<Box px="md" pt="md" pb="sm" w="100%">
<RuleEngineToolbar
search={search}

View File

@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
label: string;
}
/**
* A category tab above a resource list. The active tab's `filters` are sent to
* the list endpoint verbatim, so filtering happens server-side (values may be
* comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE").
*/
export interface RuleEngineListTab {
key: string;
label: string;
filters: { appliesTo?: string; trigger?: string };
}
export interface RuleEngineResourceConfig {
slug: RuleEngineResourceSlug;
label: string;
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
formFields: FormFieldDef[];
supportsSearch?: boolean;
orderConfig?: RuleEngineOrderConfig;
/** Server-filtered category tabs rendered above the list (rates page). */
listTabs?: RuleEngineListTab[];
/** Primary line on card view (inferred from columns when omitted). */
cardTitleKey?: string;
/** Secondary line under title on card view (inferred when omitted). */
@@ -148,9 +161,15 @@ const RATE_APPLIES_TO = [
/** Surcharge triggers — only relevant when Applies to = Other. */
const RATE_TRIGGERS = [
{ label: "Hazardous cargo", value: "HAZARDOUS" },
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
{
label: "Overweight (export only — import derives from container price)",
value: "OVERWEIGHT",
},
{ label: "Reefer cargo", value: "REEFER" },
{ label: "Empty container return", value: "WITH_RETURN" },
{
label: "Empty container return (import, per route + container type)",
value: "WITH_RETURN",
},
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Lashing (flat, per booking)", value: "LASHING" },
@@ -175,6 +194,15 @@ const INTERCITY_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
/**
* Rates priced per leg: base rail freight, plus the customs clearance fee and
* the empty-container return surcharge (sold per route + container type).
*/
const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
@@ -604,6 +632,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...",
supportsSearch: true,
// Category tabs — each filters server-side by appliesTo / trigger.
listTabs: [
{ key: "all", label: "All", filters: {} },
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
{
key: "trucking",
label: "First / Last mile",
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
},
{
key: "customs",
label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE" },
},
{
key: "return",
label: "Container return",
filters: { trigger: "WITH_RETURN" },
},
{
key: "surcharges",
label: "Surcharges",
filters: {
appliesTo: "OTHER",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
},
},
],
columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
@@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "What makes this surcharge apply?",
showWhen: { field: "appliesTo", equals: ["OTHER"] },
},
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
// ── Trade direction — Bulk & Container base freight, plus the route-
// scoped surcharges (customs clearance; empty-container return, which is
// import-only for now so export is not offered) ────────────────────────
{
name: "tradeDirection",
label: "Trade direction",
type: "select",
required: true,
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
optionsFromValues: (v: Record<string, unknown>) =>
String(v.trigger ?? "") === "WITH_RETURN" &&
String(v.appliesTo ?? "") === "OTHER"
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
},
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{
@@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
getInitialValue: (record) =>
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
},
// ── Container type — Container freight, and container-kind intercity ──
// ── Container type — Container freight, container-kind intercity, and
// the empty-container return surcharge (20ft vs 40ft price differently) ─
{
name: "containerTypeId",
label: "Container type",
@@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select container type (optional)",
showIf: (v) =>
v.appliesTo === "CONTAINER" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
},
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
{
@@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
placeholder: "Where the leg starts",
showIf: isBaseFreightRate,
showIf: isRouteScopedRate,
},
{
name: "destinationYardId",
@@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
placeholder: "Where the leg ends",
showIf: isBaseFreightRate,
showIf: isRouteScopedRate,
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight

View File

@@ -45,6 +45,8 @@ import {
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
@@ -76,6 +78,12 @@ export default function TrainBuilderDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const { user } = useAuth();
const canUpdate = canFleetAction(user, "trains", "update");
const canDelete = canFleetAction(user, "trains", "delete");
const canAssign =
hasPermission(user, FREIGHT_PERMS.trains.assignWagons) ||
hasPermission(user, FREIGHT_PERMS.fleet.manage);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
@@ -162,59 +170,67 @@ export default function TrainBuilderDetailPage() {
</Group>
}
action={
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Menu.Target>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
Actions
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)}
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDisbandOpen(true)}
>
Disband train
</Menu.Item>
</Menu.Dropdown>
</Menu>
canUpdate || canDelete ? (
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Menu.Target>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
Actions
</Button>
</Menu.Target>
<Menu.Dropdown>
{canUpdate ? (
<>
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)}
</>
) : null}
{canDelete ? (
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDisbandOpen(true)}
>
Disband train
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
) : undefined
}
/>
@@ -259,16 +275,18 @@ export default function TrainBuilderDetailPage() {
.
</Text>
<Group gap="xs">
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Replace size={14} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Detach & replace locomotives
</Button>
{canUpdate ? (
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Replace size={14} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Detach & replace locomotives
</Button>
) : null}
<Button
size="compact-sm"
variant="subtle"
@@ -330,7 +348,7 @@ export default function TrainBuilderDetailPage() {
</Stack>
<Grid gap="lg" align="stretch">
{composition.editable ? (
{composition.editable && canAssign ? (
<Grid.Col span={{ base: 12, md: 5 }}>
<Card h="100%">
<Stack gap="sm">
@@ -355,7 +373,7 @@ export default function TrainBuilderDetailPage() {
</Card>
</Grid.Col>
) : null}
<Grid.Col span={{ base: 12, md: composition.editable ? 7 : 12 }}>
<Grid.Col span={{ base: 12, md: composition.editable && canAssign ? 7 : 12 }}>
<Card h="100%">
<Stack gap="sm">
<Text fw={600}>Wagon order</Text>
@@ -364,7 +382,7 @@ export default function TrainBuilderDetailPage() {
</Text>
<ConsistWagonList
wagons={composition.wagons}
editable={composition.editable}
editable={composition.editable && canAssign}
busy={busy}
onReorder={(wagonIds) =>
void withToast(

View File

@@ -35,6 +35,8 @@ import {
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import type {
BuiltTrainListFilters,
BuiltTrainStatus,
@@ -54,6 +56,9 @@ export default function TrainBuilderListPage() {
const [yardFilter, setYardFilter] = useState("ALL");
const [buildOpen, setBuildOpen] = useState(false);
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
const { user } = useAuth();
const canCreate = canFleetAction(user, "trains", "create");
const canUpdate = canFleetAction(user, "trains", "update");
const resetPage = useCallback(() => {
setPagination((prev) =>
@@ -240,24 +245,25 @@ export default function TrainBuilderListPage() {
id: "actions",
header: "",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Edit train ${row.original.code}`}
title="Edit name & train numbers"
onClick={(e) => {
// Row click navigates to the detail page — keep the edit local.
e.stopPropagation();
setEditTarget(row.original);
}}
>
<Pencil size={15} />
</ActionIcon>
),
cell: ({ row }) =>
canUpdate ? (
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Edit train ${row.original.code}`}
title="Edit name & train numbers"
onClick={(e) => {
// Row click navigates to the detail page — keep the edit local.
e.stopPropagation();
setEditTarget(row.original);
}}
>
<Pencil size={15} />
</ActionIcon>
) : null,
},
];
}, []);
}, [canUpdate]);
const tableStatus = trainsQuery.isLoading
? "loading"
@@ -271,9 +277,11 @@ export default function TrainBuilderListPage() {
title="Train Builder"
subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit."
action={
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
Build train
</Button>
canCreate ? (
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
Build train
</Button>
) : undefined
}
/>

View File

@@ -935,6 +935,7 @@ export default function TrainScheduleV2DetailPage() {
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
/>
) : (
<Box maw={340}>

View File

@@ -49,7 +49,7 @@ export interface GetIframeUrlRequest {
// Create a dedicated axios instance for Metabase API
const metabaseAxios = axios.create({
baseURL: getEnvUrl("VITE_CHRONICLE_URL"),
baseURL: getEnvUrl("VITE_CHRONICLE_URL", false),
});
// Add auth token interceptor

View File

@@ -17,6 +17,9 @@ export interface RuleEngineListParams {
sortBy?: string;
sortOrder?: "ASC" | "DESC";
requiresDirectorApproval?: boolean;
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
appliesTo?: string;
trigger?: string;
}
export interface RuleEngineReorderPayload {
@@ -205,6 +208,8 @@ export const ruleEngineService = {
sortBy: params?.sortBy,
sortOrder: params?.sortOrder,
requiresDirectorApproval: params?.requiresDirectorApproval,
appliesTo: params?.appliesTo,
trigger: params?.trigger,
},
});
return normalizeList<T>(response.data, page, pageSize);

View File

@@ -9,5 +9,3 @@ function normalizeBaseUrl(url: string) {
export const AUDITLOG_API_URL: string = normalizeBaseUrl(
getEnvUrl("VITE_AUDITLOG_API_URL", false),
);
if (!AUDITLOG_API_URL) console.warn("Missing VITE_AUDITLOG_API_URL");

View File

@@ -9,18 +9,12 @@ import {
} from "../utils/authPersistence";
import { handleSessionExpiry } from "./sessionExpiry";
if (!import.meta.env.VITE_CHRONICLE_URL) {
console.warn("Missing VITE_CHRONICLE_URL — chronicle axios instance has no base URL");
}
// Chronicle/audit-log backend is optional in this deployment — no warning
// when unset; the module's screens are simply non-functional without it.
const chronicleBaseUrl =
getEnvUrl("VITE_CHRONICLE_URL", false) ||
getEnvUrl("VITE_AUDITLOG_API_URL", false);
if (!chronicleBaseUrl) {
console.warn("Missing VITE_CHRONICLE_URL and VITE_AUDITLOG_API_URL");
}
const chronicleInstance = axios.create({
baseURL: chronicleBaseUrl,
});

View File

@@ -8,14 +8,10 @@ import {
} from "../utils/authPersistence";
import { handleSessionExpiry } from "./sessionExpiry";
if (!import.meta.env.VITE_RECORD_API_URL) {
console.warn(
"Missing VITE_RECORD_API_URL — record axios instance has no base URL",
);
}
// Record backend is optional in this deployment — no warning when unset; the
// module's screens are simply non-functional without it.
const recordAxiosInstance = axios.create({
baseURL: getEnvUrl("VITE_RECORD_API_URL"),
baseURL: getEnvUrl("VITE_RECORD_API_URL", false),
});
// Attach auth token and CSRF defence header to every request

View File

@@ -169,6 +169,8 @@ export interface BookingDetail {
contractType: string;
freightType: "CONTAINER" | "BULK";
tradeDirection: string;
/** What the containers carry / bulk commodity label — entered at booking time. */
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
isHazardous: boolean;
consolidationPartnerId?: string | null;

View File

@@ -640,6 +640,8 @@ export interface TrainScheduleDetail {
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
maxGrossWeightTons?: number | null;
warnings?: string[];
}

View File

@@ -8,7 +8,7 @@ import Cookies from "js-cookie";
import { getEnvUrl } from "@/shared/config/env";
const TENANT_ID = "adf98293-41ba-4bda-bdb4-70e30a70c1b7";
const API_URL = getEnvUrl("VITE_RECORD_API_URL");
const API_URL = getEnvUrl("VITE_RECORD_API_URL", false);
const apiInstance = axios.create({
baseURL: API_URL,

View File

@@ -11,7 +11,7 @@ import Cookies from "js-cookie";
import { getEnvUrl } from "@/shared/config/env";
const TENANT_ID = "adf98293-41ba-4bda-bdb4-70e30a70c1b7";
const API_URL = getEnvUrl("VITE_RECORD_API_URL");
const API_URL = getEnvUrl("VITE_RECORD_API_URL", false);
// Create axios instance for template API
const apiInstance = axios.create({