mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge freight/develop into Warehouse_updates
This commit is contained in:
@@ -9,10 +9,7 @@
|
||||
"preview": "vite preview --port 5183",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit",
|
||||
"build:user-management": "cd user-management-config && npm run build",
|
||||
"backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice",
|
||||
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
@@ -22,7 +19,7 @@
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tria-plc/iamui-common": "1.1.2",
|
||||
"@tria-plc/iamui": "0.0.3",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -44,6 +44,7 @@ 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 CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||
@@ -212,41 +213,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Administration",
|
||||
items: [
|
||||
{
|
||||
label: "User management",
|
||||
href: "/dashboard/user-management",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
children: [
|
||||
{
|
||||
label: "Users",
|
||||
href: "/dashboard/user-management/users",
|
||||
},
|
||||
{
|
||||
label: "Employees",
|
||||
href: "/dashboard/user-management/employees",
|
||||
},
|
||||
{
|
||||
label: "Position Types",
|
||||
href: "/dashboard/user-management/position-types",
|
||||
},
|
||||
{
|
||||
label: "Permissions",
|
||||
href: "/dashboard/user-management/permissions",
|
||||
},
|
||||
{
|
||||
label: "Roles",
|
||||
href: "/dashboard/user-management/roles",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "File settings",
|
||||
href: "/dashboard/file-settings",
|
||||
icon: <Paperclip />,
|
||||
{
|
||||
title: "Administration",
|
||||
items: [
|
||||
{
|
||||
label: "File settings",
|
||||
href: "/dashboard/file-settings",
|
||||
icon: <Paperclip />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
{
|
||||
@@ -339,222 +312,216 @@ const App = () => {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path='/um/*' element={<UserManagementHostPage />} />
|
||||
<Route path="*" element={<Navigate to="/um" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<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="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
<Route
|
||||
path="payments"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||
<PaymentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchBoardPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchScheduleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2ListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2DetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleTrackPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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 />} />
|
||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
|
||||
<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-configs" replace />}
|
||||
/>
|
||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
<Route
|
||||
path="rule-engine"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
/>
|
||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||
|
||||
<Route path="user1" element={<DemoUser1Page />} />
|
||||
<Route path="user2" element={<DemoUser2Page />} />
|
||||
|
||||
<Route
|
||||
path="org-structure"
|
||||
element={<Navigate to="/dashboard/user-management" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="org-structure/*"
|
||||
element={<Navigate to="/dashboard/user-management" replace />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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 />} />
|
||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
|
||||
<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/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
<Route
|
||||
path="rules"
|
||||
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
|
||||
/>
|
||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
<Route
|
||||
path="rule-engine"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
/>
|
||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||
|
||||
<Route path="user1" element={<DemoUser1Page />} />
|
||||
<Route path="user2" element={<DemoUser2Page />} />
|
||||
|
||||
<Route path="org-structure" element={<Navigate to="/um" replace />} />
|
||||
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -226,7 +226,7 @@ const FreightSidebar = ({
|
||||
return (
|
||||
<Box component="aside" className="fsb-aside">
|
||||
<div className="fsb-brand">
|
||||
<div className="fsb-logo">
|
||||
<div className="fsb-logo">
|
||||
<Train size={23} color="white" strokeWidth={2.1} />
|
||||
</div>
|
||||
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
|
||||
|
||||
@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
|
||||
@@ -132,6 +132,29 @@ export const useBookableSchedules = (
|
||||
enabled: Boolean(originYardId && destinationYardId),
|
||||
});
|
||||
|
||||
/**
|
||||
* Day-level pool: which days have an OPEN departure on the route. Staff pick a
|
||||
* day (not a train) when creating a booking; the engine assigns the train.
|
||||
*/
|
||||
export const useAvailableDays = (
|
||||
originYardId?: string | null,
|
||||
destinationYardId?: string | null,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"available-days",
|
||||
originYardId ?? "",
|
||||
destinationYardId ?? "",
|
||||
],
|
||||
queryFn: () =>
|
||||
trainSchedulingService.getAvailableDays(
|
||||
originYardId ?? undefined,
|
||||
destinationYardId ?? undefined,
|
||||
),
|
||||
enabled: Boolean(originYardId && destinationYardId),
|
||||
});
|
||||
|
||||
export const useTrainTrack = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
||||
|
||||
@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { api } from "@/auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
|
||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
|
||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
|
||||
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
|
||||
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
||||
|
||||
// container freight
|
||||
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
|
||||
label: c.name || c.email || c.tin || c.id,
|
||||
}));
|
||||
|
||||
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
|
||||
// Day-level pool: fetch only the days that have a departure on the route (no
|
||||
// train, no capacity). The batch engine assigns the train after booking.
|
||||
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
||||
s.scheduleDate,
|
||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
|
||||
const dayOptions = (availableDays ?? []).map((day) => ({
|
||||
value: day,
|
||||
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
}));
|
||||
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
|
||||
const hasAvailableDays = (availableDays ?? []).length > 0;
|
||||
|
||||
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
|
||||
const effectiveDepartureIso = selectedSchedule
|
||||
? new Date(selectedSchedule.scheduleDate).toISOString()
|
||||
: scheduledDate
|
||||
? new Date(scheduledDate).toISOString()
|
||||
: "";
|
||||
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
|
||||
const effectiveDepartureIso = scheduledDay
|
||||
? new Date(`${scheduledDay}T00:00:00`).toISOString()
|
||||
: "";
|
||||
|
||||
const yardRecords = refData?.yard ?? [];
|
||||
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
||||
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
||||
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
||||
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
|
||||
|
||||
// Reset the day when the route changes — available days depend on the route.
|
||||
useEffect(() => {
|
||||
setTrainScheduleId(null);
|
||||
setScheduledDay(null);
|
||||
}, [originYardId, destinationYardId]);
|
||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
@@ -302,16 +305,15 @@ export default function NewBookingPage() {
|
||||
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
||||
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
||||
|
||||
const scheduleSatisfied =
|
||||
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
|
||||
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
|
||||
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
|
||||
// the train afterwards (same flow as the customer portal).
|
||||
const departureSatisfied = Boolean(scheduledDay);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(originYardId) &&
|
||||
Boolean(destinationYardId) &&
|
||||
!sameYard &&
|
||||
Boolean(tradeDirection) &&
|
||||
scheduleSatisfied &&
|
||||
Boolean(serviceTypeId) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
|
||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
trainScheduleId: trainScheduleId || undefined,
|
||||
// Day-level pool: no trainScheduleId — the engine assigns the train.
|
||||
serviceTypeId,
|
||||
shippingLineId: shippingLineId || undefined,
|
||||
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
||||
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
|
||||
value={destinationYardId}
|
||||
onChange={(v) => {
|
||||
setDestinationYardId(v);
|
||||
setTrainScheduleId(null);
|
||||
setScheduledDay(null);
|
||||
}}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
error={sameYard ? "Same as origin" : undefined}
|
||||
/>
|
||||
</Group>
|
||||
{hasBookableSchedules ? (
|
||||
<Select
|
||||
label="Train schedule"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select an open schedule on this route"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={scheduleOptions}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
searchable
|
||||
required
|
||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
||||
nothingFoundMessage="No open schedules on this route"
|
||||
description="The booking will be batched against this schedule once its contract is signed."
|
||||
/>
|
||||
) : originYardId && destinationYardId ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No open train schedule on this route — set a preferred departure below. Staff can
|
||||
link a schedule later.
|
||||
</Text>
|
||||
) : null}
|
||||
<Select
|
||||
label="Shipment day"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select a day with a departure"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={dayOptions}
|
||||
value={scheduledDay}
|
||||
onChange={setScheduledDay}
|
||||
searchable
|
||||
disabled={!originYardId || !destinationYardId || daysLoading}
|
||||
nothingFoundMessage={
|
||||
hasAvailableDays ? "No match" : "No departures on this route"
|
||||
}
|
||||
description="Pick a day with a departure. The batch engine assigns the train by priority."
|
||||
/>
|
||||
<Group grow align="flex-end">
|
||||
<Select
|
||||
label="Service type"
|
||||
@@ -528,21 +524,22 @@ export default function NewBookingPage() {
|
||||
|
||||
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
||||
<Group grow align="flex-start">
|
||||
{selectedSchedule ? (
|
||||
<TextInput
|
||||
label="Departure"
|
||||
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
|
||||
readOnly
|
||||
description="Taken from the selected train schedule"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Preferred departure"
|
||||
type="datetime-local"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="Shipment day"
|
||||
value={
|
||||
scheduledDay
|
||||
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: ""
|
||||
}
|
||||
placeholder="Pick a day in the Route section"
|
||||
readOnly
|
||||
description="The engine assigns the train on this day"
|
||||
/>
|
||||
<Select
|
||||
label="Payment currency"
|
||||
data={[
|
||||
|
||||
@@ -1,113 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { getCookie } from '@/auth/cookies';
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import {
|
||||
UserManagementApp,
|
||||
type UserManagementRuntimeOptions,
|
||||
type UserManagementSessionSeed,
|
||||
} from "@tria-plc/iamui";
|
||||
|
||||
function readToken(): string | null {
|
||||
return getCookie('auth-token') ?? null;
|
||||
}
|
||||
import { getCookie } from "@/auth/cookies";
|
||||
|
||||
function readRefreshToken(): string | null {
|
||||
return getCookie('refresh-token') ?? null;
|
||||
import { iamConfig } from "./iamConfig";
|
||||
|
||||
function readInitialSession(): UserManagementSessionSeed | null {
|
||||
const token = getCookie("auth-token");
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshToken = getCookie("refresh-token") ?? undefined;
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
rememberMe: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function UserManagementHostPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = useRef<Root | null>(null);
|
||||
const unmountTimerRef = useRef<number | null>(null);
|
||||
|
||||
const mountBase = (
|
||||
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
|
||||
).replace(/\/$/, '');
|
||||
|
||||
const moduleOrigin = window.location.origin;
|
||||
|
||||
const [iframeSrc] = useState(() => {
|
||||
const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, '');
|
||||
return mountBase + (sub || '/') + location.search;
|
||||
});
|
||||
|
||||
// ✅ Send token when iframe loads
|
||||
const handleIframeLoad = () => {
|
||||
const token = readToken();
|
||||
const refreshToken = readRefreshToken();
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
|
||||
if (!token) {
|
||||
console.warn('⚠️ No authentication token found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
console.warn('⚠️ No iframe reference');
|
||||
return;
|
||||
}
|
||||
|
||||
target.postMessage(
|
||||
{
|
||||
type: 'UM_AUTH_TOKEN',
|
||||
token,
|
||||
refreshToken,
|
||||
},
|
||||
moduleOrigin
|
||||
);
|
||||
|
||||
console.log('✅ Token sent to iframe module');
|
||||
};
|
||||
|
||||
// ✅ Listen for messages from iframe
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
// Security: Only accept from same origin
|
||||
if (event.origin !== moduleOrigin) {
|
||||
console.warn('🚫 Blocked message from different origin:', event.origin);
|
||||
return;
|
||||
}
|
||||
const mountNode = mountRef.current;
|
||||
|
||||
const data = event.data as { type?: string; path?: string } | undefined;
|
||||
if (!data) return;
|
||||
if (!mountNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle auth request (if module asks for token again)
|
||||
if (data.type === 'UM_REQUEST_AUTH') {
|
||||
const token = readToken();
|
||||
const refreshToken = readRefreshToken();
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
if (unmountTimerRef.current !== null) {
|
||||
window.clearTimeout(unmountTimerRef.current);
|
||||
unmountTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (token && target) {
|
||||
target.postMessage(
|
||||
{
|
||||
type: 'UM_AUTH_TOKEN',
|
||||
token,
|
||||
refreshToken,
|
||||
},
|
||||
moduleOrigin
|
||||
);
|
||||
console.log('✅ Token resent to iframe (on request)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!rootRef.current) {
|
||||
rootRef.current = createRoot(mountNode);
|
||||
}
|
||||
|
||||
// Handle route synchronization
|
||||
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
|
||||
const target = '/dashboard/um' + data.path;
|
||||
if (window.location.pathname + window.location.search !== target) {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
}
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
|
||||
const iamApiUrl = "/um-api";
|
||||
const runtime: UserManagementRuntimeOptions = {
|
||||
basename: "/um",
|
||||
apiBaseUrl,
|
||||
apiUrl: iamApiUrl,
|
||||
recordApiUrl: iamApiUrl,
|
||||
chronicleUrl: iamApiUrl,
|
||||
auditApiUrl: iamApiUrl,
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [moduleOrigin, navigate]);
|
||||
rootRef.current.render(
|
||||
<UserManagementApp
|
||||
config={iamConfig}
|
||||
runtime={runtime}
|
||||
session={{
|
||||
initialSession: readInitialSession(),
|
||||
enableEmbeddedAuthBridge: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="User Management"
|
||||
src={iframeSrc}
|
||||
onLoad={handleIframeLoad}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return () => {
|
||||
unmountTimerRef.current = window.setTimeout(() => {
|
||||
rootRef.current?.unmount();
|
||||
rootRef.current = null;
|
||||
unmountTimerRef.current = null;
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { DesignConfig } from "@tria-plc/iamui";
|
||||
|
||||
import {
|
||||
FREIGHT_BRAND,
|
||||
FREIGHT_BRAND_DARK,
|
||||
FREIGHT_BRAND_LIGHT,
|
||||
freightBrand,
|
||||
} from "@/theme/freight-brand";
|
||||
|
||||
export const iamConfig: DesignConfig = {
|
||||
brand: {
|
||||
appName: "EDR Freight Backoffice",
|
||||
logoUrl: "/assets/logo.svg",
|
||||
},
|
||||
colors: {
|
||||
primary: FREIGHT_BRAND,
|
||||
primaryForeground: "#ffffff",
|
||||
secondary: "#f4f7fb",
|
||||
background: "#f7f9fb",
|
||||
foreground: "#0f172a",
|
||||
border: "#eef1f4",
|
||||
muted: "#f1f5f9",
|
||||
mutedForeground: "#64748b",
|
||||
card: "#ffffff",
|
||||
sidebar: "#ffffff",
|
||||
danger: "#ef4444",
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
headingFontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
baseFontSize: "15px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
shape: {
|
||||
radius: "1rem",
|
||||
},
|
||||
shadows: {
|
||||
card: "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
dropdown: "0 12px 30px rgba(15, 23, 42, 0.12)",
|
||||
modal: "0 20px 45px rgba(15, 23, 42, 0.2)",
|
||||
},
|
||||
components: {
|
||||
buttonDefaultVariant: "filled",
|
||||
inputDefaultSize: "sm",
|
||||
inputRadius: "md",
|
||||
modalRadius: "lg",
|
||||
tableHighlightOnHover: true,
|
||||
},
|
||||
layout: {
|
||||
userManagementView: "classic",
|
||||
showTopBar: true,
|
||||
sidebarWidth: "280px",
|
||||
sidebarCollapsedWidth: "80px",
|
||||
headerHeight: "80px",
|
||||
contentMaxWidth: "none",
|
||||
sidebarBackground: "#ffffff",
|
||||
sidebarColor: "#475569",
|
||||
sidebarMutedColor: "#94a3b8",
|
||||
sidebarActiveBackground:
|
||||
"linear-gradient(135deg, rgba(45, 191, 149, 0.14) 0%, rgba(27, 158, 122, 0.06) 100%)",
|
||||
sidebarActiveColor: FREIGHT_BRAND,
|
||||
sidebarHoverBackground: "#f5f7fa",
|
||||
sidebarBorder: "#eef1f4",
|
||||
sidebarRail: `linear-gradient(180deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||
sidebarBrandLabel: "EDR Freight",
|
||||
sidebarBrandSublabel: "Backoffice Console",
|
||||
menuBackground: "#ffffff",
|
||||
menuActiveColor: FREIGHT_BRAND,
|
||||
menuActiveBorderColor: FREIGHT_BRAND,
|
||||
menuColor: "#64748b",
|
||||
menuHoverColor: "#0f172a",
|
||||
modalAccentColor: `linear-gradient(135deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||
modalHeaderBackground: "#ffffff",
|
||||
modalHeaderEditBackground: "#ffffff",
|
||||
modalIconBackground: freightBrand.mutedBg,
|
||||
modalIconColor: FREIGHT_BRAND,
|
||||
modalTitleColor: "#0f172a",
|
||||
modalFocusColor: FREIGHT_BRAND,
|
||||
modalSurface: "#ffffff",
|
||||
},
|
||||
appearance: {
|
||||
colorScheme: "light",
|
||||
slots: {
|
||||
root: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
color: "#0f172a",
|
||||
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
},
|
||||
},
|
||||
page: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "24px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
card: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "20px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "16px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
"sidebar-brand": {
|
||||
styles: {
|
||||
minHeight: "80px",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
},
|
||||
},
|
||||
topbar: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "16px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
"topbar-panel": {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "12px",
|
||||
},
|
||||
},
|
||||
"topbar-user-summary": {
|
||||
styles: {
|
||||
borderRadius: "14px",
|
||||
},
|
||||
},
|
||||
table: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "20px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
},
|
||||
"table-header": {
|
||||
styles: {
|
||||
background: "#f8fafc",
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
styles: {
|
||||
borderRadius: "24px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
},
|
||||
"modal-header": {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
borderBottom: "1px solid #eef1f4",
|
||||
},
|
||||
},
|
||||
},
|
||||
customCss: `
|
||||
[data-um-app="user-management"] {
|
||||
--um-page-gap: 20px;
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] h1,
|
||||
[data-um-app="user-management"] h2,
|
||||
[data-um-app="user-management"] h3,
|
||||
[data-um-app="user-management"] h4,
|
||||
[data-um-app="user-management"] h5,
|
||||
[data-um-app="user-management"] h6 {
|
||||
letter-spacing: -0.02em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] [data-um-slot="sidebar-item"][aria-current="page"] {
|
||||
box-shadow: inset 3px 0 0 ${FREIGHT_BRAND};
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] button,
|
||||
[data-um-app="user-management"] input,
|
||||
[data-um-app="user-management"] select,
|
||||
[data-um-app="user-management"] textarea {
|
||||
font-family: 'Outfit', var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
`,
|
||||
},
|
||||
};
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Badge as MantineBadge } from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import "@/components/overview/overview.css";
|
||||
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||
import type {
|
||||
PaymentMethod,
|
||||
@@ -39,11 +42,16 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
||||
{ key: "success", label: "Success", statuses: "success" },
|
||||
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
|
||||
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
|
||||
{
|
||||
key: "processing",
|
||||
label: "Processing",
|
||||
statuses: "processing,action-required",
|
||||
icon: Loader2,
|
||||
},
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
|
||||
] as const;
|
||||
|
||||
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
|
||||
|
||||
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
||||
|
||||
const tabCounts: Record<StatusTabKey, number | undefined> = {
|
||||
all:
|
||||
summary === undefined
|
||||
? undefined
|
||||
: (summary.success ?? 0) +
|
||||
(summary.processing ?? 0) +
|
||||
(summary.failed ?? 0) +
|
||||
(summary.refunded ?? 0),
|
||||
success: summary?.success,
|
||||
processing: summary?.processing,
|
||||
failed: summary?.failed,
|
||||
refunded: summary?.refunded,
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PaymentRow>[] = [
|
||||
{
|
||||
id: "order",
|
||||
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
|
||||
setStatusTab((value as StatusTabKey) ?? "all");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
variant="pills"
|
||||
color="green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.key} value={t.key}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
{STATUS_TABS.map((t) => {
|
||||
const isActive = statusTab === t.key;
|
||||
const count = tabCounts[t.key];
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={t.key}
|
||||
value={t.key}
|
||||
leftSection={<Icon size={17} strokeWidth={1.85} />}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<MantineBadge
|
||||
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}
|
||||
</MantineBadge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Boxes,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Home,
|
||||
Layers,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import {
|
||||
getRuleEngineResource,
|
||||
type FormFieldDef,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import {
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
const CARGO_SLUG = "cargo-types";
|
||||
const BASE_PATH = "/dashboard/configuration/cargo-types";
|
||||
|
||||
interface CargoNode extends RuleEngineRecord {
|
||||
cargoTypeName?: string;
|
||||
code?: string;
|
||||
parentGroupId?: string | null;
|
||||
showFreeTextBox?: boolean;
|
||||
requiresDirectorApproval?: boolean;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
||||
|
||||
/** Create/edit form fields. Parent is set from the current page, never picked. */
|
||||
const FORM_FIELDS: FormFieldDef[] = [
|
||||
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
];
|
||||
|
||||
type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
|
||||
|
||||
const CargoTypesPage = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { id: currentId } = useParams<{ id: string }>();
|
||||
const config = getRuleEngineResource(CARGO_SLUG);
|
||||
|
||||
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||
|
||||
// One fetch of the whole (small) set; the tree, ancestry and each level are
|
||||
// derived client-side so drilling between levels is instant.
|
||||
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
sortBy: "displayOrder",
|
||||
sortOrder: "ASC",
|
||||
});
|
||||
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||
|
||||
const all = (data?.data ?? []) as CargoNode[];
|
||||
|
||||
const { byId, childrenOf } = useMemo(() => {
|
||||
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));
|
||||
const childrenOf = new Map<string, CargoNode[]>();
|
||||
for (const node of all) {
|
||||
const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
|
||||
const key = parentId || "__root__";
|
||||
const list = childrenOf.get(key) ?? [];
|
||||
list.push(node);
|
||||
childrenOf.set(key, list);
|
||||
}
|
||||
for (const list of childrenOf.values()) {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
orderOf(a) - orderOf(b) ||
|
||||
str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
|
||||
);
|
||||
}
|
||||
return { byId, childrenOf };
|
||||
}, [all]);
|
||||
|
||||
// Current node (null at root) and its ancestor chain for the breadcrumb.
|
||||
const current = currentId ? byId.get(currentId) ?? null : null;
|
||||
const ancestors = useMemo(() => {
|
||||
const chain: CargoNode[] = [];
|
||||
let node = current;
|
||||
const seen = new Set<string>();
|
||||
while (node && !seen.has(node.id)) {
|
||||
chain.unshift(node);
|
||||
seen.add(node.id);
|
||||
node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
|
||||
}
|
||||
return chain;
|
||||
}, [current, byId]);
|
||||
|
||||
const levelKey = current ? current.id : "__root__";
|
||||
const levelNodes = childrenOf.get(levelKey) ?? [];
|
||||
|
||||
const term = search.trim().toLowerCase();
|
||||
const matches = (n: CargoNode) =>
|
||||
!term ||
|
||||
str(n.cargoTypeName).toLowerCase().includes(term) ||
|
||||
str(n.code).toLowerCase().includes(term);
|
||||
const visibleNodes = useMemo(
|
||||
() => (term ? levelNodes.filter(matches) : levelNodes),
|
||||
[levelNodes, term],
|
||||
);
|
||||
|
||||
if (!config) return <Navigate to="/dashboard/overview" replace />;
|
||||
if (!canView) return <Navigate to="/dashboard/overview" replace />;
|
||||
// A bad/stale :id (after data loads) → fall back to the root list.
|
||||
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;
|
||||
|
||||
const atRoot = !current;
|
||||
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||
|
||||
const handleSubmit = (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = { ...values };
|
||||
// Add always attaches to the page we're on; edit keeps the node's parent.
|
||||
if (formMode?.kind === "create" && current) {
|
||||
payload.parentGroupId = current.id;
|
||||
}
|
||||
const done = () => setFormMode(null);
|
||||
if (formMode?.kind === "edit") {
|
||||
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
|
||||
} else {
|
||||
create.mutate(payload, { onSuccess: done });
|
||||
}
|
||||
};
|
||||
|
||||
const addLabel = atRoot ? "Add category" : "Add cargo type";
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<Card
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
separator={<ChevronRight size={14} style={{ color: "var(--mantine-color-gray-5)" }} />}
|
||||
mb="md"
|
||||
>
|
||||
<UnstyledButton onClick={() => navigate(BASE_PATH)}>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Home size={14} style={{ color: "var(--mantine-color-teal-7)" }} />
|
||||
<Text fz={13} fw={600} c={atRoot ? "dark.7" : "teal.7"}>
|
||||
Cargo Types
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{ancestors.map((node, i) => {
|
||||
const isLast = i === ancestors.length - 1;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={node.id}
|
||||
onClick={() => !isLast && navigate(`${BASE_PATH}/${node.id}`)}
|
||||
style={{ cursor: isLast ? "default" : "pointer" }}
|
||||
>
|
||||
<Text fz={13} fw={isLast ? 700 : 600} c={isLast ? "dark.7" : "teal.7"} truncate maw={220}>
|
||||
{str(node.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||
{atRoot ? <Boxes size={26} /> : <Layers size={26} />}
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={22} c="dark.8" truncate>
|
||||
{atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
{!atRoot && current?.code ? (
|
||||
<Badge variant="default" radius="sm">
|
||||
{str(current.code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!atRoot && current?.isActive === false ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{atRoot
|
||||
? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
|
||||
: `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search this level…"
|
||||
leftSection={<Search size={16} />}
|
||||
w={240}
|
||||
/>
|
||||
{canManage && (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setFormMode({ kind: "create" })}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* ── Level list ─────────────────────────────────────────── */}
|
||||
<Card
|
||||
p={0}
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader color="teal" />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Text p="xl" c="red" ta="center">
|
||||
Failed to load cargo types.
|
||||
</Text>
|
||||
) : visibleNodes.length === 0 ? (
|
||||
<Stack align="center" gap="sm" py={56}>
|
||||
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
|
||||
<Package size={26} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} c="dark.6">
|
||||
{term
|
||||
? "Nothing matches your search"
|
||||
: atRoot
|
||||
? "No cargo categories yet"
|
||||
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||
</Text>
|
||||
{!term && canManage && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setFormMode({ kind: "create" })}
|
||||
>
|
||||
{atRoot ? "Add your first category" : "Add the first cargo type"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{visibleNodes.map((node, i) => (
|
||||
<CargoRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||
topBorder={i > 0}
|
||||
canManage={canManage}
|
||||
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||
onDelete={() => setDeleteTarget(node)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Create / edit dialog ───────────────────────────────── */}
|
||||
<RuleEngineFormDialog
|
||||
open={Boolean(formMode)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFormMode(null);
|
||||
}}
|
||||
title={
|
||||
formMode?.kind === "edit"
|
||||
? `Edit ${str(formMode.record.cargoTypeName)}`
|
||||
: atRoot
|
||||
? "Add category"
|
||||
: `Add cargo under “${str(current?.cargoTypeName)}”`
|
||||
}
|
||||
description={
|
||||
formMode?.kind === "edit"
|
||||
? "Update this cargo type."
|
||||
: atRoot
|
||||
? "Create a top-level cargo category."
|
||||
: "Create a cargo type inside this category. It's attached here automatically."
|
||||
}
|
||||
fields={FORM_FIELDS}
|
||||
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
{/* ── Delete confirm ─────────────────────────────────────── */}
|
||||
<Modal
|
||||
opened={Boolean(deleteTarget)}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete cargo type?"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<Text span fw={600}>
|
||||
{str(deleteTarget?.cargoTypeName)}
|
||||
</Text>{" "}
|
||||
has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
|
||||
leaves them without a category. Continue?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will delete{" "}
|
||||
<Text span fw={600}>
|
||||
{str(deleteTarget?.cargoTypeName)}
|
||||
</Text>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// ── A single cargo row — drills into its own page on click ──────────────────
|
||||
interface CargoRowProps {
|
||||
node: CargoNode;
|
||||
childCount: number;
|
||||
topBorder: boolean;
|
||||
canManage: boolean;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function CargoRow({
|
||||
node,
|
||||
childCount,
|
||||
topBorder,
|
||||
canManage,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CargoRowProps) {
|
||||
const inactive = node.isActive === false;
|
||||
const hasChildren = childCount > 0;
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="lg"
|
||||
py="md"
|
||||
style={{
|
||||
borderTop: topBorder ? "1px solid var(--mantine-color-gray-2)" : undefined,
|
||||
transition: "background 120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--mantine-color-teal-0)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "";
|
||||
}}
|
||||
>
|
||||
<UnstyledButton onClick={onOpen} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
|
||||
{hasChildren ? <Layers size={18} /> : <Package size={18} />}
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={650} fz={15} c="dark.8" truncate>
|
||||
{str(node.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
{node.code ? (
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{str(node.code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{node.requiresDirectorApproval ? (
|
||||
<Tooltip label="Requires director approval" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
>
|
||||
Approval
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{node.showFreeTextBox ? (
|
||||
<Tooltip label="Shows a free-text box on booking" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<FileText size={11} />}
|
||||
>
|
||||
Free text
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{inactive ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={12.5} c="dimmed" mt={2}>
|
||||
{hasChildren
|
||||
? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
|
||||
: "No cargo types inside yet — open to add"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{canManage && (
|
||||
<>
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label="Open" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CargoTypesPage;
|
||||
@@ -109,6 +109,21 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Day-level pool: the days that have an OPEN departure on the route. Staff pick
|
||||
* a day; the batch engine assigns the train. No capacity is returned.
|
||||
*/
|
||||
getAvailableDays: async (
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<string[]> => {
|
||||
const response = await client.get<{ days: string[] }>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
|
||||
{ params: { originYardId, destinationYardId } },
|
||||
);
|
||||
return unwrap(response.data).days;
|
||||
},
|
||||
|
||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.post<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||
|
||||
1
apps/edr-freight-web/backoffice/user-management
Submodule
1
apps/edr-freight-web/backoffice/user-management
Submodule
Submodule apps/edr-freight-web/backoffice/user-management added at e3a50a7c91
@@ -1,58 +1,127 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { loadEnv, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import type { ViteDevServer, PreviewServer } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function userManagementSpaFallback() {
|
||||
const rewrite = (req: IncomingMessage) => {
|
||||
const url = req.url ?? '';
|
||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
||||
req.url = '/_um/index.html';
|
||||
};
|
||||
function createIamApiAdapter(apiBaseUrl: string): Plugin {
|
||||
const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`;
|
||||
|
||||
return {
|
||||
name: 'user-management-spa-fallback',
|
||||
configureServer(s: ViteDevServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
});
|
||||
|
||||
},
|
||||
configurePreviewServer(s: PreviewServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
name: "iam-api-adapter",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/um-api", async (req, res) => {
|
||||
const requestPath = req.url ?? "/";
|
||||
const normalizedPath = requestPath.replace(/^\/+/, "");
|
||||
const targetUrl = new URL(normalizedPath, `${upstreamBaseUrl}/`);
|
||||
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value || key.toLowerCase() === "host") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
headers.append(key, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const body =
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? undefined
|
||||
: await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) =>
|
||||
chunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
),
|
||||
);
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
|
||||
const upstreamResponse = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (targetUrl.pathname.endsWith("/auth/me")) {
|
||||
const payload = await upstreamResponse.json();
|
||||
const unwrappedPayload =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"success" in payload &&
|
||||
"data" in payload
|
||||
? payload.data
|
||||
: payload;
|
||||
|
||||
res.statusCode = upstreamResponse.status;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(unwrappedPayload));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = upstreamResponse.status;
|
||||
upstreamResponse.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
res.end(Buffer.from(await upstreamResponse.arrayBuffer()));
|
||||
} catch (error) {
|
||||
server.ssrFixStacktrace(error as Error);
|
||||
res.statusCode = 502;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
message: "Failed to forward IAM request",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [userManagementSpaFallback(), react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, "");
|
||||
const apiBaseUrl =
|
||||
env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"node:buffer": "buffer",
|
||||
"node:stream": "stream-browserify",
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(
|
||||
__dirname,
|
||||
"../../../packages/types/src/index.ts",
|
||||
),
|
||||
},
|
||||
// Force a single copy of these singletons so MantineProvider context is
|
||||
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||
// own node_modules copy). Without this, two separate @mantine/core
|
||||
// instances are bundled and the context lookup fails at runtime.
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
// Force a single copy of these singletons so MantineProvider context is
|
||||
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||
// own node_modules copy). Without this, two separate @mantine/core
|
||||
// instances are bundled and the context lookup fails at runtime.
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user