Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-09 01:44:26 +00:00
313 changed files with 19072 additions and 4277 deletions

View File

@@ -1,6 +1,7 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FolderOpen,
Layers,
LayoutGrid,
Milestone,
@@ -37,6 +38,7 @@ import {
BookingContractSummaryCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
BookingDocumentsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
@@ -142,14 +144,17 @@ export default function BookingRequestDetailPage() {
// 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";
const showTabs = showClearanceTab || isGeneralContract;
// The Documents tab is always available — every booking can accrue clearance,
// 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"
: "overview";
: requestedTab === "documents"
? "documents"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -187,10 +192,10 @@ export default function BookingRequestDetailPage() {
)}
<Grid gap="lg">
{/* LEFT — primary content, split into tabs to keep each view focused */}
{/* LEFT — primary content, split into tabs to keep each view focused.
The Documents tab is always present, so the tab bar always renders. */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{showTabs ? (
<Tabs
<Tabs
value={activeTab}
onChange={setActiveTab}
variant="pills"
@@ -217,6 +222,12 @@ export default function BookingRequestDetailPage() {
Customer clearance
</Tabs.Tab>
)}
<Tabs.Tab
value="documents"
leftSection={<FolderOpen size={16} />}
>
Documents
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
@@ -238,10 +249,10 @@ export default function BookingRequestDetailPage() {
/>
</Tabs.Panel>
)}
<Tabs.Panel value="documents">
<BookingDocumentsPanel bookingId={booking.id} />
</Tabs.Panel>
</Tabs>
) : (
<OverviewPanel booking={booking} row={row} />
)}
</Grid.Col>
{/* RIGHT — sticky action / summary rail */}

View File

@@ -30,6 +30,7 @@ import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -199,6 +200,10 @@ export default function ContractClearanceDetailPage() {
<ClearanceHero contract={contract} stats={stats} />
{/* Windows on this contract's routes/direction only — tells GL ET when
it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingAlreadyCreated ? (
<Alert
color="blue"

View File

@@ -1,6 +1,7 @@
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
Card,
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
import {
BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
{
id: "type",
header: "Role",
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
},
{
id: "reference",
header: "Reference",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
<div className="space-y-2">
<ProfileTypeBadge type={row.original.type} />
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
</div>
),
},
{
id: "businessLicense",
header: "Business license",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.businessLicense || "—"}
</Text>
),
id: "licenseFiles",
header: "License documents",
cell: ({ row }) => {
const files = row.original.licenseFiles ?? [];
if (files.length === 0) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Stack gap={4}>
{files.map((f) => (
<Group key={f.id} gap={6} wrap="nowrap">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
onClick={() =>
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
>
<Eye size={14} />
</ActionIcon>
<Anchor
component="button"
type="button"
size="xs"
lineClamp={1}
onClick={() =>
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
style={{
maxWidth: 170,
textAlign: "left",
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
>
{f.name}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removing
</Badge>
)}
</Group>
))}
</Stack>
);
},
},
{
id: "status",
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
),
},
],
[],
[view],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
subtitle={`TIN ${company.tin}${
company.country ? ` · ${company.country}` : ""
}`}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
}`}
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} />
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
/>
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<ChangeRequestReview company={company} />
<KpiStrip
items={[
{
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
<ProfileChips profiles={company.companyProfiles} />
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}>
<Box miw={1040}>
<DataTable
columns={profileColumns}
data={company.companyProfiles}
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
: undefined
}
/>
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Stack key={p.id} gap={6}>
<Text size="sm" fw={600} c="edr-text">
{humanize(p.type)} · {p.reference}
</Text>
{(p.licenseFiles ?? []).map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Group key={f.id} gap={8} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
component="button"
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
onClick={() =>
view({
name: f.name,
url: f.url,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
size="xs"
style={{
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
>
{f.name}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending approval
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removal pending
</Badge>
)}
</Group>
))}
{(p.licenseFiles ?? []).length === 0 && (
<Text size="xs" c="dimmed">
No license documents.
</Text>
)}
</Stack>
))}
</Stack>
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
: undefined
}
/>
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
: undefined
}
pagination={{

View File

@@ -18,14 +18,11 @@ import {
HardDrive,
Layers,
Paperclip,
Pencil,
Plus,
Search,
Settings,
Trash2,
X,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -33,9 +30,7 @@ import { api } from "@/services/api";
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
@@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() {
const { data, isLoading, isError, error, refetch } = useQuery(
api.fileUploadSettings.list.queryOptions(),
);
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
const fileUploadSettings = useMemo(
() => (Array.isArray(data) ? data : []),
@@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
},
// Settings are seeded/fixed — staff may only update a setting's fields,
// not create, edit, or delete the settings themselves.
cell: ({ row }) => {
const setting = row.original;
return (
@@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() {
Fields
</Button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog mode="edit" setting={setting}>
<ActionIcon variant="default" aria-label="Edit setting">
<Pencil size={16} />
</ActionIcon>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() => deleteMutation.mutate({ id: setting.id })}
>
<ActionIcon
variant="default"
color="red"
disabled={deleteMutation.isPending}
aria-label="Delete setting"
>
<Trash2 size={16} />
</ActionIcon>
</DeleteFileUploadSettingDialog>
</Group>
);
},
},
];
}, [deleteMutation]);
}, []);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() {
<PageHeader
title="File upload settings"
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
action={
<EditFileUploadSettingDialog mode="create">
<Button leftSection={<Plus size={18} />}>New setting</Button>
</EditFileUploadSettingDialog>
}
/>
<KpiStrip
@@ -286,7 +256,7 @@ export default function FileUploadSettingsPage() {
emptyMessage={
query.trim()
? "No file upload settings match your search."
: 'No file upload settings yet. Click "New setting" to add one.'
: "No file upload settings configured."
}
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
/>

View File

@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
record={historyTarget}
/>
) : (
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
)}
</Container>
);
};

View File

@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
// details captured at arrival (plate, driver, tare, gate-in).
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
@@ -1282,6 +1285,17 @@ const LastMilePage = () => {
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
{pastTransit && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Receipt size={13} />}
onClick={() => setDetentionRecord(row.original)}
>
Detention
</Button>
)}
<Menu
position="bottom-end"
width={200}

View File

@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
{ label: "Both", value: "BOTH" },
];
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
const YARD_COUNTRIES = [
{ label: "Ethiopia", value: "Ethiopia" },
{ label: "Djibouti", value: "Djibouti" },
];
const APPROVAL_ROLES = [
{ label: "Line staff", value: "LINE_STAFF" },
{ label: "Director", value: "DIRECTOR" },
@@ -181,6 +187,7 @@ const CURRENCIES = [
const PRIORITY_CONFIG_TYPES = [
{ label: "Wagon count", value: "WAGON" },
{ label: "Payment currency", value: "CURRENCY" },
{ label: "Customs clearance", value: "CUSTOMS" },
];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
@@ -304,7 +311,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
slug: "priority-configs",
label: "Priority Rules",
category: "rules",
subtitle: "Wagon-count and payment-currency scoring rules",
subtitle: "Wagon-count, payment-currency, and customs scoring rules",
searchPlaceholder: "Search priority rules...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
@@ -331,7 +338,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
optional: true,
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON"] },
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
},
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
@@ -351,7 +358,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
activeColumn,
],
formFields: [
@@ -361,7 +367,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -431,7 +436,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "country", label: "Country", type: "text", required: true },
{
name: "country",
label: "Country",
type: "select",
required: true,
options: YARD_COUNTRIES,
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},

View File

@@ -33,6 +33,7 @@ import {
RefreshCw,
Ruler,
TrainFront,
Trophy,
Weight,
XCircle,
} from "lucide-react";
@@ -55,6 +56,8 @@ import {
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() {
api.trainScheduling.batchBoardDetail.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId),
refetchInterval: 30_000,
// Poll fast while a window cycle is actively moving (open / doc-review /
// payment) so the priority ranking + pay countdowns stay live; back off to
// 30s once the cycle is idle (pre-window / closed / done).
refetchInterval: (query) => {
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
?.windowPhase;
return phase === "OPEN" ||
phase === "DOC_REVIEW" ||
phase === "PAYMENT"
? 5_000
: 30_000;
},
}),
);
// Keep the board in sync with server-pushed window-phase transitions too
// (invalidates the batch-board list + patches window carousels).
useBookingWindowSocket(Boolean(scheduleId));
const runAllocation = useMutation(
api.trainScheduling.runAllocation.mutationOptions(),
);
@@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() {
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab
value="priority"
leftSection={<Trophy size={14} />}
>
Priority Tracking{" "}
{allBookings.length > 0 && `(${allBookings.length})`}
</Tabs.Tab>
<Tabs.Tab value="composition">
Train Composition{" "}
{scheduleDetailQuery.data?.trainSet?.wagons &&
@@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() {
</Stack>
</Tabs.Panel>
<Tabs.Panel value="priority" pt="lg">
<PriorityTrackingTab data={data} bookings={allBookings} />
</Tabs.Panel>
<Tabs.Panel value="composition" pt="lg">
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
<Group align="stretch" gap="md" wrap="nowrap">

View File

@@ -16,7 +16,7 @@ import {
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
@@ -26,7 +26,11 @@ import {
import { PageContainer } from "@/components/page";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
import {
RouteCorridor,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { freightBrand } from "@/theme/freight-brand";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -52,8 +56,11 @@ function formatDateTime(iso?: string | null) {
});
}
/** Compact icon + label + value cell used in the header meta strip. */
function MetaStat({
/**
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
* value, laid on the translucent panel over the gradient.
*/
function HeroStat({
icon,
label,
value,
@@ -63,15 +70,33 @@ function MetaStat({
value: string;
}) {
return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="edr-green">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 34,
height: 34,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
color: "white",
flexShrink: 0,
}}
>
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}>
</Box>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
>
{label}
</Text>
<Text size="sm" fw={700} c="dark.5" truncate>
<Text size="sm" fw={700} c="white" truncate>
{value}
</Text>
</Stack>
@@ -79,6 +104,38 @@ function MetaStat({
);
}
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint: string;
}) {
return (
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} size="sm">
{title}
</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Stack>
</Group>
);
}
const CARD_STYLE = {
borderColor: scheduleBrand.mutedBorder,
boxShadow: scheduleBrand.shadowSm,
} as const;
export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
@@ -116,9 +173,13 @@ export default function TrainScheduleTrackPage() {
const canLog = track.status === "DISPATCHED";
const totalStations = track.stations.length;
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
const progressPct =
totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
const clampedPct = Math.min(100, Math.max(0, progressPct));
const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
const currentStation =
track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
const inTransit = track.status === "DISPATCHED";
const arrived = track.status === "ARRIVED";
const handleLog = (sequenceNo: number) => {
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
@@ -156,160 +217,259 @@ export default function TrainScheduleTrackPage() {
Back to schedule
</Button>
{/* Header */}
<Paper radius="lg" withBorder p="lg">
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 48,
height: 48,
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: freightBrand.gradient,
color: "white",
flexShrink: 0,
}}
>
<Navigation size={24} />
</Box>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={3} fw={800}>
Train tracking
</Title>
{track.trainNumber ? (
<Badge variant="light" color="edr-green" radius="sm">
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge variant="light" color="gray" radius="sm">
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={360}>
<RouteCorridor origin={track.origin} destination={track.destination} variant="compact" />
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
<Paper
radius="lg"
p={0}
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
>
<Box
style={{
background: scheduleBrand.heroGradient,
padding: "26px 28px",
position: "relative",
}}
>
{/* soft decorative glow, purely artistic */}
<Box
style={{
position: "absolute",
top: -80,
right: -60,
width: 260,
height: 260,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="xl"
style={{ position: "relative" }}
>
{/* left — identity + route */}
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
<Group gap="md" align="center" wrap="nowrap">
<Box
style={{
width: 52,
height: 52,
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.26)",
color: "white",
flexShrink: 0,
}}
>
<Navigation size={26} />
</Box>
</Stack>
</Group>
<StatusPill status={track.status} />
</Group>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={3} fw={800} c="white">
Train tracking
</Title>
{track.trainNumber ? (
<Badge
variant="white"
color="dark"
radius="sm"
styles={{ root: { color: freightBrand.primaryDark } }}
>
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge
variant="outline"
radius="sm"
styles={{
root: {
color: "white",
borderColor: "rgba(255,255,255,0.5)",
},
}}
>
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={380}>
<RouteCorridor
origin={track.origin}
destination={track.destination}
variant="compact"
onDark
/>
</Box>
</Stack>
</Group>
{/* Journey progress */}
<Box>
<Group justify="space-between" mb={6}>
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
Journey progress
</Text>
<Text size="xs" fw={700} c="edr-green.8">
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
</Text>
</Group>
<Progress
value={clampedPct}
size="lg"
radius="xl"
color="edr-green"
striped={track.status === "DISPATCHED"}
animated={track.status === "DISPATCHED"}
/>
</Box>
<Group gap="sm">
<StatusPill status={track.status} size="md" />
<Box
px={12}
py={5}
style={{
borderRadius: 999,
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
}}
>
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
{arrived
? "Journey complete"
: inTransit
? `En route · ${currentStation}`
: "Awaiting dispatch"}
</Text>
</Box>
</Group>
</Stack>
{/* Meta strip */}
<Group justify="space-between" wrap="wrap" gap="lg">
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<MetaStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<MetaStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<MetaStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
{/* right — progress ring, the artistic focal point */}
<RingProgress
size={132}
thickness={11}
roundCaps
sections={[{ value: clampedPct, color: "white" }]}
rootColor="rgba(255,255,255,0.22)"
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1} c="white">
{Math.round(clampedPct)}%
</Text>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
>
{reached}/{totalStations} stops
</Text>
</Stack>
}
/>
</Group>
</Stack>
</Box>
{/* glass meta strip below the wash */}
<Group
justify="space-between"
wrap="wrap"
gap="lg"
px={28}
py="md"
style={{
background: freightBrand.primaryDark,
borderTop: "1px solid rgba(255,255,255,0.12)",
}}
>
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<HeroStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<HeroStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<HeroStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
/>
</Group>
</Paper>
{/* Corridor */}
<Paper radius="lg" p="lg" withBorder>
{/* ── Route corridor ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Stack gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
<Navigation size={17} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={800} size="sm">
Route corridor
</Text>
<Text size="xs" c="dimmed">
{canLog
? "Log the train passing each station; the final station marks arrival."
: track.status === "ARRIVED"
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."}
</Text>
</Stack>
</Group>
<SectionHead
icon={<Navigation size={17} />}
title="Route corridor"
hint={
canLog
? "Log the train passing each station; the final station marks arrival."
: arrived
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."
}
/>
<RouteCorridorTrack
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
recordCheckpoint.isPending
? recordCheckpoint.variables?.payload.sequenceNo
: null
}
onLogCheckpoint={handleLog}
/>
</Stack>
</Paper>
{/* Checkpoint log */}
<Paper radius="lg" p="lg" withBorder>
<Group gap="sm" align="center" wrap="nowrap" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={17} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={800} size="sm">
Checkpoint log
</Text>
<Text size="xs" c="dimmed">
{track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
</Text>
</Stack>
{/* ── Checkpoint log ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Group justify="space-between" wrap="nowrap" mb="md">
<SectionHead
icon={<CheckCircle2 size={17} />}
title="Checkpoint log"
hint={`${track.checkpoints.length} event${
track.checkpoints.length === 1 ? "" : "s"
} recorded`}
/>
</Group>
{track.checkpoints.length === 0 ? (
<Stack align="center" gap="xs" py="xl">
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
<MapPin size={20} />
<Stack
align="center"
gap="xs"
py={40}
style={{
borderRadius: 14,
border: `1px dashed ${scheduleBrand.mutedBorder}`,
background: scheduleBrand.softSurface,
}}
>
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
<MapPin size={22} />
</ThemeIcon>
<Text size="sm" fw={600} c="gray.7">
<Text size="sm" fw={700} c="gray.7">
No checkpoints yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={300}>
Each station the train passes will be logged here with its timestamp.
<Text size="xs" c="dimmed" ta="center" maw={320}>
Each station the train passes will be logged here with its
timestamp.
</Text>
</Stack>
) : (
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="edr-green">
<Timeline
active={track.checkpoints.length}
bulletSize={24}
lineWidth={2}
color="edr-green"
>
{track.checkpoints.map((cp) => (
<Timeline.Item
key={cp.id}
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
bullet={
cp.kind === "ARRIVED" ? (
<CheckCircle2 size={13} />
) : (
<MapPin size={12} />
)
}
title={
<Group gap="sm">
<Text fw={700} size="sm">
@@ -319,7 +479,13 @@ export default function TrainScheduleTrackPage() {
size="xs"
radius="sm"
variant="light"
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"}
color={
cp.kind === "ARRIVED"
? "teal"
: cp.kind === "DEPARTED"
? "blue"
: "edr-green"
}
>
{cp.kind}
</Badge>

View File

@@ -49,6 +49,7 @@ import {
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -863,6 +864,16 @@ export default function TrainScheduleV2DetailPage() {
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
@@ -1131,6 +1142,7 @@ export default function TrainScheduleV2DetailPage() {
void detailQuery.refetch();
}}
/>
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
{scheduleId ? (
<IntercityRideAlongPanel
scheduleId={scheduleId}

View File

@@ -89,6 +89,13 @@ export default function TrainScheduleV2ListPage() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [originFilter, setOriginFilter] = useState("ALL");
const [destinationFilter, setDestinationFilter] = useState("ALL");
// Default: newest-created first, matching the API's default order.
const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">(
"createdAt",
);
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
const [editDateSchedule, setEditDateSchedule] =
@@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() {
return base;
}, [allSchedules]);
// Distinct origins/destinations present in the loaded schedules, for the
// corridor filters. Sorted A→Z; "ALL" prepended by the Select data below.
const originOptions = useMemo(
() =>
[...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[],
[allSchedules],
);
const destinationOptions = useMemo(
() =>
[
...new Set(allSchedules.map((s) => s.destination).filter(Boolean)),
].sort() as string[],
[allSchedules],
);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return allSchedules.filter((s) => {
const matched = allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (originFilter !== "ALL" && s.origin !== originFilter) return false;
if (destinationFilter !== "ALL" && s.destination !== destinationFilter)
return false;
if (!query) return true;
const haystack = [
s.reference,
s.trainNumber,
s.routeName,
s.origin,
@@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() {
.toLowerCase();
return haystack.includes(query);
});
}, [allSchedules, search, statusFilter, freightFilter]);
const dir = sortDir === "asc" ? 1 : -1;
const sorted = [...matched].sort((a, b) => {
let cmp = 0;
if (sortBy === "reference") {
cmp = (a.reference ?? "").localeCompare(b.reference ?? "");
} else {
// createdAt or scheduleDate — compare as timestamps (missing sorts last).
const av = new Date(a[sortBy] ?? 0).getTime();
const bv = new Date(b[sortBy] ?? 0).getTime();
cmp = av - bv;
}
return cmp * dir;
});
return sorted;
}, [
allSchedules,
search,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
@@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "reference",
header: "Ref",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
{row.original.reference ?? "—"}
</Text>
),
},
{
id: "date",
header: "Departure",
@@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() {
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Origin"
searchable
value={originFilter}
onChange={(v) => setOriginFilter(v ?? "ALL")}
data={[
{ value: "ALL", label: "All origins" },
...originOptions.map((o) => ({ value: o, label: o })),
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Destination"
searchable
value={destinationFilter}
onChange={(v) => setDestinationFilter(v ?? "ALL")}
data={[
{ value: "ALL", label: "All destinations" },
...destinationOptions.map((d) => ({ value: d, label: d })),
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={`${sortBy}:${sortDir}`}
onChange={(v) => {
if (!v) return;
const [by, dir] = v.split(":") as [
typeof sortBy,
typeof sortDir,
];
setSortBy(by);
setSortDir(dir);
}}
data={[
{ value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduleDate:desc", label: "Departure ↓" },
{ value: "scheduleDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" },
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</>
}
/>
@@ -693,9 +805,16 @@ function ScheduleCard({
<Train size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
<Group gap={6} wrap="nowrap">
{schedule.reference ? (
<Text size="xs" fw={700} ff="monospace" c="edr-green.8">
{schedule.reference}
</Text>
) : null}
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
</Group>
<Text size="xs" c="dimmed">
{day} · {time}
</Text>

View File

@@ -38,7 +38,7 @@ const columns: ColumnDef<Loading>[] = [
},
{
id: 'weight',
header: 'Loaded Weight (kg)',
header: 'Loaded Weight (t)',
cell: ({ row }) => formatNumber(row.original.loadedWeight),
},
{

View File

@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
{ id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'payment',
header: 'Payment',

View File

@@ -16,7 +16,7 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Info, Plus, Trash2 } from 'lucide-react';
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -30,6 +30,8 @@ import {
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
useUpdateAllocationRule,
useUpdateFeeRule,
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import {
@@ -37,6 +39,9 @@ import {
FEE_RULE_BASIS_LABELS,
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES,
type AllocationRule,
type FeeRule,
type FeeRuleBasis,
type FeeRuleType,
} from '@/types/warehouse';
@@ -120,8 +125,10 @@ function AllocationRules() {
const { data, isLoading } = useAllocationRules();
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
const create = useCreateAllocationRule();
const update = useUpdateAllocationRule();
const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
priority: 100,
@@ -141,7 +148,8 @@ function AllocationRules() {
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
}));
const resetForm = () =>
const resetForm = () => {
setEditingId(null);
setForm({
name: '',
priority: 100,
@@ -152,6 +160,22 @@ function AllocationRules() {
targetYardCode: '',
storageType: '',
});
};
const startEdit = (rule: AllocationRule) => {
setForm({
name: rule.name,
priority: rule.priority ?? 100,
freightType: rule.freightType ?? '',
tradeDirection: rule.tradeDirection ?? '',
cargoTypeCode: rule.cargoTypeCode ?? '',
containerStatus: rule.containerStatus ?? '',
targetYardCode: rule.targetYardCode ?? '',
storageType: rule.storageType ?? '',
});
setEditingId(rule.id);
setOpen(true);
};
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
@@ -159,7 +183,7 @@ function AllocationRules() {
return;
}
await create.mutateAsync({
const payload = {
name: form.name.trim(),
priority: form.priority,
freightType: clean(form.freightType) ?? null,
@@ -169,10 +193,20 @@ function AllocationRules() {
targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null,
isActive: true,
} as never);
toast({ title: 'Allocation rule created' });
setOpen(false);
resetForm();
};
try {
if (editingId) {
await update.mutateAsync({ id: editingId, payload: payload as never });
toast({ title: 'Allocation rule updated' });
} else {
await create.mutateAsync(payload as never);
toast({ title: 'Allocation rule created' });
}
setOpen(false);
resetForm();
} catch (error) {
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
}
};
return (
@@ -181,7 +215,7 @@ function AllocationRules() {
<Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
</Button>
</Group>
@@ -228,14 +262,19 @@ function AllocationRules() {
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -244,7 +283,7 @@ function AllocationRules() {
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
<Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="gray.0">
<Stack gap={4}>
@@ -339,11 +378,11 @@ function AllocationRules() {
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
<Button loading={create.isPending || update.isPending} onClick={submit}>
{editingId ? 'Save changes' : 'Create'}
</Button>
</Group>
</Stack>
@@ -362,8 +401,10 @@ function FeeRules() {
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule();
const update = useUpdateFeeRule();
const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
@@ -372,6 +413,7 @@ function FeeRules() {
tradeDirection: '',
cargoTypeCode: '',
containerType: '',
vehicleType: '',
freeDays: 3,
freeHours: 3,
ratePerDay: 0,
@@ -389,8 +431,11 @@ function FeeRules() {
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
// with day tiers. Uses "free hours" instead of "free days".
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
const isImportOnly = isDoubleHandling || isTruckDetention;
const resetForm = () =>
const resetForm = () => {
setEditingId(null);
setForm({
name: '',
ruleType: 'DEMURRAGE_FEE',
@@ -399,12 +444,34 @@ function FeeRules() {
tradeDirection: '',
cargoTypeCode: '',
containerType: '',
vehicleType: '',
freeDays: 3,
freeHours: 3,
ratePerDay: 0,
tiers: [],
currency: 'USD',
});
};
const startEdit = (rule: FeeRule) => {
setForm({
name: rule.name,
ruleType: rule.ruleType,
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
freightType: rule.freightType ?? '',
tradeDirection: rule.tradeDirection ?? '',
cargoTypeCode: rule.cargoTypeCode ?? '',
containerType: rule.containerType ?? '',
vehicleType: rule.vehicleType ?? '',
freeDays: rule.freeDays ?? 3,
freeHours: rule.freeHours ?? 3,
ratePerDay: rule.ratePerDay ?? 0,
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
currency: rule.currency ?? 'USD',
});
setEditingId(rule.id);
setOpen(true);
};
const addTier = () =>
setForm((f) => {
@@ -461,7 +528,7 @@ function FeeRules() {
name: form.name.trim(),
ruleType: form.ruleType,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
tradeDirection: isImportOnly ? 'IMPORT' : clean(form.tradeDirection) ?? null,
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
@@ -469,17 +536,22 @@ function FeeRules() {
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
...(isDoubleHandling ? { basis: form.basis } : {}),
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}),
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
};
try {
await create.mutateAsync(payload as never);
toast({ title: 'Fee rule created' });
if (editingId) {
await update.mutateAsync({ id: editingId, payload: payload as never });
toast({ title: 'Fee rule updated' });
} else {
await create.mutateAsync(payload as never);
toast({ title: 'Fee rule created' });
}
setOpen(false);
resetForm();
} catch (error) {
if (tiers.length && isUnknownTiersError(error)) {
if (!editingId && tiers.length && isUnknownTiersError(error)) {
const legacyPayload: Omit<typeof payload, 'tiers'> = {
name: payload.name,
ruleType: payload.ruleType,
@@ -500,7 +572,7 @@ function FeeRules() {
resetForm();
return;
}
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
}
};
@@ -510,7 +582,7 @@ function FeeRules() {
<Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
</Button>
</Group>
@@ -572,14 +644,19 @@ function FeeRules() {
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -588,7 +665,7 @@ function FeeRules() {
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput
@@ -633,11 +710,23 @@ function FeeRules() {
/>
<Select
label="Trade direction"
description={isImportOnly ? 'Import only for this fee type' : undefined}
data={TRADE}
value={form.tradeDirection || null}
value={isImportOnly ? 'IMPORT' : form.tradeDirection || null}
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
clearable
disabled={isImportOnly}
clearable={!isImportOnly}
/>
{isTruckDetention && (
<Select
label="Truck type"
placeholder="Any truck type"
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
value={form.vehicleType || null}
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
clearable
/>
)}
{isBulkRule && (
<Select
label="Cargo type"
@@ -758,11 +847,11 @@ function FeeRules() {
</Stack>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
<Button loading={create.isPending || update.isPending} onClick={submit}>
{editingId ? 'Save changes' : 'Create'}
</Button>
</Group>
</Stack>