mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
automation of loading and unloading
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
Boxes,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Paperclip,
|
||||
PackageCheck,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
Container,
|
||||
Package,
|
||||
PackageOpen,
|
||||
Users,
|
||||
Wallet,
|
||||
//TrainTrack,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -24,9 +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";
|
||||
@@ -37,15 +44,16 @@ 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 {
|
||||
CargoesCrudPage,
|
||||
ContainersCrudPage,
|
||||
LocomotivesCrudPage,
|
||||
TrainMasterDataPage,
|
||||
WagonsCrudPage,
|
||||
} from "./pages/fleet/FleetCrudPages";
|
||||
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 WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
@@ -68,11 +76,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,
|
||||
],
|
||||
},
|
||||
@@ -81,8 +100,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
items: [
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling",
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -93,37 +119,40 @@ 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: "Cargoes",
|
||||
href: "/dashboard/cargoes",
|
||||
icon: <Package />,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
// href: "/dashboard/containers",
|
||||
// icon: <Container />,
|
||||
// },
|
||||
// {
|
||||
// label: "Cargoes",
|
||||
// href: "/dashboard/cargoes",
|
||||
// icon: <Package />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -178,6 +207,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "User management",
|
||||
href: "/dashboard/user-management",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
children: [
|
||||
{
|
||||
label: "Users",
|
||||
@@ -205,11 +235,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,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -221,7 +253,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Configuration",
|
||||
href: "/dashboard/configuration",
|
||||
icon: <Boxes />,
|
||||
children: getCategorySidebarChildren("configuration"),
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
// {
|
||||
// label: "Train scheduling rules",
|
||||
// href: "/dashboard/configuration/train-scheduling-rules",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Rules",
|
||||
@@ -233,18 +271,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 = () => {
|
||||
@@ -254,7 +299,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 (
|
||||
@@ -283,7 +331,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>
|
||||
);
|
||||
}
|
||||
@@ -292,27 +341,25 @@ const App = () => {
|
||||
<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="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||
<Route path="containers" element={<ContainersCrudPage />} />
|
||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
@@ -323,6 +370,111 @@ const App = () => {
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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 />} />
|
||||
@@ -330,18 +482,40 @@ 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"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/train-scheduling-rules"
|
||||
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}</>;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
getNextPendingApprovalStep,
|
||||
isAllocateAction,
|
||||
isContractNavAction,
|
||||
listRowHasActions,
|
||||
type BookingActionContext,
|
||||
@@ -20,12 +21,14 @@ interface BookingActionsMenuProps {
|
||||
className?: string;
|
||||
/** Suppresses table row navigation after menu/dialog close (click-through). */
|
||||
onSuppressRowClick?: () => void;
|
||||
onAllocateBooking?: () => void;
|
||||
}
|
||||
|
||||
export function BookingActionsMenu({
|
||||
row,
|
||||
variant = "table",
|
||||
onSuppressRowClick,
|
||||
onAllocateBooking,
|
||||
}: BookingActionsMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
@@ -34,6 +37,7 @@ export function BookingActionsMenu({
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: row.reference,
|
||||
approvalSteps: row.approvalSteps,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
};
|
||||
|
||||
const flow = useBookingActionDialog(row.id, context);
|
||||
@@ -46,6 +50,8 @@ export function BookingActionsMenu({
|
||||
onSuppressRowClick?.();
|
||||
if (isContractNavAction(action.id)) {
|
||||
goToContract();
|
||||
} else if (isAllocateAction(action.id)) {
|
||||
onAllocateBooking?.();
|
||||
} else {
|
||||
flow.openAction(action);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||
import { Stack, Text, Button } from "@mantine/core";
|
||||
|
||||
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
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>;
|
||||
@@ -16,8 +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();
|
||||
@@ -98,7 +106,11 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
<BookingActionsMenu
|
||||
row={row}
|
||||
variant="toolbar"
|
||||
onAllocateBooking={() => setAllocateOpen(true)}
|
||||
/>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -118,6 +130,14 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
</Button>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{canAllocate && canAllocateBooking(booking) ? (
|
||||
<AllocateBookingWizard
|
||||
booking={booking}
|
||||
opened={allocateOpen}
|
||||
onClose={() => setAllocateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LayoutList,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
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";
|
||||
|
||||
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
|
||||
const PIPELINE_STAGES: Array<{
|
||||
key: keyof BookingListSummaryTabs;
|
||||
label: string;
|
||||
color: string;
|
||||
}> = [
|
||||
{ key: "intake", label: "Intake", color: "#38bdf8" },
|
||||
{ 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: "#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;
|
||||
loading?: boolean;
|
||||
isFetching?: boolean;
|
||||
onCreate: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function BookingRequestsHeader({
|
||||
metrics,
|
||||
tabs,
|
||||
loading,
|
||||
isFetching,
|
||||
onCreate,
|
||||
onRefresh,
|
||||
}: BookingRequestsHeaderProps) {
|
||||
const val = (n?: number) => (loading ? "—" : (n ?? 0));
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<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,
|
||||
accent = "emerald",
|
||||
variant = "area",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
ratio?: number;
|
||||
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, ...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>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 }));
|
||||
const total = segments.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="lg" style={CARD_STYLE}>
|
||||
<Group justify="space-between" mb={10}>
|
||||
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
|
||||
Booking pipeline
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} active
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
height: 14,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
background: "var(--mantine-color-gray-2)",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{total > 0 ? (
|
||||
segments.map((s) =>
|
||||
s.count > 0 ? (
|
||||
<Box
|
||||
key={s.key}
|
||||
title={`${s.label}: ${s.count}`}
|
||||
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
|
||||
/>
|
||||
) : null,
|
||||
)
|
||||
) : (
|
||||
<Box style={{ width: "100%" }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group gap="md" mt={10} wrap="wrap">
|
||||
{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="dimmed">
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
|
||||
{s.count}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,22 +8,23 @@ import {
|
||||
Wallet,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
|
||||
import { Badge, Tabs } from "@mantine/core";
|
||||
|
||||
import {
|
||||
BOOKING_LIST_TABS,
|
||||
type BookingStatusTabKey,
|
||||
} from "@/features/bookings/booking-status.config";
|
||||
import "@/components/overview/overview.css";
|
||||
|
||||
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
||||
all: <LayoutGrid size={18} strokeWidth={1.75} />,
|
||||
intake: <Inbox size={18} strokeWidth={1.75} />,
|
||||
in_approval: <ClipboardCheck size={18} strokeWidth={1.75} />,
|
||||
approved_contract: <FileSignature size={18} strokeWidth={1.75} />,
|
||||
payment: <Wallet size={18} strokeWidth={1.75} />,
|
||||
operations: <Train size={18} strokeWidth={1.75} />,
|
||||
completed: <CheckCircle size={18} strokeWidth={1.75} />,
|
||||
closed: <XCircle size={18} strokeWidth={1.75} />,
|
||||
all: <LayoutGrid size={17} strokeWidth={1.85} />,
|
||||
intake: <Inbox size={17} strokeWidth={1.85} />,
|
||||
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
||||
payment: <Wallet size={17} strokeWidth={1.85} />,
|
||||
operations: <Train size={17} strokeWidth={1.85} />,
|
||||
completed: <CheckCircle size={17} strokeWidth={1.85} />,
|
||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
||||
};
|
||||
|
||||
interface BookingStatusTabsProps {
|
||||
@@ -38,74 +39,47 @@ export function BookingStatusTabs({
|
||||
counts,
|
||||
}: BookingStatusTabsProps) {
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="md"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
scrollBehavior: "smooth",
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
<Tabs
|
||||
value={active}
|
||||
onChange={(value) => onChange((value as BookingStatusTabKey) ?? "all")}
|
||||
variant="pills"
|
||||
color="green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
{BOOKING_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={tab.key}
|
||||
onClick={() => onChange(tab.key)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: isActive ? "white" : "transparent",
|
||||
border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 16px",
|
||||
transition: "all 0.2s ease",
|
||||
cursor: "pointer",
|
||||
boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
<Group gap={8}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "8px",
|
||||
background: isActive ? "var(--freight-brand-muted)" : "var(--mantine-color-gray-1)",
|
||||
color: isActive ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{TAB_ICONS[tab.key]}
|
||||
</div>
|
||||
<Text size="sm" fw={600}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Group>
|
||||
{count !== undefined && count > 0 && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={isActive ? "filled" : "light"}
|
||||
color={isActive ? "green" : "gray"}
|
||||
radius="lg"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
<Tabs.List>
|
||||
{BOOKING_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
leftSection={TAB_ICONS[tab.key]}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowRight, Building2, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
|
||||
|
||||
function BookingQueueRow({
|
||||
booking,
|
||||
selected,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
booking: BookingListRow;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs">
|
||||
<Package size={14} />
|
||||
<Text fw={600} size="sm">{booking.reference}</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
|
||||
Government
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
|
||||
{booking.schedulingStatus ? (
|
||||
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
|
||||
<Group gap={6}>
|
||||
<Text size="xs">{booking.originLabel}</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="xs">{booking.destinationLabel}</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.serviceTypeLabel ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.serviceTypeLabel}
|
||||
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperationsBookingQueue({
|
||||
bookings,
|
||||
isLoading,
|
||||
onAllocate,
|
||||
}: {
|
||||
bookings: BookingListRow[];
|
||||
isLoading?: boolean;
|
||||
onAllocate: (bookingIds: string[]) => void;
|
||||
}) {
|
||||
const { government, commercial } = useMemo(
|
||||
() => groupBookingsForOperationsQueue(bookings),
|
||||
[bookings],
|
||||
);
|
||||
const [govSelected, setGovSelected] = useState<string[]>([]);
|
||||
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
|
||||
|
||||
const allocatable = (row: BookingListRow) =>
|
||||
row.status === "PAID" &&
|
||||
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
|
||||
|
||||
const govSelection = govSelected.length
|
||||
? govSelected
|
||||
: government.filter(allocatable).map((b) => b.id);
|
||||
|
||||
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
|
||||
const existing = selectedByBucket[bucketKey];
|
||||
if (existing) return existing;
|
||||
return bucketBookings.filter(allocatable).map((b) => b.id);
|
||||
};
|
||||
|
||||
const toggleGov = (bookingId: string) => {
|
||||
setGovSelected((prev) => {
|
||||
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
|
||||
return base.includes(bookingId)
|
||||
? base.filter((id) => id !== bookingId)
|
||||
: [...base, bookingId];
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBucket = (bucketKey: string, bookingId: string) => {
|
||||
setSelectedByBucket((prev) => {
|
||||
const current = prev[bucketKey] ?? [];
|
||||
const next = current.includes(bookingId)
|
||||
? current.filter((id) => id !== bookingId)
|
||||
: [...current, bookingId];
|
||||
return { ...prev, [bucketKey]: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Text size="sm" c="dimmed">Loading operations queue…</Text>;
|
||||
}
|
||||
|
||||
if (!government.length && !commercial.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No PAID bookings ready to allocate.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{government.length > 0 ? (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={5}>Government priority</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Served first — not grouped by 3-hour window
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{govSelection.length} selected</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="violet"
|
||||
disabled={!govSelection.length}
|
||||
onClick={() => onAllocate(govSelection)}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{government.map((booking) => (
|
||||
<BookingQueueRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
selected={govSelection.includes(booking.id)}
|
||||
disabled={!allocatable(booking)}
|
||||
onToggle={() => toggleGov(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{commercial.length > 0 ? (
|
||||
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
|
||||
{commercial.map((bucket) => {
|
||||
const selected = bucketSelection(bucket.key, bucket.bookings);
|
||||
return (
|
||||
<Accordion.Item key={bucket.key} value={bucket.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">{bucket.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{bucket.bookings.length} commercial booking
|
||||
{bucket.bookings.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{selected.length} selected</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
disabled={!selected.length}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocate(selected);
|
||||
}}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{bucket.bookings.map((booking) => (
|
||||
<BookingQueueRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
selected={selected.includes(booking.id)}
|
||||
disabled={!allocatable(booking)}
|
||||
onToggle={() => toggleBucket(bucket.key, booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
export function OperationsScheduledBookings({
|
||||
bookings,
|
||||
isLoading,
|
||||
}: {
|
||||
bookings: BookingListRow[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const columns: ColumnDef<BookingListRow>[] = [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Booking",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Group gap={6}>
|
||||
<Text fw={600} size="sm">{row.original.reference}</Text>
|
||||
{row.original.isGovernment ? (
|
||||
<Badge color="violet" size="xs">Government</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{row.original.customerLabel}</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Route",
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6}>
|
||||
<Text size="sm">{row.original.originLabel}</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="sm">{row.original.destinationLabel}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: "Scheduled",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{String(row.original.scheduledDate).slice(0, 16)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Scheduling",
|
||||
cell: ({ row }) =>
|
||||
row.original.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={row.original.schedulingStatus} />
|
||||
) : (
|
||||
<Badge variant="light">—</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${row.original.id}`}
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
>
|
||||
View booking
|
||||
</Button>
|
||||
{row.original.trainScheduleId ? (
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${row.original.trainScheduleId}`}
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
>
|
||||
Train schedule
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={bookings}
|
||||
status={isLoading ? "loading" : "success"}
|
||||
emptyMessage="No bookings currently assigned to a train schedule"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCar
|
||||
<SectionCard
|
||||
icon={CheckCircle}
|
||||
title="Approval Workflow"
|
||||
accent="green"
|
||||
extra={
|
||||
<Badge color="green" variant="light" radius="sm">
|
||||
{approvedCount} / {steps.length} approved
|
||||
|
||||
@@ -15,7 +15,7 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications">
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
<MetricTile
|
||||
label="Cargo type"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export function BookingContainersCard({ containers }: BookingContainersCardProps
|
||||
<SectionCard
|
||||
icon={Boxes}
|
||||
title="Containers & Cargo"
|
||||
accent="teal"
|
||||
extra={
|
||||
<Badge color="gray" variant="light" radius="sm">
|
||||
{containers.length} line{containers.length === 1 ? "" : "s"}
|
||||
@@ -41,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>
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BookingContractSummaryCardProps {
|
||||
/** Generated contract terms, shown verbatim. */
|
||||
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
|
||||
return (
|
||||
<SectionCard icon={Anchor} title="Contract summary">
|
||||
<SectionCard icon={Anchor} title="Contract summary" accent="teal">
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -15,6 +15,7 @@ export function BookingDocumentsCard({ files, onDownload }: BookingDocumentsCard
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Documents"
|
||||
accent="indigo"
|
||||
extra={
|
||||
<Badge color="gray" variant="light" radius="sm">
|
||||
{files.length}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<SectionCard icon={Hash} title="Booking Details">
|
||||
<SectionCard icon={Hash} title="Booking Details" accent="cyan">
|
||||
<Stack gap={0}>
|
||||
{facts.map((fact, index) => (
|
||||
<div key={fact.label}>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard icon={Truck} title="Mile services">
|
||||
<SectionCard icon={Truck} title="Mile services" accent="grape">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||
{booking.firstMilePickupAddress && (
|
||||
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import { Building2, Calendar, Clock, RefreshCw, ArrowLeft } from "lucide-react";
|
||||
import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Container as ContainerIcon,
|
||||
Flame,
|
||||
RefreshCw,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
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 { detailStyles, formatDate } from "./booking-detail.styles";
|
||||
import { formatDate } from "./booking-detail.styles";
|
||||
|
||||
export interface BookingRequestHeroProps {
|
||||
booking: BookingDetail;
|
||||
@@ -16,7 +29,7 @@ export interface BookingRequestHeroProps {
|
||||
isFetching?: boolean;
|
||||
}
|
||||
|
||||
/** Top hero for the request detail page: identity, status, next step, total value. */
|
||||
/** Top hero for the request detail page: identity, status, next step, key figures. */
|
||||
export function BookingRequestHero({
|
||||
booking,
|
||||
customerLabel,
|
||||
@@ -25,85 +38,180 @@ export function BookingRequestHero({
|
||||
isFetching,
|
||||
}: BookingRequestHeroProps) {
|
||||
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);
|
||||
|
||||
return (
|
||||
<Paper radius="md" withBorder p="xl" style={detailStyles.card}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={onBack}
|
||||
mb="md"
|
||||
ml={-8}
|
||||
fw={600}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.06em">
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</Group>
|
||||
|
||||
{booking.nextStep && (
|
||||
<Box maw={520}>
|
||||
<NextStepBanner nextStep={booking.nextStep} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Group gap="lg" mt={4}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Building2 size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={500}>
|
||||
{customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Calendar size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Scheduled {booking.scheduledDate}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Clock size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Created {formatDate(booking.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm" align="flex-end">
|
||||
<Paper radius="md" withBorder p="md" miw={200} style={detailStyles.highlightCard}>
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em" ta="right">
|
||||
Total value
|
||||
</Text>
|
||||
<Text size="xl" fw={700} c="green.9" ta="right" mt={4} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{booking.paymentCurrency}{" "}
|
||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="right" mt={2}>
|
||||
{booking.paymentStatus}
|
||||
</Text>
|
||||
</Paper>
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={onBack}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<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} fw={700} style={{ letterSpacing: "-0.4px", color: "#0f172a" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} strong />
|
||||
<MetaItem icon={Calendar} text={`Scheduled ${booking.scheduledDate}`} />
|
||||
<MetaItem icon={Clock} text={`Created ${formatDate(booking.createdAt)}`} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{booking.nextStep ? (
|
||||
<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}
|
||||
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<HeroTile
|
||||
icon={Wallet}
|
||||
label="Total value"
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
accent="green"
|
||||
/>
|
||||
<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>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
strong,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
text: ReactNode;
|
||||
strong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
accent = "green",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
flex: "1 1 160px",
|
||||
minWidth: 150,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap", color: "#0f172a" }}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface BookingReviewNotesCardProps {
|
||||
export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<SectionCard icon={MessageSquare} title="Review Notes">
|
||||
<SectionCard icon={MessageSquare} title="Review Notes" accent="grape">
|
||||
<Text size="sm" c="dimmed">
|
||||
No review notes have been added yet.
|
||||
</Text>
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BookingRouteCardProps {
|
||||
|
||||
export function BookingRouteCard({ booking }: BookingRouteCardProps) {
|
||||
return (
|
||||
<SectionCard icon={MapPin} title="Shipment Route">
|
||||
<SectionCard icon={MapPin} title="Shipment Route" accent="blue">
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="xl">
|
||||
{/* Origin */}
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function BookingRouteServiceCard({
|
||||
];
|
||||
|
||||
return (
|
||||
<SectionCard icon={Train} title="Route & service">
|
||||
<SectionCard icon={Train} title="Route & service" accent="blue">
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||
<Endpoint label="Origin" station={originLabel} />
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,20 +7,68 @@ import { detailStyles } from "./booking-detail.styles";
|
||||
export interface SectionCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
/** Optional one-line context shown under the title. */
|
||||
subtitle?: string;
|
||||
/** Mantine palette key used to tint the icon chip + top accent (default green). */
|
||||
accent?: string;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Consistent flat card with a minimal icon + title header used by every detail section. */
|
||||
export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) {
|
||||
/** Consistent card with a colored icon chip + accent stripe header used by every detail section. */
|
||||
export function SectionCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
accent = "green",
|
||||
extra,
|
||||
children,
|
||||
}: SectionCardProps) {
|
||||
return (
|
||||
<Paper radius="md" withBorder style={detailStyles.card}>
|
||||
<Group justify="space-between" px="xl" py="md" style={detailStyles.cardHeader}>
|
||||
<Group gap="sm">
|
||||
<Icon size={16} color="var(--mantine-color-gray-6)" />
|
||||
<Text fw={600} size="sm" c="dark">
|
||||
{title}
|
||||
</Text>
|
||||
<Paper radius="md" withBorder style={{ ...detailStyles.card, overflow: "hidden" }}>
|
||||
<Box
|
||||
style={{
|
||||
height: 3,
|
||||
background: `linear-gradient(90deg, var(--mantine-color-${accent}-5) 0%, var(--mantine-color-${accent}-7) 100%)`,
|
||||
}}
|
||||
/>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="xl"
|
||||
py="md"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
...detailStyles.cardHeader,
|
||||
background: `linear-gradient(180deg, var(--mantine-color-${accent}-0) 0%, white 100%)`,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: `var(--mantine-color-${accent}-1)`,
|
||||
color: `var(--mantine-color-${accent}-7)`,
|
||||
border: `1px solid var(--mantine-color-${accent}-2)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" c="dark" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Group>
|
||||
{extra}
|
||||
</Group>
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@edr/ui-common';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useContainers, useAssignContainerToWagon } from './use-containers';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
export function AssignContainerDialog({ wagonId }: { wagonId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [containerId, setContainerId] = useState('');
|
||||
const [position, setPosition] = useState<number>();
|
||||
const { data: containers } = useContainers();
|
||||
const assign = useAssignContainerToWagon();
|
||||
const { toast } = useToast();
|
||||
|
||||
const available = containers?.filter(c => c.status === 'AVAILABLE' && !c.wagonId);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!containerId) return;
|
||||
await assign.mutateAsync({ containerId, wagonId, position });
|
||||
toast({ title: 'Assigned', description: 'Container placed on wagon.' });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />Assign Container</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Assign Container to Wagon</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Container</Label><Select value={containerId} onValueChange={setContainerId}><SelectTrigger><SelectValue placeholder="Select container" /></SelectTrigger><SelectContent>{available?.map(c => <SelectItem key={c.id} value={c.id}>{c.containerNumber}</SelectItem>)}</SelectContent></Select></div>
|
||||
<div><Label>Position (optional)</Label><Input type="number" value={position ?? ''} onChange={e => setPosition(parseInt(e.target.value) || undefined)} /></div>
|
||||
<Button onClick={handleAssign} disabled={assign.isPending}>Assign</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@edr/ui-common';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCargoTypes } from './use-cargo-types';
|
||||
import { useCargoMutations } from './use-cargoes';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
cargoNumber: string;
|
||||
cargoTypeId: string;
|
||||
weight: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface CargoFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cargo?: Cargo | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function CargoFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
cargo,
|
||||
onSuccess,
|
||||
}: CargoFormDialogProps) {
|
||||
const { data: cargoTypes } = useCargoTypes();
|
||||
const { createCargo, updateCargo } = useCargoMutations();
|
||||
const [formData, setFormData] = useState<Partial<Cargo>>({
|
||||
cargoNumber: '',
|
||||
cargoTypeId: '',
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cargo) {
|
||||
setFormData(cargo);
|
||||
} else {
|
||||
setFormData({
|
||||
cargoNumber: '',
|
||||
cargoTypeId: '',
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
}
|
||||
}, [cargo, open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (cargo?.id) {
|
||||
updateCargo.mutate(
|
||||
{ id: cargo.id, data: formData },
|
||||
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
|
||||
);
|
||||
} else {
|
||||
createCargo.mutate(formData, {
|
||||
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createCargo.isPending || updateCargo.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader><DialogTitle>{cargo ? 'Edit Cargo' : 'Create New Cargo'}</DialogTitle></DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Cargo Number *</Label>
|
||||
<Input value={formData.cargoNumber} onChange={e => setFormData({...formData, cargoNumber: e.target.value})} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Cargo Type *</Label>
|
||||
<Select
|
||||
value={formData.cargoTypeId || ''}
|
||||
onValueChange={(val) => setFormData({ ...formData, cargoTypeId: val })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select cargo type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cargoTypes?.map((type: any) => (
|
||||
<SelectItem key={type.id} value={type.id}>{type.cargo_type_name || type.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Weight (kg) *</Label>
|
||||
<Input type="number" value={formData.weight} onChange={e => setFormData({...formData, weight: parseFloat(e.target.value)})} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Remarks</Label>
|
||||
<Textarea value={formData.remarks} onChange={e => setFormData({...formData, remarks: e.target.value})} rows={3} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}{cargo ? 'Update' : 'Create'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@edr/ui-common';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useContainerTypes } from './use-container-types';
|
||||
import { useContainerMutations } from './use-containers';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Container {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
containerTypeId: string;
|
||||
wagonId?: string;
|
||||
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
|
||||
capacity: number;
|
||||
weight: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface Wagon {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
}
|
||||
|
||||
interface ContainerFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
container?: Container | null;
|
||||
wagons: Wagon[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function ContainerFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
container,
|
||||
wagons = [],
|
||||
onSuccess,
|
||||
}: ContainerFormDialogProps) {
|
||||
const { data: containerTypes } = useContainerTypes();
|
||||
const { createContainer, updateContainer } = useContainerMutations();
|
||||
const [formData, setFormData] = useState<Partial<Container>>({
|
||||
containerNumber: '',
|
||||
containerTypeId: '',
|
||||
status: 'AVAILABLE',
|
||||
capacity: 0,
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (container) {
|
||||
setFormData(container);
|
||||
} else {
|
||||
setFormData({
|
||||
containerNumber: '',
|
||||
containerTypeId: '',
|
||||
status: 'AVAILABLE',
|
||||
capacity: 0,
|
||||
weight: 0,
|
||||
remarks: '',
|
||||
});
|
||||
}
|
||||
}, [container, open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.containerNumber || !formData.containerTypeId) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
if (container?.id) {
|
||||
updateContainer.mutate(
|
||||
{ id: container.id, data: formData },
|
||||
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
|
||||
);
|
||||
} else {
|
||||
createContainer.mutate(formData, {
|
||||
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = createContainer.isPending || updateContainer.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{container ? 'Edit Container' : 'Create New Container'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="containerNumber">Container Number *</Label>
|
||||
<Input
|
||||
id="containerNumber"
|
||||
value={formData.containerNumber || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, containerNumber: e.target.value })
|
||||
}
|
||||
placeholder="e.g., CNT001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="containerTypeId">Container Type *</Label>
|
||||
<Select
|
||||
value={formData.containerTypeId || ''}
|
||||
onValueChange={(val:any) => setFormData({ ...formData, containerTypeId: val })}
|
||||
>
|
||||
<SelectTrigger id="containerTypeId">
|
||||
<SelectValue placeholder="Select container type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containerTypes?.map((type: any) => (
|
||||
<SelectItem key={type.id} value={type.id}>{type.name || type.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="wagonId">Wagon (Optional)</Label>
|
||||
<Select
|
||||
value={formData.wagonId || 'none'}
|
||||
onValueChange={(val:any) => setFormData({ ...formData, wagonId: val === 'none' ? undefined : val })}
|
||||
>
|
||||
<SelectTrigger id="wagonId">
|
||||
<SelectValue placeholder="Select a wagon..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{wagons.map((wagon) => (
|
||||
<SelectItem key={wagon.id} value={wagon.id}>
|
||||
{wagon.wagonNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={formData.status || 'AVAILABLE'}
|
||||
onValueChange={(val:any) => setFormData({ ...formData, status: val })}
|
||||
>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AVAILABLE">Available</SelectItem>
|
||||
<SelectItem value="IN_USE">In Use</SelectItem>
|
||||
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
|
||||
<SelectItem value="RETIRED">Retired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="capacity">Capacity *</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
type="number"
|
||||
value={formData.capacity || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
capacity: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="weight">Weight (kg)</Label>
|
||||
<Input
|
||||
id="weight"
|
||||
type="number"
|
||||
value={formData.weight || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
weight: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="remarks">Remarks</Label>
|
||||
<Textarea
|
||||
id="remarks"
|
||||
value={formData.remarks || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, remarks: e.target.value })
|
||||
}
|
||||
placeholder="Add any additional notes..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{container ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useContainersByWagon, useUnassignContainer } from './use-containers';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import type { Container } from './container.service';
|
||||
|
||||
export function ContainersTable({ wagonId }: { wagonId: string }) {
|
||||
const { data: containers, refetch } = useContainersByWagon(wagonId);
|
||||
const unassign = useUnassignContainer();
|
||||
|
||||
if (!containers?.length) return <div className="text-muted-foreground">No containers assigned.</div>;
|
||||
|
||||
return (
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Number</th>
|
||||
<th className="text-left">Type</th>
|
||||
<th className="text-left">Position</th>
|
||||
<th className="text-left">Status</th>
|
||||
<th className="text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((container: Container) => (
|
||||
<tr key={container.id}>
|
||||
<td className="py-2">{container.containerNumber}</td>
|
||||
<td className="py-2">{container.containerTypeId}</td>
|
||||
<td className="py-2">{container.position}</td>
|
||||
<td className="py-2">{container.status}</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => unassign.mutateAsync(container.id).then(() => refetch())}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { api } from '../../auth/http';
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const cargoTypesService = {
|
||||
async getCargoTypes() {
|
||||
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
|
||||
params: { isActive: true, pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { api } from "../../auth/http";
|
||||
|
||||
export const cargoService = {
|
||||
async getCargoes() {
|
||||
const response = await api.get('/cargoes');
|
||||
return response.data;
|
||||
},
|
||||
async createCargo(data: any) {
|
||||
const response = await api.post('/cargoes', data);
|
||||
return response.data;
|
||||
},
|
||||
async updateCargo(id: string, data: any) {
|
||||
const response = await api.patch(`/cargoes/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
async deleteCargo(id: string) {
|
||||
await api.delete(`/cargoes/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { api } from '../../auth/http';
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const containerTypesService = {
|
||||
async getContainerTypes() {
|
||||
const response = await api.get<ListResponse<unknown>>('/container-types', {
|
||||
params: { isActive: true, pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { api } from "../../auth/http";
|
||||
|
||||
export const containerService = {
|
||||
async getContainers() {
|
||||
const response = await api.get('/containers');
|
||||
return response.data;
|
||||
},
|
||||
async getContainersByWagon(wagonId: string) {
|
||||
const response = await api.get('/containers', { params: { wagonId } });
|
||||
return response.data;
|
||||
},
|
||||
async createContainer(data: any) {
|
||||
const response = await api.post('/containers', data);
|
||||
return response.data;
|
||||
},
|
||||
async updateContainer(id: string, data: any) {
|
||||
const response = await api.patch(`/containers/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
async deleteContainer(id: string) {
|
||||
await api.delete(`/containers/${id}`);
|
||||
},
|
||||
async assignToWagon(containerId: string, wagonId: string, position?: number) {
|
||||
const response = await api.post(`/containers/${containerId}/assign-wagon`, { wagonId, position });
|
||||
return response.data;
|
||||
},
|
||||
async unassignFromWagon(containerId: string) {
|
||||
const response = await api.post(`/containers/${containerId}/unassign-wagon`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { cargoTypesService } from './cargo-types.service';
|
||||
|
||||
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
|
||||
|
||||
export function useCargoTypes() {
|
||||
return useQuery({
|
||||
queryKey: CARGO_TYPES_QUERY_KEY,
|
||||
queryFn: () => cargoTypesService.getCargoTypes(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { cargoService } from './cargo.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const CARGOES_QUERY_KEY = ['cargoes'];
|
||||
|
||||
export function useCargoes() {
|
||||
return useQuery({
|
||||
queryKey: CARGOES_QUERY_KEY,
|
||||
queryFn: () => cargoService.getCargoes(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCargoMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createCargo = useMutation({
|
||||
mutationFn: (data: any) => cargoService.createCargo(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
||||
toast.success('Cargo created successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const updateCargo = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => cargoService.updateCargo(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
||||
toast.success('Cargo updated successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCargo = useMutation({
|
||||
mutationFn: (id: string) => cargoService.deleteCargo(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
|
||||
toast.success('Cargo deleted successfully');
|
||||
},
|
||||
});
|
||||
|
||||
return { createCargo, updateCargo, deleteCargo };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { containerTypesService } from './container-types.service';
|
||||
|
||||
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
|
||||
|
||||
export function useContainerTypes() {
|
||||
return useQuery({
|
||||
queryKey: CONTAINER_TYPES_QUERY_KEY,
|
||||
queryFn: () => containerTypesService.getContainerTypes(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { containerService } from './container.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const CONTAINERS_QUERY_KEY = ['containers'];
|
||||
|
||||
export function useContainers() {
|
||||
return useQuery({
|
||||
queryKey: CONTAINERS_QUERY_KEY,
|
||||
queryFn: () => containerService.getContainers(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContainersByWagon(wagonId: string) {
|
||||
return useQuery({
|
||||
queryKey: [...CONTAINERS_QUERY_KEY, 'wagon', wagonId],
|
||||
queryFn: () => containerService.getContainersByWagon(wagonId),
|
||||
enabled: !!wagonId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContainerMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createContainer = useMutation({
|
||||
mutationFn: (data: any) => containerService.createContainer(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container created successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const updateContainer = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => containerService.updateContainer(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container updated successfully');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteContainer = useMutation({
|
||||
mutationFn: (id: string) => containerService.deleteContainer(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container deleted successfully');
|
||||
},
|
||||
});
|
||||
|
||||
return { createContainer, updateContainer, deleteContainer };
|
||||
}
|
||||
|
||||
export function useUnassignContainer() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => containerService.unassignFromWagon(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container unassigned from wagon');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignContainerToWagon() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ containerId, wagonId, position }: { containerId: string; wagonId: string; position?: number }) =>
|
||||
containerService.assignToWagon(containerId, wagonId, position),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
|
||||
toast.success('Container assigned to wagon');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { wagonTypesService } from './wagon-types.service';
|
||||
|
||||
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
|
||||
|
||||
export function useWagonTypes() {
|
||||
return useQuery({
|
||||
queryKey: WAGON_TYPES_QUERY_KEY,
|
||||
queryFn: () => wagonTypesService.getWagonTypes(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { wagonService } from './wagon.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const WAGONS_QUERY_KEY = ['wagons'];
|
||||
|
||||
export function useWagons() {
|
||||
return useQuery({
|
||||
queryKey: WAGONS_QUERY_KEY,
|
||||
queryFn: () => wagonService.getWagons(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWagonMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createWagon = useMutation({
|
||||
mutationFn: (data: any) => wagonService.createWagon(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
|
||||
toast.success('Wagon created successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to create wagon');
|
||||
},
|
||||
});
|
||||
|
||||
const updateWagon = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => wagonService.updateWagon(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
|
||||
toast.success('Wagon updated successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update wagon');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteWagon = useMutation({
|
||||
mutationFn: (id: string) => wagonService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
|
||||
toast.success('Wagon deleted successfully');
|
||||
},
|
||||
});
|
||||
|
||||
return { createWagon, updateWagon, deleteWagon };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { api } from '../../auth/http';
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const wagonTypesService = {
|
||||
async getWagonTypes() {
|
||||
const response = await api.get<ListResponse<unknown>>('/wagon-types');
|
||||
return asList(response.data);
|
||||
},
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { api } from "../../auth/http";
|
||||
|
||||
export const wagonService = {
|
||||
async getWagons() {
|
||||
const response = await api.get('/wagons');
|
||||
return response.data;
|
||||
},
|
||||
async getWagonById(id: string) {
|
||||
const response = await api.get(`/wagons/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
async createWagon(data: any) {
|
||||
const response = await api.post('/wagons', data);
|
||||
return response.data;
|
||||
},
|
||||
async updateWagon(id: string, data: any) {
|
||||
const response = await api.patch(`/wagons/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
async deleteWagon(id: string) {
|
||||
await api.delete(`/wagons/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
import FleetRecordActions from "./FleetRecordActions";
|
||||
import { cardInitials, resolveFleetCardPresentation } from "./fleetCardMeta";
|
||||
import { formatFleetCell } from "./fleetFormat";
|
||||
import RuleEngineListFooter from "../ruleEngine/RuleEngineListFooter";
|
||||
|
||||
export interface FleetCardGridProps {
|
||||
config: FleetResourceConfig;
|
||||
rows: FleetRecord[];
|
||||
status: "loading" | "error" | "success";
|
||||
emptyMessage: string;
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
}
|
||||
|
||||
const FleetCardGrid = ({
|
||||
config,
|
||||
rows,
|
||||
status,
|
||||
emptyMessage,
|
||||
pagination,
|
||||
pageCount,
|
||||
totalCount,
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: FleetCardGridProps) => {
|
||||
const presentation = resolveFleetCardPresentation(config);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<Text size="sm" c="red" py="xl" ta="center">
|
||||
Failed to load data
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
|
||||
{rows.map((record) => {
|
||||
const title = String(
|
||||
(record as unknown as Record<string, unknown>)[presentation.titleKey] ?? config.entityLabel,
|
||||
);
|
||||
const code = presentation.codeKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.codeKey]
|
||||
: null;
|
||||
const subtitle = presentation.subtitleKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.subtitleKey]
|
||||
: null;
|
||||
const statusValue = presentation.statusKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.statusKey]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={String((record as { id: string }).id)}
|
||||
radius="lg"
|
||||
padding="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{cardInitials(title)}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{title || "—"}
|
||||
</Text>
|
||||
{subtitle != null && subtitle !== "" ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{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">
|
||||
{String(code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Stack gap={6}>
|
||||
{config.columns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.accessorKey !== presentation.titleKey &&
|
||||
col.accessorKey !== presentation.codeKey &&
|
||||
col.accessorKey !== presentation.statusKey,
|
||||
)
|
||||
.slice(0, 4)
|
||||
.map((col) => (
|
||||
<Group key={col.id} justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{col.header}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{formatFleetCell(
|
||||
(record as unknown as Record<string, unknown>)[col.accessorKey],
|
||||
col.format,
|
||||
col.accessorKey,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{statusValue != null ? (
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Status
|
||||
</Text>
|
||||
{formatFleetCell(statusValue, "statusBadge")}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<FleetRecordActions
|
||||
record={record}
|
||||
config={config}
|
||||
layout="compact"
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
itemLabel={config.entityLabel.toLowerCase() + "s"}
|
||||
onPaginationChange={onPaginationChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetCardGrid;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
fields: FleetFormFieldDef[];
|
||||
initialRecord?: FleetRecord | null;
|
||||
emptyValues: Record<string, unknown>;
|
||||
isSubmitting: boolean;
|
||||
selectOptionsLoading?: boolean;
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = (
|
||||
fields: FleetFormFieldDef[],
|
||||
emptyValues: Record<string, unknown>,
|
||||
record?: FleetRecord | null,
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = { ...emptyValues };
|
||||
if (!record) return values;
|
||||
|
||||
fields.forEach((field) => {
|
||||
const raw = (record as unknown as Record<string, unknown>)[field.name];
|
||||
if (raw === null || raw === undefined) {
|
||||
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
|
||||
return;
|
||||
}
|
||||
values[field.name] = raw;
|
||||
});
|
||||
return values;
|
||||
};
|
||||
|
||||
const FleetFormDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
fields,
|
||||
initialRecord,
|
||||
emptyValues,
|
||||
isSubmitting,
|
||||
selectOptionsLoading,
|
||||
onSubmit,
|
||||
}: FleetFormDialogProps) => {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, fields, emptyValues, initialRecord]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
[fields],
|
||||
);
|
||||
const longFields = useMemo(
|
||||
() => fields.filter((f) => f.type === "textarea"),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
fields.forEach((field) => {
|
||||
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`;
|
||||
}
|
||||
});
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => {
|
||||
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined];
|
||||
return [key, value];
|
||||
})
|
||||
.filter(([, value]) => value !== undefined),
|
||||
);
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
const renderField = (field: FleetFormFieldDef) => {
|
||||
const value = values[field.name];
|
||||
const error = errors[field.name];
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
data={field.options ?? []}
|
||||
value={value == null || value === "" ? (field.noneOption ? FLEET_SELECT_NONE : null) : String(value)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<NumberInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={value === "" || value == null ? "" : Number(value)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: next === "" ? "" : next,
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<Textarea
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: e.target?.value ?? "",
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
minRows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: e.target?.value ?? "",
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={<Text fw={600}>{title}</Text>}
|
||||
size="lg"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{shortFields.map(renderField)}
|
||||
</SimpleGrid>
|
||||
{longFields.map(renderField)}
|
||||
<Group justify="flex-end" gap="sm" mt="sm">
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" loading={isSubmitting} onClick={handleSubmit}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetFormDialog;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MoreHorizontal, Pencil, Trash2, Truck } from "lucide-react";
|
||||
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetRecordActionsProps {
|
||||
record: FleetRecord;
|
||||
config: FleetResourceConfig;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
layout?: "row" | "compact";
|
||||
}
|
||||
|
||||
const FleetRecordActions = ({
|
||||
record,
|
||||
config,
|
||||
onEdit,
|
||||
onRemove,
|
||||
layout = "row",
|
||||
}: FleetRecordActionsProps) => {
|
||||
const navigate = useNavigate();
|
||||
const removeLabel = config.removeActionLabel ?? "Delete";
|
||||
const showDetail = Boolean(config.detailPath && "id" in record);
|
||||
|
||||
const handleDetail = () => {
|
||||
if (!config.detailPath || !("id" in record)) return;
|
||||
navigate(config.detailPath.replace(":id", String(record.id)));
|
||||
};
|
||||
|
||||
if (layout === "compact") {
|
||||
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>
|
||||
) : 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>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" 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>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => onRemove(record)}>
|
||||
{removeLabel}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetRecordActions;
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||
import { Box, Button, Group, SegmentedControl, TextInput } from "@mantine/core";
|
||||
|
||||
import type { FleetViewMode } from "./useFleetViewMode";
|
||||
|
||||
export interface FleetToolbarProps {
|
||||
search?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
showSearch?: boolean;
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
viewMode: FleetViewMode;
|
||||
onViewModeChange: (mode: FleetViewMode) => void;
|
||||
/** Optional filters rendered beside search (status, freight type, etc.) */
|
||||
filters?: ReactNode;
|
||||
}
|
||||
|
||||
const FleetToolbar = ({
|
||||
search = "",
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search…",
|
||||
showSearch = true,
|
||||
onAdd,
|
||||
addLabel = "Add",
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
filters,
|
||||
}: FleetToolbarProps) => (
|
||||
<Box w="100%">
|
||||
<Group
|
||||
gap="md"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
style={{ flex: "1 1 280px", minWidth: 0 }}
|
||||
>
|
||||
{showSearch && onSearchChange ? (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={16} />}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
style={{ flex: "1 1 200px", minWidth: 180, maxWidth: 360 }}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
) : null}
|
||||
{filters ? (
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{filters}
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<SegmentedControl
|
||||
value={viewMode}
|
||||
onChange={(value) => onViewModeChange(value as FleetViewMode)}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="green"
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Table2 size={14} />
|
||||
<span>Table</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<LayoutGrid size={14} />
|
||||
<span>Cards</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
styles={{
|
||||
root: { background: "var(--mantine-color-gray-1)" },
|
||||
}}
|
||||
/>
|
||||
{onAdd ? (
|
||||
<Button
|
||||
color="green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
fw={600}
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={onAdd}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default FleetToolbar;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
|
||||
export interface FleetCardPresentation {
|
||||
titleKey: string;
|
||||
subtitleKey?: string;
|
||||
codeKey?: string;
|
||||
statusKey?: string;
|
||||
}
|
||||
|
||||
export const resolveFleetCardPresentation = (config: FleetResourceConfig): FleetCardPresentation => {
|
||||
const titleKey =
|
||||
config.cardTitleKey ??
|
||||
config.columns.find((col) => col.format !== "code" && col.accessorKey !== "status")
|
||||
?.accessorKey ??
|
||||
"id";
|
||||
|
||||
const codeKey =
|
||||
config.cardCodeKey ?? config.columns.find((col) => col.format === "code")?.accessorKey;
|
||||
|
||||
const statusKey = config.columns.find((col) => col.format === "statusBadge")?.accessorKey;
|
||||
|
||||
const subtitleKey =
|
||||
config.cardSubtitleKey ??
|
||||
config.columns.find(
|
||||
(col) =>
|
||||
col.accessorKey !== titleKey &&
|
||||
col.accessorKey !== codeKey &&
|
||||
col.accessorKey !== statusKey,
|
||||
)?.accessorKey;
|
||||
|
||||
return { titleKey, subtitleKey, codeKey, statusKey };
|
||||
};
|
||||
|
||||
export const cardInitials = (title: string) => {
|
||||
const parts = title.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
|
||||
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||
|
||||
export type FleetColumnFormat = ColumnFormat | "statusBadge";
|
||||
|
||||
const optionLabelMap = new Map<string, Map<string, string>>();
|
||||
|
||||
export const registerFleetOptionLabels = (
|
||||
fieldKey: string,
|
||||
options: { value: string; label: string }[],
|
||||
) => {
|
||||
optionLabelMap.set(fieldKey, new Map(options.map((o) => [o.value, o.label])));
|
||||
};
|
||||
|
||||
export const formatFleetCell = (
|
||||
value: unknown,
|
||||
format?: FleetColumnFormat,
|
||||
accessorKey?: string,
|
||||
): ReactNode => {
|
||||
if (format === "statusBadge") {
|
||||
const status = value == null || value === "" ? "—" : String(value);
|
||||
return (
|
||||
<Badge variant="light" color="gray" size="sm" radius="md">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (accessorKey && optionLabelMap.has(accessorKey)) {
|
||||
const label = optionLabelMap.get(accessorKey)?.get(String(value ?? ""));
|
||||
if (label) {
|
||||
return <Text size="sm">{label}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
|
||||
export type FleetViewMode = "table" | "cards";
|
||||
|
||||
const STORAGE_PREFIX = "edr-freight-fleet-view:";
|
||||
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2" | "batch-board";
|
||||
|
||||
const readStored = (slug: ViewModeSlug): FleetViewMode => {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${slug}`);
|
||||
return raw === "cards" ? "cards" : "table";
|
||||
} catch {
|
||||
return "table";
|
||||
}
|
||||
};
|
||||
|
||||
export const useFleetViewMode = (slug: ViewModeSlug) => {
|
||||
const [viewMode, setViewModeState] = useState<FleetViewMode>(() => readStored(slug));
|
||||
|
||||
useEffect(() => {
|
||||
setViewModeState(readStored(slug));
|
||||
}, [slug]);
|
||||
|
||||
const setViewMode = useCallback(
|
||||
(mode: FleetViewMode) => {
|
||||
setViewModeState(mode);
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${slug}`, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[slug],
|
||||
);
|
||||
|
||||
return { viewMode, setViewMode };
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/* ============================================================
|
||||
EDR Freight — Header styles
|
||||
============================================================ */
|
||||
|
||||
.fdh-root {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
|
||||
/* eyebrow above the page title */
|
||||
.fdh-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: #1B9E7A;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.fdh-eyebrow-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #2DBF95;
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
|
||||
}
|
||||
|
||||
/* action icon buttons */
|
||||
.fdh-icon-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #f7f9fb;
|
||||
border: 1px solid #eef1f4;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
.fdh-icon-btn:hover {
|
||||
background: #ffffff;
|
||||
border-color: rgba(27, 158, 122, 0.28);
|
||||
color: #1B9E7A;
|
||||
box-shadow: 0 4px 12px -4px rgba(27, 158, 122, 0.28);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.fdh-icon-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* notification badge */
|
||||
.fdh-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 2px 6px -1px rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
|
||||
.fdh-divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: #e9eef3;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* user button */
|
||||
.fdh-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 12px 5px 5px;
|
||||
border-radius: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
.fdh-user:hover {
|
||||
background: #f7f9fb;
|
||||
border-color: #eef1f4;
|
||||
}
|
||||
|
||||
.fdh-avatar-ring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%);
|
||||
box-shadow: 0 4px 10px -3px rgba(27, 158, 122, 0.4);
|
||||
}
|
||||
.fdh-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1B9E7A 0%, #15805F 100%);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
border: 2px solid #ffffff;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
Languages,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
@@ -9,10 +10,11 @@ import {
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core";
|
||||
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import "./FreightDashboardHeader.css";
|
||||
|
||||
export interface FreightDashboardHeaderProps {
|
||||
pageMeta: PageMeta;
|
||||
@@ -37,14 +39,16 @@ const FreightDashboardHeader = ({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
}: FreightDashboardHeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
const initials =
|
||||
userInitials ??
|
||||
userName
|
||||
(userName
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0].toUpperCase())
|
||||
.join("");
|
||||
.join("") ||
|
||||
"U");
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -74,133 +78,149 @@ const FreightDashboardHeader = ({
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "80px",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "16px",
|
||||
padding: "0 24px",
|
||||
// borderBottom: `3px solid ${freightBrand.primary}`,
|
||||
}}
|
||||
>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
|
||||
<header className="fdh-root">
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="fdh-eyebrow">
|
||||
{/* <span className="fdh-eyebrow-dot" />
|
||||
Freight Backoffice */}
|
||||
</span>
|
||||
<Text
|
||||
fw={700}
|
||||
truncate
|
||||
style={{ fontSize: "20px", lineHeight: 1.2, color: "#0f172a", letterSpacing: "-0.4px" }}
|
||||
>
|
||||
{pageMeta.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
|
||||
{pageMeta.subtitle}
|
||||
</Text>
|
||||
</Text> */}
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap">
|
||||
{enableThemeToggle && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
onClick={onToggleTheme}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
<Tooltip
|
||||
label={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
withArrow
|
||||
openDelay={300}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</ActionIcon>
|
||||
<button
|
||||
type="button"
|
||||
className="fdh-icon-btn"
|
||||
onClick={onToggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
<Languages size={18} />
|
||||
</ActionIcon>
|
||||
<Tooltip label="Language" withArrow openDelay={300}>
|
||||
<button type="button" className="fdh-icon-btn" aria-label="Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
circle
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-3px",
|
||||
right: "-3px",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Tooltip label="Messages" withArrow openDelay={300}>
|
||||
<button type="button" className="fdh-icon-btn" aria-label="Messages">
|
||||
<MessageSquare size={18} />
|
||||
<span className="fdh-badge">3</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
circle
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-3px",
|
||||
right: "-3px",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Tooltip label="Notifications" withArrow openDelay={300}>
|
||||
<button
|
||||
type="button"
|
||||
className="fdh-icon-btn"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={18} />
|
||||
<span className="fdh-badge">5</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
|
||||
<div className="fdh-divider" />
|
||||
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
radius="md"
|
||||
width={240}
|
||||
opened={isUserMenuOpen}
|
||||
onOpen={() => setIsUserMenuOpen(true)}
|
||||
onClose={() => setIsUserMenuOpen(false)}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
|
||||
<Avatar name={initials} color="green" size="md" styles={{ root: { background: freightBrand.primary } }} />
|
||||
<ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item disabled>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
<div className="fdh-user">
|
||||
<div className="fdh-avatar-ring">
|
||||
<div className="fdh-avatar">{initials}</div>
|
||||
</div>
|
||||
<Stack gap={0} style={{ minWidth: 0 }} visibleFrom="sm">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
truncate
|
||||
style={{ color: "#0f172a", lineHeight: 1.25, maxWidth: 140 }}
|
||||
>
|
||||
{userName}
|
||||
</Text>
|
||||
{userEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{userEmail}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
size="xs"
|
||||
truncate
|
||||
style={{ color: "#94a3b8", lineHeight: 1.25, maxWidth: 140 }}
|
||||
>
|
||||
{userEmail ?? "Administrator"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Menu.Item>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
color: "#94a3b8",
|
||||
flexShrink: 0,
|
||||
transition: "transform 0.2s",
|
||||
transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Box px="sm" py="xs">
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<div className="fdh-avatar-ring">
|
||||
<div className="fdh-avatar">{initials}</div>
|
||||
</div>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate style={{ color: "#0f172a" }}>
|
||||
{userName}
|
||||
</Text>
|
||||
{userEmail && (
|
||||
<Text size="xs" truncate style={{ color: "#94a3b8" }}>
|
||||
{userEmail}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<User size={14} />}
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
leftSection={<User size={15} />}
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
navigate("/dashboard/profile");
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={14} />}
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
navigate("/dashboard/profile#signature");
|
||||
}}
|
||||
>
|
||||
My signature
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={15} />}
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/* ============================================================
|
||||
EDR Freight — Sidebar styles
|
||||
Polished, professional navigation surface.
|
||||
============================================================ */
|
||||
|
||||
.fsb-aside {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #eef1f4;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.04),
|
||||
0 8px 24px -16px rgba(15, 23, 42, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Brand header ---- */
|
||||
.fsb-brand {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 80px;
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fsb-brand::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fsb-logo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 60%, #15805F 100%);
|
||||
box-shadow:
|
||||
0 6px 16px -4px rgba(27, 158, 122, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Nav scroll region ---- */
|
||||
.fsb-nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 14px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.fsb-nav::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.fsb-nav::-webkit-scrollbar-thumb {
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.fsb-nav::-webkit-scrollbar-thumb:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
.fsb-nav::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fsb-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.7px;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
padding: 0 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ---- Top-level item ---- */
|
||||
.fsb-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.fsb-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.fsb-item[data-active="true"] {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(34, 197, 94, 0.12) 0%,
|
||||
rgba(27, 158, 122, 0.06) 100%
|
||||
);
|
||||
color: #1B9E7A;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fsb-item[data-active="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 22px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: linear-gradient(180deg, #2DBF95 0%, #1B9E7A 100%);
|
||||
}
|
||||
|
||||
.fsb-item-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Icon well ---- */
|
||||
.fsb-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.fsb-item:hover .fsb-icon {
|
||||
background: #e6ebf1;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.fsb-item[data-active="true"] .fsb-icon {
|
||||
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 5px 12px -2px rgba(27, 158, 122, 0.45);
|
||||
}
|
||||
|
||||
.fsb-chevron {
|
||||
flex-shrink: 0;
|
||||
color: #94a3b8;
|
||||
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;
|
||||
padding-left: 12px;
|
||||
border-left: 1.5px solid #eef2f6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* group header (non-navigable) */
|
||||
.fsb-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
.fsb-group:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
.fsb-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.fsb-group[data-active="true"] .fsb-group-label {
|
||||
color: #1B9E7A;
|
||||
}
|
||||
|
||||
/* child leaf */
|
||||
.fsb-child {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
.fsb-child:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #0f172a;
|
||||
}
|
||||
.fsb-child[data-active="true"] {
|
||||
color: #1B9E7A;
|
||||
font-weight: 600;
|
||||
background-color: rgba(27, 158, 122, 0.08);
|
||||
}
|
||||
|
||||
.fsb-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: #cbd5e1;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.fsb-child:hover .fsb-dot {
|
||||
background: #94a3b8;
|
||||
}
|
||||
.fsb-child[data-active="true"] .fsb-dot {
|
||||
background: #1B9E7A;
|
||||
box-shadow: 0 0 0 3px rgba(27, 158, 122, 0.16);
|
||||
}
|
||||
|
||||
/* ---- Footer status card ---- */
|
||||
.fsb-footer {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
.fsb-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, #E7F8F2 0%, #f8fafc 100%);
|
||||
border: 1px solid #e7f3ec;
|
||||
}
|
||||
.fsb-pulse {
|
||||
position: relative;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #2DBF95;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fsb-pulse::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: #2DBF95;
|
||||
animation: fsb-pulse 2s ease-out infinite;
|
||||
}
|
||||
@keyframes fsb-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import {
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ChevronDown, ChevronRight, Train } from "lucide-react";
|
||||
import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core";
|
||||
import { ChevronDown, Train } from "lucide-react";
|
||||
import { Box, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { SidebarItem, SidebarSection } from "./types";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import "./FreightSidebar.css";
|
||||
|
||||
export interface FreightSidebarProps {
|
||||
sections: SidebarSection[];
|
||||
@@ -105,7 +106,7 @@ const FreightSidebar = ({
|
||||
children: SidebarItem[],
|
||||
depth: number,
|
||||
parentKey: string,
|
||||
) =>
|
||||
): ReactNode =>
|
||||
children.map((child) => {
|
||||
const key = sidebarItemKey(child, parentKey);
|
||||
const isGroup = Boolean(child.children?.length) && !child.href;
|
||||
@@ -115,87 +116,50 @@ const FreightSidebar = ({
|
||||
const groupActive = branchContainsActive(child.children!);
|
||||
|
||||
return (
|
||||
<Stack key={key} gap={4}>
|
||||
<UnstyledButton
|
||||
<div key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className="fsb-group"
|
||||
data-active={groupActive}
|
||||
onClick={() => toggleExpanded(key)}
|
||||
style={{
|
||||
background: groupActive ? freightBrand.mutedBg : "transparent",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
width: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600} style={{ color: groupActive ? freightBrand.primary : undefined }} c={groupActive ? undefined : "dimmed"} tt="uppercase">
|
||||
{child.label}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
transition: "transform 0.2s",
|
||||
color: groupActive ? freightBrand.primary : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<span className="fsb-group-label">{child.label}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className="fsb-chevron"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
color: groupActive ? "#1B9E7A" : undefined,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
|
||||
<div className="fsb-branch">
|
||||
{renderNavBranch(child.children!, depth + 1, key)}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!child.href) return null;
|
||||
|
||||
const childHref = child.href.toLowerCase();
|
||||
const childActiveHref = isHrefActive(childHref);
|
||||
const childActive = isHrefActive(child.href);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
<a
|
||||
key={key}
|
||||
component="a"
|
||||
href={child.href}
|
||||
onClick={(e) => navigateTo(e as any, child.href!)}
|
||||
label={child.label}
|
||||
active={childActiveHref}
|
||||
color="green"
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
rightSection={<ChevronRight size={16} />}
|
||||
/>
|
||||
className="fsb-child"
|
||||
data-active={childActive}
|
||||
onClick={(e) => navigateTo(e, child.href!)}
|
||||
>
|
||||
<span className="fsb-dot" />
|
||||
<span className="fsb-item-label">{child.label}</span>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
const renderIconWell = (icon: React.ReactNode, active: boolean) => {
|
||||
if (!icon) return null;
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "8px",
|
||||
flexShrink: 0,
|
||||
background: active ? freightBrand.gradient : "var(--mantine-color-gray-1)",
|
||||
color: active ? "white" : "var(--mantine-color-gray-6)",
|
||||
boxShadow: active ? freightBrand.shadowSm : "none",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTopLevelItem = (item: SidebarItem) => {
|
||||
if (!item.href) return null;
|
||||
|
||||
@@ -207,132 +171,106 @@ const FreightSidebar = ({
|
||||
const isCurrentItem = hasChildren
|
||||
? activePath === itemHref
|
||||
: isHrefActive(itemHref);
|
||||
const isSectionActive = childActive && !isCurrentItem;
|
||||
const isActive = isCurrentItem || isSectionActive;
|
||||
const isActive = isCurrentItem || childActive;
|
||||
const isOpen = expanded[item.href] ?? false;
|
||||
const leafActive = isCurrentItem && !hasChildren;
|
||||
|
||||
return (
|
||||
<Stack key={item.href} gap={0}>
|
||||
<NavLink
|
||||
component="a"
|
||||
<Box key={item.href}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(e) => navigateTo(e as any, item.href!)}
|
||||
label={item.label}
|
||||
leftSection={renderIconWell(item.icon, isActive)}
|
||||
active={leafActive}
|
||||
color="green"
|
||||
variant="light"
|
||||
style={{
|
||||
borderRadius: "10px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
padding: "8px 10px",
|
||||
className="fsb-item"
|
||||
data-active={isActive}
|
||||
onClick={(e) => {
|
||||
if (hasChildren) {
|
||||
setExpanded((current) => ({
|
||||
...current,
|
||||
[item.href!]: true,
|
||||
}));
|
||||
}
|
||||
navigateTo(e, item.href!);
|
||||
}}
|
||||
rightSection={
|
||||
hasChildren ? (
|
||||
>
|
||||
{item.icon && <span className="fsb-icon">{item.icon}</span>}
|
||||
<span className="fsb-item-label">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<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)",
|
||||
transition: "transform 0.2s",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleExpanded(item.href!);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{hasChildren && isOpen && (
|
||||
<Stack gap={4} style={{ paddingLeft: "16px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
|
||||
<div className="fsb-branch">
|
||||
{renderNavBranch(item.children!, 0, item.href)}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="aside"
|
||||
style={{
|
||||
height: "100%",
|
||||
maxHeight: "100%",
|
||||
width: "280px",
|
||||
flexShrink: 0,
|
||||
borderRadius: "12px",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "white",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap={12}
|
||||
px="lg"
|
||||
py="md"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
flexShrink: 0,
|
||||
height: "80px",
|
||||
}}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "44px",
|
||||
height: "44px",
|
||||
borderRadius: "12px",
|
||||
background: freightBrand.gradient,
|
||||
boxShadow: freightBrand.shadow,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Train size={24} color="white" strokeWidth={2} />
|
||||
</Box>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="md" fw={700} style={{ letterSpacing: "-0.3px", lineHeight: 1.2 }}>
|
||||
<Box component="aside" className="fsb-aside">
|
||||
<div className="fsb-brand">
|
||||
<div className="fsb-logo">
|
||||
<Train size={23} color="white" strokeWidth={2.1} />
|
||||
</div>
|
||||
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
|
||||
<Text
|
||||
size="md"
|
||||
fw={700}
|
||||
style={{ letterSpacing: "-0.3px", lineHeight: 1.2, color: "#0f172a" }}
|
||||
>
|
||||
EDR Freight
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={500} style={{ letterSpacing: "0.3px" }}>
|
||||
Backoffice
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
|
||||
>
|
||||
Backoffice Console
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Stack
|
||||
component="nav"
|
||||
gap="lg"
|
||||
p="md"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overscrollBehavior: "contain",
|
||||
}}
|
||||
>
|
||||
<nav className="fsb-nav">
|
||||
{sections.map((section) => (
|
||||
<Stack key={section.title} gap={8}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.5px", paddingLeft: "8px" }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<div key={section.title}>
|
||||
<div className="fsb-section-label">{section.title}</div>
|
||||
<Stack gap={3}>
|
||||
{section.items.map((item) => renderTopLevelItem(item))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</nav>
|
||||
|
||||
<div className="fsb-footer">
|
||||
<div className="fsb-status">
|
||||
<span className="fsb-pulse" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} style={{ color: "#15805F", lineHeight: 1.3 }}>
|
||||
All systems operational
|
||||
</Text>
|
||||
<Text size="10px" style={{ color: "#94a3b8", lineHeight: 1.3 }}>
|
||||
EDR Platform · v1.0
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PageMeta } from "./types";
|
||||
import { getFleetRouteMeta } from "@/pages/fleet/config/resources";
|
||||
import {
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_RESOURCES,
|
||||
@@ -35,6 +36,34 @@ 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: {
|
||||
title: "Train schedule",
|
||||
subtitle: "Assign bookings, auto-pin wagons, finalize and dispatch",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/operations/train-scheduling-v2",
|
||||
meta: {
|
||||
title: "Train Schedules",
|
||||
subtitle: "Operational train scheduling with full allocation workflow",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/routes",
|
||||
meta: {
|
||||
@@ -42,11 +71,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage route definitions built from freight yards",
|
||||
},
|
||||
},
|
||||
...getFleetRouteMeta(),
|
||||
{
|
||||
prefix: "/dashboard/locomotives",
|
||||
prefix: "/dashboard/trains/",
|
||||
meta: {
|
||||
title: "Locomotives",
|
||||
subtitle: "Manage locomotive master data and service status",
|
||||
title: "Train detail",
|
||||
subtitle: "Manage fleet consist and wagon assignments",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -91,6 +121,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage dropdown options used across the platform",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/configuration/train-scheduling-rules",
|
||||
meta: {
|
||||
title: "Train scheduling rules",
|
||||
subtitle: "Global limits for train length, weight, wagons, and 20ft container balance",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
||||
meta: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewTrendPoint } from "@/types/overview";
|
||||
import { overviewChartColors } from "./overview.styles";
|
||||
|
||||
function formatDateLabel(date: string) {
|
||||
const parsed = new Date(`${date}T00:00:00`);
|
||||
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function OverviewBookingTrendChart({ data }: { data: IOverviewTrendPoint[] }) {
|
||||
const hasData = data.some((point) => point.count > 0);
|
||||
|
||||
return (
|
||||
<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={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">
|
||||
<stop offset="5%" stopColor={overviewChartColors.primary} stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor={overviewChartColors.primary} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||
formatter={(value) => [value, "Bookings"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={overviewChartColors.primary}
|
||||
fill="url(#bookingTrendFill)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { overviewChartColors } from "./overview.styles";
|
||||
|
||||
export interface DonutChartItem {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface OverviewDonutChartProps {
|
||||
title: string;
|
||||
data: DonutChartItem[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function OverviewDonutChart({
|
||||
title,
|
||||
data,
|
||||
emptyMessage = "No data available",
|
||||
}: 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="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="lg">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { overviewChartColors } from "./overview.styles";
|
||||
|
||||
export interface HorizontalBarItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface OverviewHorizontalBarChartProps {
|
||||
title: string;
|
||||
data: HorizontalBarItem[];
|
||||
emptyMessage?: string;
|
||||
valueLabel?: string;
|
||||
}
|
||||
|
||||
export function OverviewHorizontalBarChart({
|
||||
title,
|
||||
data,
|
||||
emptyMessage = "No data available",
|
||||
valueLabel = "Count",
|
||||
}: OverviewHorizontalBarChartProps) {
|
||||
const chartData = data
|
||||
.filter((item) => item.value > 0)
|
||||
.map((item) => ({ name: item.label, value: item.value }));
|
||||
const hasData = chartData.length > 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 36)}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 16, left: 8, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" horizontal={false} />
|
||||
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={120}
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="#94a3b8"
|
||||
/>
|
||||
<Tooltip formatter={(value) => [value, valueLabel]} />
|
||||
<Bar dataKey="value" radius={[0, 6, 6, 0]} barSize={18}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={
|
||||
overviewChartColors.pipeline[
|
||||
index % overviewChartColors.pipeline.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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. */
|
||||
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 {
|
||||
label: string;
|
||||
value: number | string;
|
||||
hint?: string;
|
||||
icon: LucideIcon;
|
||||
accent?: keyof typeof ACCENT_CHIP_BG;
|
||||
/** Share 0..1 used as a baseline for the decorative trend + ring gauge. */
|
||||
progress?: number;
|
||||
/** Mini-graph type; defaults to "area" when not provided. */
|
||||
variant?: KpiGraphVariant;
|
||||
}
|
||||
|
||||
export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) {
|
||||
const Icon = item.icon;
|
||||
const accent = item.accent ?? "default";
|
||||
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="md"
|
||||
radius={16}
|
||||
withBorder
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
flex: "1 1 240px",
|
||||
minWidth: 220,
|
||||
transition: "transform 160ms ease, box-shadow 160ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-3px)";
|
||||
e.currentTarget.style.boxShadow = "0 12px 28px -12px rgba(15,23,42,0.18)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 11,
|
||||
background: chipBg,
|
||||
color: accentDeep,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{variant !== "ring" ? (
|
||||
<MiniSparkline
|
||||
variant={variant}
|
||||
accent={accentKey}
|
||||
baseline={item.progress ?? 0}
|
||||
seed={item.label}
|
||||
height={22}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
Box,
|
||||
Clock,
|
||||
Container,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Train,
|
||||
Truck,
|
||||
UserCheck,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewKpis } from "@/types/overview";
|
||||
import { OverviewKpiCard } from "./OverviewKpiCard";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) {
|
||||
const bookingItems = [
|
||||
{
|
||||
label: "Active bookings",
|
||||
value: kpis.bookings.totalActive,
|
||||
icon: FileText,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: kpis.bookings.needsAction,
|
||||
icon: AlertCircle,
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: kpis.bookings.urgent,
|
||||
icon: Clock,
|
||||
accent: "rose" as const,
|
||||
},
|
||||
{
|
||||
label: "In approval",
|
||||
value: kpis.bookings.inApproval,
|
||||
icon: UserCheck,
|
||||
accent: "sky" as const,
|
||||
},
|
||||
{
|
||||
label: "Submitted today",
|
||||
value: kpis.bookings.submittedToday,
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const operationsItems = [
|
||||
{
|
||||
label: "Active trains",
|
||||
value: kpis.operations.trainsActive,
|
||||
icon: Train,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Wagons available",
|
||||
value: kpis.operations.wagonsAvailable,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Containers in transit",
|
||||
value: kpis.operations.containersInTransit,
|
||||
icon: Container,
|
||||
},
|
||||
{
|
||||
label: "Cargoes loaded",
|
||||
value: kpis.operations.cargoesLoaded,
|
||||
icon: Box,
|
||||
},
|
||||
];
|
||||
|
||||
const billingItems = [
|
||||
{
|
||||
label: "Revenue MTD (ETB)",
|
||||
value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"),
|
||||
icon: Banknote,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Revenue MTD (USD)",
|
||||
value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"),
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
label: "Pending payments",
|
||||
value: kpis.billing.pendingPayments,
|
||||
icon: CreditCard,
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: "Successful MTD",
|
||||
value: kpis.billing.successfulPaymentsMtd,
|
||||
icon: Banknote,
|
||||
},
|
||||
];
|
||||
|
||||
const peopleItems = [
|
||||
{
|
||||
label: "Total customers",
|
||||
value: kpis.customers.totalCustomers,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "New this month",
|
||||
value: kpis.customers.newCustomersThisMonth,
|
||||
icon: Users,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Active employees",
|
||||
value: kpis.staff.activeEmployees,
|
||||
icon: UserCheck,
|
||||
},
|
||||
{
|
||||
label: "Active users",
|
||||
value: kpis.staff.activeUsers,
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
const sections = [
|
||||
{ title: "Bookings", items: bookingItems },
|
||||
{ title: "Operations", items: operationsItems },
|
||||
{ title: "Billing", items: billingItems },
|
||||
{ title: "Customers & staff", items: peopleItems },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{sections.map((section) => (
|
||||
<Paper
|
||||
key={section.title}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600} mb="sm" c="dimmed">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
|
||||
{section.items.map((item) => (
|
||||
<OverviewKpiCard key={item.label} item={item} />
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Group, Paper, Text } from "@mantine/core";
|
||||
|
||||
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;
|
||||
items: OverviewKpiItem[];
|
||||
}
|
||||
|
||||
/** Parse a numeric magnitude out of a KPI value (handles formatted currency strings). */
|
||||
function toNumber(value: number | string): number {
|
||||
if (typeof value === "number") return value;
|
||||
const parsed = Number(String(value).replace(/[^0-9.-]/g, ""));
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
|
||||
const max = Math.max(...items.map((item) => toNumber(item.value)), 0);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<Text size="sm" fw={600} mb="md" c="dimmed">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="md" align="stretch" wrap="wrap">
|
||||
{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),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "7 days", value: "7d" },
|
||||
{ label: "30 days", value: "30d" },
|
||||
{ label: "90 days", value: "90d" },
|
||||
];
|
||||
|
||||
function formatRelativeTime(iso: string | undefined) {
|
||||
if (!iso) return "—";
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
interface OverviewPageHeaderProps {
|
||||
range: OverviewRange;
|
||||
onRangeChange: (range: OverviewRange) => void;
|
||||
generatedAt?: string;
|
||||
onRefresh: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function OverviewPageHeader({
|
||||
range,
|
||||
onRangeChange,
|
||||
generatedAt,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: OverviewPageHeaderProps) {
|
||||
return (
|
||||
<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>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewPaymentTrendPoint } from "@/types/overview";
|
||||
import { overviewChartColors } from "./overview.styles";
|
||||
|
||||
function formatDateLabel(date: string) {
|
||||
const parsed = new Date(`${date}T00:00:00`);
|
||||
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatAmount(value: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoint[] }) {
|
||||
const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0);
|
||||
|
||||
return (
|
||||
<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={210}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||
formatter={(value, name) => [
|
||||
formatAmount(Number(value), name === "amountUsd" ? "USD" : "ETB"),
|
||||
name === "amountUsd" ? "USD" : "ETB",
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="amountEtb"
|
||||
name="ETB"
|
||||
stackId="payments"
|
||||
fill={overviewChartColors.etb}
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amountUsd"
|
||||
name="USD"
|
||||
stackId="payments"
|
||||
fill={overviewChartColors.usd}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: "Booking requests",
|
||||
description: "Review and action incoming freight bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Train scheduling v2",
|
||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: Train,
|
||||
},
|
||||
{
|
||||
title: "Trains",
|
||||
description: "Manage train master data and fleet status",
|
||||
href: "/dashboard/trains",
|
||||
icon: Train,
|
||||
},
|
||||
{
|
||||
title: "User management",
|
||||
description: "Employees, roles, and permissions",
|
||||
href: "/dashboard/user-management",
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
export function OverviewQuickLinks() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack gap="md" h="100%">
|
||||
<Text fw={600}>Quick links</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{links.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Card
|
||||
key={link.href}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(link.href)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="green" size="lg" radius="md">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{link.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{link.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Paper, Stack, Table, Text } from "@mantine/core";
|
||||
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import type { IOverviewRecentBooking } from "@/types/overview";
|
||||
|
||||
function formatAmount(amount: number | null, currency: string | null) {
|
||||
if (amount == null) return "—";
|
||||
const code = currency === "USD" ? "USD" : "ETB";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function OverviewRecentBookingsTable({
|
||||
bookings,
|
||||
}: {
|
||||
bookings: IOverviewRecentBooking[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Recent bookings</Text>
|
||||
{bookings.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No recent bookings
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Priority</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((booking) => (
|
||||
<Table.Tr
|
||||
key={booking.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{booking.customerLabel}</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatAmount(booking.totalAmount, booking.paymentCurrency)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(booking.createdAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||
import type { IOverviewPipelineCount } from "@/types/overview";
|
||||
import { overviewChartColors } from "./overview.styles";
|
||||
|
||||
function getPipelineLabel(stage: string) {
|
||||
return BOOKING_LIST_TABS.find((tab) => tab.key === stage)?.label ?? stage;
|
||||
}
|
||||
|
||||
export function OverviewStatusChart({ data }: { data: IOverviewPipelineCount[] }) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
label: getPipelineLabel(item.stage),
|
||||
}));
|
||||
const hasData = chartData.some((item) => item.count > 0);
|
||||
|
||||
return (
|
||||
<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={210}>
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 24 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
interval={0}
|
||||
angle={-20}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
stroke="#94a3b8"
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<Tooltip formatter={(value) => [value, "Bookings"]} />
|
||||
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.stage}
|
||||
fill={
|
||||
overviewChartColors.pipeline[
|
||||
index % overviewChartColors.pipeline.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
import {
|
||||
useOverviewBillingTab,
|
||||
useOverviewBookingsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
useOverviewStaffTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||
|
||||
function TabSkeleton() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Skeleton height={120} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabContentProps {
|
||||
tab: OverviewTabKey;
|
||||
range: OverviewRange;
|
||||
}
|
||||
|
||||
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
||||
const operations = useOverviewOperationsTab(tab === "operations");
|
||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||
const staff = useOverviewStaffTab(range, tab === "staff");
|
||||
|
||||
const query =
|
||||
tab === "bookings"
|
||||
? bookings
|
||||
: tab === "billing"
|
||||
? billing
|
||||
: tab === "operations"
|
||||
? operations
|
||||
: tab === "customers"
|
||||
? customers
|
||||
: staff;
|
||||
|
||||
const { isLoading, isError, refetch, isFetching } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return <TabSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !query.data) {
|
||||
return (
|
||||
<Paper p="xl" radius="lg" withBorder>
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Failed to load tab data"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<span>Could not load {tab} metrics. Please try again.</span>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md" pos="relative">
|
||||
{isFetching && (
|
||||
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
|
||||
<Loader size="sm" color="green" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{tab === "bookings" && bookings.data && (
|
||||
<OverviewBookingsTabPanel data={bookings.data} />
|
||||
)}
|
||||
{tab === "billing" && billing.data && (
|
||||
<OverviewBillingTabPanel data={billing.data} />
|
||||
)}
|
||||
{tab === "operations" && operations.data && (
|
||||
<OverviewOperationsTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "customers" && customers.data && (
|
||||
<OverviewCustomersTabPanel data={customers.data} />
|
||||
)}
|
||||
{tab === "staff" && staff.data && (
|
||||
<OverviewStaffTabPanel data={staff.data} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* ============================================================
|
||||
EDR Freight — Overview page styles (hero controls + tabs)
|
||||
============================================================ */
|
||||
|
||||
/* ---- Hero range segmented control (on gradient) ---- */
|
||||
.ov-seg-root {
|
||||
background: rgba(255, 255, 255, 0.18) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.ov-seg-indicator {
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.ov-seg-label {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ov-seg-label[data-active] {
|
||||
color: #15805f;
|
||||
}
|
||||
|
||||
/* ---- Premium tab bar ---- */
|
||||
.ov-tablist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.ov-tab {
|
||||
border-radius: 11px;
|
||||
padding: 10px 18px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
.ov-tab:hover {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 2px 10px -4px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.ov-tab[data-active] {
|
||||
background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 100%) !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 10px 20px -8px rgba(27, 158, 122, 0.55);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.ov-tab[data-active]:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
export const overviewChartColors = {
|
||||
primary: "#F2A516",
|
||||
primaryLight: "#FBD171",
|
||||
primaryDark: "#D98A0B",
|
||||
muted: freightBrand.mutedBg,
|
||||
etb: "#F2A516",
|
||||
usd: "#0369a1",
|
||||
/** Vibrant, well-separated categorical palette for charts (gold-forward). */
|
||||
pipeline: [
|
||||
"#F2A516", // gold (brand accent)
|
||||
"#0ea5e9", // sky
|
||||
"#8b5cf6", // violet
|
||||
"#FB8C2E", // orange
|
||||
"#14b8a6", // teal
|
||||
"#ec4899", // pink
|
||||
"#f43f5e", // rose
|
||||
"#6366f1", // indigo
|
||||
"#eab308", // yellow
|
||||
"#06b6d4", // cyan
|
||||
],
|
||||
} 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"],
|
||||
emerald: ["#34D9AE", "#1B9E7A"],
|
||||
amber: ["#fbbf24", "#d97706"],
|
||||
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;
|
||||
|
||||
export const overviewCardStyle = {
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
} as const;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Banknote, CreditCard, Wallet } from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Grid, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewBillingTab } from "@/types/overview";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { OverviewPaymentChart } from "../OverviewPaymentChart";
|
||||
import { overviewChartColors } from "../overview.styles";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
telebirr: "Telebirr",
|
||||
"cbe-birr": "CBE Birr",
|
||||
ebirr: "eBirr",
|
||||
};
|
||||
|
||||
interface OverviewBillingTabPanelProps {
|
||||
data: IOverviewBillingTab;
|
||||
}
|
||||
|
||||
export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps) {
|
||||
const methodChartData = data.paymentsByMethod.map((item) => ({
|
||||
name: METHOD_LABELS[item.method] ?? item.method,
|
||||
count: item.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Revenue MTD (ETB)",
|
||||
value: formatCurrency(data.kpis.revenueMtdEtb, "ETB"),
|
||||
icon: Banknote,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Revenue MTD (USD)",
|
||||
value: formatCurrency(data.kpis.revenueMtdUsd, "USD"),
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
label: "Pending payments",
|
||||
value: data.kpis.pendingPayments,
|
||||
icon: CreditCard,
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
label: "Successful MTD",
|
||||
value: data.kpis.successfulPaymentsMtd,
|
||||
icon: Banknote,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewPaymentChart data={data.paymentTrend} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Revenue by currency (MTD)"
|
||||
data={data.revenueByCurrency.map((item) => ({
|
||||
name: item.currency,
|
||||
value: item.amount,
|
||||
}))}
|
||||
emptyMessage="No revenue this month"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Payments by status"
|
||||
data={data.paymentsByStatus.map((item) => ({
|
||||
name: item.status.replace(/-/g, " "),
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<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={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" />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="count" name="Transactions" radius={[6, 6, 0, 0]}>
|
||||
{methodChartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={
|
||||
overviewChartColors.pipeline[
|
||||
index % overviewChartColors.pipeline.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { BOOKING_STATUS_META } from "@/features/bookings/booking-status.config";
|
||||
import type { IOverviewBookingsTab } from "@/types/overview";
|
||||
import { OverviewBookingTrendChart } from "../OverviewBookingTrendChart";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { OverviewRecentBookingsTable } from "../OverviewRecentBookingsTable";
|
||||
import { OverviewStatusChart } from "../OverviewStatusChart";
|
||||
|
||||
interface OverviewBookingsTabPanelProps {
|
||||
data: IOverviewBookingsTab;
|
||||
}
|
||||
|
||||
export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Active bookings",
|
||||
value: data.kpis.totalActive,
|
||||
icon: FileText,
|
||||
accent: "emerald",
|
||||
hint: "Currently in workflow",
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: data.kpis.needsAction,
|
||||
icon: AlertCircle,
|
||||
accent: "amber",
|
||||
hint: "Awaiting your review",
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: data.kpis.urgent,
|
||||
icon: Clock,
|
||||
accent: "rose",
|
||||
hint: "High priority queue",
|
||||
},
|
||||
{
|
||||
label: "In approval",
|
||||
value: data.kpis.inApproval,
|
||||
icon: UserCheck,
|
||||
accent: "sky",
|
||||
hint: "Pending sign-off",
|
||||
},
|
||||
{
|
||||
label: "Submitted today",
|
||||
value: data.kpis.submittedToday,
|
||||
icon: FileText,
|
||||
hint: "New since midnight",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewBookingTrendChart data={data.bookingTrend} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewStatusChart data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="By status"
|
||||
data={data.bookingsByStatus.map((item) => ({
|
||||
name: BOOKING_STATUS_META[item.status]?.title ?? item.status,
|
||||
value: item.count,
|
||||
}))}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="By freight type"
|
||||
data={data.bookingsByFreightType.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<OverviewHorizontalBarChart
|
||||
title="By payment currency"
|
||||
data={data.bookingsByCurrency.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<OverviewRecentBookingsTable bookings={data.recentBookings} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Users } from "lucide-react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Grid, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewCustomersTab } from "@/types/overview";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { overviewChartColors } from "../overview.styles";
|
||||
|
||||
function formatDateLabel(date: string) {
|
||||
const parsed = new Date(`${date}T00:00:00`);
|
||||
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface OverviewCustomersTabPanelProps {
|
||||
data: IOverviewCustomersTab;
|
||||
}
|
||||
|
||||
export function OverviewCustomersTabPanel({ data }: OverviewCustomersTabPanelProps) {
|
||||
const hasGrowth = data.customerGrowthTrend.some((point) => point.count > 0);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Total customers",
|
||||
value: data.kpis.totalCustomers,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "New this month",
|
||||
value: data.kpis.newCustomersThisMonth,
|
||||
icon: Users,
|
||||
accent: "emerald",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<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={210}>
|
||||
<AreaChart data={data.customerGrowthTrend}>
|
||||
<defs>
|
||||
<linearGradient id="customerGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={overviewChartColors.primary}
|
||||
stopOpacity={0.35}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={overviewChartColors.primary}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||
formatter={(value) => [value, "New customers"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={overviewChartColors.primary}
|
||||
fill="url(#customerGrowthFill)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Customers by type"
|
||||
data={data.customersByType.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<OverviewHorizontalBarChart
|
||||
title="Top customers by bookings"
|
||||
data={data.topCustomersByBookings.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
valueLabel="Bookings"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import type { IOverviewOperationsTab } from "@/types/overview";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
|
||||
interface OverviewOperationsTabPanelProps {
|
||||
data: IOverviewOperationsTab;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Active trains",
|
||||
value: data.kpis.trainsActive,
|
||||
icon: Train,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Wagons available",
|
||||
value: data.kpis.wagonsAvailable,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Containers in transit",
|
||||
value: data.kpis.containersInTransit,
|
||||
icon: ContainerIcon,
|
||||
},
|
||||
{
|
||||
label: "Cargoes loaded",
|
||||
value: data.kpis.cargoesLoaded,
|
||||
icon: Box,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={data.trainStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Wagon status"
|
||||
data={data.wagonStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={data.containerStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Cargo status"
|
||||
data={data.cargoStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { UserCheck, Users } from "lucide-react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Grid, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewStaffTab } from "@/types/overview";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { overviewChartColors } from "../overview.styles";
|
||||
|
||||
function formatDateLabel(date: string) {
|
||||
const parsed = new Date(`${date}T00:00:00`);
|
||||
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface OverviewStaffTabPanelProps {
|
||||
data: IOverviewStaffTab;
|
||||
}
|
||||
|
||||
export function OverviewStaffTabPanel({ data }: OverviewStaffTabPanelProps) {
|
||||
const hasGrowth = data.employeeGrowthTrend.some((point) => point.count > 0);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Active employees",
|
||||
value: data.kpis.activeEmployees,
|
||||
icon: UserCheck,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Active users",
|
||||
value: data.kpis.activeUsers,
|
||||
icon: Users,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<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={210}>
|
||||
<AreaChart data={data.employeeGrowthTrend}>
|
||||
<defs>
|
||||
<linearGradient id="employeeGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={overviewChartColors.primaryDark}
|
||||
stopOpacity={0.35}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={overviewChartColors.primaryDark}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||
formatter={(value) => [value, "New employees"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={overviewChartColors.primaryDark}
|
||||
fill="url(#employeeGrowthFill)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Active vs inactive users"
|
||||
data={data.activeUsersBreakdown.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<OverviewDonutChart
|
||||
title="Users by account status"
|
||||
data={data.usersByStatus.map((item) => ({
|
||||
name: item.status.replace(/_/g, " "),
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
Droppable,
|
||||
type DraggableProvided,
|
||||
type DraggableStateSnapshot,
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { GripVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
import { getOrderItemLabel, getOrderValue } from "./ruleEngineOrder.utils";
|
||||
|
||||
interface OrderDraftItem {
|
||||
id: string;
|
||||
label: string;
|
||||
code?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ManageRuleEngineOrderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: RuleEngineResourceConfig;
|
||||
items: RuleEngineRecord[];
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: (payload: { ids: string[]; requiresDirectorApproval?: boolean }) => void;
|
||||
}
|
||||
|
||||
const toDraftItems = (
|
||||
rows: RuleEngineRecord[],
|
||||
config: RuleEngineResourceConfig,
|
||||
): OrderDraftItem[] => {
|
||||
const field = config.orderConfig!.field;
|
||||
return [...rows]
|
||||
.sort((a, b) => getOrderValue(a, field) - getOrderValue(b, field))
|
||||
.map((row) => ({
|
||||
id: String(row.id),
|
||||
label: getOrderItemLabel(row, config.slug),
|
||||
code: row.code ? String(row.code) : undefined,
|
||||
order: getOrderValue(row, field),
|
||||
}));
|
||||
};
|
||||
|
||||
/** Reparent dragged row to body — fixes position:fixed inside Modal transforms. */
|
||||
const PortalAwareRow = ({
|
||||
snapshot,
|
||||
children,
|
||||
}: {
|
||||
snapshot: DraggableStateSnapshot;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
if (snapshot.isDragging) {
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const OrderRow = ({
|
||||
item,
|
||||
index,
|
||||
dragProvided,
|
||||
snapshot,
|
||||
}: {
|
||||
item: OrderDraftItem;
|
||||
index: number;
|
||||
dragProvided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
}) => (
|
||||
<PortalAwareRow snapshot={snapshot}>
|
||||
<Group
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
...dragProvided.draggableProps.style,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||
cursor: snapshot.isDragging ? "grabbing" : "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
||||
<GripVertical size={18} />
|
||||
</Box>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.code ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{item.code}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</PortalAwareRow>
|
||||
);
|
||||
|
||||
const ManageRuleEngineOrderDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
items,
|
||||
isLoading,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: ManageRuleEngineOrderDialogProps) => {
|
||||
const isScoped = config.orderConfig?.scopeField === "requiresDirectorApproval";
|
||||
const [tab, setTab] = useState<"standard" | "director">("standard");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [standardItems, setStandardItems] = useState<OrderDraftItem[]>([]);
|
||||
const [directorItems, setDirectorItems] = useState<OrderDraftItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (isScoped) {
|
||||
setStandardItems(
|
||||
toDraftItems(
|
||||
items.filter((row) => !row.requiresDirectorApproval),
|
||||
config,
|
||||
),
|
||||
);
|
||||
setDirectorItems(
|
||||
toDraftItems(
|
||||
items.filter((row) => row.requiresDirectorApproval),
|
||||
config,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setStandardItems(toDraftItems(items, config));
|
||||
}
|
||||
setFilter("");
|
||||
}, [open, items, config, isScoped]);
|
||||
|
||||
const activeItems = isScoped
|
||||
? tab === "director"
|
||||
? directorItems
|
||||
: standardItems
|
||||
: standardItems;
|
||||
|
||||
const setActiveItems = isScoped
|
||||
? tab === "director"
|
||||
? setDirectorItems
|
||||
: setStandardItems
|
||||
: setStandardItems;
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return activeItems;
|
||||
return activeItems.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
(item.code?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}, [activeItems, filter]);
|
||||
|
||||
const droppableId = isScoped
|
||||
? `rule-engine-order-${tab}`
|
||||
: "rule-engine-order-list";
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination || filter.trim()) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destIndex = result.destination.index;
|
||||
if (sourceIndex === destIndex) return;
|
||||
|
||||
setActiveItems((prev) => {
|
||||
const next = [...prev];
|
||||
const [removed] = next.splice(sourceIndex, 1);
|
||||
next.splice(destIndex, 0, removed!);
|
||||
return next.map((item, index) => ({ ...item, order: index + 1 }));
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isScoped) {
|
||||
onSave({
|
||||
ids: (tab === "director" ? directorItems : standardItems).map((item) => item.id),
|
||||
requiresDirectorApproval: tab === "director",
|
||||
});
|
||||
return;
|
||||
}
|
||||
onSave({ ids: standardItems.map((item) => item.id) });
|
||||
};
|
||||
|
||||
const renderList = (listItems: OrderDraftItem[]) => (
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided) => (
|
||||
<Stack
|
||||
gap="xs"
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
style={{ minHeight: 120 }}
|
||||
>
|
||||
{listItems.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No items to reorder.
|
||||
</Text>
|
||||
) : (
|
||||
listItems.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={item.id}
|
||||
index={index}
|
||||
isDragDisabled={Boolean(filter.trim())}
|
||||
>
|
||||
{(dragProvided, snapshot) => (
|
||||
<OrderRow
|
||||
item={item}
|
||||
index={index}
|
||||
dragProvided={dragProvided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={`Manage order · ${config.label}`}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
transitionProps={{ duration: 0, transition: "fade" }}
|
||||
styles={{
|
||||
content: {
|
||||
transform: "none",
|
||||
overflow: "visible",
|
||||
},
|
||||
body: {
|
||||
overflow: "visible",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Drag items anywhere in the list to set display order. Changes apply when you save.
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader2 size={28} style={{ animation: "spin 1s linear infinite" }} />
|
||||
</Group>
|
||||
) : isScoped ? (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => setTab((value as "standard" | "director") ?? "standard")}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="standard">Standard chain ({standardItems.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="director">Director chain ({directorItems.length})</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="standard" pt="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
placeholder="Filter items…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||
/>
|
||||
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||
{renderList(filteredItems)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="director" pt="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
placeholder="Filter items…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||
/>
|
||||
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||
{renderList(filteredItems)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Filter items…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||
/>
|
||||
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||
{renderList(filteredItems)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filter.trim() ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Clear the filter to drag and reorder items.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || isSaving}
|
||||
leftSection={
|
||||
isSaving ? (
|
||||
<Loader2 size={16} style={{ animation: "spin 1s linear infinite" }} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save order"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DragDropContext>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageRuleEngineOrderDialog;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Stack, Group, Text, Pagination, Card, SimpleGrid } from "@mantine/core";
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
import RuleEngineListFooter from "./RuleEngineListFooter";
|
||||
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
||||
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
||||
import { formatCell } from "./ruleEngineFormat";
|
||||
@@ -13,12 +15,10 @@ export interface RuleEngineCardGridProps {
|
||||
status: "loading" | "error" | "success";
|
||||
emptyMessage: string;
|
||||
itemLabel: string;
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
onEdit?: (record: RuleEngineRecord) => void;
|
||||
onDelete?: (record: RuleEngineRecord) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -71,6 +71,9 @@ const RuleEngineCardGrid = ({
|
||||
emptyMessage,
|
||||
itemLabel,
|
||||
pagination,
|
||||
pageCount,
|
||||
totalCount,
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewChain,
|
||||
@@ -249,19 +252,13 @@ const RuleEngineCardGrid = ({
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{pagination.pageCount > 1 && (
|
||||
<Group justify="space-between" align="center" p="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel}
|
||||
</Text>
|
||||
<Pagination
|
||||
value={pagination.pageIndex + 1}
|
||||
total={pagination.pageCount}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
itemLabel={itemLabel}
|
||||
onPaginationChange={onPaginationChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type FormFieldDef,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils";
|
||||
|
||||
export interface RuleEngineFormDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,6 +31,8 @@ export interface RuleEngineFormDialogProps {
|
||||
initialRecord?: RuleEngineRecord | null;
|
||||
isSubmitting: boolean;
|
||||
selectOptionsLoading?: boolean;
|
||||
positionOptions?: { label: string; value: string }[];
|
||||
positionLoading?: boolean;
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
@@ -146,19 +149,33 @@ const RuleEngineFormDialog = ({
|
||||
initialRecord,
|
||||
isSubmitting,
|
||||
selectOptionsLoading = false,
|
||||
positionOptions,
|
||||
positionLoading = false,
|
||||
onSubmit,
|
||||
}: RuleEngineFormDialogProps) => {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||
buildInitialValues(fields, initialRecord),
|
||||
);
|
||||
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, initialRecord));
|
||||
setPosition(RULE_ENGINE_POSITION_END);
|
||||
}
|
||||
}, [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 }));
|
||||
@@ -168,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;
|
||||
@@ -192,6 +209,10 @@ const RuleEngineFormDialog = ({
|
||||
payload.code = String(payload.code).toUpperCase();
|
||||
}
|
||||
|
||||
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
|
||||
payload.insertAfterId = position;
|
||||
}
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
@@ -315,6 +336,23 @@ const RuleEngineFormDialog = ({
|
||||
<Stack gap="lg">
|
||||
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
|
||||
<Stack gap="md">
|
||||
{!initialRecord && positionOptions ? (
|
||||
<Select
|
||||
label="Position"
|
||||
description="New items are appended to the end by default."
|
||||
value={position}
|
||||
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)}
|
||||
data={[
|
||||
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END },
|
||||
...positionOptions,
|
||||
]}
|
||||
searchable
|
||||
disabled={positionLoading}
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
) : null}
|
||||
{formRows.map((row) =>
|
||||
row.kind === "pair" ? (
|
||||
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { Group, Pagination, Select, Text } from "@mantine/core";
|
||||
|
||||
export interface RuleEngineListFooterProps {
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
itemLabel: string;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["5", "10", "25", "50"];
|
||||
|
||||
const RuleEngineListFooter = ({
|
||||
pagination,
|
||||
pageCount,
|
||||
totalCount,
|
||||
itemLabel,
|
||||
onPaginationChange,
|
||||
}: RuleEngineListFooterProps) => {
|
||||
const { pageIndex, pageSize } = pagination;
|
||||
const start = totalCount === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min((pageIndex + 1) * pageSize, totalCount);
|
||||
|
||||
const setPageIndex = (nextIndex: number) => {
|
||||
onPaginationChange({ pageIndex: nextIndex, pageSize });
|
||||
};
|
||||
|
||||
const setPageSize = (nextSize: number) => {
|
||||
onPaginationChange({ pageIndex: 0, pageSize: nextSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
p="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
Rows per page
|
||||
</Text>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onChange={(value) => value && setPageSize(Number(value))}
|
||||
data={PAGE_SIZE_OPTIONS}
|
||||
size="xs"
|
||||
w={70}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {start}–{end} of {totalCount} {itemLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<Pagination
|
||||
value={pageIndex + 1}
|
||||
total={pageCount}
|
||||
size="sm"
|
||||
radius="md"
|
||||
onChange={(page) => setPageIndex(page - 1)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleEngineListFooter;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import type { RuleEngineOrderConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
import { getOrderValue } from "./ruleEngineOrder.utils";
|
||||
|
||||
export interface RuleEngineOrderControlsProps {
|
||||
record: RuleEngineRecord;
|
||||
orderConfig: RuleEngineOrderConfig;
|
||||
totalCount: number;
|
||||
disabled?: boolean;
|
||||
onMove: (id: string, direction: "up" | "down") => void;
|
||||
}
|
||||
|
||||
const RuleEngineOrderControls = ({
|
||||
record,
|
||||
orderConfig,
|
||||
totalCount,
|
||||
disabled = false,
|
||||
onMove,
|
||||
}: RuleEngineOrderControlsProps) => {
|
||||
const id = String(record.id);
|
||||
const order = getOrderValue(record, orderConfig.field);
|
||||
|
||||
const canMoveUp = order > 1;
|
||||
const canMoveDown = orderConfig.scopeField ? true : order < totalCount;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Move up">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={disabled || !canMoveUp}
|
||||
onClick={() => onMove(id, "up")}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move down">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={disabled || !canMoveDown}
|
||||
onClick={() => onMove(id, "down")}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleEngineOrderControls;
|
||||
@@ -1,42 +1,50 @@
|
||||
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||
import { LayoutGrid, ListOrdered, Plus, Search, Table2 } from "lucide-react";
|
||||
import { Button, TextInput, Group, SegmentedControl } from "@mantine/core";
|
||||
|
||||
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
||||
|
||||
export interface RuleEngineToolbarProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
search?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
showSearch?: boolean;
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
onManageOrder?: () => void;
|
||||
viewMode: RuleEngineViewMode;
|
||||
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
||||
}
|
||||
|
||||
const RuleEngineToolbar = ({
|
||||
search,
|
||||
search = "",
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchPlaceholder = "Search…",
|
||||
showSearch = true,
|
||||
onAdd,
|
||||
addLabel = "Add",
|
||||
onManageOrder,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: RuleEngineToolbarProps) => (
|
||||
<Group gap="md" justify="space-between" align="center" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={18} />}
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showSearch && onSearchChange ? (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={18} />}
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1 }} />
|
||||
)}
|
||||
|
||||
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
|
||||
<SegmentedControl
|
||||
@@ -72,6 +80,21 @@ const RuleEngineToolbar = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{onManageOrder ? (
|
||||
<Button
|
||||
onClick={onManageOrder}
|
||||
leftSection={<ListOrdered size={18} />}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
variant="light"
|
||||
color="gray"
|
||||
fw={600}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Manage order
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{onAdd ? (
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RuleEngineRecord, RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
|
||||
export const RULE_ENGINE_POSITION_END = "__end__";
|
||||
|
||||
export function getOrderItemLabel(
|
||||
record: RuleEngineRecord,
|
||||
slug: RuleEngineResourceSlug,
|
||||
): string {
|
||||
const code = String(record.code ?? "").trim();
|
||||
switch (slug) {
|
||||
case "cargo-types":
|
||||
return String(record.cargoTypeName ?? (code || record.id));
|
||||
case "container-types":
|
||||
case "yards":
|
||||
case "shipping-lines":
|
||||
return String(record.label ?? (code || record.id));
|
||||
case "service-types":
|
||||
return String(record.serviceName ?? (code || record.id));
|
||||
case "approval-rules":
|
||||
return String(record.actionLabel ?? record.requiredRole ?? record.id);
|
||||
default:
|
||||
return String(record.label ?? record.code ?? record.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrderValue(
|
||||
record: RuleEngineRecord,
|
||||
field: "displayOrder" | "stepOrder",
|
||||
): number {
|
||||
const raw = record[field];
|
||||
return typeof raw === "number" ? raw : Number(raw ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Radio,
|
||||
RingProgress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Train,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useAvailableLocomotives,
|
||||
useEligibleBookings,
|
||||
useScheduleList,
|
||||
useScheduleMutations,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useRoutes } from "@/hooks/useRoutes";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
FreightType,
|
||||
ReschedulePlan,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleListItem,
|
||||
TrainSchedulePreviewResponse,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
|
||||
import {
|
||||
autoFillPlacements,
|
||||
mergePlacementsWithSaved,
|
||||
placementsFromScheduleWagons,
|
||||
validateLocalPlacements,
|
||||
} from "./containerPlacement.util";
|
||||
import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util";
|
||||
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
|
||||
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
|
||||
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
|
||||
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)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
const message = data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
const violations = data?.violations;
|
||||
if (Array.isArray(violations)) return violations.join(", ");
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const formatCountdown = (expiresAt?: string | null) => {
|
||||
if (!expiresAt) return null;
|
||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||
if (diff <= 0) return "Hold expired";
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.floor((diff % 3600000) / 60000);
|
||||
return `${hours}h ${mins}m remaining`;
|
||||
};
|
||||
|
||||
export function AllocateBookingWizard({
|
||||
booking,
|
||||
opened,
|
||||
onClose,
|
||||
initialBookingIds,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
initialBookingIds?: string[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const bookingFreightType = booking.freightType as FreightType;
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [scheduleMode, setScheduleMode] = useState<"existing" | "new">("existing");
|
||||
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const scheduleDate = booking.scheduledDate;
|
||||
const [locomotiveId, setLocomotiveId] = useState("");
|
||||
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
|
||||
const [forceAssign, setForceAssign] = useState(false);
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [assignedSchedule, setAssignedSchedule] = useState<TrainScheduleDetail | null>(null);
|
||||
const [reschedulePlan, setReschedulePlan] = useState<ReschedulePlan | null>(null);
|
||||
const [confirmPreempt, setConfirmPreempt] = useState(false);
|
||||
const [allocationComplete, setAllocationComplete] = useState(false);
|
||||
|
||||
const originId = booking.originYard?.id;
|
||||
const destinationId = booking.destinationYard?.id;
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() => ({
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
}),
|
||||
[originId, destinationId],
|
||||
);
|
||||
|
||||
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
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(
|
||||
(s: TrainScheduleListItem) =>
|
||||
s.status === "DRAFT" &&
|
||||
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),
|
||||
),
|
||||
[schedulesQuery.data, bookingFreightType],
|
||||
);
|
||||
|
||||
const allBookingIds = useMemo(
|
||||
() => [booking.id, ...extraBookingIds.filter((id) => id !== booking.id)],
|
||||
[booking.id, extraBookingIds],
|
||||
);
|
||||
|
||||
const containerUnits = previewResult?.containerUnits ?? [];
|
||||
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
||||
const hasContainerStep = useMemo(
|
||||
() =>
|
||||
shouldShowContainerPlacementStep({
|
||||
containerUnitCount: containerUnits.length,
|
||||
scheduleFreightType: booking.freightType,
|
||||
bookingFreightTypes: [
|
||||
booking.freightType,
|
||||
...(eligibleQuery.data?.items ?? [])
|
||||
.filter((item) => allBookingIds.includes(item.id))
|
||||
.map((item) => item.freightType),
|
||||
],
|
||||
}),
|
||||
[
|
||||
allBookingIds,
|
||||
booking.freightType,
|
||||
containerUnits.length,
|
||||
eligibleQuery.data?.items,
|
||||
],
|
||||
);
|
||||
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setActiveStep(0);
|
||||
setPreviewResult(null);
|
||||
setAssignedSchedule(null);
|
||||
setExtraBookingIds([]);
|
||||
setContainerPlacements([]);
|
||||
setReschedulePlan(null);
|
||||
setConfirmPreempt(false);
|
||||
setAllocationComplete(false);
|
||||
return;
|
||||
}
|
||||
if (initialBookingIds?.length) {
|
||||
setExtraBookingIds(initialBookingIds.filter((id) => id !== booking.id));
|
||||
}
|
||||
}, [opened, booking.id, initialBookingIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (matchingSchedules.length && !selectedScheduleId) {
|
||||
setSelectedScheduleId(matchingSchedules[0].id);
|
||||
}
|
||||
}, [matchingSchedules, selectedScheduleId]);
|
||||
|
||||
const savedPlacementsFromSchedule = useMemo(
|
||||
() =>
|
||||
assignedSchedule?.trainSet?.wagons
|
||||
? placementsFromScheduleWagons(assignedSchedule.trainSet.wagons)
|
||||
: [],
|
||||
[assignedSchedule?.trainSet?.wagons],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerUnits.length || !containerSlots.length) return;
|
||||
|
||||
setContainerPlacements((current) => {
|
||||
if (current.length && current.some((p) => p.containerNumber?.trim())) {
|
||||
return current;
|
||||
}
|
||||
const autoFilled = autoFillPlacements(containerUnits, containerSlots);
|
||||
if (savedPlacementsFromSchedule.length) {
|
||||
return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule);
|
||||
}
|
||||
if (current.length) return current;
|
||||
return autoFilled;
|
||||
});
|
||||
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
||||
[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) {
|
||||
throw new Error("Select route, date, and locomotive");
|
||||
}
|
||||
const created = await create.mutateAsync({
|
||||
payload: { routeId, scheduleDate, locomotiveId },
|
||||
});
|
||||
setSelectedScheduleId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!originId || !destinationId) {
|
||||
toast({ title: "Booking missing origin or destination", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const targetScheduleId =
|
||||
scheduleMode === "existing" ? (selectedScheduleId ?? undefined) : undefined;
|
||||
const result = await preview.mutateAsync({
|
||||
payload: {
|
||||
bookingIds: allBookingIds,
|
||||
scheduleDate,
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
targetScheduleId,
|
||||
},
|
||||
});
|
||||
setPreviewResult(result);
|
||||
if (booking.isGovernment && targetScheduleId) {
|
||||
const plan = (await trainSchedulingService.previewReschedule(targetScheduleId, {
|
||||
incomingBookingIds: allBookingIds,
|
||||
trigger: "GOVERNMENT_PREEMPT",
|
||||
})) as ReschedulePlan;
|
||||
setReschedulePlan(plan);
|
||||
} else {
|
||||
setReschedulePlan(null);
|
||||
}
|
||||
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
|
||||
const autoFilled = autoFillPlacements(
|
||||
result.containerUnits,
|
||||
result.containerSlotSequenceNos,
|
||||
);
|
||||
setContainerPlacements(autoFilled);
|
||||
}
|
||||
setActiveStep(1);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Preview failed",
|
||||
description: parseError(err, "Could not preview"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (hasContainerStep) {
|
||||
const issues = validateLocalPlacements(containerUnits, containerPlacements);
|
||||
if (issues.length) {
|
||||
toast({
|
||||
title: "Complete container assignments",
|
||||
description: issues.join(", "),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (reschedulePlan?.displaced.length && !confirmPreempt) {
|
||||
toast({
|
||||
title: "Confirm displacement",
|
||||
description: "Acknowledge displaced bookings before assigning",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduleId = await ensureSchedule();
|
||||
let result: TrainScheduleDetail;
|
||||
if (reschedulePlan?.displaced.length) {
|
||||
const executed = await trainSchedulingService.executeReschedule(scheduleId, {
|
||||
incomingBookingIds: allBookingIds,
|
||||
trigger: "GOVERNMENT_PREEMPT",
|
||||
finalBookingIds: reschedulePlan.finalBookingIds,
|
||||
displacedBookingIds: reschedulePlan.displaced.map((b) => b.id),
|
||||
});
|
||||
result = (executed as { schedule: TrainScheduleDetail }).schedule;
|
||||
} else {
|
||||
result = await assign.mutateAsync({
|
||||
id: scheduleId,
|
||||
freightType: previewFreightType,
|
||||
payload: {
|
||||
bookingIds: allBookingIds,
|
||||
forceAssign,
|
||||
containerPlacements: hasContainerStep ? containerPlacements : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
setAssignedSchedule(result);
|
||||
const saved = result.trainSet?.wagons
|
||||
? placementsFromScheduleWagons(result.trainSet.wagons)
|
||||
: [];
|
||||
if (saved.length) {
|
||||
setContainerPlacements(saved);
|
||||
}
|
||||
setActiveStep(finalizeStep);
|
||||
toast({ title: "Bookings assigned — wagons auto-pinned" });
|
||||
if (result.deferredBookings?.length) {
|
||||
toast({
|
||||
title: "Partial assignment",
|
||||
description: `${result.deferredBookings.length} booking(s) deferred to next train`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Assign failed",
|
||||
description: parseError(err, "Could not assign"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
const scheduleId = assignedSchedule?.id ?? selectedScheduleId;
|
||||
if (!scheduleId) return;
|
||||
try {
|
||||
const finalized = await finalize.mutateAsync(scheduleId);
|
||||
setAssignedSchedule(finalized);
|
||||
setAllocationComplete(true);
|
||||
toast({ title: "Schedule finalized — booking allocated" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Finalize failed",
|
||||
description: parseError(err, "Could not finalize schedule"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 containerComplete =
|
||||
hasContainerStep &&
|
||||
containerUnits.length > 0 &&
|
||||
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
||||
|
||||
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}
|
||||
withCloseButton
|
||||
size="90%"
|
||||
radius="lg"
|
||||
centered
|
||||
padding="lg"
|
||||
styles={{ content: { maxWidth: 1200 }, body: { paddingTop: 8 } }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Box maw={360}>
|
||||
<RouteCorridor
|
||||
onDark
|
||||
origin={booking.originYard?.name ?? booking.originYard?.label}
|
||||
destination={
|
||||
booking.destinationYard?.name ?? booking.destinationYard?.label
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Group gap="sm" align="center">
|
||||
<FreightTypeBadge freightType={booking.freightType} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
{previewResult ? (
|
||||
<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)",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</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}
|
||||
/>
|
||||
<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"}`}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2, Container } from "lucide-react";
|
||||
|
||||
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
|
||||
|
||||
import { autoFillPlacements, unitKey, validateLocalPlacements } from "./containerPlacement.util";
|
||||
|
||||
export function ContainerPlacementGrid({
|
||||
units,
|
||||
containerSlots,
|
||||
placements,
|
||||
onChange,
|
||||
}: {
|
||||
units: ContainerUnitRow[];
|
||||
containerSlots: number[];
|
||||
placements: ContainerPlacement[];
|
||||
onChange: (placements: ContainerPlacement[]) => void;
|
||||
}) {
|
||||
const placementMap = useMemo(() => {
|
||||
const map = new Map<string, ContainerPlacement>();
|
||||
for (const placement of placements) {
|
||||
map.set(unitKey(placement.bookingContainerId, placement.unitIndex), placement);
|
||||
}
|
||||
return map;
|
||||
}, [placements]);
|
||||
|
||||
const issues = useMemo(() => validateLocalPlacements(units, placements), [units, placements]);
|
||||
|
||||
const completedCount = useMemo(
|
||||
() =>
|
||||
units.filter((unit) => {
|
||||
const placement = placementMap.get(unitKey(unit.bookingContainerId, unit.unitIndex));
|
||||
return placement?.sequenceNo && placement.containerNumber?.trim();
|
||||
}).length,
|
||||
[units, placementMap],
|
||||
);
|
||||
|
||||
const slotOptions = containerSlots.map((seq) => ({
|
||||
value: String(seq),
|
||||
label: `Wagon #${seq}`,
|
||||
}));
|
||||
|
||||
const updatePlacement = (unit: ContainerUnitRow, patch: Partial<ContainerPlacement>) => {
|
||||
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
|
||||
const existing = placementMap.get(key);
|
||||
const next: ContainerPlacement = {
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo: existing?.sequenceNo ?? containerSlots[0] ?? 1,
|
||||
containerNumber: existing?.containerNumber,
|
||||
sealNumber: existing?.sealNumber,
|
||||
...patch,
|
||||
};
|
||||
onChange([
|
||||
...placements.filter(
|
||||
(p) => !(p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex),
|
||||
),
|
||||
next,
|
||||
]);
|
||||
};
|
||||
|
||||
if (!units.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No container units in this selection.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = units.length ? Math.round((completedCount / units.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Container size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
Container assignment
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Map each booking unit to a wagon slot and enter the container number. One wagon fits
|
||||
either 1×40ft or 2×20ft containers.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() => onChange(autoFillPlacements(units, containerSlots))}
|
||||
>
|
||||
Auto-fill slots
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6} mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{completedCount} of {units.length} units complete
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{progress}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progress}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={issues.length ? "yellow" : "green"}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{issues.length ? (
|
||||
<Stack gap={6}>
|
||||
{issues.map((issue) => (
|
||||
<Badge key={issue} color="red" variant="light" size="sm" w="fit-content">
|
||||
{issue}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
size="sm"
|
||||
w="fit-content"
|
||||
leftSection={<CheckCircle2 size={12} />}
|
||||
>
|
||||
All units mapped
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{units.map((unit) => {
|
||||
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
|
||||
const placement = placementMap.get(key);
|
||||
const isComplete = placement?.sequenceNo && placement.containerNumber?.trim();
|
||||
|
||||
return (
|
||||
<Card key={key} radius="xl" padding="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{unit.bookingReference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="sm" variant="light" color={isComplete ? "green" : "gray"}>
|
||||
{isComplete ? "Ready" : "Pending"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Wagon slot"
|
||||
size="sm"
|
||||
data={slotOptions}
|
||||
value={placement?.sequenceNo ? String(placement.sequenceNo) : null}
|
||||
onChange={(value) =>
|
||||
updatePlacement(unit, { sequenceNo: Number(value ?? containerSlots[0]) })
|
||||
}
|
||||
placeholder="Select wagon"
|
||||
searchable
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
size="sm"
|
||||
placeholder="e.g. MSCU1234567"
|
||||
value={placement?.containerNumber ?? ""}
|
||||
onChange={(e) =>
|
||||
updatePlacement(unit, {
|
||||
containerNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useMemo } from "react";
|
||||
import { ArrowRight, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||
import { groupBookingsByThreeHourWindow } from "@/utils/groupBookingsByThreeHourWindow";
|
||||
|
||||
function EligibleBookingRow({
|
||||
booking,
|
||||
freightType,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
booking: EligibleContainerBooking;
|
||||
freightType?: FreightType;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const resolvedFreightType = booking.freightType ?? freightType;
|
||||
const isBulk = resolvedFreightType === "BULK";
|
||||
|
||||
return (
|
||||
<Group
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
border: `1px solid ${
|
||||
selected ? "var(--mantine-color-green-3)" : "var(--mantine-color-gray-2)"
|
||||
}`,
|
||||
borderRadius: 12,
|
||||
background: selected ? "var(--mantine-color-green-0)" : "white",
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} onChange={onToggle} mt={4} color="green" />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Package size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{resolvedFreightType ? (
|
||||
<Badge variant="outline" size="xs">
|
||||
{resolvedFreightType}
|
||||
</Badge>
|
||||
) : null}
|
||||
{booking.schedulingStatus ? (
|
||||
<Badge variant="light" size="xs">
|
||||
{booking.schedulingStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.customer}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
<Text size="xs">{booking.origin}</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="xs">{booking.destination}</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<BookingPriorityBadge score={booking.priorityScore ?? 0} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{isBulk
|
||||
? `${booking.weightTons}T`
|
||||
: `${booking.quantity} × ${booking.containerType}`}
|
||||
</Text>
|
||||
{booking.preferredDepartureDate ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(booking.preferredDepartureDate).toLocaleString("en-GB", {
|
||||
timeZone: "UTC",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}{" "}
|
||||
UTC
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function EligibleBookingsPanel({
|
||||
items,
|
||||
isLoading,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
assignedIds = [],
|
||||
freightType,
|
||||
}: {
|
||||
items: EligibleContainerBooking[];
|
||||
isLoading?: boolean;
|
||||
selectedIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
assignedIds?: string[];
|
||||
freightType?: FreightType;
|
||||
}) {
|
||||
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
|
||||
|
||||
const availableItems = useMemo(
|
||||
() => items.filter((b) => !assignedSet.has(b.id)),
|
||||
[items, assignedSet],
|
||||
);
|
||||
|
||||
const buckets = useMemo(
|
||||
() => groupBookingsByThreeHourWindow(availableItems),
|
||||
[availableItems],
|
||||
);
|
||||
|
||||
const selectableIds = useMemo(() => {
|
||||
return [...assignedIds, ...availableItems.map((b) => b.id)];
|
||||
}, [availableItems, assignedIds]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChange(selectedIds.filter((x) => x !== id));
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBucket = (bucketIds: string[], select: boolean) => {
|
||||
if (select) {
|
||||
const merged = new Set([...selectedIds, ...bucketIds]);
|
||||
onSelectionChange([...merged]);
|
||||
} else {
|
||||
onSelectionChange(selectedIds.filter((id) => !bucketIds.includes(id)));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading eligible bookings…
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items.length && !assignedIds.length) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No eligible bookings for this corridor
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="sm" fw={500}>
|
||||
Eligible bookings ({availableItems.length})
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() => onSelectionChange(selectableIds)}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
<Button variant="subtle" size="compact-sm" onClick={() => onSelectionChange(assignedIds)}>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{buckets.length > 0 ? (
|
||||
<Accordion defaultValue={buckets[0]?.key} variant="separated" radius="lg">
|
||||
{buckets.map((bucket) => {
|
||||
const bucketIds = bucket.bookings.map((b) => b.id);
|
||||
const selectedInBucket = bucketIds.filter((id) => selectedIds.includes(id));
|
||||
const allSelected = bucketIds.length > 0 && selectedInBucket.length === bucketIds.length;
|
||||
|
||||
return (
|
||||
<Accordion.Item key={bucket.key} value={bucket.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{bucket.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{bucket.bookings.length} booking
|
||||
{bucket.bookings.length === 1 ? "" : "s"} · priority sorted
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Badge variant="light" color="green">
|
||||
{selectedInBucket.length} selected
|
||||
</Badge>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleBucket(bucketIds, !allSelected);
|
||||
}}
|
||||
>
|
||||
{allSelected ? "Deselect bucket" : "Select bucket"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{bucket.bookings.map((booking) => (
|
||||
<EligibleBookingRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
freightType={freightType}
|
||||
selected={selectedIds.includes(booking.id)}
|
||||
onToggle={() => toggle(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No additional eligible bookings in this corridor.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle, Train } from "lucide-react";
|
||||
|
||||
import type { DeferredBookingRow, FleetAvailabilityRow } from "@/types/trainScheduling";
|
||||
|
||||
export function FleetAvailabilitySummary({
|
||||
fleetAvailability = [],
|
||||
deferredBookings = [],
|
||||
}: {
|
||||
fleetAvailability?: FleetAvailabilityRow[];
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
}) {
|
||||
if (!fleetAvailability.length && !deferredBookings.length) return null;
|
||||
|
||||
const totalNeeded = fleetAvailability.reduce((sum, row) => sum + row.needed, 0);
|
||||
const totalAvailable = fleetAvailability.reduce((sum, row) => sum + row.available, 0);
|
||||
const totalShortfall = fleetAvailability.reduce((sum, row) => sum + row.shortfall, 0);
|
||||
const fillRate =
|
||||
totalNeeded > 0 ? Math.round((Math.min(totalAvailable, totalNeeded) / totalNeeded) * 100) : 100;
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
<Train size={18} />
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
Fleet wagon availability
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Plan is capped to available physical wagons by type
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "green"}>
|
||||
{fillRate}% fleet coverage
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{totalNeeded > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.min(totalAvailable, totalNeeded)} of {totalNeeded} wagon slots can be filled
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={fillRate}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={totalShortfall > 0 ? "yellow" : "green"}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{fleetAvailability.length > 0 ? (
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon type</Table.Th>
|
||||
<Table.Th>Needed</Table.Th>
|
||||
<Table.Th>Available</Table.Th>
|
||||
<Table.Th>Shortfall</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{fleetAvailability.map((row) => (
|
||||
<Table.Tr key={row.wagonTypeId}>
|
||||
<Table.Td>{row.wagonTypeCode}</Table.Td>
|
||||
<Table.Td>{row.needed}</Table.Td>
|
||||
<Table.Td>{row.available}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.shortfall > 0 ? (
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{row.shortfall}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="green">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : null}
|
||||
|
||||
{totalShortfall > 0 || deferredBookings.length > 0 ? (
|
||||
<Alert color="yellow" variant="light" radius="lg" icon={<AlertTriangle size={16} />}>
|
||||
<Text size="sm">
|
||||
Train will depart with available wagons only.
|
||||
{deferredBookings.length
|
||||
? ` ${deferredBookings.length} booking(s) will wait for the next train.`
|
||||
: ""}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{deferredBookings.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||
{deferredBookings.map((booking) => (
|
||||
<Paper key={booking.id} p="sm" radius="lg" withBorder bg="gray.0">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.reason}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
Next train
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle, Link2, Wand2 } from "lucide-react";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability";
|
||||
|
||||
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
|
||||
|
||||
export function PinWagonsForm({
|
||||
schedule,
|
||||
availableWagons,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
autoFillOnMount = true,
|
||||
}: {
|
||||
schedule: TrainScheduleDetail;
|
||||
availableWagons: Wagon[];
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (assignments: PinWagonAssignment[]) => void;
|
||||
autoFillOnMount?: boolean;
|
||||
}) {
|
||||
const originYardId = schedule.originStation?.id;
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
|
||||
const wagonOptionsByType = useMemo(() => {
|
||||
const map = new Map<string, Array<{ value: string; label: string }>>();
|
||||
for (const wagon of availableWagons) {
|
||||
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
|
||||
if (
|
||||
!wagonMatchesScheduleOrigin(wagon, originYardId, {
|
||||
allowPinned: isPinnedOnSlot,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (wagon.status !== Freight.WagonStatus.Available && !isPinnedOnSlot) {
|
||||
continue;
|
||||
}
|
||||
const typeId = wagon.wagonTypeId;
|
||||
const list = map.get(typeId) ?? [];
|
||||
list.push({ value: wagon.id, label: wagon.wagonNumber });
|
||||
map.set(typeId, list);
|
||||
}
|
||||
return map;
|
||||
}, [availableWagons, originYardId, slots]);
|
||||
|
||||
const runAutoFill = useCallback(
|
||||
(preserveManual = false) => {
|
||||
const existing = preserveManual ? assignments : {};
|
||||
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType, existing));
|
||||
},
|
||||
[assignments, slots, wagonOptionsByType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFillOnMount || !slots.length) return;
|
||||
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType));
|
||||
}, [schedule.id, slots, wagonOptionsByType, autoFillOnMount]);
|
||||
|
||||
const fillStats = useMemo(
|
||||
() => countFilledSlots(slots, assignments),
|
||||
[slots, assignments],
|
||||
);
|
||||
|
||||
const progress =
|
||||
fillStats.total > 0 ? Math.round((fillStats.filled / fillStats.total) * 100) : 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const payload: PinWagonAssignment[] = Object.entries(assignments)
|
||||
.filter(([, wagonId]) => Boolean(wagonId))
|
||||
.map(([trainSetWagonId, physicalWagonId]) => ({ trainSetWagonId, physicalWagonId }));
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
if (!slots.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Assign bookings first to create wagon slots.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Link2 size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
Pin physical wagons
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Match each train slot to a fleet wagon. Slots are auto-filled when possible.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<Wand2 size={14} />}
|
||||
onClick={() => runAutoFill(false)}
|
||||
>
|
||||
Auto-fill all slots
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6} mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{fillStats.filled} of {fillStats.total} slots filled
|
||||
</Text>
|
||||
<Badge variant="light" color={progress === 100 ? "teal" : "yellow"}>
|
||||
{progress}%
|
||||
</Badge>
|
||||
</Group>
|
||||
<Progress value={progress} size="sm" radius="xl" color={progress === 100 ? "teal" : "yellow"} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{fillStats.unfilledSlotNumbers.length > 0 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="lg"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Some slots could not be auto-filled"
|
||||
>
|
||||
<Text size="sm">
|
||||
No matching fleet wagon for slot
|
||||
{fillStats.unfilledSlotNumbers.length === 1 ? "" : "s"} #
|
||||
{fillStats.unfilledSlotNumbers.join(", #")}. Select manually or add wagons to the fleet.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
{slots.map((slot) => {
|
||||
const typeId = slot.wagonType?.id ?? "";
|
||||
const options =
|
||||
wagonOptionsByType.get(typeId) ??
|
||||
availableWagons.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.wagonNumber,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Paper key={slot.id} p="md" radius="lg" withBorder>
|
||||
<Group align="flex-end" wrap="nowrap" gap="md">
|
||||
<Stack gap={2} style={{ minWidth: 90 }}>
|
||||
<Group gap={6}>
|
||||
<ThemeIcon size="sm" radius="md" variant="light" color="teal">
|
||||
<Text size="xs" fw={700}>
|
||||
{slot.sequenceNo}
|
||||
</Text>
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
Slot #{slot.sequenceNo}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{slot.wagonType?.code ?? "—"} · {slot.capacityTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Select physical wagon"
|
||||
data={options}
|
||||
value={assignments[slot.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAssignments((current) => ({
|
||||
...current,
|
||||
[slot.id]: value ?? "",
|
||||
}))
|
||||
}
|
||||
searchable
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button color="teal" loading={isSubmitting} onClick={handleSubmit}>
|
||||
Pin wagons
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
|
||||
export function RescheduleTrainDialog({
|
||||
scheduleId,
|
||||
currentBookingIds,
|
||||
opened,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
currentBookingIds: string[];
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
const [newDepartureDate, setNewDepartureDate] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!newDepartureDate) {
|
||||
toast.error("Select a new departure date");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await trainSchedulingService.maintenanceReschedule(scheduleId, {
|
||||
incomingBookingIds: currentBookingIds,
|
||||
newDepartureDate: new Date(newDepartureDate).toISOString(),
|
||||
reason,
|
||||
});
|
||||
toast.success("Train rescheduled for maintenance");
|
||||
onComplete?.();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error("Reschedule failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Reschedule train (maintenance)" radius="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Updates departure and rebalances bookings on this train. Displaced bookings return to
|
||||
the operations queue when capacity is insufficient.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="New departure"
|
||||
type="datetime-local"
|
||||
value={newDepartureDate}
|
||||
onChange={(e) => setNewDepartureDate(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="e.g. Locomotive maintenance"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={loading} onClick={handleSubmit}>
|
||||
Reschedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user