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

This commit is contained in:
Nathnael
2026-07-22 07:20:08 +00:00
195 changed files with 9081 additions and 2516 deletions

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -199,7 +200,7 @@ export default function ClearanceDocumentsPage() {
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLocation, useParams } from "react-router-dom";
@@ -468,7 +469,7 @@ function ClearanceHero({
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
{directionLabel(direction)}
</Badge>
{customs ? (
<Badge

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
Fragment,
useCallback,
@@ -180,7 +181,7 @@ function CustomsBadge({ customs }: { customs: boolean }) {
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
const label = directionLabel(direction);
return (
<Tooltip label={label} withArrow>
<ThemeIcon

View File

@@ -1,6 +1,8 @@
import { directionLabel } from "@/lib/utils";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Box as BoxIcon,
@@ -22,6 +24,7 @@ import {
Users,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
@@ -264,7 +267,8 @@ export default function ContractRequestDetailPage() {
const showApprovalCard =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
contract.status === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const phasedCustoms =
@@ -428,6 +432,32 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={18} />}
title="Rejection reason"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestRejectionNote}
</Text>
</Alert>
) : null}
{contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Sent back in the approval chain"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestSendBackNote}
</Text>
</Alert>
) : null}
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
@@ -568,7 +598,7 @@ export default function ContractRequestDetailPage() {
<SectionCard icon={Package} title="Cargo scope">
<Group gap="sm" mb="md">
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.tradeDirection}
{directionLabel(contract.tradeDirection)}
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.freightType}

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -299,7 +300,7 @@ export default function ContractRequestsPage() {
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
@@ -16,6 +17,7 @@ import {
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
ClipboardList,
FileText,
Upload,
@@ -36,6 +38,8 @@ import {
type GlClearanceUploadKind,
} from "@/components/contracts/GlClearanceUploadModal";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
@@ -146,6 +150,9 @@ export default function GlClearanceDetailPage() {
"vesselDepartureDate" in data.clearance
? (data.clearance.vesselDepartureDate ?? null)
: null;
// Incident reporting attaches to a booking; a contract-level clearance can
// only report against its linked booking once one exists.
const incidentBookingId = data.kind === "booking" ? id : linkedBookingId;
// The shipment booking instance backing this clearance (per-booking GENERAL
// customs). Bare until GL completes it: no cargo, no price.
@@ -173,7 +180,7 @@ export default function GlClearanceDetailPage() {
]}
meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{data.tradeDirection}
{directionLabel(data.tradeDirection)}
</Badge>
}
action={
@@ -220,6 +227,11 @@ export default function GlClearanceDetailPage() {
>
Customs documents (all steps)
</Tabs.Tab>
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
) : null}
</Tabs.List>
<Tabs.Panel value="workflow">
@@ -309,6 +321,19 @@ export default function GlClearanceDetailPage() {
</Box>
)}
</Tabs.Panel>
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Log container or seal issues discovered during clearance handling.
</Text>
<IncidentReportCard bookingId={incidentBookingId} />
</Stack>
</SectionCard>
</Tabs.Panel>
) : null}
</Tabs>
</Stack>

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -249,7 +250,7 @@ function toContractRow(c: Freight.IContract): ContractRow {
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
const label = directionLabel(direction);
return (
<Tooltip label={label} withArrow>
<ThemeIcon

View File

@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core';
import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/auth/http';
@@ -51,28 +52,46 @@ interface StatCardProps {
value: string | number;
color?: string;
change?: number;
/** Detail route the card opens. When set the card is a link; otherwise static. */
href?: string;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change, href }: StatCardProps) => {
const card = (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}`, height: '100%' }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
</Stack>
</Card>
);
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
</Group>
</Stack>
</Card>
);
// Wrap in a link to the detail view rather than morphing the Card itself —
// keeps Mantine's Card typing clean. Static when no href.
return href ? (
<Link
to={href}
aria-label={`${label} — view detail`}
className="block h-full cursor-pointer no-underline transition-opacity hover:opacity-90"
>
{card}
</Link>
) : (
card
);
};
export function FleetDashboard() {
const { data: vehicles = [] } = useQuery({
@@ -160,16 +179,16 @@ export function FleetDashboard() {
{/* Primary Metrics */}
<Grid mb="xl">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" />
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" href="/dashboard/vehicles" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" href="/dashboard/drivers" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" />
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" href="/dashboard/fuel-purchases" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" />
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" href="/dashboard/maintenance" />
</Grid.Col>
</Grid>

View File

@@ -19,7 +19,6 @@ import {
Divider,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -36,6 +35,7 @@ import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
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 { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
@@ -47,7 +47,7 @@ import {
} from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type MilestoneFormRow = { yardId: string; distanceKm: string };
type MilestoneFormRow = { yardId: string };
type RouteFormState = {
status: RouteStatus;
@@ -56,12 +56,12 @@ type RouteFormState = {
const emptyForm = (): RouteFormState => ({
status: "AVAILABLE",
milestones: [
{ yardId: "", distanceKm: "0" },
{ yardId: "", distanceKm: "" },
],
milestones: [{ yardId: "" }, { yardId: "" }],
});
/** Order-insensitive pair key — yard distances are symmetric. */
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
const yardLabel = (yard?: YardRef | null) =>
yard ? `${yard.label} (${yard.code})` : "—";
@@ -163,6 +163,15 @@ export default function RoutesPage() {
const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
const yardDistancesQuery = useQuery({
queryKey: ["yard-distances", "all"],
queryFn: () =>
ruleEngineService.listAll<{ id: string; fromYardId: string; toYardId: string; distanceKm: string }>(
"yard-distances",
),
});
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
@@ -206,15 +215,33 @@ export default function RoutesPage() {
[yardsQuery.data],
);
const formTotalKm = useMemo(
() =>
form.milestones.reduce(
(sum, row, index) =>
index === 0 ? sum : sum + Number(row.distanceKm || 0),
0,
),
[form.milestones],
);
const distanceByPair = useMemo(() => {
const map = new Map<string, number>();
for (const row of yardDistancesQuery.data ?? []) {
map.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return map;
}, [yardDistancesQuery.data]);
/** Configured km for the segment ending at `index` (undefined = pair not configured yet). */
const segmentKm = (index: number): number | undefined => {
if (index === 0) return 0;
const from = form.milestones[index - 1]?.yardId;
const to = form.milestones[index]?.yardId;
if (!from || !to) return undefined;
return distanceByPair.get(pairKey(from, to));
};
const formTotalKm = useMemo(() => {
let total = 0;
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1]?.yardId;
const to = form.milestones[i]?.yardId;
if (!from || !to) continue;
total += distanceByPair.get(pairKey(from, to)) ?? 0;
}
return total;
}, [form.milestones, distanceByPair]);
const resetForm = () => {
setFormOpen(false);
@@ -234,10 +261,7 @@ export default function RoutesPage() {
status: route.status,
milestones: [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m, index) => ({
yardId: m.yardId,
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
})),
.map((m) => ({ yardId: m.yardId })),
});
setFormOpen(true);
};
@@ -254,7 +278,7 @@ export default function RoutesPage() {
const addMilestone = () => {
setForm((current) => ({
...current,
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
milestones: [...current.milestones, { yardId: "" }],
}));
};
@@ -267,11 +291,7 @@ export default function RoutesPage() {
const buildPayload = () => ({
status: form.status,
milestones: form.milestones.map((row, index) => ({
yardId: row.yardId,
distanceKm:
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
})),
milestones: form.milestones.map((row) => ({ yardId: row.yardId })),
});
const handleSubmit = async (event: FormEvent) => {
@@ -284,12 +304,23 @@ export default function RoutesPage() {
});
return;
}
for (let i = 1; i < form.milestones.length; i++) {
const km = Number(form.milestones[i].distanceKm);
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
// Pre-empt the API's missing-pair rejection with a readable message; if the
// distance list failed to load, skip and let the API validate.
if (yardDistancesQuery.data) {
const missing: string[] = [];
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1].yardId;
const to = form.milestones[i].yardId;
if (!distanceByPair.has(pairKey(from, to))) {
const label = (id: string) =>
yardOptions.find((o) => o.value === id)?.label ?? id;
missing.push(`${label(from)}${label(to)}`);
}
}
if (missing.length > 0) {
toast({
title: "Save failed",
description: `Enter segment KM for stop ${i + 1}`,
description: `No distance configured for: ${missing.join(", ")}. Add it under Configuration → Yard Distances first.`,
variant: "destructive",
});
return;
@@ -580,8 +611,11 @@ export default function RoutesPage() {
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
const km = segmentKm(index);
const bothSelected =
index > 0 && Boolean(row.yardId && form.milestones[index - 1]?.yardId);
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
<Group key={`${role}-${index}`} align="center" wrap="nowrap" gap="sm">
<Text w={90} size="sm" fw={500}>
{role}
</Text>
@@ -594,16 +628,25 @@ export default function RoutesPage() {
searchable
/>
{index > 0 ? (
<NumberInput
w={120}
label="KM"
min={0}
decimalScale={2}
value={row.distanceKm ? Number(row.distanceKm) : ""}
onChange={(value) =>
setMilestone(index, { distanceKm: String(value ?? "") })
}
/>
<Box w={120}>
{bothSelected ? (
km != null ? (
<Text size="sm" fw={600} ta="right">
{km} km
</Text>
) : (
<Tooltip label="No distance configured for this yard pair — add it under Configuration → Yard Distances">
<Text size="xs" c="red.7" fw={600} ta="right">
Not configured
</Text>
</Tooltip>
)
) : (
<Text size="xs" c="dimmed" ta="right">
km
</Text>
)}
</Box>
) : (
<Box w={120} />
)}
@@ -619,7 +662,8 @@ export default function RoutesPage() {
);
})}
<Text size="sm" c="dimmed">
Total route distance: <strong>{formTotalKm} km</strong>
Total route distance: <strong>{formTotalKm} km</strong> segment
distances come from Configuration Yard Distances
</Text>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>

View File

@@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
* (e.g. a license expiry); "past" (default) = cannot be in the future.
*/
dateBound?: "past" | "future";
/**
* Format the value must match, checked on submit. The value is upper-cased and
* trimmed before the test, matching the server. Empty optional fields skip it.
*/
pattern?: { regex: RegExp; message: string; uppercase?: boolean };
}
export interface FleetListFilterDef {

View File

@@ -1,5 +1,15 @@
import type { FleetResourceConfig } from "./resources";
/**
* A plate is two or three letters, a hyphen, then two to six digits — ET-9875,
* AA-8642. Mirrors VEHICLE_PLATE_REGEX on the API so the form and the server
* agree on what a plate looks like.
*/
const PLATE_PATTERN = {
regex: /^[A-Z]{2,3}-\d{2,6}$/,
message: "Use letters and numbers like ET-9875 or AA-8642",
};
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
@@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = {
],
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },

View File

@@ -244,7 +244,12 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const usesYardField = Boolean(
config?.formFields.some((f) => f.name === "originYardId"),
config?.formFields.some(
(f) =>
f.name === "originYardId" ||
f.name === "fromYardId" ||
f.name === "toYardId",
),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
@@ -347,6 +352,19 @@ const RuleEngineResourcePage = () => {
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
// with the direction the admin picks.
// Yard-distance endpoints have no country restriction — any yard can pair
// with any other; the other end is just excluded so A↔A can't be entered.
if (field.name === "fromYardId" || field.name === "toYardId") {
const otherEnd = field.name === "fromYardId" ? "toYardId" : "fromYardId";
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
(yardOptions ?? [])
.filter(({ value }) => value !== String(values[otherEnd] ?? ""))
.map(({ label, value }) => ({ label, value })),
};
}
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {
@@ -604,7 +622,7 @@ const RuleEngineResourcePage = () => {
title={config.label}
subtitle={config.subtitle}
action={
canManage ? (
canManage && config.slug !== "container-types" ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>

View File

@@ -1,4 +1,5 @@
import type { SidebarItem } from "@/components/layout/types";
import { ruleEngineViewKey } from "@/lib/permissions";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export type RuleEngineNavCategory = "configuration" | "rules";
@@ -348,6 +349,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
@@ -370,6 +372,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",
category: "configuration",
subtitle: "Rail distance between yard pairs — routes read their segment km from here",
searchPlaceholder: "Search by yard name or code...",
supportsSearch: true,
cardTitleKey: "fromYardLabel",
cardSubtitleKey: "toYardLabel",
columns: [
{ id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" },
{ id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" },
{ id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" },
],
formFields: [
// Options injected at render from useYardOptions (RuleEngineResourcePage).
{ name: "fromYardId", label: "From yard", type: "select", required: true, placeholder: "Select yard" },
{ name: "toYardId", label: "To yard", type: "select", required: true, placeholder: "Select yard" },
{
name: "distanceKm",
label: "Distance (km)",
type: "number",
required: true,
description:
"Symmetric — one entry covers both directions. Route segments between these yards use this value.",
},
],
},
{
slug: "priority-configs",
label: "Priority Rules",
@@ -758,6 +788,7 @@ export const getCategorySidebarChildren = (
RULE_ENGINE_RESOURCES.filter((r) => r.category === category).map((r) => ({
label: r.label,
href: ruleEngineResourcePath(r.slug),
permission: ruleEngineViewKey(r.slug),
}));
export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";

View File

@@ -36,8 +36,11 @@ import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import {
directionColor,
locomotiveStatusColor,
locomotiveStatusLabel,
trainStatusColor,
trainStatusLabel,
UNFIT_LOCOMOTIVE_STATUSES,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
@@ -131,6 +134,9 @@ export default function TrainBuilderDetailPage() {
const { totals } = composition;
const yard = composition.currentYard;
const blockingLocomotives = composition.locomotives.filter((loco) =>
UNFIT_LOCOMOTIVE_STATUSES.has(loco.status),
);
return (
<PageContainer>
@@ -180,6 +186,7 @@ export default function TrainBuilderDetailPage() {
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
@@ -234,6 +241,59 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{composition.status === "DEACTIVATED" && blockingLocomotives.length > 0 ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
<Stack gap="xs">
<Text size="sm">
Cannot reactivate {blockingLocomotives.length > 1 ? "these locomotives are" : "this locomotive is"}{" "}
not fit for service:{" "}
{blockingLocomotives.map((loco, i) => (
<span key={loco.id}>
{i > 0 ? ", " : ""}
<Text span fw={600} ff="monospace">
{loco.code}
</Text>{" "}
({locomotiveStatusLabel(loco.status)})
</span>
))}
.
</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>
<Button
size="compact-sm"
variant="subtle"
onClick={() => navigate(`/dashboard/locomotives`)}
>
Go to locomotives
</Button>
</Group>
</Stack>
</Alert>
) : null}
<Group gap="xs">
{composition.locomotives.map((loco) => (
<Badge
key={loco.id}
variant="light"
color={locomotiveStatusColor(loco.status)}
leftSection={<TrainFront size={12} />}
>
{loco.code} · {locomotiveStatusLabel(loco.status)}
</Badge>
))}
</Group>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({

View File

@@ -866,7 +866,7 @@ export default function BatchScheduleDetailPage() {
>
Refresh
</Button>
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
{/* {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
<Button
variant="light"
color="edr-green"
@@ -876,7 +876,7 @@ export default function BatchScheduleDetailPage() {
>
Adjust consist
</Button>
) : null}
) : null} */}
{data.windowPhase === "DOC_REVIEW" ? (
<Button
color="yellow"

View File

@@ -59,6 +59,7 @@ import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWor
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
SegmentOccupancyStrip,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
@@ -388,12 +389,22 @@ export default function TrainScheduleV2DetailPage() {
const unloadedCount = dispatchBookings.filter(
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
).length;
// Intercity ride-alongs load through the journey flow (Load at their origin
// yard), not the workspace toggle — dispatching before that leaves paid cargo
// stranded on the platform while its train departs.
const intercityNotLoadedCount = dispatchBookings.filter(
(b) =>
b.tradeDirection === "DOMESTIC" &&
!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;
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -916,17 +927,28 @@ export default function TrainScheduleV2DetailPage() {
</Text>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
/>
</Box>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
@@ -1059,6 +1081,14 @@ export default function TrainScheduleV2DetailPage() {
{
label: "Bookings",
value: schedule.bookings?.length ?? 0,
hint: (() => {
const intercity = (schedule.bookings ?? []).filter(
(b) => b.tradeDirection === "DOMESTIC",
).length;
return intercity > 0
? `${intercity} intercity ride-along${intercity === 1 ? "" : "s"}`
: undefined;
})(),
icon: Package,
},
{
@@ -1282,6 +1312,16 @@ export default function TrainScheduleV2DetailPage() {
unloaded
</List.Item>
) : null}
{intercityNotLoadedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{intercityNotLoadedCount}
</Text>{" "}
intercity ride-along{intercityNotLoadedCount === 1 ? "" : "s"} not
loaded yet load them from the Workspace tab (Yard work) before
the train leaves their origin yard
</List.Item>
) : null}
</List>
<Text size="xs" c="dimmed" mt={6}>
You can still dispatch confirm to proceed.

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -47,15 +48,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
if (rows.length === 0) {
return (
<Alert variant="light" color="gray">
No trucks on site.
No trucks assigned or on site.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={980}>
<Table.ScrollContainer minWidth={1040}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Haulage</Table.Th>
<Table.Th>Driver</Table.Th>
@@ -69,6 +71,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={`${row.source}-${row.assignmentId}`}>
<Table.Td>
<Badge
size="sm"
radius="sm"
variant={row.status === "ON_SITE" ? "filled" : "light"}
color={row.status === "ON_SITE" ? "edr-green" : "gray"}
>
{row.status === "ON_SITE" ? "On site" : "Inbound"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
{row.plateNumber ?? "—"}
@@ -101,9 +113,13 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
<Text size="sm">{row.containers ?? "Bulk"}</Text>
</Table.Td>
<Table.Td>
{isLongDwell(row.arrivedAt) ? (
{row.arrivedAt == null ? (
<Text size="sm" c="dimmed">
</Text>
) : isLongDwell(row.arrivedAt) ? (
<Tooltip
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt as string).toLocaleString()}`}
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt).toLocaleString()}`}
withArrow
>
<Text size="sm" c="red" fw={600}>
@@ -124,12 +140,20 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
// deep-links here with ?scope=ON_SITE to land on the matching tab.
const [searchParams] = useSearchParams();
const scopeParam = searchParams.get("scope");
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");
const rows = useMemo(() => {
const term = search.trim().toLowerCase();
return trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source)
.filter((t) =>
!term
@@ -137,8 +161,10 @@ export default function TrucksOnSitePage() {
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
.some((field) => field?.toLowerCase().includes(term)),
);
}, [trucks, source, search]);
}, [trucks, scope, source, search]);
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length;
const edrCount = trucks.length - customerCount;
@@ -146,20 +172,32 @@ export default function TrucksOnSitePage() {
<PageContainer>
<PageHeader
title="Trucks on site"
subtitle="Arrived at the yard and not yet left — customer self-haul and EDR last-mile."
subtitle="Customer self-haul and EDR last-mile trucks — assigned (inbound) or arrived, until they leave the yard."
/>
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
value={scope}
onChange={(v) => setScope(v as typeof scope)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
{ label: `Inbound (${inboundCount})`, value: "INBOUND" },
]}
/>
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: "All", value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
</Group>
<TextInput
size="xs"
w={280}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackageOpen, Search, Truck } from 'lucide-react';
import { PackageOpen, Search, Truck, X } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import {
@@ -25,14 +25,36 @@ export default function WarehouseInventoryPage() {
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
// KPI drill-downs arriving from the ops dashboard cards; each shows as a
// dismissible chip so the list can be widened back out in place.
const [quick, setQuick] = useState<
Pick<InventoryFilter, 'receivedToday' | 'pendingInspection' | 'agingOverDays'>
>(() => {
const aging = Number(searchParams.get('agingOverDays'));
return {
receivedToday: searchParams.get('receivedToday') ? true : undefined,
pendingInspection: searchParams.get('pendingInspection') ? true : undefined,
agingOverDays: Number.isFinite(aging) && aging > 0 ? aging : undefined,
};
});
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
() => ({ ...filter, ...quick, direction, search: debouncedSearch || undefined }),
[filter, quick, direction, debouncedSearch],
);
const quickChips: Array<{ key: keyof typeof quick; label: string }> = [
...(quick.receivedToday ? [{ key: 'receivedToday' as const, label: 'Received today' }] : []),
...(quick.pendingInspection
? [{ key: 'pendingInspection' as const, label: 'Pending inspection' }]
: []),
...(quick.agingOverDays
? [{ key: 'agingOverDays' as const, label: `In warehouse >${quick.agingOverDays}d` }]
: []),
];
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
@@ -136,6 +158,17 @@ export default function WarehouseInventoryPage() {
}
w={200}
/>
{quickChips.map((chip) => (
<Button
key={chip.key}
size="compact-xs"
variant="light"
rightSection={<X size={12} />}
onClick={() => setQuick((q) => ({ ...q, [chip.key]: undefined }))}
>
{chip.label}
</Button>
))}
</Group>
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />