mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Warehouse Enhancemendt
This commit is contained in:
@@ -3,14 +3,20 @@ import {
|
||||
Boxes,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Paperclip,
|
||||
PackageCheck,
|
||||
Send,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
Container,
|
||||
Package,
|
||||
PackageOpen,
|
||||
Users,
|
||||
Wallet,
|
||||
//TrainTrack,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -21,10 +27,13 @@ import LoginPage from "./pages/auth/LoginPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||
@@ -33,21 +42,40 @@ import UserManagementPage from "./pages/dashboard/user-management/UserManagement
|
||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -59,11 +87,22 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/overview",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "UM",
|
||||
href: "/um",
|
||||
icon: <Users />,
|
||||
},
|
||||
{
|
||||
label: "Booking requests",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Payments",
|
||||
href: "/dashboard/payments",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
@@ -72,13 +111,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
items: [
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules v2",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,62 +130,119 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Trains",
|
||||
href: "/dashboard/trains",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Wagon types",
|
||||
href: "/dashboard/wagon-types",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
// {
|
||||
// label: "Trains",
|
||||
// href: "/dashboard/trains",
|
||||
// icon: <Train />,
|
||||
// },
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
// href: "/dashboard/wagon-types",
|
||||
// icon: <Boxes />,
|
||||
// },
|
||||
{
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Containers",
|
||||
href: "/dashboard/containers",
|
||||
icon: <Container />,
|
||||
label: "Vehicles",
|
||||
href: "/dashboard/vehicles",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Cargoes",
|
||||
href: "/dashboard/cargoes",
|
||||
icon: <Package />,
|
||||
label: "Drivers",
|
||||
href: "/dashboard/drivers",
|
||||
icon: <Users />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
// href: "/dashboard/containers",
|
||||
// icon: <Container />,
|
||||
// },
|
||||
// {
|
||||
// label: "Cargoes",
|
||||
// href: "/dashboard/cargoes",
|
||||
// icon: <Package />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse dashboard",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses/list",
|
||||
icon: <Container />,
|
||||
},
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse dashboard",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses/list",
|
||||
icon: <Container />,
|
||||
},
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -154,6 +252,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "User management",
|
||||
href: "/dashboard/user-management",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
children: [
|
||||
{
|
||||
label: "Users",
|
||||
@@ -181,11 +280,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "File settings",
|
||||
href: "/dashboard/file-settings",
|
||||
icon: <Paperclip />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
{
|
||||
label: "Dropdown settings",
|
||||
href: "/dashboard/dropdown-settings",
|
||||
icon: <Settings />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -199,10 +300,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Boxes />,
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
},
|
||||
// {
|
||||
// label: "Train scheduling rules",
|
||||
// href: "/dashboard/configuration/train-scheduling-rules",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -215,18 +316,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
];
|
||||
|
||||
const hasPermission = (
|
||||
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
||||
const filterSidebarByPermission = (
|
||||
sections: SidebarSection[],
|
||||
user: ReturnType<typeof useAuth>["user"],
|
||||
key: string,
|
||||
) => {
|
||||
if (!user) return false;
|
||||
if (user.permissions?.some((p) => p.key === key)) return true;
|
||||
): SidebarSection[] => {
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
if (!item.permission) return true;
|
||||
const keys = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
return keys.some((key) => hasFreightPermission(user, key));
|
||||
};
|
||||
|
||||
return (user.employee ?? []).some((emp) =>
|
||||
(emp.positions ?? []).some((pos) =>
|
||||
(pos.permissions ?? []).some((p) => p.key === key),
|
||||
),
|
||||
);
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter(itemAllowed),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
};
|
||||
|
||||
const DashboardShell = () => {
|
||||
@@ -236,7 +344,10 @@ const DashboardShell = () => {
|
||||
|
||||
const demoItems: SidebarItem[] = [];
|
||||
|
||||
const sidebarSections = buildSidebarSections(demoItems);
|
||||
const sidebarSections = filterSidebarByPermission(
|
||||
buildSidebarSections(demoItems),
|
||||
user,
|
||||
);
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
|
||||
return (
|
||||
@@ -265,7 +376,8 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
<Route path='/um/*' element={<UserManagementHostPage />} />
|
||||
<Route path="*" element={<Navigate to="/um" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -273,42 +385,179 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
<Route
|
||||
path="payments"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||
<PaymentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={<TrainScheduleV2DetailPage />}
|
||||
/>
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||
<Route path="trains" element={<FleetResourcePage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<FleetResourcePage />} />
|
||||
<Route path="containers" element={<FleetResourcePage />} />
|
||||
<Route path="cargoes" element={<FleetResourcePage />} />
|
||||
|
||||
<Route path="warehouse-dashboard" element={<Navigate to="/dashboard/warehouses" replace />} />
|
||||
<Route path="warehouses" element={<WarehouseDashboardPage />} />
|
||||
<Route path="warehouses/list" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={<TrainScheduleV2DetailPage />}
|
||||
/>
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||
<Route path="trains" element={<FleetResourcePage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<FleetResourcePage />} />
|
||||
<Route path="containers" element={<FleetResourcePage />} />
|
||||
<Route path="cargoes" element={<FleetResourcePage />} />
|
||||
|
||||
<Route path="warehouse-dashboard" element={<Navigate to="/dashboard/warehouses" replace />} />
|
||||
<Route path="warehouses" element={<WarehouseDashboardPage />} />
|
||||
<Route path="warehouses/list" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchBoardPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchScheduleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2ListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2DetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleTrackPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="vehicles"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="drivers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* iframe-based user management module */}
|
||||
<Route path="um/*" element={<UserManagementHostPage />} />
|
||||
|
||||
{/* Legacy embedded user management routes */}
|
||||
<Route path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="user-management/users" element={<UsersPage />} />
|
||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||
@@ -316,8 +565,22 @@ const App = () => {
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
|
||||
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
||||
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
||||
<Route
|
||||
path="file-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<FileUploadSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dropdown-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<DropdownSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
@@ -325,13 +588,17 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="configuration/train-scheduling-rules"
|
||||
element={<TrainSchedulingGlobalRulesPage />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainSchedulingGlobalRulesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
<Route
|
||||
path="rules"
|
||||
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
|
||||
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
|
||||
/>
|
||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import {
|
||||
AUTH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
@@ -16,7 +17,7 @@ type RetriableRequest = {
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`,
|
||||
baseURL: `${API_BASE_URL}/api`,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
117
apps/edr-freight-web/backoffice/src/auth/tokenManager.ts
Normal file
117
apps/edr-freight-web/backoffice/src/auth/tokenManager.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
export interface TokenMessage {
|
||||
type: 'UM_AUTH_TOKEN';
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages authentication tokens received from parent window (host)
|
||||
* Used by iframe modules to receive and store auth tokens
|
||||
*/
|
||||
class TokenManager {
|
||||
private token: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
private tokenResolve: ((token: string) => void) | null = null;
|
||||
private tokenPromise: Promise<string>;
|
||||
private isFramed: boolean;
|
||||
|
||||
constructor() {
|
||||
// Check if we're running in an iframe
|
||||
this.isFramed = window.self !== window.top;
|
||||
|
||||
// Create a promise that resolves when token is received
|
||||
this.tokenPromise = new Promise((resolve) => {
|
||||
this.tokenResolve = resolve;
|
||||
});
|
||||
|
||||
if (this.isFramed) {
|
||||
this.setupMessageListener();
|
||||
|
||||
// Request token after 2 seconds if not received
|
||||
setTimeout(() => {
|
||||
if (!this.token) {
|
||||
this.requestTokenFromHost();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
private setupMessageListener() {
|
||||
window.addEventListener('message', (event) => {
|
||||
// Security: Only accept from parent window
|
||||
if (event.source !== window.parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data as TokenMessage | undefined;
|
||||
|
||||
if (data?.type === 'UM_AUTH_TOKEN') {
|
||||
this.token = data.token;
|
||||
this.refreshToken = data.refreshToken;
|
||||
|
||||
// Store in localStorage for persistence
|
||||
localStorage.setItem('um_auth_token', data.token);
|
||||
localStorage.setItem('um_refresh_token', data.refreshToken);
|
||||
|
||||
console.log('✅ Token received from host');
|
||||
|
||||
// Resolve the promise
|
||||
if (this.tokenResolve) {
|
||||
this.tokenResolve(data.token);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private requestTokenFromHost() {
|
||||
console.log('📤 Requesting token from host...');
|
||||
window.parent.postMessage(
|
||||
{ type: 'UM_REQUEST_AUTH' },
|
||||
window.location.origin
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token - waits for it if not yet received
|
||||
*/
|
||||
async getToken(): Promise<string> {
|
||||
if (this.token) {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// Check localStorage as fallback
|
||||
const stored = localStorage.getItem('um_auth_token');
|
||||
if (stored) {
|
||||
this.token = stored;
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Wait for token to arrive from parent
|
||||
return this.tokenPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token synchronously (returns null if not available)
|
||||
*/
|
||||
getTokenSync(): string | null {
|
||||
return this.token || localStorage.getItem('um_auth_token');
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.refreshToken || localStorage.getItem('um_refresh_token');
|
||||
}
|
||||
|
||||
clearTokens() {
|
||||
this.token = null;
|
||||
this.refreshToken = null;
|
||||
localStorage.removeItem('um_auth_token');
|
||||
localStorage.removeItem('um_refresh_token');
|
||||
}
|
||||
|
||||
isInFrame(): boolean {
|
||||
return this.isFramed;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const tokenManager = new TokenManager();
|
||||
@@ -4,7 +4,6 @@ import { AuthContext } from "./AuthProvider";
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
|
||||
interface RequirePermissionProps {
|
||||
/** Permission key(s); access is granted if the user has ANY of them. */
|
||||
permission: string | string[];
|
||||
/** Where to send users who lack the permission. */
|
||||
redirectTo?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level guard: renders children only when the current user holds one of
|
||||
* the given permissions, otherwise redirects (default: overview).
|
||||
*/
|
||||
export function RequirePermission({
|
||||
permission,
|
||||
redirectTo = "/dashboard/overview",
|
||||
children,
|
||||
}: RequirePermissionProps) {
|
||||
const { user } = useAuth();
|
||||
const keys = Array.isArray(permission) ? permission : [permission];
|
||||
const allowed = keys.some((key) => hasPermission(user, key));
|
||||
|
||||
if (!allowed) return <Navigate to={redirectTo} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canManageScheduling } from "@/lib/permissions";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
@@ -19,9 +21,11 @@ interface BookingActionsToolbarProps {
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
const { user } = useAuth();
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const canAllocate = canManageScheduling(user);
|
||||
|
||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||
const blob = await fn();
|
||||
@@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{canAllocateBooking(booking) ? (
|
||||
{canAllocate && canAllocateBooking(booking) ? (
|
||||
<AllocateBookingWizard
|
||||
booking={booking}
|
||||
opened={allocateOpen}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
|
||||
import { ACCENT_CHIP_BG } from "@/components/overview/OverviewKpiCard";
|
||||
import {
|
||||
overviewAccentGradients,
|
||||
type OverviewAccent,
|
||||
} from "@/components/overview/overview.styles";
|
||||
import type {
|
||||
BookingListSummaryMetrics,
|
||||
BookingListSummaryTabs,
|
||||
} from "@/services/bookings.service";
|
||||
|
||||
const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`;
|
||||
|
||||
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
|
||||
const PIPELINE_STAGES: Array<{
|
||||
key: keyof BookingListSummaryTabs;
|
||||
@@ -26,13 +28,18 @@ const PIPELINE_STAGES: Array<{
|
||||
color: string;
|
||||
}> = [
|
||||
{ key: "intake", label: "Intake", color: "#38bdf8" },
|
||||
{ key: "in_approval", label: "Approval", color: "#fbbf24" },
|
||||
{ key: "approved_contract", label: "Contract", color: "#a78bfa" },
|
||||
{ key: "in_approval", label: "Approval", color: "#f59e0b" },
|
||||
{ key: "approved_contract", label: "Contract", color: "#8b5cf6" },
|
||||
{ key: "payment", label: "Payment", color: "#fb923c" },
|
||||
{ key: "operations", label: "Operations", color: "#2dd4bf" },
|
||||
{ key: "completed", label: "Completed", color: "#86efac" },
|
||||
{ key: "operations", label: "Operations", color: "#14b8a6" },
|
||||
{ key: "completed", label: "Completed", color: "#22c55e" },
|
||||
];
|
||||
|
||||
const CARD_STYLE = {
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
} as const;
|
||||
|
||||
export interface BookingRequestsHeaderProps {
|
||||
metrics?: BookingListSummaryMetrics;
|
||||
tabs?: BookingListSummaryTabs;
|
||||
@@ -53,232 +60,128 @@ export function BookingRequestsHeader({
|
||||
const val = (n?: number) => (loading ? "—" : (n ?? 0));
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: HERO_GRADIENT,
|
||||
boxShadow: freightBrand.shadow,
|
||||
}}
|
||||
>
|
||||
{/* decorative glows */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -100,
|
||||
right: -60,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -130,
|
||||
right: 160,
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg">
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: freightBrand.primary }}>
|
||||
<Inbox size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={700} c="rgba(255,255,255,0.8)" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
Operations
|
||||
</Text>
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Booking Requests
|
||||
</Title>
|
||||
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
|
||||
Track every booking from submission through approval, payment, and
|
||||
dispatch — prioritize what needs action.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="lg"
|
||||
leftSection={<Plus size={18} />}
|
||||
onClick={onCreate}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="white"
|
||||
radius="lg"
|
||||
c="white"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={onRefresh}
|
||||
style={{ background: "rgba(255,255,255,0.15)" }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<HeroStat
|
||||
icon={LayoutList}
|
||||
label="In queue"
|
||||
value={val(metrics?.inQueue)}
|
||||
hint="Matching current filter"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={Clock}
|
||||
label="Needs action"
|
||||
value={val(metrics?.needsAction)}
|
||||
hint="Submitted or pending"
|
||||
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
||||
ratioColor="#fbbf24"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={AlertTriangle}
|
||||
label="Urgent"
|
||||
value={val(metrics?.urgent)}
|
||||
hint="High priority score"
|
||||
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
|
||||
ratioColor="#fca5a5"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={CheckCircle2}
|
||||
label="Completed"
|
||||
value={val(tabs?.completed)}
|
||||
hint="Fully executed"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{tabs ? <PipelineBar tabs={tabs} /> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact ring gauge with the stat icon at its center. */
|
||||
function MiniDonut({
|
||||
pct,
|
||||
color = "white",
|
||||
children,
|
||||
size = 52,
|
||||
stroke = 5,
|
||||
}: {
|
||||
pct?: number | null;
|
||||
color?: string;
|
||||
children: ReactNode;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
}) {
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped =
|
||||
pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
|
||||
const dash = clamped != null ? (clamped / 100) * circumference : 0;
|
||||
|
||||
return (
|
||||
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ transform: "rotate(-90deg)", display: "block" }}
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.18)"
|
||||
strokeWidth={stroke}
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<HeroStat
|
||||
icon={LayoutList}
|
||||
label="In queue"
|
||||
value={val(metrics?.inQueue)}
|
||||
hint="Matching current filter"
|
||||
accent="gold"
|
||||
variant="area"
|
||||
/>
|
||||
{clamped != null ? (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${circumference}`}
|
||||
style={{ transition: "stroke-dasharray 400ms ease" }}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
<HeroStat
|
||||
icon={Clock}
|
||||
label="Needs action"
|
||||
value={val(metrics?.needsAction)}
|
||||
hint="Submitted or pending"
|
||||
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
||||
accent="orange"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={AlertTriangle}
|
||||
label="Urgent"
|
||||
value={val(metrics?.urgent)}
|
||||
hint="High priority score"
|
||||
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
|
||||
accent="rose"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={CheckCircle2}
|
||||
label="Completed"
|
||||
value={val(tabs?.completed)}
|
||||
hint="Fully executed"
|
||||
accent="gold"
|
||||
variant="line"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* {tabs ? <PipelineBar tabs={tabs} /> : null} */}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI card matching the overview style: icon chip + value + label, with a mini
|
||||
* graph at the bottom. Ratio cards show a ring; the rest show an area/line trend.
|
||||
*/
|
||||
function HeroStat({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
ratio,
|
||||
ratioColor = "white",
|
||||
accent = "emerald",
|
||||
variant = "area",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
ratio?: number;
|
||||
ratioColor?: string;
|
||||
accent?: OverviewAccent;
|
||||
variant?: "area" | "line";
|
||||
}) {
|
||||
const [, accentDeep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
|
||||
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
flex: "1 1 180px",
|
||||
minWidth: 160,
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
>
|
||||
<Group gap="md" wrap="nowrap" align="center">
|
||||
<MiniDonut pct={pct} color={ratioColor}>
|
||||
<Icon size={19} />
|
||||
</MiniDonut>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="28px" c="white" lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="rgba(255,255,255,0.72)" truncate>
|
||||
{pct != null ? `${pct}% of queue` : hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: chipBg,
|
||||
color: accentDeep,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
{label}
|
||||
{hint ? ` · ${pct != null ? `${pct}% of queue` : hint}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
{pct != null ? (
|
||||
<MiniRing pct={pct} accent={accent} size={44} stroke={5}>
|
||||
<Text size="10px" fw={800} style={{ color: accentDeep }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</MiniRing>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{pct == null ? (
|
||||
<MiniSparkline variant={variant} accent={accent} baseline={0.5} seed={label} height={22} />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -288,20 +191,12 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
const total = segments.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
>
|
||||
<Paper p="md" radius="lg" style={CARD_STYLE}>
|
||||
<Group justify="space-between" mb={10}>
|
||||
<Text size="sm" fw={700} c="white">
|
||||
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
|
||||
Booking pipeline
|
||||
</Text>
|
||||
<Text size="xs" c="rgba(255,255,255,0.75)">
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} active
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -312,7 +207,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
height: 14,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
background: "var(--mantine-color-gray-2)",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
@@ -322,11 +217,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
<Box
|
||||
key={s.key}
|
||||
title={`${s.label}: ${s.count}`}
|
||||
style={{
|
||||
width: `${(s.count / total) * 100}%`,
|
||||
background: s.color,
|
||||
transition: "width 200ms ease",
|
||||
}}
|
||||
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
|
||||
/>
|
||||
) : null,
|
||||
)
|
||||
@@ -339,10 +230,10 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
{segments.map((s) => (
|
||||
<Group key={s.key} gap={6} wrap="nowrap">
|
||||
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
|
||||
<Text size="xs" c="rgba(255,255,255,0.85)">
|
||||
<Text size="xs" c="dimmed">
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c="white">
|
||||
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
|
||||
{s.count}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
@@ -13,6 +14,8 @@ const statusColorMap: Record<string, string> = {
|
||||
FULLY_EXECUTED: "indigo",
|
||||
PNR_GENERATED: "violet",
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
||||
SELECTED_FOR_BATCH: "orange",
|
||||
EXPIRED: "red",
|
||||
PAID: "green",
|
||||
IN_TRANSIT: "cyan",
|
||||
COMPLETED: "indigo",
|
||||
@@ -22,14 +25,26 @@ const statusColorMap: Record<string, string> = {
|
||||
CONSOLIDATED: "indigo",
|
||||
};
|
||||
|
||||
export function BookingStatusBadge({ status }: { status: string }) {
|
||||
interface BookingStatusBadgeProps {
|
||||
status: string;
|
||||
/** When the booking is part of a consolidation, show a sibling badge. */
|
||||
consolidated?: boolean;
|
||||
/** Partner booking reference for the consolidated badge tooltip. */
|
||||
partnerReference?: string | null;
|
||||
}
|
||||
|
||||
export function BookingStatusBadge({
|
||||
status,
|
||||
consolidated,
|
||||
partnerReference,
|
||||
}: BookingStatusBadgeProps) {
|
||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||
label: status,
|
||||
color: "gray",
|
||||
};
|
||||
const color = statusColorMap[status] ?? "gray";
|
||||
|
||||
return (
|
||||
const statusBadge = (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
@@ -49,4 +64,34 @@ export function BookingStatusBadge({ status }: { status: string }) {
|
||||
{style.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!consolidated) return statusBadge;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{statusBadge}
|
||||
<Badge
|
||||
color="indigo"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
leftSection={<Link2 size={12} />}
|
||||
title={
|
||||
partnerReference
|
||||
? `Consolidated with ${partnerReference}`
|
||||
: "Part of a consolidation"
|
||||
}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Consolidated
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
FileCheck,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BookingCompanyCardProps {
|
||||
booking: BookingDetail;
|
||||
}
|
||||
|
||||
/** Customer (company) information for the booking. */
|
||||
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
|
||||
const company = booking.company;
|
||||
|
||||
// Government bookings may not carry a company; show the institution instead.
|
||||
if (!company && booking.isGovernment) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<InfoRow
|
||||
icon={Building2}
|
||||
label="Government"
|
||||
value={booking.governmentInstitution}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer linked to this booking.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const companyName = company.companyName ?? company.name ?? company.label;
|
||||
|
||||
const rows: InfoRowProps[] = [
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: User, label: "Contact person", value: company.contactPersonName },
|
||||
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||
].filter((r) => r.value);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Building2}
|
||||
title="Customer"
|
||||
subtitle={companyName}
|
||||
accent="blue"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No additional company details available.
|
||||
</Text>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<div key={row.label}>
|
||||
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||
<InfoRow {...row} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function BookingContainersCard({ containers }: BookingContainersCardProps
|
||||
<Table.Td>{container.quantity}</Table.Td>
|
||||
<Table.Td>{container.vgmPerUnitTons} t</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} c="green.7" size="sm">
|
||||
<Text fw={600} size="sm" style={{ color: "#B26C09" }}>
|
||||
{(container.quantity * container.vgmPerUnitTons).toFixed(2)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
@@ -36,7 +36,11 @@ export function BookingDetailHeader({
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingStatusBadge
|
||||
status={booking.status}
|
||||
consolidated={Boolean(booking.consolidationPartnerId)}
|
||||
partnerReference={booking.consolidationPartner?.reference}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Building2 size={14} color="var(--mantine-color-gray-5)" />
|
||||
|
||||
@@ -21,10 +21,10 @@ export function BookingPaymentCard({
|
||||
Total Amount
|
||||
</Text>
|
||||
<Group align="flex-end" gap="xs">
|
||||
<Title order={1} fw={700} c="green.9" style={{ letterSpacing: "-1px" }}>
|
||||
<Title order={1} fw={700} style={{ letterSpacing: "-1px", color: "#8A5304" }}>
|
||||
{totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</Title>
|
||||
<Text fw={600} c="green.7" mb={6}>
|
||||
<Text fw={600} mb={6} style={{ color: "#B26C09" }}>
|
||||
{currency}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { Timer } from "lucide-react";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
export interface BookingPaymentCountdownCardProps {
|
||||
/** ISO timestamp marking the end of the pay window. */
|
||||
paymentDeadline: string;
|
||||
}
|
||||
|
||||
interface Remaining {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
function getRemaining(deadlineMs: number): Remaining {
|
||||
const diff = deadlineMs - Date.now();
|
||||
if (diff <= 0) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
||||
}
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
return {
|
||||
days: Math.floor(totalSeconds / 86400),
|
||||
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
expired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function Segment({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<Stack gap={2} align="center" style={{ minWidth: 56 }}>
|
||||
<Text fw={700} size="2rem" style={{ lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>
|
||||
{String(value).padStart(2, "0")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" lts="0.06em">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live countdown to the payment deadline. Ticks every second; shows an expired state past the deadline. */
|
||||
export function BookingPaymentCountdownCard({ paymentDeadline }: BookingPaymentCountdownCardProps) {
|
||||
const deadlineMs = new Date(paymentDeadline).getTime();
|
||||
const [remaining, setRemaining] = useState<Remaining>(() => getRemaining(deadlineMs));
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(getRemaining(deadlineMs));
|
||||
const interval = setInterval(() => {
|
||||
const next = getRemaining(deadlineMs);
|
||||
setRemaining(next);
|
||||
if (next.expired) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [deadlineMs]);
|
||||
|
||||
const accent = remaining.expired ? "red" : "orange";
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Timer}
|
||||
title="Payment Deadline"
|
||||
subtitle={
|
||||
remaining.expired
|
||||
? "The pay window has closed"
|
||||
: "Time remaining to complete payment"
|
||||
}
|
||||
accent={accent}
|
||||
>
|
||||
{remaining.expired ? (
|
||||
<Text fw={600} c="red.7">
|
||||
Expired
|
||||
</Text>
|
||||
) : (
|
||||
<Group justify="center" gap="lg" wrap="nowrap">
|
||||
<Segment value={remaining.days} label="Days" />
|
||||
<Segment value={remaining.hours} label="Hours" />
|
||||
<Segment value={remaining.minutes} label="Mins" />
|
||||
<Segment value={remaining.seconds} label="Secs" />
|
||||
</Group>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -18,12 +18,9 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
import { formatDate } from "./booking-detail.styles";
|
||||
|
||||
const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`;
|
||||
|
||||
export interface BookingRequestHeroProps {
|
||||
booking: BookingDetail;
|
||||
customerLabel: string;
|
||||
@@ -55,40 +52,25 @@ export function BookingRequestHero({
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: HERO_GRADIENT,
|
||||
boxShadow: freightBrand.shadow,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -110,
|
||||
right: -50,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="white"
|
||||
color="white"
|
||||
c="white"
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={onBack}
|
||||
style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.25)" }}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
@@ -101,11 +83,11 @@ export function BookingRequestHero({
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" c="rgba(255,255,255,0.78)" fw={700} tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
<Text size="xs" fw={700} tt="uppercase" style={{ letterSpacing: 1, color: "#B26C09" }}>
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px", color: "#0f172a" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
@@ -116,7 +98,7 @@ export function BookingRequestHero({
|
||||
</Group>
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Text size="xs" c="rgba(255,255,255,0.85)">
|
||||
<Text size="xs" c="orange.7">
|
||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -130,7 +112,12 @@ export function BookingRequestHero({
|
||||
</Group>
|
||||
|
||||
{booking.nextStep ? (
|
||||
<Paper radius="lg" p={4} style={{ background: "rgba(255,255,255,0.92)" }} maw={640}>
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={4}
|
||||
maw={640}
|
||||
style={{ background: "var(--mantine-color-gray-0)", border: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<NextStepBanner nextStep={booking.nextStep} />
|
||||
</Paper>
|
||||
) : null}
|
||||
@@ -143,19 +130,22 @@ export function BookingRequestHero({
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
accent="green"
|
||||
/>
|
||||
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
||||
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
|
||||
<HeroTile
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
value={containerCount || "—"}
|
||||
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
||||
accent="teal"
|
||||
/>
|
||||
<HeroTile
|
||||
icon={Flame}
|
||||
label="Priority score"
|
||||
value={booking.priorityScore ?? 0}
|
||||
hint={booking.tradeDirection}
|
||||
accent="orange"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -174,8 +164,8 @@ function MetaItem({
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="rgba(255,255,255,0.8)" />
|
||||
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "white" : "rgba(255,255,255,0.85)"}>
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -187,11 +177,13 @@ function HeroTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
accent = "green",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
@@ -200,36 +192,23 @@ function HeroTile({
|
||||
style={{
|
||||
flex: "1 1 160px",
|
||||
minWidth: 150,
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backdropFilter: "blur(6px)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" c="white" lh={1.1} style={{ whiteSpace: "nowrap" }}>
|
||||
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap", color: "#0f172a" }}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c="rgba(255,255,255,0.7)" truncate>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
|
||||
<Stack gap="md">
|
||||
{notes.map((note) => (
|
||||
<Group key={note.id} align="flex-start" gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={34} radius="xl" variant="light" color="green">
|
||||
<ThemeIcon size={34} radius="xl" variant="light" color="#F2A516">
|
||||
<FileText size={16} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Alert, List, Text } from "@mantine/core";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
|
||||
/**
|
||||
* Shown on a booking parked in PENDING_CONSOLIDATION. Explains that the booking
|
||||
* cannot be approved until a complementary booking fills the wagon, and lists
|
||||
* the partial-wagon container lines that are waiting for a partner.
|
||||
*/
|
||||
export function ConsolidationWaitingBanner({ bookingId }: { bookingId: string }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: [...QUERY_KEYS.BOOKINGS.byId(bookingId), "consolidation"],
|
||||
queryFn: () => bookingsService.getConsolidationDetails(bookingId),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<Link2 size={18} />}
|
||||
title="Waiting for a consolidation partner"
|
||||
>
|
||||
<Text size="sm">
|
||||
This booking cannot be approved until a matching booking fills the
|
||||
wagon. It will return to the approval queue automatically once a partner
|
||||
is found.
|
||||
</Text>
|
||||
{data?.wagonSlots?.length ? (
|
||||
<List size="sm" mt="xs" spacing={2}>
|
||||
{data.wagonSlots.map((slot) => (
|
||||
<List.Item key={slot.containerTypeCode}>
|
||||
{slot.slotsNeeded} more × {slot.containerTypeCode} (
|
||||
{slot.containersPerWagon} per wagon; you have {slot.quantity})
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
) : null}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { FREIGHT_BRAND } from "@/theme/freight-brand";
|
||||
|
||||
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
|
||||
export const BRAND_GREEN = FREIGHT_BRAND;
|
||||
/** Single brand accent (gold). Used sparingly for icons/highlights, no gradients. */
|
||||
export const BRAND_GREEN = "#F2A516";
|
||||
|
||||
/** Centralised style tokens for the booking detail page + cards. */
|
||||
export const detailStyles = {
|
||||
@@ -124,6 +122,7 @@ export interface BookingFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface BookingDetailView {
|
||||
@@ -139,6 +138,8 @@ export interface BookingDetailView {
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
pnrCode?: string | null;
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
paymentDeadline?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
company?: BookingNamedRefView;
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from "./BookingContainersCard";
|
||||
export * from "./BookingApprovalCard";
|
||||
export * from "./BookingReviewNotesCard";
|
||||
export * from "./BookingPaymentCard";
|
||||
export * from "./BookingPaymentCountdownCard";
|
||||
export * from "./BookingFactsCard";
|
||||
export * from "./BookingDocumentsCard";
|
||||
export * from "./BookingRequestHero";
|
||||
@@ -16,3 +17,4 @@ export * from "./BookingRouteServiceCard";
|
||||
export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
@@ -32,8 +33,6 @@ interface CargoFormDialogProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function CargoFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { PackageCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useDeliverCargo } from '@/hooks/useCargoes';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
/**
|
||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||
* Records receiver name, pickup date and remarks, then marks the cargo DELIVERED.
|
||||
*/
|
||||
export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [receiverName, setReceiverName] = useState('');
|
||||
const [pickupDate, setPickupDate] = useState('');
|
||||
const [deliveryRemarks, setDeliveryRemarks] = useState('');
|
||||
const deliver = useDeliverCargo();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleDeliver = async () => {
|
||||
if (!receiverName.trim()) {
|
||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliver.mutateAsync({
|
||||
id: cargoId,
|
||||
payload: {
|
||||
receiverName: receiverName.trim(),
|
||||
pickupDate: pickupDate ? new Date(pickupDate).toISOString() : undefined,
|
||||
deliveryRemarks: deliveryRemarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Delivered', description: 'Proof of delivery recorded; cargo marked delivered.' });
|
||||
setOpen(false);
|
||||
setReceiverName('');
|
||||
setPickupDate('');
|
||||
setDeliveryRemarks('');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'Could not record delivery.';
|
||||
toast({ title: 'Delivery failed', description: String(message), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="Customer pickup / Proof of delivery">
|
||||
<PackageCheck className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customer Pickup & Proof of Delivery</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Receiver name*</Label>
|
||||
<Input
|
||||
required
|
||||
value={receiverName}
|
||||
placeholder="Name of person collecting the cargo"
|
||||
onChange={(e) => setReceiverName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Pickup date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={pickupDate}
|
||||
onChange={(e) => setPickupDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Remarks</Label>
|
||||
<Textarea
|
||||
value={deliveryRemarks}
|
||||
placeholder="Condition on handover, ID checked, etc."
|
||||
onChange={(e) => setDeliveryRemarks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleDeliver} disabled={deliver.isPending} className="w-full">
|
||||
{deliver.isPending ? 'Recording…' : 'Confirm Pickup & Mark Delivered'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import {
|
||||
overviewAccentGradients,
|
||||
type OverviewAccent,
|
||||
} from "@/components/overview/overview.styles";
|
||||
|
||||
/**
|
||||
* Tiny inline-SVG mini graphs shared by every KPI / stat card across the app.
|
||||
* No charting dependency — crisp and cheap to render in long card grids.
|
||||
*
|
||||
* - `MiniSparkline` (variant "area" | "line") draws a smooth decorative trend.
|
||||
* - `MiniRing` draws a circular percentage gauge.
|
||||
*
|
||||
* The sparkline series is deterministic (seeded from a string) so a given card
|
||||
* always renders the same shape — purely decorative, not a real time-series.
|
||||
*/
|
||||
|
||||
/** Deterministic, gently-rising series seeded by a string (purely decorative). */
|
||||
export function sparkHeights(seed: string, baseline: number, count = 9): number[] {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
const out: number[] = [];
|
||||
let v = 0.35 + (baseline > 0 ? Math.min(0.35, baseline * 0.35) : 0.15);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
h = (Math.imul(h, 1103515245) + 12345) >>> 0;
|
||||
const delta = ((h % 1000) / 1000 - 0.42) * 0.28;
|
||||
v = Math.min(1, Math.max(0.22, v + delta));
|
||||
out.push(v);
|
||||
}
|
||||
// Nudge the final point up so the series reads as an upward trend.
|
||||
out[out.length - 1] = Math.min(1, out[out.length - 1] + 0.15);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a smooth (Catmull-Rom → bezier) path string through y-points in [0,1]. */
|
||||
function smoothPath(values: number[], width: number, height: number, pad = 2): string {
|
||||
const n = values.length;
|
||||
if (n === 0) return "";
|
||||
const innerW = width - pad * 2;
|
||||
const innerH = height - pad * 2;
|
||||
const pts = values.map((v, i) => ({
|
||||
x: pad + (n === 1 ? 0 : (i / (n - 1)) * innerW),
|
||||
y: pad + (1 - v) * innerH,
|
||||
}));
|
||||
if (n === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||||
for (let i = 0; i < n - 1; i += 1) {
|
||||
const p0 = pts[i - 1] ?? pts[i];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[i + 2] ?? p2;
|
||||
const c1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const c1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const c2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const c2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export type SparklineVariant = "area" | "line";
|
||||
|
||||
/**
|
||||
* Smooth decorative trend. `area` fills under the curve with an accent gradient;
|
||||
* `line` is a clean stroke with a highlighted dot at the latest point.
|
||||
*/
|
||||
export function MiniSparkline({
|
||||
variant,
|
||||
accent = "default",
|
||||
seed,
|
||||
baseline = 0,
|
||||
height = 38,
|
||||
}: {
|
||||
variant: SparklineVariant;
|
||||
accent?: OverviewAccent;
|
||||
seed: string;
|
||||
baseline?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
const [light, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const values = sparkHeights(seed, baseline);
|
||||
const width = 120;
|
||||
const pad = 3;
|
||||
const line = smoothPath(values, width, height, pad);
|
||||
const last = values[values.length - 1];
|
||||
const lastX = pad + ((values.length - 1) / (values.length - 1 || 1)) * (width - pad * 2);
|
||||
const lastY = pad + (1 - last) * (height - pad * 2);
|
||||
// Unique gradient id per seed+accent so multiple cards don't collide.
|
||||
const gid = `spark-${variant}-${accent}-${seed.replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
|
||||
return (
|
||||
<Box style={{ width: "100%" }}>
|
||||
<svg
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
style={{ display: "block", overflow: "visible" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={deep} stopOpacity={variant === "area" ? 0.32 : 0} />
|
||||
<stop offset="100%" stopColor={light} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{variant === "area" ? (
|
||||
<path
|
||||
d={`${line} L ${width - pad} ${height - pad} L ${pad} ${height - pad} Z`}
|
||||
fill={`url(#${gid})`}
|
||||
stroke="none"
|
||||
/>
|
||||
) : null}
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={deep}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{variant === "line" ? (
|
||||
<circle cx={lastX} cy={lastY} r={3} fill={deep} stroke="white" strokeWidth={1.5} />
|
||||
) : null}
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circular percentage gauge with an accent stroke. */
|
||||
export function MiniRing({
|
||||
pct,
|
||||
accent = "default",
|
||||
size = 52,
|
||||
stroke = 5,
|
||||
children,
|
||||
}: {
|
||||
pct: number | null | undefined;
|
||||
accent?: OverviewAccent;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
|
||||
const dash = clamped != null ? (clamped / 100) * circumference : 0;
|
||||
|
||||
return (
|
||||
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
|
||||
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--mantine-color-gray-2)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{clamped != null ? (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={deep}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${circumference}`}
|
||||
style={{ transition: "stroke-dasharray 400ms ease" }}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: deep,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -84,40 +84,60 @@ const FleetCardGrid = ({
|
||||
radius="lg"
|
||||
padding="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
transition: "all 200ms ease",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="hover:shadow-md hover:border-green-3"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow =
|
||||
"0 4px 12px rgba(0, 0, 0, 0.08)";
|
||||
e.currentTarget.style.borderColor =
|
||||
"var(--mantine-color-green-3)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
e.currentTarget.style.borderColor =
|
||||
"var(--mantine-color-gray-2)";
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, var(--mantine-color-green-0), var(--mantine-color-green-1))",
|
||||
color: "var(--mantine-color-green-8)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
fontSize: 16,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{cardInitials(title)}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" lineClamp={1} c="dark">
|
||||
{title || "—"}
|
||||
</Text>
|
||||
{subtitle != null && subtitle !== "" ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{String(subtitle)}
|
||||
{presentation.subtitleKey === "currentYard" ||
|
||||
presentation.subtitleKey === "currentYardId"
|
||||
? formatFleetCell(subtitle, "entityLabel", presentation.subtitleKey)
|
||||
: String(subtitle)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
{code != null && code !== "" ? (
|
||||
<Badge variant="light" color="blue" size="sm" radius="md">
|
||||
<Badge variant="filled" color="green" size="sm" radius="md" style={{ flexShrink: 0 }}>
|
||||
{String(code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Calendar } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
MultiSelect,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
|
||||
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
@@ -83,9 +86,25 @@ const FleetFormDialog = ({
|
||||
const value = values[field.name];
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
|
||||
if (field.required && (stringValue === "" || stringValue === FLEET_SELECT_NONE)) {
|
||||
next[field.name] = `${field.label} is required`;
|
||||
}
|
||||
|
||||
// Validate date format (YYYY-MM-DD) - DatePicker ensures this
|
||||
if (field.type === "date" && stringValue && stringValue !== FLEET_SELECT_NONE) {
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(stringValue)) {
|
||||
next[field.name] = `${field.label} must be a valid date`;
|
||||
} else {
|
||||
const date = new Date(stringValue + "T00:00:00Z");
|
||||
if (isNaN(date.getTime())) {
|
||||
next[field.name] = `${field.label} is not a valid date`;
|
||||
} else if (date > new Date()) {
|
||||
next[field.name] = `${field.label} cannot be in the future`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
@@ -126,11 +145,34 @@ const FleetFormDialog = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "multiselect") {
|
||||
const arrayValue = Array.isArray(value) ? value : (typeof value === "string" && value ? [value] : []);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder || "Select options"}
|
||||
data={field.options ?? []}
|
||||
value={arrayValue.map(String)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next }))
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
clearable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<NumberInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={value === "" || value == null ? "" : Number(value)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({
|
||||
@@ -139,6 +181,7 @@ const FleetFormDialog = ({
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -148,12 +191,53 @@ const FleetFormDialog = ({
|
||||
<Textarea
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
|
||||
}
|
||||
error={error}
|
||||
minRows={3}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
return (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
type="date"
|
||||
label={field.label}
|
||||
placeholder={field.placeholder || "YYYY-MM-DD"}
|
||||
value={typeof value === "string" ? value.slice(0, 10) : ""}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: e.currentTarget?.value ?? "",
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
disabled={field.disabled}
|
||||
description={field.description || "Select a date"}
|
||||
rightSection={
|
||||
<ActionIcon size="sm" variant="subtle" color="green" pointer={false}>
|
||||
<Calendar size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
size="sm"
|
||||
radius="md"
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
backgroundColor: "var(--mantine-color-gray-0)",
|
||||
transition: "all 200ms ease",
|
||||
"&:focus": {
|
||||
borderColor: "var(--mantine-color-green-5)",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -162,11 +246,14 @@ const FleetFormDialog = ({
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
description={field.description}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget?.value }))
|
||||
}
|
||||
error={error}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -198,4 +285,4 @@ const FleetFormDialog = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetFormDialog;
|
||||
export default FleetFormDialog;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MoreHorizontal, Pencil, Trash2, Truck } from "lucide-react";
|
||||
import { MoreHorizontal, Pencil, Trash2, Eye, Copy } from "lucide-react";
|
||||
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -33,37 +33,40 @@ const FleetRecordActions = ({
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{showDetail ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={handleDetail}
|
||||
leftSection={<Truck size={14} />}
|
||||
>
|
||||
Manage wagons
|
||||
</Button>
|
||||
<Tooltip label="View details">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="md"
|
||||
radius="md"
|
||||
onClick={handleDetail}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Pencil size={14} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="md"
|
||||
radius="md"
|
||||
onClick={() => onEdit(record)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={removeLabel}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="md"
|
||||
radius="md"
|
||||
onClick={() => onRemove(record)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -71,22 +74,24 @@ const FleetRecordActions = ({
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
{showDetail ? (
|
||||
<Tooltip label="Manage wagons">
|
||||
<ActionIcon variant="subtle" color="green" size="md" radius="md" onClick={handleDetail}>
|
||||
<Truck size={16} />
|
||||
<Tooltip label="View details">
|
||||
<ActionIcon variant="light" color="green" size="md" radius="md" onClick={handleDetail}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" size="md" radius="md" onClick={() => onEdit(record)}>
|
||||
<ActionIcon variant="light" color="teal" size="md" radius="md" onClick={() => onEdit(record)}>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="md" radius="md">
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
<Tooltip label="More actions">
|
||||
<ActionIcon variant="light" color="green" size="md" radius="md">
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => onRemove(record)}>
|
||||
|
||||
@@ -6,7 +6,7 @@ export type FleetViewMode = "table" | "cards";
|
||||
|
||||
const STORAGE_PREFIX = "edr-freight-fleet-view:";
|
||||
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2";
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2" | "batch-board";
|
||||
|
||||
const readStored = (slug: ViewModeSlug): FleetViewMode => {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
Languages,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
import "./FreightDashboardHeader.css";
|
||||
@@ -37,6 +39,7 @@ const FreightDashboardHeader = ({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
}: FreightDashboardHeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
const initials =
|
||||
userInitials ??
|
||||
(userName
|
||||
@@ -78,8 +81,8 @@ const FreightDashboardHeader = ({
|
||||
<header className="fdh-root">
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="fdh-eyebrow">
|
||||
<span className="fdh-eyebrow-dot" />
|
||||
Freight Backoffice
|
||||
{/* <span className="fdh-eyebrow-dot" />
|
||||
Freight Backoffice */}
|
||||
</span>
|
||||
<Text
|
||||
fw={700}
|
||||
@@ -88,9 +91,9 @@ const FreightDashboardHeader = ({
|
||||
>
|
||||
{pageMeta.title}
|
||||
</Text>
|
||||
<Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
|
||||
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
|
||||
{pageMeta.subtitle}
|
||||
</Text>
|
||||
</Text> */}
|
||||
</Stack>
|
||||
|
||||
<Group gap={10} wrap="nowrap">
|
||||
@@ -200,10 +203,22 @@ const FreightDashboardHeader = ({
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<User size={15} />}
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
navigate("/dashboard/profile");
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
navigate("/dashboard/profile#signature");
|
||||
}}
|
||||
>
|
||||
My signature
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={15} />}
|
||||
color="red"
|
||||
|
||||
@@ -79,11 +79,11 @@ const FreightDashboardLayout = ({
|
||||
height: "100dvh",
|
||||
overflow: "hidden",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
padding: "8px",
|
||||
padding: "0px",
|
||||
fontFamily: "'Outfit', var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "8px" }}>
|
||||
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "5px" }}>
|
||||
<FreightSidebar
|
||||
sections={sidebarSections}
|
||||
activeHref={activeHref}
|
||||
|
||||
@@ -181,6 +181,22 @@
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
.fsb-chevron-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fsb-chevron-btn:hover .fsb-chevron {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ---- Nested branch ---- */
|
||||
.fsb-branch {
|
||||
margin: 2px 0 2px 22px;
|
||||
|
||||
@@ -180,23 +180,37 @@ const FreightSidebar = ({
|
||||
href={item.href}
|
||||
className="fsb-item"
|
||||
data-active={isActive}
|
||||
onClick={(e) => navigateTo(e, item.href!)}
|
||||
onClick={(e) => {
|
||||
if (hasChildren) {
|
||||
setExpanded((current) => ({
|
||||
...current,
|
||||
[item.href!]: true,
|
||||
}));
|
||||
}
|
||||
navigateTo(e, item.href!);
|
||||
}}
|
||||
>
|
||||
{item.icon && <span className="fsb-icon">{item.icon}</span>}
|
||||
<span className="fsb-item-label">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="fsb-chevron"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
className="fsb-chevron-btn"
|
||||
aria-label={isOpen ? "Collapse section" : "Expand section"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleExpanded(item.href!);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="fsb-chevron"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
|
||||
|
||||
@@ -36,6 +36,20 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Dashboard summary and key metrics",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/profile",
|
||||
meta: {
|
||||
title: "My Profile",
|
||||
subtitle: "Manage your account and signature",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/payments",
|
||||
meta: {
|
||||
title: "Payments",
|
||||
subtitle: "View booking payment transactions",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling-v2/",
|
||||
meta: {
|
||||
@@ -45,16 +59,9 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling-v2",
|
||||
meta: {
|
||||
title: "Train Schedules v2",
|
||||
subtitle: "Operational train scheduling with full allocation workflow",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling",
|
||||
meta: {
|
||||
title: "Train Schedules",
|
||||
subtitle: "Create and manage container train schedules",
|
||||
subtitle: "Operational train scheduling with full allocation workflow",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface SidebarItem {
|
||||
href?: string;
|
||||
icon?: ReactNode;
|
||||
children?: SidebarItem[];
|
||||
/** Permission key(s) required to see this item; ANY grants access. */
|
||||
permission?: string | string[];
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
|
||||
@@ -21,15 +21,15 @@ export function OverviewBookingTrendChart({ data }: { data: IOverviewTrendPoint[
|
||||
const hasData = data.some((point) => point.count > 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>Booking trend</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No bookings in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="bookingTrendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { overviewChartColors } from "./overview.styles";
|
||||
|
||||
@@ -27,42 +27,100 @@ export function OverviewDonutChart({
|
||||
}: OverviewDonutChartProps) {
|
||||
const filtered = data.filter((item) => item.value > 0);
|
||||
const hasData = filtered.length > 0;
|
||||
const total = filtered.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Text fw={600}>{title}</Text>
|
||||
<Paper p="md" radius="lg" withBorder h="100%">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filtered}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
<Group gap="md" wrap="nowrap" align="center" style={{ flex: 1 }}>
|
||||
{/* Compact donut with the total in its center */}
|
||||
<Box style={{ position: "relative", width: 132, height: 132, flexShrink: 0 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filtered}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={42}
|
||||
outerRadius={62}
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
>
|
||||
{filtered.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={
|
||||
overviewChartColors.pipeline[
|
||||
index % overviewChartColors.pipeline.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => [value, "Count"]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{filtered.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={
|
||||
overviewChartColors.pipeline[
|
||||
index % overviewChartColors.pipeline.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => [value, "Count"]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Text fw={800} size="xl" lh={1} style={{ color: "#0f172a" }}>
|
||||
{total}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" fw={600} tt="uppercase" style={{ letterSpacing: 0.4 }}>
|
||||
Total
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legend fills the space — color, name, count and share */}
|
||||
<Stack gap={6} style={{ flex: 1, minWidth: 0 }}>
|
||||
{filtered.map((entry, index) => {
|
||||
const color =
|
||||
overviewChartColors.pipeline[index % overviewChartColors.pipeline.length];
|
||||
const pct = total > 0 ? Math.round((entry.value / total) * 100) : 0;
|
||||
return (
|
||||
<Group key={entry.name} gap={8} wrap="nowrap" justify="space-between">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: 3, background: color, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Text size="xs" fw={700} c="dark.5">
|
||||
{entry.value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ width: 34, textAlign: "right" }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -36,8 +36,8 @@ export function OverviewHorizontalBarChart({
|
||||
const hasData = chartData.length > 0;
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 240 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>{title}</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
|
||||
import {
|
||||
overviewAccentGradients,
|
||||
type OverviewAccent,
|
||||
} from "./overview.styles";
|
||||
|
||||
/** Mini-graph type rendered at the bottom of a KPI card. */
|
||||
export type KpiGraphVariant = "area" | "line" | "ring";
|
||||
|
||||
/** Pale chip background per accent — matches the Pencil "tinted icon chip" look. */
|
||||
const ACCENT_CHIP_BG: Record<string, string> = {
|
||||
export const ACCENT_CHIP_BG: Record<string, string> = {
|
||||
default: "#F1F5F4",
|
||||
emerald: "#E7F8F2",
|
||||
amber: "#FEF9C3",
|
||||
rose: "#FFE4E6",
|
||||
sky: "#E0F2FE",
|
||||
violet: "#EDE9FE",
|
||||
gold: "#FEF1D5",
|
||||
orange: "#FEEAD7",
|
||||
};
|
||||
|
||||
export interface OverviewKpiItem {
|
||||
@@ -22,67 +28,10 @@ export interface OverviewKpiItem {
|
||||
hint?: string;
|
||||
icon: LucideIcon;
|
||||
accent?: keyof typeof ACCENT_CHIP_BG;
|
||||
/** Share 0..1 used as a baseline for the decorative trend sparkline. */
|
||||
/** Share 0..1 used as a baseline for the decorative trend + ring gauge. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/** Deterministic, gently-rising series seeded by the KPI label (purely decorative). */
|
||||
function sparkHeights(seed: string, baseline: number, count = 9): number[] {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
const out: number[] = [];
|
||||
let v = 0.35 + (baseline > 0 ? Math.min(0.35, baseline * 0.35) : 0.15);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
h = (Math.imul(h, 1103515245) + 12345) >>> 0;
|
||||
const delta = ((h % 1000) / 1000 - 0.42) * 0.28;
|
||||
v = Math.min(1, Math.max(0.22, v + delta));
|
||||
out.push(v);
|
||||
}
|
||||
// Nudge the final bar up so the series reads as an upward trend.
|
||||
out[out.length - 1] = Math.min(1, out[out.length - 1] + 0.15);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Compact bar sparkline; last bar highlighted in the accent colour. */
|
||||
function KpiSparkline({
|
||||
accent,
|
||||
baseline,
|
||||
seed,
|
||||
}: {
|
||||
accent: OverviewAccent;
|
||||
baseline: number;
|
||||
seed: string;
|
||||
}) {
|
||||
const [light, deep] = overviewAccentGradients[accent] ?? overviewAccentGradients.default;
|
||||
const bars = sparkHeights(seed, baseline);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: 4,
|
||||
height: 38,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{bars.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: Math.round(8 + value * 30),
|
||||
borderRadius: 3,
|
||||
background: index === bars.length - 1 ? deep : light,
|
||||
opacity: index === bars.length - 1 ? 1 : 0.55,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
/** Mini-graph type; defaults to "area" when not provided. */
|
||||
variant?: KpiGraphVariant;
|
||||
}
|
||||
|
||||
export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
@@ -91,10 +40,11 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
const accentKey = accent as OverviewAccent;
|
||||
const [, accentDeep] = overviewAccentGradients[accentKey] ?? overviewAccentGradients.default;
|
||||
const chipBg = ACCENT_CHIP_BG[accent] ?? ACCENT_CHIP_BG.default;
|
||||
const variant = item.variant ?? "area";
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="lg"
|
||||
p="md"
|
||||
radius={16}
|
||||
withBorder
|
||||
style={{
|
||||
@@ -113,15 +63,15 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
>
|
||||
<Stack gap={14}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 38,
|
||||
height: 38,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: chipBg,
|
||||
color: accentDeep,
|
||||
@@ -130,25 +80,38 @@ export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="24px" fw={800} style={{ lineHeight: 1.05, letterSpacing: "-0.02em" }} truncate>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c="dimmed" truncate>
|
||||
{item.label}
|
||||
{item.hint ? ` · ${item.hint}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
{variant === "ring" ? (
|
||||
<MiniRing
|
||||
pct={Math.round((item.progress ?? 0) * 100)}
|
||||
accent={accentKey}
|
||||
size={44}
|
||||
stroke={5}
|
||||
>
|
||||
<Text size="10px" fw={800} style={{ color: accentDeep }}>
|
||||
{Math.round((item.progress ?? 0) * 100)}%
|
||||
</Text>
|
||||
</MiniRing>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Stack gap={3} style={{ minWidth: 0 }}>
|
||||
<Text size="30px" fw={800} style={{ lineHeight: 1, letterSpacing: "-0.02em" }} truncate>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} c="dimmed" truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.hint && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
· {item.hint}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<KpiSparkline accent={accentKey} baseline={item.progress ?? 0} seed={item.label} />
|
||||
{variant !== "ring" ? (
|
||||
<MiniSparkline
|
||||
variant={variant}
|
||||
accent={accentKey}
|
||||
baseline={item.progress ?? 0}
|
||||
seed={item.label}
|
||||
height={22}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { Group, Paper, Text } from "@mantine/core";
|
||||
|
||||
import { OverviewKpiCard, type OverviewKpiItem } from "./OverviewKpiCard";
|
||||
import {
|
||||
OverviewKpiCard,
|
||||
type KpiGraphVariant,
|
||||
type OverviewKpiItem,
|
||||
} from "./OverviewKpiCard";
|
||||
|
||||
/** Rotate mini-graph types per card so each strip reads as a lively mix. */
|
||||
const VARIANT_CYCLE: KpiGraphVariant[] = ["area", "line", "ring"];
|
||||
|
||||
/** Cohesive accent rotation — gold-forward with an orange and neutral break. */
|
||||
const ACCENT_CYCLE: NonNullable<OverviewKpiItem["accent"]>[] = [
|
||||
"gold",
|
||||
"orange",
|
||||
"default",
|
||||
];
|
||||
|
||||
interface OverviewKpiStripProps {
|
||||
title?: string;
|
||||
@@ -33,11 +47,13 @@ export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="md" align="stretch" wrap="wrap">
|
||||
{items.map((item) => (
|
||||
{items.map((item, index) => (
|
||||
<OverviewKpiCard
|
||||
key={item.label}
|
||||
item={{
|
||||
...item,
|
||||
accent: ACCENT_CYCLE[index % ACCENT_CYCLE.length],
|
||||
variant: item.variant ?? VARIANT_CYCLE[index % VARIANT_CYCLE.length],
|
||||
progress:
|
||||
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0),
|
||||
}}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Activity, RefreshCw } from "lucide-react";
|
||||
import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
import "./overview.css";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "7 days", value: "7d" },
|
||||
@@ -19,8 +9,6 @@ const RANGE_OPTIONS = [
|
||||
{ label: "90 days", value: "90d" },
|
||||
];
|
||||
|
||||
const HERO_GRADIENT = `linear-gradient(125deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 50%, ${freightBrand.primaryLight} 125%)`;
|
||||
|
||||
function formatRelativeTime(iso: string | undefined) {
|
||||
if (!iso) return "—";
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
@@ -32,51 +20,6 @@ function formatRelativeTime(iso: string | undefined) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
/** Decorative line-art locomotive + rails, sits faintly on the right of the hero. */
|
||||
function TrainArtwork() {
|
||||
return (
|
||||
<Box
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -10,
|
||||
bottom: -8,
|
||||
width: 360,
|
||||
height: 200,
|
||||
opacity: 0.16,
|
||||
pointerEvents: "none",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 360 200" fill="none" width="100%" height="100%">
|
||||
{/* rails */}
|
||||
<path d="M0 168 H360" stroke="currentColor" strokeWidth="2" strokeDasharray="2 10" strokeLinecap="round" />
|
||||
<path d="M0 180 H360" stroke="currentColor" strokeWidth="2" />
|
||||
{/* locomotive body */}
|
||||
<path
|
||||
d="M70 60 H250 a14 14 0 0 1 14 14 V150 H56 V94 a34 34 0 0 1 14-28 Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{/* cab windows */}
|
||||
<rect x="84" y="80" width="40" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="140" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="200" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
{/* lower stripe */}
|
||||
<path d="M56 130 H264" stroke="currentColor" strokeWidth="3" />
|
||||
{/* wheels */}
|
||||
<circle cx="96" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="150" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="214" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
{/* coupling */}
|
||||
<path d="M264 120 H300 a8 8 0 0 1 8 8 V150 H300" stroke="currentColor" strokeWidth="3" />
|
||||
{/* headlight beam */}
|
||||
<path d="M56 100 l-28 -10 M56 112 l-30 0 M56 124 l-28 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewPageHeaderProps {
|
||||
range: OverviewRange;
|
||||
onRangeChange: (range: OverviewRange) => void;
|
||||
@@ -93,85 +36,31 @@ export function OverviewPageHeader({
|
||||
isRefreshing,
|
||||
}: OverviewPageHeaderProps) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
borderRadius: 20,
|
||||
padding: "28px 28px",
|
||||
background: HERO_GRADIENT,
|
||||
boxShadow: freightBrand.shadow,
|
||||
}}
|
||||
>
|
||||
{/* decorative glows */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -120,
|
||||
right: 120,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<TrainArtwork />
|
||||
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="lg" style={{ position: "relative" }}>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} align="center">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
<Activity size={16} color="white" />
|
||||
</Box>
|
||||
<Text size="xs" fw={700} c="rgba(255,255,255,0.85)" tt="uppercase" style={{ letterSpacing: 1.2 }}>
|
||||
Freight Backoffice · Live
|
||||
</Text>
|
||||
</Group>
|
||||
<Title order={1} c="white" style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1 }}>
|
||||
Operations Overview
|
||||
</Title>
|
||||
<Text size="sm" c="rgba(255,255,255,0.85)">
|
||||
Real-time freight performance · updated {formatRelativeTime(generatedAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm" style={{ position: "relative" }}>
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
classNames={{
|
||||
root: "ov-seg-root",
|
||||
indicator: "ov-seg-indicator",
|
||||
label: "ov-seg-label",
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="white"
|
||||
color="green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh dashboard"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Updated {formatRelativeTime(generatedAt)}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="green"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh dashboard"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,15 +30,15 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
|
||||
const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>Payment trend</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No successful payments in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
|
||||
@@ -26,15 +26,15 @@ export function OverviewStatusChart({ data }: { data: IOverviewPipelineCount[] }
|
||||
const hasData = chartData.some((item) => item.count > 0);
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md" h="100%">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>Pipeline by stage</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No bookings in pipeline
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
export const overviewChartColors = {
|
||||
primary: freightBrand.primary,
|
||||
primaryLight: freightBrand.primaryLight,
|
||||
primaryDark: freightBrand.primaryDark,
|
||||
primary: "#F2A516",
|
||||
primaryLight: "#FBD171",
|
||||
primaryDark: "#D98A0B",
|
||||
muted: freightBrand.mutedBg,
|
||||
etb: freightBrand.primary,
|
||||
etb: "#F2A516",
|
||||
usd: "#0369a1",
|
||||
/** Vibrant, well-separated categorical palette for charts. */
|
||||
/** Vibrant, well-separated categorical palette for charts (gold-forward). */
|
||||
pipeline: [
|
||||
"#1B9E7A", // brand green
|
||||
"#F2A516", // gold (brand accent)
|
||||
"#0ea5e9", // sky
|
||||
"#8b5cf6", // violet
|
||||
"#f59e0b", // amber
|
||||
"#FB8C2E", // orange
|
||||
"#14b8a6", // teal
|
||||
"#ec4899", // pink
|
||||
"#f43f5e", // rose
|
||||
@@ -22,6 +22,10 @@ export const overviewChartColors = {
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Brand gold + complementary orange used as the primary KPI accents. */
|
||||
export const ACCENT_GOLD = "#F2A516";
|
||||
export const ACCENT_ORANGE = "#FB8C2E";
|
||||
|
||||
/** Two-stop gradients keyed by KPI accent — used for radial gauges + accent bars. */
|
||||
export const overviewAccentGradients = {
|
||||
default: ["#94a3b8", "#475569"],
|
||||
@@ -30,6 +34,8 @@ export const overviewAccentGradients = {
|
||||
rose: ["#fb7185", "#e11d48"],
|
||||
sky: ["#38bdf8", "#0284c7"],
|
||||
violet: ["#a78bfa", "#7c3aed"],
|
||||
gold: ["#FBD171", ACCENT_GOLD],
|
||||
orange: ["#FDBA74", ACCENT_ORANGE],
|
||||
} as const;
|
||||
|
||||
export type OverviewAccent = keyof typeof overviewAccentGradients;
|
||||
|
||||
@@ -98,15 +98,15 @@ export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps)
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Payments by method</Text>
|
||||
{methodChartData.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No payment methods recorded
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<BarChart data={methodChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" />
|
||||
|
||||
@@ -48,15 +48,15 @@ export function OverviewCustomersTabPanel({ data }: OverviewCustomersTabPanelPro
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Customer growth</Text>
|
||||
{!hasGrowth ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No new customers in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<AreaChart data={data.customerGrowthTrend}>
|
||||
<defs>
|
||||
<linearGradient id="customerGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -47,15 +47,15 @@ export function OverviewStaffTabPanel({ data }: OverviewStaffTabPanelProps) {
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Employee onboarding trend</Text>
|
||||
{!hasGrowth ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No new employees in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<AreaChart data={data.employeeGrowthTrend}>
|
||||
<defs>
|
||||
<linearGradient id="employeeGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
import { FileSignature, Loader2 } from "lucide-react";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
useMySignature,
|
||||
useSaveSignature,
|
||||
} from "@/hooks/useSavedSignature";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in user view and update the reusable signature stored on
|
||||
* their profile. The same signature is offered for approval when signing a
|
||||
* booking contract.
|
||||
*/
|
||||
export function MySignatureCard() {
|
||||
const { user } = useAuth();
|
||||
const { data: saved, isLoading } = useMySignature();
|
||||
const saveMutation = useSaveSignature();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const defaultName =
|
||||
user?.name?.en || user?.username || user?.email || "";
|
||||
|
||||
const openDialog = () => {
|
||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||
setSignatureData(null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!signatureData || !signerName.trim()) return;
|
||||
saveMutation.mutate(
|
||||
{
|
||||
signerDisplayName: signerName.trim(),
|
||||
signatureImageBase64: signatureData,
|
||||
},
|
||||
{ onSuccess: () => setOpen(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-4" />
|
||||
My signature
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This signature can be reused to sign booking contracts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-36 items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : saved?.signatureImageUrl ? (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.signatureImageUrl}
|
||||
alt="My saved signature"
|
||||
className="mx-auto h-36 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as {saved.signerDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not saved a signature yet.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save your signature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Draw your signature below. It will be stored on your profile for
|
||||
future contracts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profileSignerName">Full name</Label>
|
||||
<Input
|
||||
id="profileSignerName"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
placeholder="As shown on contracts"
|
||||
/>
|
||||
</div>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||
}
|
||||
onClick={save}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Save signature"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +165,17 @@ const RuleEngineFormDialog = ({
|
||||
}
|
||||
}, [open, fields, initialRecord]);
|
||||
|
||||
const formRows = useMemo(() => buildFormRows(fields), [fields]);
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
fields.filter(
|
||||
(field) =>
|
||||
!field.hideWhen ||
|
||||
!field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")),
|
||||
),
|
||||
[fields, values],
|
||||
);
|
||||
|
||||
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
||||
|
||||
const setField = (name: string, value: unknown) => {
|
||||
setValues((current) => ({ ...current, [name]: value }));
|
||||
@@ -175,7 +185,7 @@ const RuleEngineFormDialog = ({
|
||||
event.preventDefault();
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
for (const field of visibleFields) {
|
||||
const raw = values[field.name];
|
||||
if (field.type === "number") {
|
||||
if (raw === "" || raw === undefined) continue;
|
||||
|
||||
@@ -2,19 +2,34 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Radio,
|
||||
RingProgress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Train,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useAvailableLocomotives,
|
||||
@@ -46,10 +61,16 @@ import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util
|
||||
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
|
||||
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
|
||||
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
|
||||
import { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "./schedulingWorkflow.styles";
|
||||
import { SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import { FreightTypeBadge, SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatTile,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "./scheduleVisuals";
|
||||
import { TrainCompositionDiagram } from "./TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "./WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "./WorkflowStep";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -115,9 +136,17 @@ export function AllocateBookingWizard({
|
||||
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const locomotivesQuery = useAvailableLocomotives(
|
||||
scheduleMode === "new" && routeId ? routeId : undefined,
|
||||
);
|
||||
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleMode === "new") {
|
||||
setLocomotiveId("");
|
||||
}
|
||||
}, [routeId, scheduleMode]);
|
||||
|
||||
const matchingSchedules = useMemo(
|
||||
() =>
|
||||
(schedulesQuery.data ?? []).filter(
|
||||
@@ -157,13 +186,6 @@ export function AllocateBookingWizard({
|
||||
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
|
||||
const stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setActiveStep(0);
|
||||
@@ -216,6 +238,21 @@ export function AllocateBookingWizard({
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const displayWagonPlan = useMemo(() => {
|
||||
const savedWagons = assignedSchedule?.trainSet?.wagons ?? [];
|
||||
const physicalBySeq = new Map(
|
||||
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
|
||||
);
|
||||
if (previewResult?.wagonPlan?.length) {
|
||||
return previewResult.wagonPlan.map((slot) => ({
|
||||
...slot,
|
||||
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
|
||||
}));
|
||||
}
|
||||
if (savedWagons.length) return savedWagons;
|
||||
return [];
|
||||
}, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]);
|
||||
|
||||
const ensureSchedule = async (): Promise<string> => {
|
||||
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
|
||||
if (!routeId || !scheduleDate || !locomotiveId) {
|
||||
@@ -357,305 +394,605 @@ export function AllocateBookingWizard({
|
||||
}
|
||||
};
|
||||
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||
|
||||
const stepDescription =
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart";
|
||||
const containerComplete =
|
||||
hasContainerStep &&
|
||||
containerUnits.length > 0 &&
|
||||
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
||||
|
||||
const stepIcon =
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check";
|
||||
const stepsMeta = [
|
||||
{
|
||||
key: "bookings",
|
||||
icon: Package,
|
||||
title: "Bookings",
|
||||
subtitle: "Select cargo & preview the plan",
|
||||
complete: Boolean(previewResult) || Boolean(assignedSchedule),
|
||||
},
|
||||
{
|
||||
key: "wagon",
|
||||
icon: LayoutGrid,
|
||||
title: "Wagon plan",
|
||||
subtitle: "Review generated allocations",
|
||||
complete: displayWagonPlan.length > 0,
|
||||
},
|
||||
...(hasContainerStep
|
||||
? [
|
||||
{
|
||||
key: "container",
|
||||
icon: ContainerIcon,
|
||||
title: "Containers",
|
||||
subtitle: "Map units to wagon slots",
|
||||
complete: containerComplete,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "finalize",
|
||||
icon: CheckCircle2,
|
||||
title: "Finalize",
|
||||
subtitle: "Lock the plan & dispatch",
|
||||
complete: allocationComplete,
|
||||
},
|
||||
];
|
||||
const completedCount = stepsMeta.filter((s) => s.complete).length;
|
||||
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
|
||||
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
|
||||
|
||||
const renderStepRightSlot = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
if (previewResult) {
|
||||
return (
|
||||
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
|
||||
{previewResult.valid ? "Plan valid" : "Has issues"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return allBookingIds.length ? (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{allBookingIds.length} selected
|
||||
</Badge>
|
||||
) : null;
|
||||
}
|
||||
if (key === "wagon" && displayWagonPlan.length) {
|
||||
return (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{displayWagonPlan.length} wagons
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "container" && containerUnits.length) {
|
||||
return (
|
||||
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
|
||||
{containerUnits.length} units
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "finalize" && allocationComplete) {
|
||||
return <StatusPill status="SCHEDULED" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderStepBody = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Group gap="lg">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
{scheduleMode === "existing" ? (
|
||||
<Select
|
||||
label="Draft schedule"
|
||||
data={matchingSchedules.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
||||
}))}
|
||||
value={selectedScheduleId}
|
||||
onChange={setSelectedScheduleId}
|
||||
searchable
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{holdCountdown ? (
|
||||
<Text size="xs" c={holdCountdown.includes("expired") ? "red" : "yellow.8"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allBookingIds}
|
||||
onSelectionChange={(ids) => {
|
||||
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
||||
}}
|
||||
freightType={bookingFreightType}
|
||||
/>
|
||||
|
||||
<Group
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold / overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
leftSection={<Eye size={16} />}
|
||||
loading={preview.isPending}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
Preview plan
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "wagon") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!displayWagonPlan.length && !previewResult ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run a preview from the Bookings step to generate the wagon plan.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : null}
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-orange-2)", background: "var(--mantine-color-orange-0)" }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange.8">
|
||||
Government preempt — bookings to displace
|
||||
</Text>
|
||||
{reschedulePlan.displaced.map((b) => (
|
||||
<Text key={b.id} size="sm">
|
||||
{b.reference} (priority {b.priorityScore})
|
||||
</Text>
|
||||
))}
|
||||
<Checkbox
|
||||
label="I confirm displacing the bookings listed above"
|
||||
checked={confirmPreempt}
|
||||
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={displayWagonPlan}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending || create.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
rightSection={<ContainerIcon size={16} />}
|
||||
onClick={() => setActiveStep(2)}
|
||||
>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" radius="md" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "container") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending || create.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="default" radius="md" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// finalize
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
|
||||
<TrainCompositionDiagram
|
||||
locomotive={assignedSchedule?.trainSet?.locomotive}
|
||||
wagons={
|
||||
assignedSchedule?.trainSet?.wagons?.length
|
||||
? assignedSchedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
trainNumber={assignedSchedule?.trainNumber}
|
||||
totalLengthMeters={assignedSchedule?.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{allocationComplete ? (
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
<Text span fw={600} c="green.7">
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
<Group mt="sm">
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>Ready to finalize</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalizing locks the plan, moves the schedule to{" "}
|
||||
<Text span fw={600} c="green.7">
|
||||
SCHEDULED
|
||||
</Text>
|
||||
, and completes the booking allocation.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={18} />}
|
||||
loading={finalize.isPending}
|
||||
onClick={handleFinalize}
|
||||
>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Allocate booking — {booking.reference}</Text>}
|
||||
withCloseButton
|
||||
size="90%"
|
||||
radius="xl"
|
||||
radius="lg"
|
||||
centered
|
||||
styles={{ content: { maxWidth: 1200 } }}
|
||||
padding="lg"
|
||||
styles={{ content: { maxWidth: 1200 }, body: { paddingTop: 8 } }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Allocation workflow"
|
||||
subtitle={`${booking.reference} · ${booking.originYard?.name ?? "Origin"} → ${booking.destinationYard?.name ?? "Destination"}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={stepDescription}
|
||||
stepIcon={stepIcon}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{booking.reference}</Text>
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -50,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant="white"
|
||||
style={{ color: "var(--mantine-color-green-7)" }}
|
||||
>
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Allocate {booking.reference}
|
||||
</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.freightType} · {booking.cargoTotalWeightVgm}T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{booking.originYard?.name ?? "Origin"} →{" "}
|
||||
{booking.destinationYard?.name ?? "Destination"}
|
||||
</Text>
|
||||
{booking.freightType === "CONTAINER" && booking.bookingContainers?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.bookingContainers.map((c) => `${c.quantity}× container`).join(", ")}
|
||||
</Text>
|
||||
) : null}
|
||||
{holdCountdown ? (
|
||||
<Text size="sm" c={holdCountdown.includes("expired") ? "red" : "yellow"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{scheduleMode === "existing" ? (
|
||||
<Select
|
||||
label="Draft schedule"
|
||||
data={matchingSchedules.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
||||
}))}
|
||||
value={selectedScheduleId}
|
||||
onChange={setSelectedScheduleId}
|
||||
searchable
|
||||
<Box maw={360}>
|
||||
<RouteCorridor
|
||||
onDark
|
||||
origin={booking.originYard?.name ?? booking.originYard?.label}
|
||||
destination={
|
||||
booking.destinationYard?.name ?? booking.destinationYard?.label
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: l.code,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap="sm" align="center">
|
||||
<FreightTypeBadge freightType={booking.freightType} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allBookingIds}
|
||||
onSelectionChange={(ids) => {
|
||||
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
||||
}}
|
||||
freightType={bookingFreightType}
|
||||
/>
|
||||
|
||||
<Group align="center" wrap="wrap">
|
||||
<Button loading={preview.isPending} onClick={handlePreview}>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange">
|
||||
Government preempt — bookings to displace
|
||||
</Text>
|
||||
{reschedulePlan.displaced.map((b) => (
|
||||
<Text key={b.id} size="sm">
|
||||
{b.reference} (priority {b.priorityScore})
|
||||
</Text>
|
||||
))}
|
||||
<Checkbox
|
||||
label="I confirm displacing the bookings listed above"
|
||||
checked={confirmPreempt}
|
||||
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="white"
|
||||
c={previewResult.valid ? "green.8" : "red.7"}
|
||||
leftSection={
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: previewResult.valid
|
||||
? "var(--mantine-color-green-6)"
|
||||
: "var(--mantine-color-red-6)",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
<PreviewSummary summary={previewResult?.summary} />
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Wallet}
|
||||
label="Total value"
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={previewResult?.wagonPlan ?? []}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
value={containerCount || "—"}
|
||||
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Flame}
|
||||
label="Priority"
|
||||
value={booking.priorityScore ?? 0}
|
||||
hint={booking.tradeDirection}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Workflow */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: "green", to: "teal", deg: 135 }}
|
||||
>
|
||||
<RouteIcon size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={4} fw={700}>
|
||||
Allocation workflow
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{completedCount} of {stepsMeta.length} steps complete · expand any step
|
||||
to edit
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
<RingProgress
|
||||
size={64}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: progressPct, color: "green" }]}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700} c="green.7">
|
||||
{progressPct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
<Group>
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
{allocationComplete ? (
|
||||
<Paper p="lg" radius="xl" withBorder bg="teal.0">
|
||||
<Stack gap="md" align="center">
|
||||
<CheckCircle2 size={40} color="var(--mantine-color-teal-7)" />
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED and completes the booking
|
||||
allocation.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button color="teal" loading={finalize.isPending} onClick={handleFinalize}>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
<WorkflowRail>
|
||||
{stepsMeta.map((step, index) => (
|
||||
<WorkflowStep
|
||||
key={step.key}
|
||||
index={index}
|
||||
icon={step.icon}
|
||||
title={step.title}
|
||||
subtitle={step.subtitle}
|
||||
state={
|
||||
activeStep === index
|
||||
? "active"
|
||||
: step.complete
|
||||
? "complete"
|
||||
: "upcoming"
|
||||
}
|
||||
open={activeStep === index}
|
||||
onToggle={() => toggleStep(index)}
|
||||
rightSlot={renderStepRightSlot(step.key)}
|
||||
>
|
||||
{renderStepBody(step.key)}
|
||||
</WorkflowStep>
|
||||
))}
|
||||
</WorkflowRail>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability";
|
||||
import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability";
|
||||
|
||||
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
|
||||
|
||||
@@ -35,6 +35,7 @@ export function PinWagonsForm({
|
||||
onSubmit: (assignments: PinWagonAssignment[]) => void;
|
||||
autoFillOnMount?: boolean;
|
||||
}) {
|
||||
const originYardId = schedule.originStation?.id;
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -43,7 +44,7 @@ export function PinWagonsForm({
|
||||
for (const wagon of availableWagons) {
|
||||
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
|
||||
if (
|
||||
!wagonMatchesScheduleDirection(wagon, schedule.direction, {
|
||||
!wagonMatchesScheduleOrigin(wagon, originYardId, {
|
||||
allowPinned: isPinnedOnSlot,
|
||||
})
|
||||
) {
|
||||
@@ -58,7 +59,7 @@ export function PinWagonsForm({
|
||||
map.set(typeId, list);
|
||||
}
|
||||
return map;
|
||||
}, [availableWagons, schedule.direction, slots]);
|
||||
}, [availableWagons, originYardId, slots]);
|
||||
|
||||
const runAutoFill = useCallback(
|
||||
(preserveManual = false) => {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Fragment } from "react";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, Flag, MapPin, Train } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
||||
|
||||
export interface RouteCorridorTrackProps {
|
||||
stations: TrackStation[];
|
||||
/** Highest sequenceNo reached so far (−1 = not yet departed). */
|
||||
currentSequenceNo: number;
|
||||
checkpoints: TrainCheckpoint[];
|
||||
/** True when the train is DISPATCHED and staff may log progress. */
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
}
|
||||
|
||||
const COLUMN_WIDTH = 150;
|
||||
const PASSED = freightBrand.primary;
|
||||
const UPCOMING = "var(--mantine-color-gray-3)";
|
||||
|
||||
function railColor(active: boolean) {
|
||||
return active ? PASSED : UPCOMING;
|
||||
}
|
||||
|
||||
export function RouteCorridorTrack({
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints,
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
}: RouteCorridorTrackProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: "auto", paddingBottom: 4 }}>
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
style={{ minWidth: stations.length * COLUMN_WIDTH }}
|
||||
>
|
||||
{stations.map((station, index) => {
|
||||
const passed = station.sequenceNo <= currentSequenceNo;
|
||||
const isCurrent = station.sequenceNo === currentSequenceNo;
|
||||
const isFinal = index === lastIndex;
|
||||
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
|
||||
const checkpoint = bySeq.get(station.sequenceNo);
|
||||
// left rail solid once this node is reached; right rail solid once the next node is reached
|
||||
const leftActive = station.sequenceNo <= currentSequenceNo;
|
||||
const rightActive = station.sequenceNo + 1 <= currentSequenceNo;
|
||||
|
||||
return (
|
||||
<Fragment key={station.sequenceNo}>
|
||||
<Stack gap={6} align="center" style={{ width: COLUMN_WIDTH, flexShrink: 0 }}>
|
||||
{/* rail + node */}
|
||||
<Box style={{ position: "relative", height: 44, width: "100%" }}>
|
||||
{index > 0 && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 21,
|
||||
left: 0,
|
||||
width: "50%",
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: railColor(leftActive),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{index < lastIndex && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 21,
|
||||
left: "50%",
|
||||
width: "50%",
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: railColor(rightActive),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* train marker hovering over the current node */}
|
||||
{isCurrent && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: freightBrand.primaryDark,
|
||||
}}
|
||||
>
|
||||
<Train size={18} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* node */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: passed ? PASSED : "white",
|
||||
border: `2px solid ${
|
||||
passed
|
||||
? PASSED
|
||||
: isNext
|
||||
? freightBrand.primaryLight
|
||||
: "var(--mantine-color-gray-4)"
|
||||
}`,
|
||||
boxShadow: isCurrent ? `0 0 0 4px ${freightBrand.ring}` : "none",
|
||||
color: "white",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{passed ? (
|
||||
<Check size={13} />
|
||||
) : isFinal ? (
|
||||
<Flag size={12} color="var(--mantine-color-gray-5)" />
|
||||
) : (
|
||||
<MapPin size={12} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* label */}
|
||||
<Stack gap={0} align="center" style={{ minWidth: 0, padding: "0 6px" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={passed ? 700 : 600}
|
||||
ta="center"
|
||||
lineClamp={2}
|
||||
c={passed ? "green.8" : "dimmed"}
|
||||
>
|
||||
{station.label}
|
||||
</Text>
|
||||
{index === 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Origin
|
||||
</Badge>
|
||||
) : isFinal ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Destination
|
||||
</Badge>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{/* checkpoint time or action */}
|
||||
{checkpoint ? (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
) : isNext ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
color={isFinal ? "teal" : "green"}
|
||||
variant={isFinal ? "filled" : "light"}
|
||||
loading={loggingSeq === station.sequenceNo}
|
||||
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
|
||||
>
|
||||
{isFinal ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
) : (
|
||||
<Box style={{ height: 22 }} />
|
||||
)}
|
||||
</Stack>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import {
|
||||
useBatchActions,
|
||||
useBookableSchedules,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
interface ScheduleBatchPanelProps {
|
||||
schedule: TrainScheduleDetail;
|
||||
}
|
||||
|
||||
const windowColor: Record<string, string> = {
|
||||
OPEN: "green",
|
||||
FULL: "orange",
|
||||
CLOSED: "gray",
|
||||
};
|
||||
|
||||
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
|
||||
const { toast } = useToast();
|
||||
const actions = useBatchActions(schedule.id);
|
||||
const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN";
|
||||
const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED";
|
||||
|
||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
|
||||
const { data: targets } = useBookableSchedules(
|
||||
schedule.originStation?.id,
|
||||
schedule.destinationStation?.id,
|
||||
);
|
||||
const moveOptions = useMemo(
|
||||
() =>
|
||||
(targets ?? [])
|
||||
.filter((s) => s.id !== schedule.id)
|
||||
.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
||||
s.scheduleDate,
|
||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
|
||||
})),
|
||||
[targets, schedule.id],
|
||||
);
|
||||
|
||||
const bookings = schedule.bookings ?? [];
|
||||
|
||||
const run = (fn: Promise<unknown>, ok: string) =>
|
||||
fn
|
||||
.then(() => toast({ title: ok }))
|
||||
.catch(() => toast({ title: "Action failed", variant: "destructive" }));
|
||||
|
||||
return (
|
||||
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Group justify="space-between" align="center" mb="md" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color="green">
|
||||
<Layers size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Batch allocation</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{bookings.length} allocated · {schedule.trainSet?.wagonCount ?? 0} wagons used
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge color={windowColor[windowStatus] ?? "gray"} variant="light" radius="sm" size="lg">
|
||||
Window: {windowStatus}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!locked && (
|
||||
<Group gap="sm" mb="md">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<PlayCircle size={15} />}
|
||||
loading={actions.runBatch.isPending}
|
||||
onClick={() => run(actions.runBatch.mutateAsync(schedule.id), "Batch fill run")}
|
||||
>
|
||||
Run batch fill
|
||||
</Button>
|
||||
{windowStatus === "CLOSED" ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
leftSection={<LockOpen size={15} />}
|
||||
loading={actions.setWindow.isPending}
|
||||
onClick={() =>
|
||||
run(
|
||||
actions.setWindow.mutateAsync({ id: schedule.id, status: "OPEN" }),
|
||||
"Window opened",
|
||||
)
|
||||
}
|
||||
>
|
||||
Open window
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
leftSection={<Lock size={15} />}
|
||||
loading={actions.setWindow.isPending}
|
||||
onClick={() =>
|
||||
run(
|
||||
actions.setWindow.mutateAsync({ id: schedule.id, status: "CLOSED" }),
|
||||
"Window closed",
|
||||
)
|
||||
}
|
||||
>
|
||||
Close window
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{bookings.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No bookings allocated yet. The batch cron fills this schedule by priority; paid bookings are
|
||||
assigned automatically.
|
||||
</Text>
|
||||
) : (
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr key={b.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{b.reference ?? b.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.customer ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={b.status ?? ""} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!locked && (
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
{b.status !== "PAID" && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
onClick={() => run(actions.markPaid.mutateAsync(b.id), "Marked paid")}
|
||||
>
|
||||
Mark paid
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
leftSection={<Repeat size={13} />}
|
||||
onClick={() => {
|
||||
setMoveBookingId(b.id);
|
||||
setMoveTarget(null);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<XCircle size={13} />}
|
||||
onClick={() => run(actions.expire.mutateAsync(b.id), "Reservation expired")}
|
||||
>
|
||||
Expire
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(moveBookingId)}
|
||||
onClose={() => setMoveBookingId(null)}
|
||||
title="Move booking to another schedule"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Target schedule (same route)"
|
||||
placeholder="Select an OPEN schedule"
|
||||
data={moveOptions}
|
||||
value={moveTarget}
|
||||
onChange={setMoveTarget}
|
||||
searchable
|
||||
nothingFoundMessage="No other open schedules on this route"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setMoveBookingId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
disabled={!moveTarget}
|
||||
loading={actions.moveSchedule.isPending}
|
||||
onClick={() => {
|
||||
if (!moveBookingId || !moveTarget) return;
|
||||
run(
|
||||
actions.moveSchedule.mutateAsync({
|
||||
bookingId: moveBookingId,
|
||||
trainScheduleId: moveTarget,
|
||||
}),
|
||||
"Booking moved",
|
||||
).then(() => setMoveBookingId(null));
|
||||
}}
|
||||
>
|
||||
Move booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -93,12 +93,14 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean })
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
background: dark ? "#0f291b" : "var(--mantine-color-gray-7)",
|
||||
background: dark
|
||||
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
|
||||
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
|
||||
border: "2px solid var(--mantine-color-gray-4)",
|
||||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.25)",
|
||||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -108,7 +110,7 @@ function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean })
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box style={{ width: 16, height: 74, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<Box style={{ width: 16, height: 78, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -116,6 +118,7 @@ function Coupler() {
|
||||
borderRadius: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
||||
boxShadow: "0 1px 1px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -142,62 +145,109 @@ function LocomotiveCar({
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 74,
|
||||
borderRadius: "16px 22px 10px 10px",
|
||||
background: freightBrand.gradient,
|
||||
boxShadow: freightBrand.shadowSm,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
height: 78,
|
||||
borderRadius: "14px 26px 10px 10px",
|
||||
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
|
||||
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
|
||||
border: "1px solid rgba(0,0,0,0.1)",
|
||||
overflow: "hidden",
|
||||
padding: "8px 10px",
|
||||
padding: "10px 10px 8px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{/* cab windows */}
|
||||
{/* roofline */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 14,
|
||||
height: 5,
|
||||
background: "rgba(0,0,0,0.22)",
|
||||
borderRadius: "14px 0 0 0",
|
||||
}}
|
||||
>
|
||||
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.85)" }} />
|
||||
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.6)" }} />
|
||||
/>
|
||||
{/* roof vents */}
|
||||
<Box style={{ position: "absolute", top: 1, left: 16, display: "flex", gap: 5 }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
width: 10,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: "rgba(255,255,255,0.35)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{/* cab windows */}
|
||||
<Box style={{ position: "absolute", top: 10, right: 10, display: "flex", gap: 4 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 15,
|
||||
height: 13,
|
||||
borderRadius: "3px 6px 3px 3px",
|
||||
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
|
||||
border: "1px solid rgba(0,0,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: 12,
|
||||
height: 13,
|
||||
borderRadius: 3,
|
||||
background: "linear-gradient(135deg, rgba(232,251,255,0.8), rgba(158,217,232,0.6))",
|
||||
border: "1px solid rgba(0,0,0,0.12)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* headlight */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 6,
|
||||
width: 7,
|
||||
height: 7,
|
||||
bottom: 14,
|
||||
right: 5,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#fde68a",
|
||||
boxShadow: "0 0 8px 2px rgba(253,230,138,0.8)",
|
||||
boxShadow: "0 0 10px 3px rgba(253,230,138,0.9)",
|
||||
}}
|
||||
/>
|
||||
{/* hazard stripe on the nose */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 6,
|
||||
background:
|
||||
"repeating-linear-gradient(45deg, #fbbf24 0 7px, #1f2937 7px 14px)",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<TrainFront size={18} />
|
||||
<Text size="sm" fw={800} style={{ letterSpacing: 0.3 }}>
|
||||
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
|
||||
{code}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="9px" mt={2} style={{ opacity: 0.85 }} lineClamp={1}>
|
||||
<Text size="9px" mt={1} style={{ opacity: 0.85 }} lineClamp={1}>
|
||||
{name ?? "Locomotive"}
|
||||
</Text>
|
||||
{maxPullWeightTons ? (
|
||||
<Group gap={3} wrap="nowrap" mt={4} style={{ opacity: 0.95 }}>
|
||||
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
|
||||
<Gauge size={10} />
|
||||
<Text size="9px" fw={600}>
|
||||
<Text size="9px" fw={700}>
|
||||
{maxPullWeightTons}T pull
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Box>
|
||||
<Wheels count={3} dark />
|
||||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700}>
|
||||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
|
||||
HEAD
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -205,6 +255,15 @@ function LocomotiveCar({
|
||||
);
|
||||
}
|
||||
|
||||
const CONTAINER_GRADIENTS = [
|
||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||
];
|
||||
const CONTAINER_BORDERS = [
|
||||
"var(--mantine-color-cyan-8)",
|
||||
"var(--mantine-color-blue-8)",
|
||||
];
|
||||
|
||||
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
const utilization =
|
||||
wagon.capacityTons > 0
|
||||
@@ -219,7 +278,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||||
}${
|
||||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}`;
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
const blocks = wagon.containerNumbers.slice(0, 2);
|
||||
@@ -230,13 +289,15 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 74,
|
||||
height: 78,
|
||||
borderRadius: 12,
|
||||
background: wagon.isEmpty ? "var(--mantine-color-gray-0)" : "white",
|
||||
background: wagon.isEmpty
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
|
||||
border: wagon.isEmpty
|
||||
? "1.5px dashed var(--mantine-color-gray-4)"
|
||||
: "1px solid var(--mantine-color-gray-3)",
|
||||
boxShadow: wagon.isEmpty ? "none" : "0 2px 8px rgba(15,41,27,0.06)",
|
||||
boxShadow: wagon.isEmpty ? "none" : "0 3px 10px rgba(15,41,27,0.08)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -257,13 +318,13 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
#{wagon.sequenceNo}
|
||||
</Text>
|
||||
{wagon.isEmpty ? (
|
||||
<Text size="9px" c="dimmed" fw={600}>
|
||||
<Text size="9px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
EMPTY
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={3} wrap="nowrap">
|
||||
{wagon.isBulk ? <Fuel size={11} color={accentVar} /> : <ContainerIcon size={11} color={accentVar} />}
|
||||
<Text size="9px" fw={700} c={`${accent}.7`}>
|
||||
<Text size="9px" fw={700} c={`${accent}.7`} style={{ letterSpacing: 0.4 }}>
|
||||
{wagon.isBulk ? "BULK" : "CONT"}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -271,7 +332,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
</Group>
|
||||
|
||||
{/* body */}
|
||||
<Box style={{ flex: 1, padding: "4px 8px 6px", display: "flex", alignItems: "center" }}>
|
||||
<Box style={{ flex: 1, padding: "4px 8px 4px", display: "flex", alignItems: "center" }}>
|
||||
{wagon.isEmpty ? (
|
||||
<Group gap={4} justify="center" style={{ width: "100%" }}>
|
||||
<BoxIcon size={14} color="var(--mantine-color-gray-4)" />
|
||||
@@ -296,11 +357,13 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${utilization}%`,
|
||||
background: "linear-gradient(90deg, var(--mantine-color-orange-5), var(--mantine-color-orange-3))",
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.35)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="9px" c="dimmed" ta="center">
|
||||
<Text size="9px" c="dimmed" ta="center" fw={600}>
|
||||
{wagon.assignedWeightTons}/{wagon.capacityTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -312,17 +375,30 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
height: 28,
|
||||
borderRadius: 5,
|
||||
background: "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
border: "1px solid var(--mantine-color-cyan-8)",
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.15)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 3px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate>
|
||||
{/* corrugation lines */}
|
||||
<Box
|
||||
style={{
|
||||
width: "80%",
|
||||
height: 2,
|
||||
marginBottom: 2,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, rgba(255,255,255,0.4) 0 3px, transparent 3px 6px)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -331,6 +407,22 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* utilization hairline */}
|
||||
{!wagon.isEmpty && !wagon.isBulk ? (
|
||||
<Box style={{ height: 3, background: "var(--mantine-color-gray-1)" }}>
|
||||
<Box
|
||||
style={{
|
||||
width: `${utilization}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
utilization >= 100
|
||||
? "var(--mantine-color-red-5)"
|
||||
: `var(--mantine-color-${accent}-5)`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* footer */}
|
||||
<Box
|
||||
style={{
|
||||
@@ -339,9 +431,16 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
background: wagon.isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" c="dimmed" truncate>
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="8px" c="dimmed" fw={600} truncate>
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
|
||||
</Text>
|
||||
{!wagon.isEmpty ? (
|
||||
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||||
{wagon.assignedWeightTons}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
<Wheels count={2} />
|
||||
@@ -350,6 +449,56 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Railway track: two rails over evenly-spaced sleepers. */
|
||||
function TrackBed() {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
height: 10,
|
||||
}}
|
||||
>
|
||||
{/* sleepers */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
|
||||
opacity: 0.6,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
{/* rails */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
wagons,
|
||||
@@ -404,8 +553,7 @@ export function TrainCompositionDiagram({
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, white 40%)",
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
@@ -417,17 +565,20 @@ export function TrainCompositionDiagram({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: freightBrand.gradient,
|
||||
boxShadow: freightBrand.shadowSm,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={20} />
|
||||
<TrainFront size={21} />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700}>Train composition</Text>
|
||||
<Text fw={800} style={{ letterSpacing: 0.2 }}>
|
||||
Train composition
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{trainNumber ? `${trainNumber} · ` : ""}
|
||||
{stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty
|
||||
@@ -436,35 +587,58 @@ export function TrainCompositionDiagram({
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" />
|
||||
<Group gap="md">
|
||||
<Group
|
||||
gap={6}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 999,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{stats.totalWeight}T
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
of {stats.totalCapacity}T capacity
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Locomotive pull gauge */}
|
||||
{stats.pullUtil != null ? (
|
||||
<Box
|
||||
p="xs"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, var(--mantine-color-green-0), #F2FBF7)",
|
||||
border: "1px solid var(--mantine-color-green-1)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" fw={600} c="green.8">
|
||||
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Gauge size={14} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} c="green.8">
|
||||
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
|
||||
{stats.pullUtil}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={stats.pullUtil}
|
||||
size="sm"
|
||||
size="md"
|
||||
radius="xl"
|
||||
striped={stats.pullUtil > 95}
|
||||
animated={stats.pullUtil > 95}
|
||||
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
|
||||
/>
|
||||
</Box>
|
||||
@@ -480,20 +654,8 @@ export function TrainCompositionDiagram({
|
||||
const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left";
|
||||
return (
|
||||
<Box key={rowIndex}>
|
||||
<Box style={{ position: "relative", paddingBottom: 6 }}>
|
||||
{/* rail under the row */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
right: 4,
|
||||
bottom: 8,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-5) 0 10px, var(--mantine-color-gray-3) 10px 16px)",
|
||||
}}
|
||||
/>
|
||||
<Box style={{ position: "relative", paddingBottom: 10 }}>
|
||||
<TrackBed />
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Batch board / batch detail shared visuals */
|
||||
|
||||
@keyframes bbPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(27, 158, 122, 0.5);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(27, 158, 122, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(27, 158, 122, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.bb-pulse-dot {
|
||||
animation: bbPulse 2.2s ease-out infinite;
|
||||
}
|
||||
|
||||
.bb-card {
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.bb-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 18px 40px -16px rgba(13, 60, 44, 0.26);
|
||||
border-color: var(--mantine-color-green-3);
|
||||
}
|
||||
|
||||
.bb-card .bb-arrow {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.bb-card:hover .bb-arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.bb-window-accordion .mantine-Accordion-item {
|
||||
border: 1px solid var(--mantine-color-gray-2);
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.bb-window-accordion .mantine-Accordion-item[data-active] {
|
||||
border-color: var(--mantine-color-green-3);
|
||||
box-shadow: 0 8px 22px -14px rgba(13, 60, 44, 0.2);
|
||||
}
|
||||
|
||||
.bb-window-accordion .mantine-Accordion-control:hover {
|
||||
background: var(--mantine-color-green-0);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import "./batchVisuals.css";
|
||||
|
||||
/**
|
||||
* Shared visual building blocks for the batch board surfaces (list + detail).
|
||||
* Everything keys off the same booking-pipeline color language so the two
|
||||
* pages read as one clean, professional product on a white surface.
|
||||
*/
|
||||
|
||||
export type BatchCounts = {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
};
|
||||
|
||||
export const PIPELINE_STAGES: ReadonlyArray<{
|
||||
key: keyof BatchCounts;
|
||||
label: string;
|
||||
color: string;
|
||||
hint: string;
|
||||
}> = [
|
||||
{
|
||||
key: "allocated",
|
||||
label: "Allocated",
|
||||
color: "green",
|
||||
hint: "Assigned to wagons on the train",
|
||||
},
|
||||
{
|
||||
key: "selectedForBatch",
|
||||
label: "Selected",
|
||||
color: "orange",
|
||||
hint: "Picked by batch — customer notified to pay",
|
||||
},
|
||||
{
|
||||
key: "ready",
|
||||
label: "Ready",
|
||||
color: "teal",
|
||||
hint: "Contract signed — waiting for batch pick",
|
||||
},
|
||||
{
|
||||
key: "waiting",
|
||||
label: "Waiting",
|
||||
color: "blue",
|
||||
hint: "Paid — waiting for a slot",
|
||||
},
|
||||
{
|
||||
key: "pendingContract",
|
||||
label: "Pending contract",
|
||||
color: "gray",
|
||||
hint: "Contract not signed yet",
|
||||
},
|
||||
{
|
||||
key: "expired",
|
||||
label: "Expired",
|
||||
color: "red",
|
||||
hint: "Payment deadline missed",
|
||||
},
|
||||
];
|
||||
|
||||
export function totalBookingCount(counts: BatchCounts): number {
|
||||
return PIPELINE_STAGES.reduce((sum, stage) => sum + counts[stage.key], 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacked booking-pipeline bar: one colored segment per batch state, with an
|
||||
* optional dot legend underneath. Reads as a single glanceable funnel.
|
||||
*/
|
||||
export function BookingPipeline({
|
||||
counts,
|
||||
size = 10,
|
||||
showLegend = true,
|
||||
}: {
|
||||
counts: BatchCounts;
|
||||
size?: number;
|
||||
showLegend?: boolean;
|
||||
}) {
|
||||
const total = totalBookingCount(counts);
|
||||
const stages = PIPELINE_STAGES.filter((s) => counts[s.key] > 0);
|
||||
|
||||
if (!total) {
|
||||
return (
|
||||
<Box>
|
||||
<Progress value={0} size={size} radius="xl" />
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
No bookings yet
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Progress.Root size={size} radius="xl">
|
||||
{stages.map((stage) => (
|
||||
<Tooltip
|
||||
key={stage.key}
|
||||
label={`${counts[stage.key]} ${stage.label.toLowerCase()} — ${stage.hint}`}
|
||||
withArrow
|
||||
>
|
||||
<Progress.Section
|
||||
value={(counts[stage.key] / total) * 100}
|
||||
color={stage.color}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Progress.Root>
|
||||
{showLegend ? (
|
||||
<Group gap={12} mt={9} wrap="wrap">
|
||||
{stages.map((stage) => (
|
||||
<Group key={stage.key} gap={5} wrap="nowrap">
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${stage.color}-6)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Text component="span" size="xs" fw={700} c="dark.4">
|
||||
{counts[stage.key]}
|
||||
</Text>{" "}
|
||||
{stage.label.toLowerCase()}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const WINDOW_META: Record<string, { color: string; label: string; pulse: boolean }> = {
|
||||
OPEN: { color: "green", label: "Window open", pulse: true },
|
||||
FULL: { color: "orange", label: "Full", pulse: false },
|
||||
CLOSED: { color: "gray", label: "Closed", pulse: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Booking-window status pill with a status dot (pulsing while OPEN), styled for
|
||||
* a clean white surface.
|
||||
*/
|
||||
export function WindowStatusPill({ status }: { status: string }) {
|
||||
const meta = WINDOW_META[status] ?? { color: "gray", label: status, pulse: false };
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
padding: "4px 11px",
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${meta.color}-0)`,
|
||||
border: `1px solid var(--mantine-color-${meta.color}-2)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={7}
|
||||
h={7}
|
||||
className={meta.pulse ? "bb-pulse-dot" : undefined}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: `var(--mantine-color-${meta.color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c={`${meta.color}.8`}
|
||||
style={{ letterSpacing: 0.3, lineHeight: 1, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{meta.label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small neutral info chip used in the page headers (date, loco, status, …).
|
||||
* Clean light surface that reads well on white.
|
||||
*/
|
||||
export function HeroChip({
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Text size="xs" fw={600} c="gray.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{children}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Badge, Box, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { Building2, Package, TrainFront, Weight, X } from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import type { BookingDetailData } from "./BookingDetailModal";
|
||||
import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal";
|
||||
import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
interface AssignedBookingsPanelProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
scheduleId: string;
|
||||
selectedBookingId?: string | null;
|
||||
onSelect: (booking: BookingDetailData) => void;
|
||||
}
|
||||
|
||||
export const AssignedBookingsPanel = ({
|
||||
scheduleDetail,
|
||||
scheduleId,
|
||||
selectedBookingId,
|
||||
onSelect,
|
||||
}: AssignedBookingsPanelProps) => {
|
||||
const { toast } = useToast();
|
||||
const unassign = useScheduleMutations(scheduleId).unassign;
|
||||
const isDispatched = scheduleDetail.status === "DISPATCHED";
|
||||
const [removalTarget, setRemovalTarget] = useState<RemovalTarget | null>(null);
|
||||
|
||||
const wagons = scheduleDetail.trainSet?.wagons ?? [];
|
||||
const wagonCountByBooking = new Map<string, number>();
|
||||
for (const w of wagons) {
|
||||
for (const a of w.allocations ?? []) {
|
||||
wagonCountByBooking.set(a.bookingId, (wagonCountByBooking.get(a.bookingId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedBookings = (scheduleDetail.bookings ?? []).filter((b) =>
|
||||
wagonCountByBooking.has(b.id),
|
||||
);
|
||||
|
||||
const handleConfirmRemove = async () => {
|
||||
if (!removalTarget) return;
|
||||
try {
|
||||
await unassign.mutateAsync({ id: scheduleId, bookingId: removalTarget.bookingId });
|
||||
toast({ title: "Booking removed from train" });
|
||||
setRemovalTarget(null);
|
||||
} catch {
|
||||
toast({ title: "Could not remove booking", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
if (assignedBookings.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<Package size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
No assigned bookings
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
Assign a paid booking from the Unassigned tab to load it onto a wagon.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
{assignedBookings.map((booking) => {
|
||||
const isActive = selectedBookingId === booking.id;
|
||||
return (
|
||||
<Card
|
||||
key={booking.id}
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: booking.customer,
|
||||
freightType: scheduleDetail.freightType ?? null,
|
||||
weightTons: booking.weightTons ?? null,
|
||||
status: booking.status,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isActive ? freightBrand.primary : undefined,
|
||||
boxShadow: isActive ? `0 0 0 2px ${freightBrand.ring}` : undefined,
|
||||
background: isActive ? freightBrand.mutedBg : undefined,
|
||||
transition: "box-shadow 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color="green">
|
||||
<Package size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<TrainFront size={9} />}
|
||||
>
|
||||
{wagonCountByBooking.get(booking.id)}
|
||||
</Badge>
|
||||
{!isDispatched ? (
|
||||
<Tooltip label="Remove from train" withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={unassign.isPending && unassign.variables?.bookingId === booking.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRemovalTarget({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: booking.customer,
|
||||
weightTons: booking.weightTons ?? null,
|
||||
wagonCount: wagonCountByBooking.get(booking.id) ?? 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
{booking.customer ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Building2 size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed" truncate>
|
||||
{booking.customer}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={4} wrap="nowrap" mt={2}>
|
||||
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed">
|
||||
{(booking.weightTons ?? 0).toFixed(1)} T
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<RemoveBookingConfirmModal
|
||||
opened={Boolean(removalTarget)}
|
||||
onClose={() => setRemovalTarget(null)}
|
||||
onConfirm={handleConfirmRemove}
|
||||
isLoading={unassign.isPending}
|
||||
target={removalTarget}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Badge, Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Building2, CreditCard, Landmark, Weight, XCircle } from "lucide-react";
|
||||
import type { BatchBoardBookingDetail } from "@/types/trainScheduling";
|
||||
import type { BookingDetailData } from "./BookingDetailModal";
|
||||
|
||||
interface BatchBookingListProps {
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
variant: "payment" | "expired";
|
||||
selectedBookingId?: string | null;
|
||||
onSelect: (booking: BookingDetailData) => void;
|
||||
emptyTitle: string;
|
||||
emptyHint: string;
|
||||
}
|
||||
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: null;
|
||||
|
||||
export const BatchBookingList = ({
|
||||
bookings,
|
||||
variant,
|
||||
selectedBookingId,
|
||||
onSelect,
|
||||
emptyTitle,
|
||||
emptyHint,
|
||||
}: BatchBookingListProps) => {
|
||||
const accent = variant === "payment" ? "orange" : "red";
|
||||
const Icon = variant === "payment" ? CreditCard : XCircle;
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
{emptyTitle}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
{emptyHint}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{bookings.map((booking) => {
|
||||
const isActive = selectedBookingId === booking.id;
|
||||
const deadline = fmtDateTime(booking.paymentDeadline);
|
||||
return (
|
||||
<Card
|
||||
key={booking.id}
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: booking.company,
|
||||
freightType: null,
|
||||
weightTons: booking.weightTons ?? null,
|
||||
status: variant === "payment" ? "Awaiting payment" : "Expired",
|
||||
})
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isActive ? `var(--mantine-color-${accent}-5)` : undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color={accent}>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Landmark size={9} />}
|
||||
>
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{booking.company ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Building2 size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group justify="space-between" wrap="nowrap" mt={2} gap={6}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed">
|
||||
{(booking.weightTons ?? 0).toFixed(1)} T · {booking.wagons}w
|
||||
</Text>
|
||||
</Group>
|
||||
{variant === "payment" && deadline ? (
|
||||
<Text size="10px" fw={700} c="orange.7" style={{ whiteSpace: "nowrap" }}>
|
||||
Pay by {deadline}
|
||||
</Text>
|
||||
) : variant === "expired" ? (
|
||||
<Badge size="xs" variant="light" color="red">
|
||||
Expired
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Badge, Box, Divider, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
MapPin,
|
||||
Package,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
export interface BookingDetailData {
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
company: string | null;
|
||||
freightType: string | null;
|
||||
weightTons: number | null;
|
||||
status: string | null;
|
||||
priorityScore?: number | null;
|
||||
}
|
||||
|
||||
interface BookingDetailModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
booking: BookingDetailData | null;
|
||||
/** All wagons in the consist — used to show where this booking sits. */
|
||||
wagons: Wagon[];
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ textAlign: "right" }}>{value}</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const BookingDetailModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
booking,
|
||||
wagons,
|
||||
}: BookingDetailModalProps) => {
|
||||
if (!booking) return null;
|
||||
|
||||
const bookingWagons = wagons.filter((w) =>
|
||||
(w.allocations ?? []).some((a) => a.bookingId === booking.bookingId),
|
||||
);
|
||||
const allocations = bookingWagons.flatMap((w) =>
|
||||
(w.allocations ?? [])
|
||||
.filter((a) => a.bookingId === booking.bookingId)
|
||||
.map((a) => ({ wagon: w, allocation: a })),
|
||||
);
|
||||
const containers = allocations.flatMap(({ allocation }) => allocation.containerItems ?? []);
|
||||
const isBulk = allocations.some(({ allocation }) =>
|
||||
(allocation.loadType ?? "").toUpperCase().includes("BULK"),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<Package size={20} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={800}>{booking.reference ?? "Booking"}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Booking details
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Stack gap="sm">
|
||||
{booking.company ? (
|
||||
<InfoRow
|
||||
icon={<Building2 size={15} />}
|
||||
label="Company"
|
||||
value={
|
||||
<Text size="sm" fw={700}>
|
||||
{booking.company}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<InfoRow
|
||||
icon={isBulk ? <Fuel size={15} /> : <ContainerIcon size={15} />}
|
||||
label="Freight type"
|
||||
value={
|
||||
<Badge variant="light" color={isBulk ? "orange" : "cyan"}>
|
||||
{booking.freightType ?? (isBulk ? "BULK" : "CONTAINER")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
value={
|
||||
<Text size="sm" fw={700}>
|
||||
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<TrainFront size={15} />}
|
||||
label="Wagons"
|
||||
value={
|
||||
bookingWagons.length ? (
|
||||
<Group gap={4} justify="flex-end">
|
||||
{bookingWagons.map((w) => (
|
||||
<Badge key={w.id} size="sm" variant="outline" color="green" radius="sm">
|
||||
#{w.sequenceNo}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Not assigned to a wagon
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{booking.status ? (
|
||||
<InfoRow
|
||||
icon={<MapPin size={15} />}
|
||||
label="Status"
|
||||
value={
|
||||
<Badge variant="light" color="gray">
|
||||
{booking.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{containers.length ? (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap={6}>
|
||||
<ContainerIcon size={13} />
|
||||
<Text size="xs" fw={700}>
|
||||
Containers ({containers.length})
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<Stack gap={6}>
|
||||
{containers.map((c, i) => (
|
||||
<Group
|
||||
key={c.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ContainerIcon size={14} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="sm" fw={600}>
|
||||
{c.containerNumber?.trim() || `Container ${i + 1}`}
|
||||
</Text>
|
||||
</Group>
|
||||
{c.grossWeightTons != null ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{Number(c.grossWeightTons).toFixed(1)} T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Box, Group, Paper, ScrollArea, Tabs, Text, Tooltip } from "@mantine/core";
|
||||
import { CreditCard, History, Layers, PackageCheck, PackagePlus, XCircle } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { BatchBoardBookingDetail, TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { AssignedBookingsPanel } from "./AssignedBookingsPanel";
|
||||
import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel";
|
||||
import { RemovalLogPanel } from "./RemovalLogPanel";
|
||||
import { BatchBookingList } from "./BatchBookingList";
|
||||
import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal";
|
||||
import {
|
||||
useCompositionRemovals,
|
||||
useUnassignedBookings,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
interface CompositionBookingTabsProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
scheduleId: string;
|
||||
/** Bookings selected for batch with a payment notification sent (awaiting payment). */
|
||||
awaitingPayment?: BatchBoardBookingDetail[];
|
||||
/** Bookings whose payment window expired. */
|
||||
expired?: BatchBoardBookingDetail[];
|
||||
/** Booking id highlighted in the train consist (lifted to the page). */
|
||||
selectedBookingId?: string | null;
|
||||
onSelectBooking?: (bookingId: string | null) => void;
|
||||
}
|
||||
|
||||
type TabKey = "assigned" | "unassigned" | "payment" | "expired" | "removed";
|
||||
|
||||
const TAB_META: Record<TabKey, { label: string; icon: LucideIcon; color: string }> = {
|
||||
assigned: { label: "Assigned to train", icon: PackageCheck, color: "green" },
|
||||
unassigned: { label: "Unassigned (ready to load)", icon: PackagePlus, color: "orange" },
|
||||
payment: { label: "Awaiting payment", icon: CreditCard, color: "orange" },
|
||||
expired: { label: "Expired bookings", icon: XCircle, color: "red" },
|
||||
removed: { label: "Removed from train", icon: History, color: "gray" },
|
||||
};
|
||||
|
||||
export const CompositionBookingTabs = ({
|
||||
scheduleDetail,
|
||||
scheduleId,
|
||||
awaitingPayment = [],
|
||||
expired = [],
|
||||
selectedBookingId,
|
||||
onSelectBooking,
|
||||
}: CompositionBookingTabsProps) => {
|
||||
const [detailBooking, setDetailBooking] = useState<BookingDetailData | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>("assigned");
|
||||
|
||||
const unassignedQuery = useUnassignedBookings(scheduleId);
|
||||
const removalsQuery = useCompositionRemovals(scheduleId);
|
||||
|
||||
const { assignedCount } = useMemo(() => {
|
||||
const wagons = scheduleDetail.trainSet?.wagons ?? [];
|
||||
const ids = new Set<string>();
|
||||
for (const w of wagons) {
|
||||
for (const a of w.allocations ?? []) {
|
||||
ids.add(a.bookingId);
|
||||
}
|
||||
}
|
||||
return { assignedCount: ids.size };
|
||||
}, [scheduleDetail.trainSet?.wagons]);
|
||||
|
||||
const counts: Record<TabKey, number> = {
|
||||
assigned: assignedCount,
|
||||
unassigned: unassignedQuery.data?.bookings?.length ?? 0,
|
||||
payment: awaitingPayment.length,
|
||||
expired: expired.length,
|
||||
removed: removalsQuery.data?.length ?? 0,
|
||||
};
|
||||
|
||||
const handleSelect = (booking: BookingDetailData) => {
|
||||
setDetailBooking(booking);
|
||||
onSelectBooking?.(booking.bookingId);
|
||||
};
|
||||
|
||||
const TabButton = ({ value }: { value: TabKey }) => {
|
||||
const meta = TAB_META[value];
|
||||
const Icon = meta.icon;
|
||||
const active = tab === value;
|
||||
const count = counts[value];
|
||||
return (
|
||||
<Tooltip label={meta.label} withArrow position="top">
|
||||
<Tabs.Tab value={value} px={6}>
|
||||
<Group gap={5} wrap="nowrap" justify="center">
|
||||
<Icon size={15} />
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
variant={active ? "filled" : "light"}
|
||||
color={active ? meta.color : "gray"}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Tabs.Tab>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
{/* Header — reflects the active tab */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${freightBrand.mutedBg}, white)`,
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<Layers size={18} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={800} size="sm">
|
||||
{TAB_META[tab].label}
|
||||
</Text>
|
||||
<Text size="11px" c="dimmed">
|
||||
{counts[tab]} booking{counts[tab] === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(v) => v && setTab(v as TabKey)}
|
||||
variant="default"
|
||||
color="green"
|
||||
style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
|
||||
>
|
||||
<Tabs.List grow>
|
||||
<TabButton value="assigned" />
|
||||
<TabButton value="unassigned" />
|
||||
<TabButton value="payment" />
|
||||
<TabButton value="expired" />
|
||||
<TabButton value="removed" />
|
||||
</Tabs.List>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="auto" offsetScrollbars>
|
||||
<Tabs.Panel value="assigned" p="md">
|
||||
<AssignedBookingsPanel
|
||||
scheduleDetail={scheduleDetail}
|
||||
scheduleId={scheduleId}
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="unassigned" p="md">
|
||||
<UnassignedBookingsPanel
|
||||
scheduleId={scheduleId}
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="payment" p="md">
|
||||
<BatchBookingList
|
||||
bookings={awaitingPayment}
|
||||
variant="payment"
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
emptyTitle="No bookings awaiting payment"
|
||||
emptyHint="Bookings selected for this batch with a payment notification sent will appear here."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="expired" p="md">
|
||||
<BatchBookingList
|
||||
bookings={expired}
|
||||
variant="expired"
|
||||
selectedBookingId={selectedBookingId}
|
||||
onSelect={handleSelect}
|
||||
emptyTitle="No expired bookings"
|
||||
emptyHint="Bookings whose payment window lapsed will appear here."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="removed" p="md">
|
||||
<RemovalLogPanel scheduleId={scheduleId} />
|
||||
</Tabs.Panel>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
{/* Footer summary */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<PackageCheck size={13} color="var(--mantine-color-green-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{assignedCount} on train
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<CreditCard size={13} color="var(--mantine-color-orange-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{counts.payment} to pay
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<XCircle size={13} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{counts.expired} expired
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<BookingDetailModal
|
||||
opened={Boolean(detailBooking)}
|
||||
onClose={() => setDetailBooking(null)}
|
||||
booking={detailBooking}
|
||||
wagons={scheduleDetail.trainSet?.wagons ?? []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { Group, TextInput, Text } from "@mantine/core";
|
||||
import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
|
||||
interface ContainerNumberInputProps {
|
||||
value: string | null;
|
||||
itemId: string;
|
||||
scheduleId: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const ContainerNumberInput = ({
|
||||
value,
|
||||
itemId,
|
||||
scheduleId,
|
||||
disabled,
|
||||
}: ContainerNumberInputProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const updateMutation = useUpdateContainerItem(scheduleId);
|
||||
const isLoading = updateMutation.isPending;
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await updateMutation.mutateAsync({
|
||||
itemId,
|
||||
containerNumber: inputValue || null,
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setError("Failed to save");
|
||||
setInputValue(value ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputValue !== value) {
|
||||
handleSave();
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
setInputValue(value ?? "");
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
return <Text size="sm">{value || "TBD"}</Text>;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Group gap={4}>
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.currentTarget.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
placeholder="Container #"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{error && <Text size="xs" c="red">{error}</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{ cursor: "pointer", textDecoration: "underline" }}
|
||||
title="Click to edit"
|
||||
>
|
||||
{value || "TBD"}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,527 @@
|
||||
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
Gauge,
|
||||
Package,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||
|
||||
interface InteractiveTrainConsistProps {
|
||||
wagons: Wagon[];
|
||||
locomotive: Locomotive | null | undefined;
|
||||
/** Resolve the customer/company name for a booking id (joined from schedule bookings). */
|
||||
getCompany: (bookingId: string | undefined) => string | null;
|
||||
selectedWagonId: string | null;
|
||||
onSelectWagon: (wagon: Wagon) => void;
|
||||
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
||||
highlightBookingId?: string | null;
|
||||
}
|
||||
|
||||
const CONTAINER_GRADIENTS = [
|
||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||
];
|
||||
const CONTAINER_BORDERS = ["var(--mantine-color-cyan-8)", "var(--mantine-color-blue-8)"];
|
||||
|
||||
function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) {
|
||||
return (
|
||||
<Group gap={count > 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: dark
|
||||
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
|
||||
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
|
||||
border: "2px solid var(--mantine-color-gray-4)",
|
||||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box style={{ width: 12, height: 70, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) {
|
||||
const code = locomotive?.code ?? "LOCO";
|
||||
return (
|
||||
<Box style={{ width: 120, flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 70,
|
||||
borderRadius: "12px 22px 9px 9px",
|
||||
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
|
||||
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
|
||||
border: "1px solid rgba(0,0,0,0.1)",
|
||||
overflow: "hidden",
|
||||
padding: "8px 9px 7px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{/* cab windows */}
|
||||
<Box style={{ position: "absolute", top: 9, right: 9, display: "flex", gap: 4 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 13,
|
||||
height: 11,
|
||||
borderRadius: "3px 5px 3px 3px",
|
||||
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* headlight */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 12,
|
||||
right: 5,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: "#fde68a",
|
||||
boxShadow: "0 0 9px 3px rgba(253,230,138,0.9)",
|
||||
}}
|
||||
/>
|
||||
{/* hazard stripe */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 5,
|
||||
background: "repeating-linear-gradient(45deg, #fbbf24 0 6px, #1f2937 6px 12px)",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
<Group gap={5} wrap="nowrap" align="center">
|
||||
<TrainFront size={16} />
|
||||
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
|
||||
{code}
|
||||
</Text>
|
||||
</Group>
|
||||
{locomotive?.maxPullWeightTons ? (
|
||||
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
|
||||
<Gauge size={10} />
|
||||
<Text size="9px" fw={700}>
|
||||
{locomotive.maxPullWeightTons}T pull
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Box>
|
||||
<Wheels count={3} dark />
|
||||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
|
||||
HEAD
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonCar({
|
||||
wagon,
|
||||
company,
|
||||
selected,
|
||||
highlighted,
|
||||
onSelect,
|
||||
}: {
|
||||
wagon: Wagon;
|
||||
company: string | null;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const isEmpty = !allocation;
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
|
||||
const capacity = wagon.capacityTons ?? 0;
|
||||
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
|
||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
const containerNumbers = (allocation?.containerItems ?? []).map(
|
||||
(c) => c.containerNumber?.trim() || "—",
|
||||
);
|
||||
const blocks = containerNumbers.slice(0, 2);
|
||||
|
||||
const ringColor = selected
|
||||
? freightBrand.primary
|
||||
: highlighted
|
||||
? "var(--mantine-color-yellow-5)"
|
||||
: "transparent";
|
||||
|
||||
return (
|
||||
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
|
||||
<HoverCard.Target>
|
||||
<Box
|
||||
onClick={onSelect}
|
||||
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 70,
|
||||
borderRadius: 11,
|
||||
background: isEmpty
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
|
||||
border: isEmpty
|
||||
? "1.5px dashed var(--mantine-color-gray-4)"
|
||||
: "1px solid var(--mantine-color-gray-3)",
|
||||
boxShadow:
|
||||
ringColor !== "transparent"
|
||||
? `0 0 0 3px ${ringColor}, 0 4px 12px rgba(15,41,27,0.12)`
|
||||
: isEmpty
|
||||
? "none"
|
||||
: "0 3px 10px rgba(15,41,27,0.08)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "box-shadow 120ms ease",
|
||||
}}
|
||||
>
|
||||
{/* top accent strip */}
|
||||
<Box
|
||||
style={{
|
||||
height: 4,
|
||||
background: isEmpty
|
||||
? "var(--mantine-color-gray-3)"
|
||||
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
|
||||
}}
|
||||
/>
|
||||
{/* header */}
|
||||
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
||||
<Text size="10px" fw={800} c="gray.7">
|
||||
#{wagon.sequenceNo}
|
||||
</Text>
|
||||
{isEmpty ? (
|
||||
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
EMPTY
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{isBulk ? (
|
||||
<Fuel size={10} color={accentVar} />
|
||||
) : (
|
||||
<ContainerIcon size={10} color={accentVar} />
|
||||
)}
|
||||
<Text size="8px" fw={700} c={`${accent}.7`} style={{ letterSpacing: 0.3 }}>
|
||||
{isBulk ? "BULK" : "CONT"}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* body */}
|
||||
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
|
||||
{isEmpty ? (
|
||||
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
||||
Available
|
||||
</Text>
|
||||
) : isBulk ? (
|
||||
<Stack gap={2} style={{ width: "100%" }}>
|
||||
<Box
|
||||
style={{
|
||||
height: 16,
|
||||
borderRadius: 5,
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${utilization}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
borderRadius: 4,
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* footer */}
|
||||
<Box
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-1)",
|
||||
padding: "2px 7px",
|
||||
background: isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap={3}>
|
||||
<Text size="8px" c="dimmed" fw={600} truncate>
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Wagon"}
|
||||
</Text>
|
||||
{!isEmpty ? (
|
||||
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||||
{assigned}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
<Wheels count={2} />
|
||||
</Box>
|
||||
</HoverCard.Target>
|
||||
|
||||
<HoverCard.Dropdown p="sm">
|
||||
<Stack gap={8}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: isEmpty
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: freightBrand.gradient,
|
||||
color: isEmpty ? "var(--mantine-color-gray-6)" : "white",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={15} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{!isEmpty ? (
|
||||
<Badge size="xs" variant="light" color={isBulk ? "orange" : "cyan"}>
|
||||
{isBulk ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{isEmpty ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Empty slot — available for allocation.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{company ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Building2 size={13} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Package size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation?.bookingReference ?? "Unknown booking"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{containerNumbers.length ? (
|
||||
<div>
|
||||
<Text size="10px" c="dimmed" fw={700} mb={3} tt="uppercase">
|
||||
Containers
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{containerNumbers.map((cn, i) => (
|
||||
<Badge key={i} size="xs" variant="outline" color="cyan" radius="sm">
|
||||
{cn}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isBulk && allocation?.bulkLoad?.cargoDescription ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation.bulkLoad.cargoDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{assigned}T / {capacity}T ({utilization}%)
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${utilization}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
utilization >= 100
|
||||
? "var(--mantine-color-red-5)"
|
||||
: `var(--mantine-color-${accent}-5)`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
Click the wagon to edit or remove
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export const InteractiveTrainConsist = ({
|
||||
wagons,
|
||||
locomotive,
|
||||
getCompany,
|
||||
selectedWagonId,
|
||||
onSelectWagon,
|
||||
highlightBookingId,
|
||||
}: InteractiveTrainConsistProps) => {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
padding: "8px 12px 18px",
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(180deg, var(--mantine-color-gray-0), white)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
|
||||
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
|
||||
{wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" pl="md" pt="lg">
|
||||
No wagons assigned
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((wagon, i) => {
|
||||
const bookingId = wagon.allocations?.[0]?.bookingId;
|
||||
return (
|
||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
|
||||
{i > 0 || locomotive ? <Coupler /> : null}
|
||||
<WagonCar
|
||||
wagon={wagon}
|
||||
company={getCompany(bookingId)}
|
||||
selected={selectedWagonId === wagon.id}
|
||||
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||
onSelect={() => onSelectWagon(wagon)}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* track bed under the whole consist */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 8,
|
||||
height: 8,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
|
||||
opacity: 0.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 1,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
background: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { History, PackageX } from "lucide-react";
|
||||
import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
|
||||
interface RemovalLogPanelProps {
|
||||
scheduleId: string;
|
||||
}
|
||||
|
||||
export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => {
|
||||
const removalQuery = useCompositionRemovals(scheduleId);
|
||||
|
||||
if (removalQuery.isLoading) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const removals = removalQuery.data ?? [];
|
||||
|
||||
if (removals.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<History size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
No removals yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
Bookings removed from this train will appear here for audit.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{removals.map((removal) => (
|
||||
<Card key={removal.id} padding="xs" radius="md" withBorder>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color="red">
|
||||
<PackageX size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{removal.bookingReference || "Unknown booking"}
|
||||
</Text>
|
||||
<Text size="11px" c="dimmed">
|
||||
Removed{" "}
|
||||
{new Date(removal.removedAt).toLocaleString("en-GB", {
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}{" "}
|
||||
EAT
|
||||
</Text>
|
||||
{removal.notes ? (
|
||||
<Text size="11px" c="dimmed" mt={2} lineClamp={2}>
|
||||
{removal.notes}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Badge, Box, Button, Group, List, Modal, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { AlertTriangle, Bell, Building2, FileClock, PackageX, TrainFront, Undo2, Weight } from "lucide-react";
|
||||
|
||||
export interface RemovalTarget {
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
company: string | null;
|
||||
weightTons: number | null;
|
||||
wagonCount: number;
|
||||
}
|
||||
|
||||
interface RemoveBookingConfirmModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
target: RemovalTarget | null;
|
||||
}
|
||||
|
||||
export const RemoveBookingConfirmModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
target,
|
||||
}: RemoveBookingConfirmModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size="md"
|
||||
withCloseButton={false}
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<ThemeIcon size={40} radius="md" variant="light" color="red">
|
||||
<PackageX size={21} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={800}>Remove booking from train?</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
This change is logged and the customer is notified
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Booking summary */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={800} size="sm">
|
||||
{target?.reference ?? "Booking"}
|
||||
</Text>
|
||||
<Badge variant="light" color="green" leftSection={<TrainFront size={10} />}>
|
||||
{target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="lg" mt={6} wrap="wrap">
|
||||
{target?.company ? (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Building2 size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{target.company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{(target?.weightTons ?? 0).toFixed(1)} T
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* What happens */}
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<AlertTriangle size={14} color="var(--mantine-color-orange-7)" />
|
||||
<Text size="xs" fw={700} c="orange.8">
|
||||
Removing this booking will:
|
||||
</Text>
|
||||
</Group>
|
||||
<List spacing={6} size="xs" center>
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||
<Undo2 size={11} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
Return it to the unassigned pool
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||
<FileClock size={11} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
Create a removal log entry for audit
|
||||
</List.Item>
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||
<Bell size={11} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
Notify the customer to reschedule or cancel
|
||||
</List.Item>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<PackageX size={16} />}
|
||||
loading={isLoading}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Remove booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
interface RemoveBookingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
wagon: WagonWithAllocation | null;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const RemoveBookingModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
wagon,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: RemoveBookingModalProps) => {
|
||||
if (!wagon || !wagon.allocations?.[0]) return null;
|
||||
|
||||
const allocation = wagon.allocations[0];
|
||||
const booking = allocation.booking;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Booking Details
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<strong>Reference:</strong> {booking?.reference || "N/A"}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Freight Type:</strong>{" "}
|
||||
<Badge size="sm" variant="light">
|
||||
{booking?.freightType || "N/A"}
|
||||
</Badge>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="orange" fw={500}>
|
||||
⚠️ Warning: Removing this booking will:
|
||||
</Text>
|
||||
<ul style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
<li>Move the booking back to the unassigned pool</li>
|
||||
<li>Create a removal log for audit</li>
|
||||
<li>Notify the customer to reschedule or cancel</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm} loading={isLoading}>
|
||||
Remove Booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { MousePointerClick, TrainFront } from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { TrainStatsBar } from "./TrainStatsBar";
|
||||
import { WagonCard } from "./WagonCard";
|
||||
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||
import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
interface TrainConsistViewProps {
|
||||
scheduleDetail: TrainScheduleDetail;
|
||||
scheduleId: string;
|
||||
maxWagons: number;
|
||||
/** Booking id selected in the side panel — highlights its wagons in the consist. */
|
||||
highlightBookingId?: string | null;
|
||||
}
|
||||
|
||||
function LegendDot({ color, label, dashed }: { color: string; label: string; dashed?: boolean }) {
|
||||
return (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 3,
|
||||
background: dashed ? "var(--mantine-color-gray-1)" : `var(--mantine-color-${color}-5)`,
|
||||
border: dashed ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const TrainConsistView = ({
|
||||
scheduleDetail,
|
||||
scheduleId,
|
||||
maxWagons,
|
||||
highlightBookingId,
|
||||
}: TrainConsistViewProps) => {
|
||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
||||
|
||||
const unassignMutation = useScheduleMutations(scheduleId).unassign;
|
||||
const removeWagonMutation = useRemoveWagonSlot(scheduleId);
|
||||
|
||||
const trainSet = scheduleDetail.trainSet;
|
||||
const wagons = trainSet?.wagons ?? [];
|
||||
|
||||
// Join company/customer name from schedule bookings by booking id.
|
||||
const companyByBooking = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const b of scheduleDetail.bookings ?? []) {
|
||||
if (b.id && b.customer) map.set(b.id, b.customer);
|
||||
}
|
||||
return map;
|
||||
}, [scheduleDetail.bookings]);
|
||||
|
||||
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null;
|
||||
const loadedCount = wagons.filter((w) => (w.allocations?.length ?? 0) > 0).length;
|
||||
|
||||
const handleRemoveBooking = (wagon: Wagon) => {
|
||||
setSelectedWagonId(wagon.id);
|
||||
setRemoveModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmRemoveBooking = async () => {
|
||||
if (selectedWagon?.allocations?.[0]?.bookingId) {
|
||||
await unassignMutation.mutateAsync({
|
||||
id: scheduleId,
|
||||
bookingId: selectedWagon.allocations[0].bookingId,
|
||||
});
|
||||
setRemoveModalOpen(false);
|
||||
setSelectedWagonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveWagon = async (wagonId: string) => {
|
||||
if (confirm("Are you sure you want to remove this wagon slot?")) {
|
||||
await removeWagonMutation.mutateAsync(wagonId);
|
||||
setSelectedWagonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const weightUsed = wagons.reduce(
|
||||
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Stack gap="md" style={{ width: "100%" }}>
|
||||
<TrainStatsBar
|
||||
weightUsed={weightUsed}
|
||||
weightMax={scheduleDetail.locomotive?.maxWeightTons ?? trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
lengthUsed={lengthUsed}
|
||||
lengthMax={scheduleDetail.locomotive?.maxLengthMeters ?? trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
wagonCount={wagons.length}
|
||||
wagonMax={maxWagons}
|
||||
/>
|
||||
|
||||
{/* Consist panel */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={18} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={800} size="sm">
|
||||
Train consist
|
||||
</Text>
|
||||
<Text size="11px" c="dimmed">
|
||||
{wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" dashed />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box p="md">
|
||||
<InteractiveTrainConsist
|
||||
wagons={wagons}
|
||||
locomotive={trainSet?.locomotive}
|
||||
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
|
||||
selectedWagonId={selectedWagonId}
|
||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||
highlightBookingId={highlightBookingId}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Selected wagon — editable detail card */}
|
||||
{selectedWagon ? (
|
||||
<Box>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
Editing wagon #{selectedWagon.sequenceNo}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Update container numbers or remove the booking
|
||||
</Text>
|
||||
</Group>
|
||||
<WagonCard
|
||||
wagon={selectedWagon}
|
||||
company={
|
||||
selectedWagon.allocations?.[0]?.bookingId
|
||||
? companyByBooking.get(selectedWagon.allocations[0].bookingId) ?? null
|
||||
: null
|
||||
}
|
||||
scheduleId={scheduleId}
|
||||
scheduleStatus={scheduleDetail.status}
|
||||
onRemoveBooking={handleRemoveBooking}
|
||||
onRemoveWagon={handleRemoveWagon}
|
||||
/>
|
||||
</Box>
|
||||
) : wagons.length ? (
|
||||
<Paper
|
||||
radius="md"
|
||||
py="sm"
|
||||
px="md"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} justify="center" c="dimmed">
|
||||
<ThemeIcon size={24} radius="xl" variant="light" color="gray">
|
||||
<MousePointerClick size={13} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
Click a wagon in the train to edit container numbers or remove its booking.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<RemoveBookingModal
|
||||
opened={removeModalOpen}
|
||||
onClose={() => {
|
||||
setRemoveModalOpen(false);
|
||||
}}
|
||||
wagon={selectedWagon}
|
||||
onConfirm={handleConfirmRemoveBooking}
|
||||
isLoading={unassignMutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Box, Group, Paper, RingProgress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Ruler, Train, Weight } from "lucide-react";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
interface TrainStatsBarProps {
|
||||
weightUsed: number;
|
||||
weightMax: number | null;
|
||||
lengthUsed: number;
|
||||
lengthMax: number | null;
|
||||
wagonCount: number;
|
||||
wagonMax: number;
|
||||
}
|
||||
|
||||
function pctColor(pct: number) {
|
||||
if (pct >= 100) return "#fa5252";
|
||||
if (pct >= 85) return "#FB8C2E";
|
||||
return freightBrand.primary;
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
icon,
|
||||
label,
|
||||
pct,
|
||||
current,
|
||||
max,
|
||||
unit,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
pct: number | null;
|
||||
current: string;
|
||||
max: string;
|
||||
unit: string;
|
||||
}) {
|
||||
const color = pct != null ? pctColor(pct) : freightBrand.primary;
|
||||
const clamped = pct != null ? Math.min(100, Math.max(0, pct)) : 0;
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<RingProgress
|
||||
size={62}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: clamped, color }]}
|
||||
rootColor="var(--mantine-color-gray-1)"
|
||||
label={
|
||||
<Group justify="center">
|
||||
<ThemeIcon size={26} radius="xl" variant="transparent" style={{ color }}>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.6 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={5} align="baseline" wrap="nowrap">
|
||||
<Text size="lg" fw={800} lh={1.1} c="dark.5" style={{ whiteSpace: "nowrap" }}>
|
||||
{current}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
/ {max} {unit}
|
||||
</Text>
|
||||
</Group>
|
||||
{pct != null ? (
|
||||
<Text size="10px" fw={700} style={{ color }}>
|
||||
{Math.round(pct)}% utilized
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="10px" c="dimmed">
|
||||
no limit set
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const TrainStatsBar = ({
|
||||
weightUsed,
|
||||
weightMax,
|
||||
lengthUsed,
|
||||
lengthMax,
|
||||
wagonCount,
|
||||
wagonMax,
|
||||
}: TrainStatsBarProps) => {
|
||||
const weightPct = weightMax ? (weightUsed / weightMax) * 100 : null;
|
||||
const lengthPct = lengthMax ? (lengthUsed / lengthMax) * 100 : null;
|
||||
const wagonPct = wagonMax ? (wagonCount / wagonMax) * 100 : null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
|
||||
<StatTile
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
pct={weightPct}
|
||||
current={weightUsed.toFixed(1)}
|
||||
max={weightMax?.toFixed(1) ?? "∞"}
|
||||
unit="T"
|
||||
/>
|
||||
<Box
|
||||
px={{ base: 0, xs: "lg" }}
|
||||
style={{
|
||||
borderLeft: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<StatTile
|
||||
icon={<Ruler size={15} />}
|
||||
label="Length"
|
||||
pct={lengthPct}
|
||||
current={lengthUsed.toFixed(1)}
|
||||
max={lengthMax?.toFixed(1) ?? "∞"}
|
||||
unit="m"
|
||||
/>
|
||||
</Box>
|
||||
<StatTile
|
||||
icon={<Train size={15} />}
|
||||
label="Wagons"
|
||||
pct={wagonPct}
|
||||
current={String(wagonCount)}
|
||||
max={String(wagonMax)}
|
||||
unit=""
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react";
|
||||
import {
|
||||
useUnassignedBookings,
|
||||
useScheduleMutations,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { FleetAvailabilityRow } from "@/types/trainScheduling";
|
||||
import type { BookingDetailData } from "./BookingDetailModal";
|
||||
|
||||
interface UnassignedBookingsPanelProps {
|
||||
scheduleId: string;
|
||||
selectedBookingId?: string | null;
|
||||
onSelect: (booking: BookingDetailData) => void;
|
||||
}
|
||||
|
||||
const parseError = (error: unknown): string | null => {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const resp = (error as {
|
||||
response?: { data?: { message?: unknown; violations?: string[] } };
|
||||
}).response;
|
||||
const violations = resp?.data?.violations;
|
||||
if (Array.isArray(violations) && violations.length) return violations.join("; ");
|
||||
const msg = resp?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.join(", ");
|
||||
if (typeof msg === "string") return msg;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRow[] }) => {
|
||||
if (!fleetAtOrigin.length) {
|
||||
return (
|
||||
<Group gap={5} wrap="nowrap" px="sm" py={6} style={{ borderRadius: 8, background: "var(--mantine-color-red-0)", border: "1px solid var(--mantine-color-red-2)" }}>
|
||||
<MapPin size={13} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={700} c="red.7">
|
||||
No wagons at origin yard
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="wrap"
|
||||
px="sm"
|
||||
py={6}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
border: "1px solid var(--mantine-color-green-1)",
|
||||
}}
|
||||
>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<MapPin size={13} color="var(--mantine-color-green-7)" />
|
||||
<Text size="xs" fw={700} c="green.8">
|
||||
Origin yard
|
||||
</Text>
|
||||
</Group>
|
||||
{fleetAtOrigin.map((row) => (
|
||||
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
|
||||
{row.wagonTypeCode}: {row.available}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const UnassignedBookingsPanel = ({
|
||||
scheduleId,
|
||||
selectedBookingId,
|
||||
onSelect,
|
||||
}: UnassignedBookingsPanelProps) => {
|
||||
const { toast } = useToast();
|
||||
const unassignedQuery = useUnassignedBookings(scheduleId);
|
||||
const assignMutation = useScheduleMutations(scheduleId).assignUnassigned;
|
||||
|
||||
const handleAssign = async (bookingId: string, reference: string | null) => {
|
||||
try {
|
||||
await assignMutation.mutateAsync({
|
||||
id: scheduleId,
|
||||
bookingId,
|
||||
});
|
||||
toast({ title: `Assigned ${reference ?? "booking"} to the train` });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Could not assign booking",
|
||||
description: parseError(err) ?? "Assignment failed — check yard fleet and train limits.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (unassignedQuery.isLoading) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const bookings = unassignedQuery.data?.bookings ?? [];
|
||||
const fleetAtOrigin = unassignedQuery.data?.fleetAtOrigin ?? [];
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<ContainerIcon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
No unassigned bookings
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
Paid bookings waiting for a wagon will appear here.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<YardFleetBanner fleetAtOrigin={fleetAtOrigin} />
|
||||
|
||||
{bookings.map((booking) => {
|
||||
const isActive = selectedBookingId === booking.id;
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const fits = booking.canAssign;
|
||||
const blockReason = booking.blockReason;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={booking.id}
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
company: null,
|
||||
freightType: booking.freightType,
|
||||
weightTons: Number.isFinite(weight) ? weight : null,
|
||||
status: booking.status,
|
||||
priorityScore: booking.priorityScore,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isActive ? "var(--mantine-color-green-5)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={30} radius="md" variant="light" color="orange">
|
||||
<ContainerIcon size={16} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.priorityScore ? (
|
||||
<Badge size="xs" color="green">
|
||||
P{booking.priorityScore}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap" mt={2}>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{booking.freightType}
|
||||
</Badge>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<TrainFront size={11} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="11px" c="dimmed">
|
||||
{booking.wagonsRequired}× {booking.requiredWagonTypeCode}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{blockReason ? (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<AlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="10px" c="red.7" fw={600}>
|
||||
{blockReason}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Tooltip label={blockReason} disabled={fits} withArrow position="bottom">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
disabled={!fits}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleAssign(booking.id, booking.reference);
|
||||
}}
|
||||
loading={
|
||||
assignMutation.isPending &&
|
||||
assignMutation.variables?.bookingId === booking.id
|
||||
}
|
||||
leftSection={<Plus size={12} />}
|
||||
>
|
||||
Assign to train
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
Package,
|
||||
TrainFront,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number];
|
||||
|
||||
interface WagonCardProps {
|
||||
wagon: Wagon;
|
||||
company?: string | null;
|
||||
scheduleId: string;
|
||||
scheduleStatus?: string;
|
||||
onRemoveBooking: (wagon: Wagon) => void;
|
||||
onRemoveWagon: (wagonId: string) => void;
|
||||
}
|
||||
|
||||
export const WagonCard = ({
|
||||
wagon,
|
||||
company,
|
||||
scheduleId,
|
||||
scheduleStatus,
|
||||
onRemoveBooking,
|
||||
onRemoveWagon,
|
||||
}: WagonCardProps) => {
|
||||
const isDispatched = scheduleStatus === "DISPATCHED";
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const hasAllocations = Boolean(allocation);
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
|
||||
const weightUsed = allocation?.allocatedWeightTons ?? 0;
|
||||
const weightMax = wagon.capacityTons ?? 0;
|
||||
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
|
||||
|
||||
const wagonType = wagon.wagonType?.code || "UNKNOWN";
|
||||
|
||||
return (
|
||||
<Card padding="sm" radius="md" withBorder style={{ borderColor: freightBrand.mutedBorder }}>
|
||||
<Card.Section withBorder inheritPadding py="xs" style={{ background: freightBrand.mutedBg }}>
|
||||
<Group justify="space-between">
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size={28} radius="md" variant="white" color="green">
|
||||
<TrainFront size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Group gap={4}>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="green">
|
||||
{wagonType}
|
||||
</Badge>
|
||||
</Group>
|
||||
{wagon.physicalWagonNumber || wagon.physicalWagonId ? (
|
||||
<Text size="10px" c="dimmed">
|
||||
{wagon.physicalWagonNumber || wagon.physicalWagonId?.slice(0, 8)}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
{hasAllocations ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={isBulk ? "orange" : "cyan"}
|
||||
leftSection={isBulk ? <Fuel size={11} /> : <ContainerIcon size={11} />}
|
||||
>
|
||||
{isBulk ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Stack gap="sm" mt="sm">
|
||||
{hasAllocations && allocation ? (
|
||||
<>
|
||||
{company ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Building2 size={14} color={freightBrand.primary} />
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Package size={14} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocation.bookingReference || "Unknown booking"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{allocation.loadType === "CONTAINER" && allocation.containerItems?.length ? (
|
||||
<Box>
|
||||
<Text size="10px" c="dimmed" fw={700} tt="uppercase" mb={4}>
|
||||
Containers
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{allocation.containerItems.map((item, idx) => (
|
||||
<Group key={item.id} gap={8} wrap="nowrap">
|
||||
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
#{idx + 1}
|
||||
</Text>
|
||||
<ContainerNumberInput
|
||||
value={item.containerNumber ?? null}
|
||||
itemId={item.id}
|
||||
scheduleId={scheduleId}
|
||||
disabled={isDispatched}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{isBulk ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Fuel size={14} color="var(--mantine-color-orange-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation.bulkLoad?.cargoDescription || "Bulk load"}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Weight
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{weightUsed.toFixed(1)} / {weightMax.toFixed(1)} T
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={Math.min(weightPercent, 100)}
|
||||
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "green"}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!isDispatched ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => onRemoveBooking(wagon)}
|
||||
fullWidth
|
||||
>
|
||||
Remove booking
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Stack gap="xs" align="center" py="sm">
|
||||
<ThemeIcon size={36} radius="xl" variant="light" color="gray">
|
||||
<TrainFront size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
Empty slot
|
||||
</Text>
|
||||
{!isDispatched ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<Trash2 size={14} />}
|
||||
onClick={() => onRemoveWagon(wagon.id)}
|
||||
>
|
||||
Remove wagon
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export { TrainStatsBar } from "./TrainStatsBar";
|
||||
export { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
export { RemoveBookingModal } from "./RemoveBookingModal";
|
||||
export { WagonCard } from "./WagonCard";
|
||||
export { TrainConsistView } from "./TrainConsistView";
|
||||
export { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
||||
export { BookingDetailModal } from "./BookingDetailModal";
|
||||
export { BatchBookingList } from "./BatchBookingList";
|
||||
export { RemoveBookingConfirmModal } from "./RemoveBookingConfirmModal";
|
||||
export { AssignedBookingsPanel } from "./AssignedBookingsPanel";
|
||||
export { UnassignedBookingsPanel } from "./UnassignedBookingsPanel";
|
||||
export { RemovalLogPanel } from "./RemovalLogPanel";
|
||||
export { CompositionBookingTabs } from "./CompositionBookingTabs";
|
||||
@@ -3,8 +3,13 @@ import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph";
|
||||
import type { OverviewAccent } from "@/components/overview/overview.styles";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
/** Mini-graph variants a StatTile can render at its bottom. */
|
||||
export type StatTileGraph = "area" | "line" | "ring" | "none";
|
||||
|
||||
/**
|
||||
* Shared visual building blocks for the Train Scheduling V2 surfaces.
|
||||
* Everything keys off the freight brand green so the list + detail pages
|
||||
@@ -95,6 +100,9 @@ export function StatTile({
|
||||
hint,
|
||||
onDark = false,
|
||||
accent = freightBrand.primary,
|
||||
graph = "none",
|
||||
graphAccent = "emerald",
|
||||
graphPct,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
@@ -102,7 +110,15 @@ export function StatTile({
|
||||
hint?: ReactNode;
|
||||
onDark?: boolean;
|
||||
accent?: string;
|
||||
/** Optional decorative mini-graph at the bottom (ignored on dark tiles). */
|
||||
graph?: StatTileGraph;
|
||||
graphAccent?: OverviewAccent;
|
||||
/** Percentage 0..100 for the ring variant. */
|
||||
graphPct?: number | null;
|
||||
}) {
|
||||
// Mini-graphs only render on light tiles (the gradient reads poorly on dark).
|
||||
const showGraph = graph !== "none" && !onDark;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
@@ -115,55 +131,69 @@ export function StatTile({
|
||||
backdropFilter: "blur(6px)",
|
||||
}
|
||||
: {
|
||||
background: "white",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{Icon ? (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: onDark ? "rgba(255,255,255,0.16)" : `${accent}1a`,
|
||||
color: onDark ? "white" : accent,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.4 }}
|
||||
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
fw={700}
|
||||
size="lg"
|
||||
lh={1.1}
|
||||
c={onDark ? "white" : undefined}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c={onDark ? "rgba(255,255,255,0.7)" : "dimmed"}>
|
||||
{hint}
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
{Icon ? (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: onDark ? "rgba(255,255,255,0.16)" : `${accent}1a`,
|
||||
color: onDark ? "white" : accent,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text
|
||||
fw={800}
|
||||
size="lg"
|
||||
lh={1.05}
|
||||
c={onDark ? "white" : "dark.5"}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
|
||||
truncate
|
||||
>
|
||||
{label}
|
||||
{hint ? <Text component="span" size="xs" c="dimmed"> · {hint}</Text> : null}
|
||||
</Text>
|
||||
</Stack>
|
||||
{showGraph && graph === "ring" ? (
|
||||
<MiniRing pct={graphPct ?? 0} accent={graphAccent} size={42} stroke={5}>
|
||||
<Text size="9px" fw={800} c="dark.4">
|
||||
{Math.round(graphPct ?? 0)}%
|
||||
</Text>
|
||||
</MiniRing>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{showGraph && graph !== "ring" ? (
|
||||
<MiniSparkline
|
||||
variant={graph}
|
||||
accent={graphAccent}
|
||||
baseline={(graphPct ?? 50) / 100}
|
||||
seed={label}
|
||||
height={20}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouteYards } from "@/hooks/useRoutes";
|
||||
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
|
||||
|
||||
export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
@@ -12,6 +13,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
const [wagonId, setWagonId] = useState<string | null>(null);
|
||||
const [sequence, setSequence] = useState<number | "">("");
|
||||
const { data: wagons } = useWagons();
|
||||
const { data: yards = [] } = useRouteYards();
|
||||
const assign = useAssignWagonToTrain();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -19,9 +21,19 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
|
||||
);
|
||||
|
||||
const yardLabelById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
yards.map((y) => [y.id, y.label ?? y.code ?? y.id]),
|
||||
),
|
||||
[yards],
|
||||
);
|
||||
|
||||
const wagonOptions = available.map((w) => ({
|
||||
value: w.id,
|
||||
label: `${w.wagonNumber} (${w.readiness.replace("_", " ").toLowerCase()})`,
|
||||
label: w.currentYardId
|
||||
? `${w.wagonNumber} (${yardLabelById.get(w.currentYardId) ?? "yard"})`
|
||||
: `${w.wagonNumber} (no yard)`,
|
||||
}));
|
||||
|
||||
const handleAssign = async () => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Center, Loader, Text, Timeline } from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
Send,
|
||||
Truck,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useInventoryActivity } from '@/hooks/useWarehouses';
|
||||
import type { ActivityType } from '@/types/warehouse';
|
||||
import { formatDate, humanizeEnum } from './options';
|
||||
|
||||
const activityIcon: Record<ActivityType, React.ReactNode> = {
|
||||
INVENTORY_RECEIVED: <PackagePlus size={14} />,
|
||||
INVENTORY_STORED: <Warehouse size={14} />,
|
||||
INVENTORY_MOVED: <ArrowRightLeft size={14} />,
|
||||
INVENTORY_RESERVED: <ClipboardCheck size={14} />,
|
||||
READY_FOR_LOADING: <PackageCheck size={14} />,
|
||||
INVENTORY_LOADED: <Truck size={14} />,
|
||||
INVENTORY_DISPATCHED: <Send size={14} />,
|
||||
};
|
||||
|
||||
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
|
||||
const { data, isLoading } = useInventoryActivity(inventoryId);
|
||||
const items = data ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No activity recorded yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline active={items.length} bulletSize={24} lineWidth={2}>
|
||||
{items.map((log) => (
|
||||
<Timeline.Item key={log.id} bullet={activityIcon[log.activityType]} title={humanizeEnum(log.activityType)}>
|
||||
{log.description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{log.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{log.performedBy ?? 'system'} · {formatDate(log.createdAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Select } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { bookingsService } from '@/services/bookings.service';
|
||||
|
||||
interface BookingSelectProps {
|
||||
value: string;
|
||||
onChange: (bookingId: string) => void;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
/** Comma-separated statuses to restrict the list (e.g. "PAID" for reservations). */
|
||||
statuses?: string;
|
||||
}
|
||||
|
||||
/** Searchable booking picker — shows the human reference (e.g. BKG-BULK-002), submits the UUID. */
|
||||
export function BookingSelect({ value, onChange, label = 'Booking', required, statuses }: BookingSelectProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['bookings', 'options', statuses ?? 'all'],
|
||||
queryFn: () =>
|
||||
bookingsService.list({ pageSize: 200, ...(statuses ? { statuses } : {}) }).then((r) => r.items),
|
||||
});
|
||||
|
||||
const options = (data ?? []).map((b) => ({
|
||||
value: b.id,
|
||||
label: b.status ? `${b.reference} · ${b.status}` : b.reference,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
required={required}
|
||||
searchable
|
||||
clearable
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
placeholder={isLoading ? 'Loading bookings…' : 'Search booking reference'}
|
||||
nothingFoundMessage="No bookings found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateWarehouse, useUpdateWarehouse, useWarehouseFacilities } from '@/hooks/useWarehouses';
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
|
||||
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
||||
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
|
||||
|
||||
@@ -24,10 +25,11 @@ interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
stationId: string;
|
||||
stationId: string | null;
|
||||
locationName: string;
|
||||
capacityWeight: number | '';
|
||||
capacityContainers: number | '';
|
||||
maxVolume: number | '';
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
@@ -35,10 +37,11 @@ const emptyForm = (): FormState => ({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'OPEN_WAREHOUSE',
|
||||
stationId: '',
|
||||
stationId: null,
|
||||
locationName: '',
|
||||
capacityWeight: '',
|
||||
capacityContainers: '',
|
||||
maxVolume: '',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
@@ -47,13 +50,10 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
const { toast } = useToast();
|
||||
const createMutation = useCreateWarehouse();
|
||||
const updateMutation = useUpdateWarehouse();
|
||||
const facilitiesQuery = useWarehouseFacilities();
|
||||
const { data: stations } = useStations();
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
|
||||
const facilityOptions = (facilitiesQuery.data ?? []).map((facility) => ({
|
||||
value: facility.id,
|
||||
label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`,
|
||||
}));
|
||||
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
@@ -63,10 +63,11 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
name: warehouse.name,
|
||||
code: warehouse.code,
|
||||
type: warehouse.type,
|
||||
stationId: warehouse.stationId ?? '',
|
||||
stationId: warehouse.stationId ?? null,
|
||||
locationName: warehouse.locationName ?? '',
|
||||
capacityWeight: warehouse.capacityWeight ?? '',
|
||||
capacityContainers: warehouse.capacityContainers ?? '',
|
||||
maxVolume: warehouse.maxVolume ?? '',
|
||||
status: warehouse.status,
|
||||
}
|
||||
: emptyForm(),
|
||||
@@ -86,10 +87,11 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
type: form.type,
|
||||
stationId: form.stationId || undefined,
|
||||
stationId: form.stationId ?? undefined,
|
||||
locationName: form.locationName.trim() || undefined,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
|
||||
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -115,17 +117,27 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
placeholder="Modjo Open Warehouse"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Code"
|
||||
placeholder="MODJO-OW"
|
||||
required
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
label="Facility / Port (Station)"
|
||||
placeholder="Select parent station"
|
||||
data={stationOptions}
|
||||
value={form.stationId}
|
||||
onChange={(value) => setForm((f) => ({ ...f, stationId: value }))}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Type"
|
||||
@@ -159,7 +171,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
label="Location name"
|
||||
placeholder="Modjo, Oromia"
|
||||
value={form.locationName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, locationName: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, locationName: v })); }}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
@@ -177,6 +189,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
value={form.capacityContainers}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max volume (m³)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.maxVolume}
|
||||
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
|
||||
@@ -19,6 +19,7 @@ interface FormState {
|
||||
type: WarehouseYardType;
|
||||
capacityWeight: number | '';
|
||||
capacityContainers: number | '';
|
||||
maxVolume: number | '';
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
|
||||
type: 'CONTAINER_YARD',
|
||||
capacityWeight: '',
|
||||
capacityContainers: '',
|
||||
maxVolume: '',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
@@ -48,6 +50,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
|
||||
type: yard.type,
|
||||
capacityWeight: yard.capacityWeight ?? '',
|
||||
capacityContainers: yard.capacityContainers ?? '',
|
||||
maxVolume: yard.maxVolume ?? '',
|
||||
status: yard.status,
|
||||
}
|
||||
: emptyForm(),
|
||||
@@ -69,6 +72,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
|
||||
type: form.type,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
|
||||
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -94,14 +98,14 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
|
||||
placeholder="Container Yard A"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Code"
|
||||
placeholder="CY-A"
|
||||
required
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -139,6 +143,13 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
|
||||
value={form.capacityContainers}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max volume (m³)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.maxVolume}
|
||||
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
|
||||
@@ -19,6 +19,7 @@ interface FormState {
|
||||
type: WarehouseZoneType;
|
||||
capacityWeight: number | '';
|
||||
capacityContainers: number | '';
|
||||
maxVolume: number | '';
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
|
||||
type: 'CONTAINER_ZONE',
|
||||
capacityWeight: '',
|
||||
capacityContainers: '',
|
||||
maxVolume: '',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
@@ -48,6 +50,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
|
||||
type: zone.type,
|
||||
capacityWeight: zone.capacityWeight ?? '',
|
||||
capacityContainers: zone.capacityContainers ?? '',
|
||||
maxVolume: zone.maxVolume ?? '',
|
||||
status: zone.status,
|
||||
}
|
||||
: emptyForm(),
|
||||
@@ -69,6 +72,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
|
||||
type: form.type,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
|
||||
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -94,14 +98,14 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
|
||||
placeholder="Zone A-01"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Code"
|
||||
placeholder="A-01"
|
||||
required
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -139,6 +143,13 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
|
||||
value={form.capacityContainers}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max volume (m³)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.maxVolume}
|
||||
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useDeliverInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface DeliverInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const deliverMutation = useDeliverInventory();
|
||||
const [receiverName, setReceiverName] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setReceiverName('');
|
||||
setRemarks('');
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!receiverName.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Receiver name is required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliverMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined },
|
||||
});
|
||||
toast({ title: 'Delivered — proof of delivery captured' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Deliver to customer (proof of delivery)" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="green" variant="light">
|
||||
<Text size="sm">
|
||||
A release order must already be issued. Capturing the receiver marks the goods <b>DELIVERED</b>.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Receiver name"
|
||||
required
|
||||
placeholder="Who received the goods"
|
||||
value={receiverName}
|
||||
onChange={(e) => setReceiverName(e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Optional delivery notes"
|
||||
minRows={2}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={deliverMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={handleSubmit} loading={deliverMutation.isPending}>
|
||||
Confirm delivery
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useFeePreview,
|
||||
useGateClearance,
|
||||
useGenerateInvoice,
|
||||
useInvoicesForInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { extractErrorMessage } from './options';
|
||||
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||
|
||||
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
ISSUED: 'orange',
|
||||
PARTIALLY_PAID: 'yellow',
|
||||
PAID: 'green',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
interface FeePreviewModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
inventoryId: string | null;
|
||||
}
|
||||
|
||||
const LABELS: Record<string, { label: string; color: string }> = {
|
||||
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
||||
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
||||
const configured = Boolean(fee.ruleId);
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" style={{ borderColor: `var(--mantine-color-${meta.color}-3)` }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<Coins size={16} />
|
||||
<Text fw={700}>{meta.label}</Text>
|
||||
{fee.endIsOpen && (
|
||||
<Badge size="xs" color={meta.color} variant="light">
|
||||
accruing
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
||||
{fee.amount.toLocaleString()} {fee.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!configured ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No active {meta.label.toLowerCase()} rule configured — amount shown as 0.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
||||
<Row label="Free days" value={String(fee.freeDays)} />
|
||||
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
|
||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
|
||||
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
|
||||
const { toast } = useToast();
|
||||
const enabledId = opened ? inventoryId ?? undefined : undefined;
|
||||
const { data, isLoading } = useFeePreview(enabledId);
|
||||
const { data: invoices } = useInvoicesForInventory(enabledId);
|
||||
const generate = useGenerateInvoice();
|
||||
const gateClear = useGateClearance();
|
||||
|
||||
const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED');
|
||||
|
||||
const handleGenerate = async (confirmZero = false) => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} — ${inv.totalAmount} ${inv.currency}` });
|
||||
} catch (error) {
|
||||
const msg = extractErrorMessage(error);
|
||||
if (/no payable warehouse fee/i.test(msg)) {
|
||||
if (window.confirm('No payable warehouse fee found. Create a zero-amount invoice anyway?')) {
|
||||
handleGenerate(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Generate failed', description: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGateClearance = async () => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
await gateClear.mutateAsync(inventoryId);
|
||||
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<CalendarClock size={18} />
|
||||
<Text fw={700}>Storage & Demurrage Preview</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{(data ?? []).map((fee) => (
|
||||
<FeeCard key={fee.ruleType} fee={fee} />
|
||||
))}
|
||||
|
||||
<Divider label="Invoice & Release" labelPosition="left" />
|
||||
|
||||
{activeInvoice ? (
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<FileText size={16} />
|
||||
<Text size="sm" fw={600}>{activeInvoice.invoiceNumber}</Text>
|
||||
<Badge variant="light" color={INVOICE_STATUS_COLOR[activeInvoice.status]}>
|
||||
{activeInvoice.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={generate.isPending}
|
||||
onClick={() => handleGenerate(false)}
|
||||
>
|
||||
Generate Fee Invoice
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<DoorOpen size={16} />}
|
||||
loading={gateClear.isPending}
|
||||
onClick={handleGateClearance}
|
||||
>
|
||||
Gate Clearance / Release
|
||||
</Button>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Charges accrue from arrival until gate clearance / release. Final release is blocked while a
|
||||
demurrage/storage invoice is unpaid.
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { CSSProperties, ReactElement } from 'react';
|
||||
|
||||
export type FreightVisualVariant =
|
||||
| 'train'
|
||||
| 'warehouse'
|
||||
| 'container'
|
||||
| 'wagon'
|
||||
| 'cargo'
|
||||
| 'route'
|
||||
| 'empty';
|
||||
|
||||
interface FreightVisualProps {
|
||||
variant: FreightVisualVariant;
|
||||
/** Pixel size of the (square) artwork. Defaults to 64. */
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight railway/freight illustrations — minimal, enterprise-logistics style.
|
||||
* Inline SVG (no network cost) using EDR brand colors: green, yellow, dark text,
|
||||
* light gray. Purposely low-contrast so it never overpowers tables/forms.
|
||||
*
|
||||
* Use only in page headers, empty states, and KPI cards.
|
||||
*/
|
||||
const EDR = {
|
||||
green: '#2F9E44',
|
||||
greenSoft: '#D3F9D8',
|
||||
yellow: '#F59F00',
|
||||
yellowSoft: '#FFF3BF',
|
||||
dark: '#343A40',
|
||||
gray: '#ADB5BD',
|
||||
graySoft: '#E9ECEF',
|
||||
};
|
||||
|
||||
function Train() {
|
||||
return (
|
||||
<>
|
||||
{/* track */}
|
||||
<rect x="2" y="52" width="60" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* locomotive body */}
|
||||
<rect x="6" y="20" width="26" height="26" rx="3" fill={EDR.green} />
|
||||
<rect x="10" y="24" width="8" height="8" rx="1.5" fill={EDR.greenSoft} />
|
||||
<rect x="22" y="24" width="6" height="8" rx="1.5" fill={EDR.greenSoft} />
|
||||
{/* cab roof */}
|
||||
<rect x="9" y="15" width="14" height="6" rx="2" fill={EDR.dark} />
|
||||
{/* wagon */}
|
||||
<rect x="36" y="26" width="22" height="20" rx="2.5" fill={EDR.yellow} />
|
||||
<rect x="40" y="30" width="14" height="6" rx="1" fill={EDR.yellowSoft} />
|
||||
{/* wheels */}
|
||||
{[12, 24, 42, 52].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Warehouse() {
|
||||
return (
|
||||
<>
|
||||
{/* ground */}
|
||||
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* roof */}
|
||||
<path d="M10 24 L32 12 L54 24 Z" fill={EDR.green} />
|
||||
{/* body */}
|
||||
<rect x="14" y="24" width="36" height="26" rx="1.5" fill={EDR.greenSoft} />
|
||||
{/* shutter door */}
|
||||
<rect x="26" y="32" width="12" height="18" rx="1" fill={EDR.dark} />
|
||||
<rect x="27.5" y="35" width="9" height="2" fill={EDR.gray} />
|
||||
<rect x="27.5" y="39" width="9" height="2" fill={EDR.gray} />
|
||||
<rect x="27.5" y="43" width="9" height="2" fill={EDR.gray} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Container() {
|
||||
return (
|
||||
<>
|
||||
{/* stacked containers */}
|
||||
<rect x="8" y="34" width="22" height="16" rx="1.5" fill={EDR.green} />
|
||||
<rect x="34" y="34" width="22" height="16" rx="1.5" fill={EDR.yellow} />
|
||||
<rect x="20" y="16" width="24" height="16" rx="1.5" fill={EDR.dark} />
|
||||
{/* corrugation lines */}
|
||||
{[12, 16, 20, 24].map((x) => (
|
||||
<rect key={`a${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.greenSoft} />
|
||||
))}
|
||||
{[38, 42, 46, 50].map((x) => (
|
||||
<rect key={`b${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.yellowSoft} />
|
||||
))}
|
||||
{[25, 29, 33, 37].map((x) => (
|
||||
<rect key={`c${x}`} x={x} y="19" width="1.5" height="10" fill={EDR.gray} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Wagon() {
|
||||
return (
|
||||
<>
|
||||
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* flatbed wagon */}
|
||||
<rect x="8" y="38" width="48" height="8" rx="1.5" fill={EDR.dark} />
|
||||
{/* cargo on wagon */}
|
||||
<rect x="14" y="22" width="16" height="16" rx="1.5" fill={EDR.green} />
|
||||
<rect x="34" y="26" width="16" height="12" rx="1.5" fill={EDR.yellow} />
|
||||
{/* wheels */}
|
||||
{[16, 26, 40, 50].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Cargo() {
|
||||
return (
|
||||
<>
|
||||
{/* cargo boxes */}
|
||||
<rect x="12" y="30" width="20" height="20" rx="2" fill={EDR.yellow} />
|
||||
<rect x="34" y="34" width="18" height="16" rx="2" fill={EDR.green} />
|
||||
{/* tape */}
|
||||
<rect x="21" y="30" width="2" height="20" fill={EDR.yellowSoft} />
|
||||
<rect x="12" y="38" width="20" height="2" fill={EDR.yellowSoft} />
|
||||
<rect x="42" y="34" width="2" height="16" fill={EDR.greenSoft} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Route() {
|
||||
return (
|
||||
<>
|
||||
{/* track line with stations */}
|
||||
<rect x="6" y="31" width="52" height="2" rx="1" fill={EDR.gray} />
|
||||
{[10, 22, 34, 46, 58].map((x) => (
|
||||
<rect key={x} x={x - 0.5} y="28" width="1.5" height="8" fill={EDR.graySoft} />
|
||||
))}
|
||||
<circle cx="10" cy="32" r="5" fill={EDR.green} />
|
||||
<circle cx="54" cy="32" r="5" fill={EDR.yellow} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<>
|
||||
{/* empty open box */}
|
||||
<path d="M14 28 L32 22 L50 28 L50 30 L32 24 L14 30 Z" fill={EDR.gray} />
|
||||
<path d="M14 30 L32 36 L32 50 L14 44 Z" fill={EDR.graySoft} />
|
||||
<path d="M50 30 L32 36 L32 50 L50 44 Z" fill={EDR.graySoft} />
|
||||
<path d="M14 30 L32 24 L50 30 L32 36 Z" fill="#F8F9FA" />
|
||||
<circle cx="32" cy="14" r="2.5" fill={EDR.yellow} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const VARIANTS: Record<FreightVisualVariant, () => ReactElement> = {
|
||||
train: Train,
|
||||
warehouse: Warehouse,
|
||||
container: Container,
|
||||
wagon: Wagon,
|
||||
cargo: Cargo,
|
||||
route: Route,
|
||||
empty: Empty,
|
||||
};
|
||||
|
||||
export function FreightVisual({ variant, size = 64, style, className, title }: FreightVisualProps) {
|
||||
const Art = VARIANTS[variant];
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label={title ?? `${variant} illustration`}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<Art />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||
import {
|
||||
INSPECTION_REPORT_TYPES,
|
||||
INSPECTION_STATUSES,
|
||||
type InspectionReportType,
|
||||
type InspectionResultStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
inventoryId: string | null;
|
||||
}
|
||||
|
||||
const REPORT_TYPE_LABELS: Record<InspectionReportType, string> = {
|
||||
INSPECTION: 'Inspection',
|
||||
DAMAGE: 'Damage',
|
||||
WEIGHT_LOSS: 'Weight loss',
|
||||
MISSING_ITEM: 'Missing item',
|
||||
GENERAL: 'General',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<InspectionResultStatus, string> = {
|
||||
PASSED: 'Passed',
|
||||
FAILED: 'Failed',
|
||||
NEEDS_REVIEW: 'Needs review',
|
||||
};
|
||||
|
||||
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
|
||||
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
|
||||
const { toast } = useToast();
|
||||
const createReport = useCreateInspectionReport();
|
||||
const uploadAttachments = useUploadInspectionAttachments();
|
||||
|
||||
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
||||
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
||||
const [hasDamage, setHasDamage] = useState(false);
|
||||
const [damageDescription, setDamageDescription] = useState('');
|
||||
const [hasWeightLoss, setHasWeightLoss] = useState(false);
|
||||
const [expectedWeight, setExpectedWeight] = useState<number | ''>('');
|
||||
const [actualWeight, setActualWeight] = useState<number | ''>('');
|
||||
const [hasMissingItems, setHasMissingItems] = useState(false);
|
||||
const [missingItemsDescription, setMissingItemsDescription] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const submitting = createReport.isPending || uploadAttachments.isPending;
|
||||
|
||||
const reset = () => {
|
||||
setReportType('INSPECTION');
|
||||
setInspectionStatus('PASSED');
|
||||
setHasDamage(false);
|
||||
setDamageDescription('');
|
||||
setHasWeightLoss(false);
|
||||
setExpectedWeight('');
|
||||
setActualWeight('');
|
||||
setHasMissingItems(false);
|
||||
setMissingItemsDescription('');
|
||||
setRemarks('');
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
const report = await createReport.mutateAsync({
|
||||
inventoryId,
|
||||
payload: {
|
||||
reportType,
|
||||
inspectionStatus,
|
||||
hasDamage,
|
||||
damageDescription: damageDescription.trim() || undefined,
|
||||
hasWeightLoss,
|
||||
expectedWeight: expectedWeight === '' ? undefined : Number(expectedWeight),
|
||||
actualWeight: actualWeight === '' ? undefined : Number(actualWeight),
|
||||
hasMissingItems,
|
||||
missingItemsDescription: missingItemsDescription.trim() || undefined,
|
||||
remarks: remarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments.mutateAsync({ reportId: report.id, files });
|
||||
}
|
||||
|
||||
toast({ title: 'Inspection report saved' });
|
||||
reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inspection / Report" centered size="lg">
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Report type"
|
||||
data={INSPECTION_REPORT_TYPES.map((t) => ({ value: t, label: REPORT_TYPE_LABELS[t] }))}
|
||||
value={reportType}
|
||||
onChange={(v) => setReportType((v as InspectionReportType) ?? 'INSPECTION')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label="Inspection status"
|
||||
data={INSPECTION_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||||
value={inspectionStatus}
|
||||
onChange={(v) => setInspectionStatus((v as InspectionResultStatus) ?? 'PASSED')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Divider my="md" label="Damage" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has damage"
|
||||
checked={hasDamage}
|
||||
onChange={(e) => setHasDamage(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasDamage && (
|
||||
<Textarea
|
||||
mt="xs"
|
||||
label="Damage description"
|
||||
value={damageDescription}
|
||||
onChange={(e) => setDamageDescription(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider my="md" label="Weight loss" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has weight loss"
|
||||
checked={hasWeightLoss}
|
||||
onChange={(e) => setHasWeightLoss(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasWeightLoss && (
|
||||
<Group grow mt="xs">
|
||||
<NumberInput
|
||||
label="Expected weight (kg)"
|
||||
min={0}
|
||||
value={expectedWeight}
|
||||
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual weight (kg)"
|
||||
min={0}
|
||||
value={actualWeight}
|
||||
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Divider my="md" label="Missing items" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has missing items"
|
||||
checked={hasMissingItems}
|
||||
onChange={(e) => setHasMissingItems(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasMissingItems && (
|
||||
<Textarea
|
||||
mt="xs"
|
||||
label="Missing items description"
|
||||
value={missingItemsDescription}
|
||||
onChange={(e) => setMissingItemsDescription(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider my="md" />
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
mt="md"
|
||||
label="Images / documents"
|
||||
placeholder="Upload jpg, png or pdf"
|
||||
accept="image/jpeg,image/png,application/pdf"
|
||||
leftSection={<Upload size={16} />}
|
||||
multiple
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
|
||||
Save report
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Modal, Tabs } from '@mantine/core';
|
||||
import { ArrowRightLeft, ListChecks } from 'lucide-react';
|
||||
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { ActivityTimeline } from './ActivityTimeline';
|
||||
import { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||
|
||||
interface InventoryHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function InventoryHistoryModal({ opened, onClose, item }: InventoryHistoryModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inventory history" centered size="xl">
|
||||
{item && (
|
||||
<Tabs defaultValue="activity">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="activity" leftSection={<ListChecks size={16} />}>
|
||||
Activity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="movements" leftSection={<ArrowRightLeft size={16} />}>
|
||||
Movements
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="activity" pt="md">
|
||||
<ActivityTimeline inventoryId={item.id} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="movements" pt="md">
|
||||
<InventoryMovementHistoryTable inventoryId={item.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Center, Loader, Table, Text } from '@mantine/core';
|
||||
|
||||
import { useInventoryMovements } from '@/hooks/useWarehouses';
|
||||
import { formatDate } from './options';
|
||||
|
||||
const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}…` : '—');
|
||||
|
||||
export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) {
|
||||
const { data, isLoading } = useInventoryMovements(inventoryId);
|
||||
const movements = data ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (movements.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No movements recorded for this item.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>From (W / Y / Z)</Table.Th>
|
||||
<Table.Th>To (W / Y / Z)</Table.Th>
|
||||
<Table.Th>Remarks</Table.Th>
|
||||
<Table.Th>By</Table.Th>
|
||||
<Table.Th>When</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{movements.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs">
|
||||
{shortId(m.fromWarehouseId)} / {shortId(m.fromYardId)} / {shortId(m.fromZoneId)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">
|
||||
{shortId(m.toWarehouseId)} / {shortId(m.toYardId)} / {shortId(m.toZoneId)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{m.remarks ?? '—'}</Table.Td>
|
||||
<Table.Td>{m.movedBy ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(m.movedAt)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useDispatchInventory,
|
||||
useMarkReadyForLoading,
|
||||
useMarkReadyForPickup,
|
||||
useStoreInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
||||
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
|
||||
const { toast } = useToast();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const storeMutation = useStoreInventory();
|
||||
const readyMutation = useMarkReadyForLoading();
|
||||
const pickupMutation = useMarkReadyForPickup();
|
||||
const dispatchMutation = useDispatchInventory();
|
||||
|
||||
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
await fn();
|
||||
toast({ title: label });
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
case 'ready-for-loading':
|
||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||
case 'load':
|
||||
setLoadItem(item);
|
||||
return;
|
||||
case 'dispatch':
|
||||
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
|
||||
case 'ready-for-pickup':
|
||||
return runDirect(item, () => pickupMutation.mutateAsync(item.id), 'Ready for pickup');
|
||||
case 'release':
|
||||
setReleaseItem(item);
|
||||
return;
|
||||
case 'deliver':
|
||||
setDeliverItem(item);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
/>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<ReserveInventoryModal
|
||||
opened={Boolean(reserveItem)}
|
||||
onClose={() => setReserveItem(null)}
|
||||
item={reserveItem}
|
||||
/>
|
||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||
<InventoryHistoryModal
|
||||
opened={Boolean(historyItem)}
|
||||
onClose={() => setHistoryItem(null)}
|
||||
item={historyItem}
|
||||
/>
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectItem)}
|
||||
onClose={() => setInspectItem(null)}
|
||||
inventoryId={inspectItem?.id ?? null}
|
||||
/>
|
||||
<FeePreviewModal
|
||||
opened={Boolean(feeItem)}
|
||||
onClose={() => setFeeItem(null)}
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Stack, Text, Textarea } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLoadInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { WagonSelect } from './WagonSelect';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface LoadInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
/** Load READY_FOR_LOADING inventory onto a wagon (creates a loading record). */
|
||||
export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const loadMutation = useLoadInventory();
|
||||
const [wagonId, setWagonId] = useState('');
|
||||
const [loadedWeight, setLoadedWeight] = useState<number | ''>('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWagonId('');
|
||||
setLoadedWeight(item?.weight ?? '');
|
||||
setNotes('');
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!wagonId.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Select a wagon' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: {
|
||||
wagonId: wagonId.trim(),
|
||||
loadedWeight: loadedWeight === '' ? undefined : Number(loadedWeight),
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Inventory loaded', description: 'Status set to LOADED' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Load onto wagon" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
The item must be <b>READY_FOR_LOADING</b> and the wagon must be available or already on a
|
||||
train schedule.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
|
||||
|
||||
<NumberInput
|
||||
label="Loaded weight (kg)"
|
||||
placeholder="Defaults to item weight"
|
||||
min={0}
|
||||
value={loadedWeight}
|
||||
onChange={(v) => setLoadedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setNotes(v);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loadMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loadMutation.isPending}>
|
||||
Load
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface MoveInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const moveMutation = useMoveInventory();
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setRemarks('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
|
||||
const yardsQuery = useWarehouseYards(warehouseId || undefined);
|
||||
const zonesQuery = useWarehouseZones(yardId || undefined);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!warehouseId || !yardId || !zoneId) {
|
||||
toast({ variant: 'destructive', title: 'Select destination warehouse, yard and zone' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await moveMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
|
||||
});
|
||||
toast({ title: 'Inventory moved' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Move failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Destination warehouse"
|
||||
placeholder="Select warehouse"
|
||||
required
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={warehouseId || null}
|
||||
onChange={(v) => {
|
||||
setWarehouseId(v ?? '');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||
required
|
||||
searchable
|
||||
disabled={!warehouseId}
|
||||
data={yardOptions}
|
||||
value={yardId || null}
|
||||
onChange={(v) => {
|
||||
setYardId(v ?? '');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Destination zone"
|
||||
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
|
||||
required
|
||||
searchable
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Reason for the move"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
|
||||
Move inventory
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { ReceiveInventoryPayload } from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface ReceiveInventoryModalProps {
|
||||
@@ -125,12 +126,10 @@ export function ReceiveInventoryModal({
|
||||
{bookingId ? (
|
||||
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
|
||||
) : (
|
||||
<TextInput
|
||||
label="Booking ID"
|
||||
placeholder="Booking UUID"
|
||||
required
|
||||
<BookingSelect
|
||||
label="Booking"
|
||||
value={form.bookingId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bookingId: e.currentTarget.value }))}
|
||||
onChange={(v) => setForm((f) => ({ ...f, bookingId: v }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -198,7 +197,7 @@ export function ReceiveInventoryModal({
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.currentTarget.value }))}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useReleaseInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface ReleaseOrderModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useReleaseInventory();
|
||||
const [reference, setReference] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
try {
|
||||
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
|
||||
toast({ title: 'Release order issued' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Records the delivery order / release order sent to the customer. Once issued, the goods can be
|
||||
picked up and delivered.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Release order reference"
|
||||
placeholder="e.g. DO-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
|
||||
Issue release order
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useReserveInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface ReserveInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const reserveMutation = useReserveInventory();
|
||||
const [bookingId, setBookingId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setBookingId(item?.bookingId ?? '');
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!bookingId.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Booking is required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await reserveMutation.mutateAsync({ inventoryId: item.id, bookingId: bookingId.trim() });
|
||||
toast({ title: 'Inventory reserved' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Reserve inventory" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">The booking must be in <b>PAID</b> status and the inventory must be <b>STORED</b>.</Text>
|
||||
</Alert>
|
||||
<BookingSelect label="Booking (PAID)" required statuses="PAID" value={bookingId} onChange={setBookingId} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={reserveMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={reserveMutation.isPending}>
|
||||
Reserve
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
|
||||
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
|
||||
|
||||
interface VisualEmptyStateProps {
|
||||
variant?: FreightVisualVariant;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Friendly empty state with a small freight illustration. */
|
||||
export function VisualEmptyState({
|
||||
variant = 'empty',
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: VisualEmptyStateProps) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs" maw={360}>
|
||||
<FreightVisual variant={variant} size={88} style={{ opacity: 0.85 }} />
|
||||
<Text fw={600} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Select } from '@mantine/core';
|
||||
|
||||
import { useLoadableWagons } from '@/hooks/useWarehouses';
|
||||
|
||||
interface WagonSelectProps {
|
||||
value: string;
|
||||
onChange: (wagonId: string) => void;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Searchable wagon picker. Lists wagons that are loadable (read-only from scheduling). */
|
||||
export function WagonSelect({ value, onChange, label = 'Wagon', required }: WagonSelectProps) {
|
||||
const { data, isLoading } = useLoadableWagons();
|
||||
|
||||
const options = (data ?? []).map((w) => ({
|
||||
value: w.id,
|
||||
label: `${w.wagonNumber} · ${w.status}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
required={required}
|
||||
searchable
|
||||
clearable
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
placeholder={isLoading ? 'Loading wagons…' : 'Search wagon number'}
|
||||
nothingFoundMessage="No loadable wagons found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { Eye, MapPin, Pencil } from 'lucide-react';
|
||||
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
|
||||
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
import { formatCapacity } from './options';
|
||||
@@ -12,6 +14,12 @@ interface WarehouseCardViewProps {
|
||||
}
|
||||
|
||||
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
|
||||
const { data: stations } = useStations();
|
||||
const stationNameById = useMemo(
|
||||
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
|
||||
[stations],
|
||||
);
|
||||
|
||||
if (warehouses.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
@@ -39,11 +47,12 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Facility: {warehouse.facility
|
||||
? `${warehouse.facility.label ?? warehouse.facility.name ?? warehouse.facility.code} (${warehouse.facility.code})`
|
||||
: '—'}
|
||||
</Text>
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<Building2 size={14} />
|
||||
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{warehouse.locationName && (
|
||||
<Group gap={6} c="dimmed">
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
||||
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
|
||||
interface WarehouseDashboardChartsProps {
|
||||
data?: WarehouseDashboard;
|
||||
}
|
||||
|
||||
const ORANGE = '#f08c00';
|
||||
const GREEN = '#5bbf4a';
|
||||
|
||||
/** Inventory lifecycle status series — alternating orange / light green. */
|
||||
const STATUS_SERIES = [
|
||||
{ key: 'stored', label: 'Stored', color: ORANGE },
|
||||
{ key: 'reserved', label: 'Reserved', color: GREEN },
|
||||
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
|
||||
{ key: 'loaded', label: 'Loaded', color: GREEN },
|
||||
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
|
||||
] as const;
|
||||
|
||||
type Granularity = 'week' | 'month' | 'year';
|
||||
|
||||
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
|
||||
const [granularity, setGranularity] = useState<Granularity>('month');
|
||||
const { data: inventory } = useWarehouseInventory();
|
||||
|
||||
const statusData = STATUS_SERIES.map((s) => ({
|
||||
name: s.label,
|
||||
value: data ? Number(data[s.key as keyof WarehouseDashboard] ?? 0) : 0,
|
||||
color: s.color,
|
||||
}));
|
||||
const hasStatus = statusData.some((d) => d.value > 0);
|
||||
|
||||
const trend = useMemo(
|
||||
() => buildTrend(inventory ?? [], granularity),
|
||||
[inventory, granularity],
|
||||
);
|
||||
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{/* Time-filtered throughput */}
|
||||
<Card withBorder radius="lg" padding="lg" style={{ gridColumn: '1 / -1' }}>
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||
<CalendarRange size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Throughput Over Time</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Received vs dispatched inventory
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
value={granularity}
|
||||
onChange={(v) => setGranularity(v as Granularity)}
|
||||
data={[
|
||||
{ label: 'Weekly', value: 'week' },
|
||||
{ label: 'Monthly', value: 'month' },
|
||||
{ label: 'Yearly', value: 'year' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{hasTrend ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={trend} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||
<Legend iconType="circle" />
|
||||
<Bar dataKey="received" name="Received" fill={ORANGE} radius={[6, 6, 0, 0]} />
|
||||
<Bar dataKey="dispatched" name="Dispatched" fill={GREEN} radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Status distribution bar */}
|
||||
<Card withBorder radius="lg" padding="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: GREEN, color: '#fff' }}>
|
||||
<BarChart3 size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Inventory by Status</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Items at each lifecycle stage
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{hasStatus ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={statusData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||
<Bar dataKey="value" name="Items" radius={[6, 6, 0, 0]}>
|
||||
{statusData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Status distribution donut */}
|
||||
<Card withBorder radius="lg" padding="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||
<PieChartIcon size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Lifecycle Distribution</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Share of inventory across statuses
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{hasStatus ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={95}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{statusData.map((entry, i) => (
|
||||
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrendBucket {
|
||||
label: string;
|
||||
received: number;
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */
|
||||
function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] {
|
||||
const now = new Date();
|
||||
const buckets: { label: string; start: Date; end: Date }[] = [];
|
||||
|
||||
if (granularity === 'week') {
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const end = new Date(now);
|
||||
end.setDate(now.getDate() - i * 7);
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - 7);
|
||||
buckets.push({ label: `W${8 - i}`, start, end });
|
||||
}
|
||||
} else if (granularity === 'month') {
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
|
||||
buckets.push({
|
||||
label: start.toLocaleString('en', { month: 'short' }),
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
const year = now.getFullYear() - i;
|
||||
buckets.push({
|
||||
label: String(year),
|
||||
start: new Date(year, 0, 1),
|
||||
end: new Date(year + 1, 0, 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inRange = (iso: string | null | undefined, start: Date, end: Date) => {
|
||||
if (!iso) return false;
|
||||
const t = new Date(iso).getTime();
|
||||
return t >= start.getTime() && t < end.getTime();
|
||||
};
|
||||
|
||||
return buckets.map((b) => ({
|
||||
label: b.label,
|
||||
received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length,
|
||||
dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function EmptyChart() {
|
||||
return (
|
||||
<Group justify="center" align="center" h={280}>
|
||||
<Text c="dimmed" size="sm">
|
||||
No inventory data to chart yet.
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box, Group, Stack, Text, Title } from '@mantine/core';
|
||||
|
||||
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
|
||||
|
||||
interface WarehouseHeroProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Primary illustration shown on the right of the hero. */
|
||||
variant?: FreightVisualVariant;
|
||||
/** Optional secondary illustration tucked behind the primary. */
|
||||
secondaryVariant?: FreightVisualVariant;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page header hero with a lightweight freight illustration. Low-contrast,
|
||||
* minimal — sets context without overpowering the data below.
|
||||
*/
|
||||
export function WarehouseHero({
|
||||
title,
|
||||
subtitle,
|
||||
variant = 'warehouse',
|
||||
secondaryVariant,
|
||||
actions,
|
||||
}: WarehouseHeroProps) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F8F9FA 0%, #F1F3F5 100%)',
|
||||
border: '1px solid #E9ECEF',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
padding: 'var(--mantine-spacing-lg)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Stack gap={4}>
|
||||
<Title order={3}>{title}</Title>
|
||||
{subtitle && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
{actions && <Group mt="sm">{actions}</Group>}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ opacity: 0.95 }}>
|
||||
{secondaryVariant && (
|
||||
<FreightVisual variant={secondaryVariant} size={56} style={{ opacity: 0.7 }} />
|
||||
)}
|
||||
<FreightVisual variant={variant} size={84} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
|
||||
import { PackagePlus, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { FreightVisual } from './FreightVisual';
|
||||
import { formatDate } from './options';
|
||||
import { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
|
||||
@@ -28,9 +29,14 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const { data, isLoading } = useWarehouseInventory({ bookingId });
|
||||
const { data: scheduleView } = useBookingSchedule(bookingId);
|
||||
|
||||
const items = data ?? [];
|
||||
const latest = items[0];
|
||||
const schedule = scheduleView?.schedule;
|
||||
const wagon = scheduleView?.wagon;
|
||||
const isLoadedOrDispatched =
|
||||
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
@@ -65,9 +71,52 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
|
||||
<Row label="Inventory Status" value={<InventoryStatusBadge status={latest.status} />} />
|
||||
<Row label="Arrived At" value={formatDate(latest.arrivedAt)} />
|
||||
<Row label="Ready For Loading At" value={formatDate(latest.readyForLoadingAt)} />
|
||||
{isLoadedOrDispatched && (
|
||||
<>
|
||||
<Row
|
||||
label="Wagon"
|
||||
value={wagon?.wagonNumber ?? '—'}
|
||||
/>
|
||||
<Row label="Loaded At" value={formatDate(latest.loadedAt)} />
|
||||
<Row label="Dispatched At" value={formatDate(latest.dispatchedAt)} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{schedule && (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap={6}>
|
||||
<TrainIcon size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Train schedule (read-only)
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
labelPosition="left"
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<FreightVisual variant="train" size={40} />
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Row
|
||||
label="Departure Status"
|
||||
value={
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<Row label="Scheduled Departure" value={formatDate(schedule.scheduledDepartureDate)} />
|
||||
<Row label="Scheduled Arrival" value={formatDate(schedule.scheduledArrivalDate)} />
|
||||
{wagon?.wagonNumber && <Row label="Assigned Wagon" value={wagon.wagonNumber} />}
|
||||
{wagon?.sequenceNo != null && <Row label="Wagon Position" value={`#${wagon.sequenceNo}`} />}
|
||||
</Stack>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { Badge, Button, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowLeftRight, ClipboardCheck, PackageCheck, Send, Truck, Warehouse } from 'lucide-react';
|
||||
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { getNextInventoryAction } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber } from './options';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
onInspect: (item: WarehouseInventoryItem) => void;
|
||||
onStore?: (item: WarehouseInventoryItem) => void;
|
||||
onReserve?: (item: WarehouseInventoryItem) => void;
|
||||
onReadyForLoading: (item: WarehouseInventoryItem) => void;
|
||||
onLoad?: (item: WarehouseInventoryItem) => void;
|
||||
onDispatch?: (item: WarehouseInventoryItem) => void;
|
||||
onMove?: (item: WarehouseInventoryItem) => void;
|
||||
busyId?: string | null;
|
||||
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
||||
onMove: (item: WarehouseInventoryItem) => void;
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
@@ -24,19 +23,25 @@ const itemKind = (item: WarehouseInventoryItem) => {
|
||||
return { label: '-', color: 'gray' };
|
||||
};
|
||||
|
||||
const isPaidBooking = (item: WarehouseInventoryItem) =>
|
||||
item.booking?.status === 'PAID' || item.booking?.paymentStatus === 'PAID';
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'green',
|
||||
'ready-for-pickup': 'orange',
|
||||
release: 'yellow',
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
items,
|
||||
onInspect,
|
||||
onStore,
|
||||
onReserve,
|
||||
onReadyForLoading,
|
||||
onLoad,
|
||||
onDispatch,
|
||||
onMove,
|
||||
busyId,
|
||||
onAdvance,
|
||||
onMove,
|
||||
onHistory,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -47,18 +52,20 @@ export function WarehouseInventoryTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={1280}>
|
||||
<Table.ScrollContainer minWidth={1150}>
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Location Path</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Item</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
<Table.Th>Ready</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -66,38 +73,26 @@ export function WarehouseInventoryTable({
|
||||
{items.map((item) => {
|
||||
const kind = itemKind(item);
|
||||
const busy = busyId === item.id;
|
||||
const paid = isPaidBooking(item);
|
||||
const facility =
|
||||
item.warehouse?.facility?.label ??
|
||||
item.warehouse?.facility?.name ??
|
||||
item.warehouse?.facility?.code ??
|
||||
'No facility';
|
||||
const locationPath = [
|
||||
facility,
|
||||
item.warehouse?.code ?? '-',
|
||||
item.yard?.code ?? '-',
|
||||
item.zone?.code ?? '-',
|
||||
].join(' -> ');
|
||||
|
||||
const nextAction = getNextInventoryAction(item);
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>
|
||||
<Tooltip label={item.bookingId} withArrow>
|
||||
<Stack gap={2}>
|
||||
{item.bookingId ? (
|
||||
<Tooltip label={item.bookingId} withArrow>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.booking?.reference ?? `${item.bookingId.slice(0, 8)}...`}
|
||||
{item.bookingId.slice(0, 8)}…
|
||||
</Text>
|
||||
<Text size="xs" c={paid ? 'green' : 'dimmed'}>
|
||||
{item.booking?.paymentStatus ?? item.booking?.status ?? 'Unknown payment'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{locationPath}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.facility?.name ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={kind.color} variant="light" size="sm" radius="md">
|
||||
{kind.label}
|
||||
@@ -111,88 +106,45 @@ export function WarehouseInventoryTable({
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(item.readyForLoadingAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeftRight size={14} />}
|
||||
disabled={!onMove || busy}
|
||||
loading={busy}
|
||||
onClick={() => onMove?.(item)}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="cyan"
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={item.status !== 'ARRIVED_AT_WAREHOUSE' || busy}
|
||||
loading={busy}
|
||||
onClick={() => onInspect(item)}
|
||||
>
|
||||
Inspect
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<Warehouse size={14} />}
|
||||
disabled={!onStore || !['RECEIVED', 'ARRIVED_AT_WAREHOUSE'].includes(item.status) || busy}
|
||||
loading={busy}
|
||||
onClick={() => onStore?.(item)}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={!onReserve || item.status !== 'STORED' || busy}
|
||||
loading={busy}
|
||||
onClick={() => onReserve?.(item)}
|
||||
>
|
||||
Reserve
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
disabled={item.status !== 'RESERVED' || busy}
|
||||
loading={busy}
|
||||
onClick={() => onReadyForLoading(item)}
|
||||
>
|
||||
Ready
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!onLoad || item.status !== 'READY_FOR_LOADING' || !paid || busy}
|
||||
loading={busy}
|
||||
onClick={() => onLoad?.(item)}
|
||||
>
|
||||
Loaded
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Send size={14} />}
|
||||
disabled={!onDispatch || item.status !== 'LOADED' || busy}
|
||||
loading={busy}
|
||||
onClick={() => onDispatch?.(item)}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color={actionColor[nextAction]}
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||
<ArrowRightLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
|
||||
import { Eye, Pencil } from 'lucide-react';
|
||||
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
import { formatCapacity } from './options';
|
||||
@@ -12,6 +14,12 @@ interface WarehouseTableProps {
|
||||
}
|
||||
|
||||
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||
const { data: stations } = useStations();
|
||||
const stationNameById = useMemo(
|
||||
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
|
||||
[stations],
|
||||
);
|
||||
|
||||
if (warehouses.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
@@ -27,7 +35,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
<Table.Tr>
|
||||
<Table.Th>Code</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Facility / Port</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||
@@ -46,9 +54,15 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
</Table.Td>
|
||||
<Table.Td>{warehouse.name}</Table.Td>
|
||||
<Table.Td>
|
||||
{warehouse.facility
|
||||
? `${warehouse.facility.label ?? warehouse.facility.name ?? warehouse.facility.code} (${warehouse.facility.code})`
|
||||
: '—'}
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) ? (
|
||||
<Text size="sm" fw={500}>
|
||||
{stationNameById.get(warehouse.stationId)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
|
||||
@@ -37,11 +37,11 @@ const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
ARRIVED_AT_WAREHOUSE: 'yellow',
|
||||
UNDER_INSPECTION: 'cyan',
|
||||
READY_FOR_LOADING: 'green',
|
||||
READY_FOR_LOADING: 'cyan',
|
||||
LOADED: 'teal',
|
||||
DISPATCHED: 'gray',
|
||||
DISPATCHED: 'green',
|
||||
READY_FOR_PICKUP: 'orange',
|
||||
DELIVERED: 'green',
|
||||
};
|
||||
|
||||
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
|
||||
|
||||
@@ -11,3 +11,19 @@ export { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||
export { ActivityTimeline } from './ActivityTimeline';
|
||||
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
export { InventoryWorkbench } from './InventoryWorkbench';
|
||||
export { BookingSelect } from './BookingSelect';
|
||||
export { WagonSelect } from './WagonSelect';
|
||||
export { LoadInventoryModal } from './LoadInventoryModal';
|
||||
export { FreightVisual } from './FreightVisual';
|
||||
export type { FreightVisualVariant } from './FreightVisual';
|
||||
export { WarehouseHero } from './WarehouseHero';
|
||||
export { VisualEmptyState } from './VisualEmptyState';
|
||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
|
||||
@@ -43,10 +43,19 @@ export const QUERY_KEYS = {
|
||||
ROOT: ["train-scheduling"] as const,
|
||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
||||
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
|
||||
locomotives: () => ["train-scheduling", "locomotives"] as const,
|
||||
locomotives: (routeId?: string) =>
|
||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
unassignedBookings: (id: string) =>
|
||||
["train-scheduling", "unassigned", id] as const,
|
||||
compositionRemovals: (id: string) =>
|
||||
["train-scheduling", "removals", id] as const,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -69,6 +69,11 @@ export const URL_CONSTANTS = {
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
COMPANIES: {
|
||||
BASE: "/companies",
|
||||
BY_ID: (id: string | number) => `/companies/${id}`,
|
||||
},
|
||||
|
||||
CUSTOMERS_API: {
|
||||
BASE: "/api/customers",
|
||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||
@@ -119,6 +124,11 @@ export const URL_CONSTANTS = {
|
||||
VERIFY: "/api/otp/verify",
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
ALL: "/payments/all",
|
||||
SUMMARY: "/payments/summary",
|
||||
},
|
||||
|
||||
LOCOMOTIVES: {
|
||||
BASE: "/locomotives",
|
||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||
@@ -132,6 +142,22 @@ export const URL_CONSTANTS = {
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
`/train-scheduling/batch-board/${scheduleId}`,
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||
EXPIRE_BOOKING: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/expire`,
|
||||
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||
PREVIEW: "/train-scheduling/preview",
|
||||
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
|
||||
@@ -160,6 +186,8 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||
RESCHEDULE_EXECUTE: (id: string) =>
|
||||
@@ -169,8 +197,14 @@ export const URL_CONSTANTS = {
|
||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
||||
CANCEL_SCHEDULE: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}/cancel`,
|
||||
PUBLISH_SCHEDULE: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}/publish`,
|
||||
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
||||
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
||||
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
||||
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/composition-removals`,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
@@ -183,8 +217,8 @@ export const URL_CONSTANTS = {
|
||||
WAGON_TYPES: "/wagon-types",
|
||||
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
|
||||
|
||||
PRIORITY_RULES: "/priority-rules",
|
||||
PRIORITY_RULE_BY_ID: (id: string) => `/priority-rules/${id}`,
|
||||
PRIORITY_CONFIGS: "/priority-configs",
|
||||
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,
|
||||
|
||||
SERVICE_TYPES: "/service-types",
|
||||
SERVICE_TYPE_BY_ID: (id: string) => `/service-types/${id}`,
|
||||
@@ -226,8 +260,14 @@ export const URL_CONSTANTS = {
|
||||
CURRENCIES: '/api/reference/currencies',
|
||||
},
|
||||
|
||||
FACILITIES: {
|
||||
BASE: '/facilities',
|
||||
BY_ID: (id: string) => `/facilities/${id}`,
|
||||
},
|
||||
|
||||
WAREHOUSES: {
|
||||
BASE: '/warehouses',
|
||||
DASHBOARD: '/warehouses/dashboard',
|
||||
BY_ID: (id: string) => `/warehouses/${id}`,
|
||||
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
||||
},
|
||||
@@ -254,5 +294,66 @@ export const URL_CONSTANTS = {
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: '/warehouse-inventory/reserve',
|
||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
// Import branch
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
},
|
||||
|
||||
WAREHOUSE_LOADINGS: {
|
||||
BASE: '/warehouse-loadings',
|
||||
},
|
||||
|
||||
WAREHOUSE_INSPECTION: {
|
||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
||||
},
|
||||
|
||||
WAREHOUSE_RULES: {
|
||||
ALLOCATION: '/warehouse-allocation-rules',
|
||||
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
||||
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
|
||||
FEES: '/warehouse-fee-rules',
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
BASE: '/warehouse-fee-invoices',
|
||||
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
BASE: '/vehicles',
|
||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||
},
|
||||
|
||||
DRIVERS: {
|
||||
BASE: '/drivers',
|
||||
BY_ID: (id: string) => `/drivers/${id}`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// API host is env-driven (set VITE_API_URL per environment, e.g. the remote
|
||||
// https://edrfreightapi.triaplc.com for prod). Falls back to the local API for dev.
|
||||
export const API_BASE_URL =
|
||||
(import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:3001';
|
||||
@@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
@@ -347,6 +348,10 @@ export function getBookingActions(
|
||||
case "CHANGES_REQUESTED":
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
case "PENDING_CONSOLIDATION":
|
||||
// View-only while waiting for a consolidation partner; cancel still allowed.
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
default:
|
||||
actions = [];
|
||||
}
|
||||
|
||||
@@ -50,6 +50,14 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "Payment Verification",
|
||||
color: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
label: "Selected for Batch",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
EXPIRED: {
|
||||
label: "Expired",
|
||||
color: "bg-red-50 text-red-700 border-red-200",
|
||||
},
|
||||
PAID: {
|
||||
label: "Paid",
|
||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||
@@ -154,6 +162,24 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-amber-700",
|
||||
stage: 3,
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_PAYMENT: {
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
EXPIRED: {
|
||||
title: "Expired",
|
||||
description: "Pay window missed — move to another schedule or cancel.",
|
||||
color: "text-red-600",
|
||||
stage: 3,
|
||||
},
|
||||
PAID: {
|
||||
title: "Paid",
|
||||
description: "Payment confirmed; ready for operations.",
|
||||
@@ -186,21 +212,25 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
},
|
||||
PENDING_CONSOLIDATION: {
|
||||
title: "Pending Consolidation",
|
||||
description: "Waiting for consolidation partner.",
|
||||
description: "Blocked — waiting for a consolidation partner before approval.",
|
||||
color: "text-amber-600",
|
||||
stage: 4,
|
||||
stage: 0,
|
||||
},
|
||||
CONSOLIDATED: {
|
||||
title: "Consolidated",
|
||||
description: "Paired with another booking.",
|
||||
color: "text-indigo-600",
|
||||
stage: 4,
|
||||
stage: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const BOOKING_LIST_TABS = [
|
||||
{ key: "all", label: "All bookings", statuses: null as string[] | null },
|
||||
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
|
||||
{
|
||||
key: "intake",
|
||||
label: "Submitted",
|
||||
statuses: ["SUBMITTED", "PENDING_CONSOLIDATION"],
|
||||
},
|
||||
{
|
||||
key: "in_approval",
|
||||
label: "In approval",
|
||||
@@ -223,12 +253,14 @@ export const BOOKING_LIST_TABS = [
|
||||
"FULLY_EXECUTED",
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"EXPIRED",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
},
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
@@ -237,7 +269,15 @@ export const BOOKING_LIST_TABS = [
|
||||
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
|
||||
|
||||
export const WORKFLOW_STAGES = [
|
||||
{ label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
|
||||
{
|
||||
label: "Submission",
|
||||
statuses: [
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"CHANGES_REQUESTED",
|
||||
"PENDING_CONSOLIDATION",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Approval",
|
||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
|
||||
@@ -252,11 +292,13 @@ export const WORKFLOW_STAGES = [
|
||||
"FULLY_EXECUTED",
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"EXPIRED",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
},
|
||||
{ label: "Done", statuses: ["COMPLETED"] },
|
||||
] as const;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user