Merge freight/develop into Warehouse_updates

This commit is contained in:
Hagernesh
2026-06-21 15:41:32 +00:00
199 changed files with 15988 additions and 6239 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 }}>

View File

@@ -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) =>

View File

@@ -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 ?? ""),

View File

@@ -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={[

View File

@@ -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 }} />;
}

View File

@@ -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;
}
`,
},
};

View File

@@ -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>

View File

@@ -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;

View File

@@ -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),

View File

@@ -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",
},
};
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
import TrackingPage from "./pages/tracking/TrackingPage";
function FullScreenSpinner() {
@@ -144,10 +146,11 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { user, company } = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const companyProfiles = company?.company?.companyProfiles ?? [];
return (
<Routes>
@@ -158,6 +161,9 @@ const App = () => {
path="/booking/check-status/:orderId"
element={<CheckPaymentPage />}
/>
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
<Route path="/payment/success" element={<PaymentSuccessPage />} />
<Route path="/payment/failure" element={<PaymentFailurePage />} />
{/* Auth pages — inaccessible once logged in */}
<Route element={<RedirectIfAuthed />}>
@@ -185,6 +191,7 @@ const App = () => {
enableThemeToggle
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
>
<Outlet />
</AppLayout>

View File

@@ -47,9 +47,19 @@ export interface AppLayoutProps {
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[];
children: ReactNode;
}
const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
function getInitials(name: string): string {
return name
.split(" ")
@@ -106,6 +116,7 @@ export function AppLayout({
enableThemeToggle = false,
userName = "User",
userEmail,
companyProfiles = [],
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -306,6 +317,34 @@ export function AppLayout({
</Text>
)}
</Box>
{companyProfiles.length > 0 && (
<>
<Divider />
<Box px="sm" py="xs">
<Stack gap={6}>
{companyProfiles.map((p) => (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
<Text
size="xs"
fw={600}
style={{ color: textColor }}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
))}
</Stack>
</Box>
</>
)}
<Divider />
<Menu.Item
leftSection={<User size={15} />}

View File

@@ -0,0 +1,151 @@
import type { ReactNode } from "react";
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
const LOGIN_IMAGE = "/assets/login.png";
const EDR_LOGO = "/assets/logo.svg";
export const fieldClass =
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
export const primaryButtonClass =
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
const LeftPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<svg
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
viewBox="0 0 400 400"
fill="none"
>
{[0, 1, 2, 3, 4, 5].map((ring) => (
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
))}
</svg>
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
</div>
);
const RightPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="auth-grid" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#auth-grid)" />
</svg>
</div>
);
export interface AuthShellProps {
children: ReactNode;
/** Tagline shown in the highlighted card over the left image panel. */
tagline?: string;
taglineBody?: string;
}
const LeftPanel = ({ tagline, taglineBody }: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
<img
src={LOGIN_IMAGE}
alt="Ethio Djibouti Railway"
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
<LeftPanelDecor />
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
<a
href="#"
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
>
Support
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</a>
</div>
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
<div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
<span className="text-sm font-semibold text-white">
{tagline ?? "Empower Your Freight Operations"}
</span>
</div>
<p className="text-sm leading-relaxed text-white/85">
{taglineBody ??
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
</p>
</div>
</div>
</div>
);
const LanguageSelector = () => (
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
<Globe className="h-4 w-4 text-gray-500" />
<span>Eng</span>
<ChevronDown className="h-4 w-4 text-gray-400" />
</div>
);
const FormFooter = () => (
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
<span className="shrink-0">© 2026 EDR Freight</span>
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Terms &amp; Conditions
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Privacy Policy
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Help &amp; Support
</a>
</div>
</div>
);
export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
>
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
<RightPanelDecor />
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
<LanguageSelector />
</div>
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
{children}
</div>
</div>
</div>
<FormFooter />
</div>
</div>
</div>
</>
);
}

View File

@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
COMPANY_PROFILES: "/api/companies/company-profiles",
DASHBOARD: "/api/companies/dashboard",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
},
@@ -100,6 +101,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
},
PAYMENTS: {

View File

@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
interface BookingRowProps {
booking: any;
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
const Icon = cfg.icon;
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page.
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
{cfg.badgeLabel}
</Text>
</Group>
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : (
<Group
gap={5}
align="center"
px={15}
py={8}
bg={ap.bg}
bd={ap.bd}
className="cursor-pointer rounded-[9px]"
>
<Text fz={13} fw={700} c={ap.c}>
{cfg.action.label}
</Text>
{AIcon && (
<AIcon
size={15}
color={ap.c === "white" ? "#fff" : cv("edr-text")}
/>
)}
</Group>
)}
</Stack>
</Group>
</Box>

View File

@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
Outstanding balance
</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">
{formatCurrency(totalOutstanding || 377500, "ETB")}
{formatCurrency(totalOutstanding || 0, "ETB")}
</Text>
<Group
justify="space-between"

View File

@@ -1,17 +1,113 @@
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
import {
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Briefcase,
Building,
Building2,
FileCheck,
Globe,
Mail,
MapPin,
Phone,
Plus,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<div className="flex items-start gap-3">
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
</div>
</div>
<Group gap="sm" align="flex-start" wrap="nowrap">
{icon && (
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
{icon}
</ThemeIcon>
)}
<Stack gap={2}>
<Text size="xs" fw={700} tt="uppercase" c="edr-muted">
{label}
</Text>
<Text size="sm" fw={600} c="edr-text">
{value || "—"}
</Text>
</Stack>
</Group>
);
}
function CardHeading({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<Stack gap={2} mb="md">
<Group gap="sm">
{icon}
<Title order={4} size="h5">
{title}
</Title>
</Group>
<Text size="sm" c="edr-muted">
{description}
</Text>
</Stack>
);
}
function PersonnelGroup({
color,
title,
children,
}: {
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Group gap="xs">
<Box w={4} h={16} bg={color} style={{ borderRadius: 2 }} />
<Text size="sm" fw={700} tt="uppercase" c="edr-text">
{title}
</Text>
</Group>
<Stack gap="sm" pl="lg">
{children}
</Stack>
</Stack>
);
}
@@ -22,163 +118,293 @@ export default function ProfilePage() {
if (isPending) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
<Center h="100%">
<Loader color="edr-green" size="lg" />
</Center>
);
}
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
<Center h="100%">
<Text c="edr-muted">No company profile found.</Text>
</Center>
);
}
// Registered operational profiles keyed by type, plus the roles this company
// type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
return (
<div className="px-4 py-8">
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex items-center gap-6">
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
<User className="size-12" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-black tracking-tight text-foreground">
{profile.companyName}
</h1>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
<Container size="xl" px="lg" py="xl">
{/* Header */}
<Group gap="lg" align="center" mb="lg">
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
<User size={44} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={1} size="h2">
{profile.companyName}
</Title>
<Badge color="edr-green" variant="light">
Verified
</Badge>
</Group>
{activeOptions.length > 0 ? (
<Group gap="xs">
{activeOptions.map((opt) => (
<Badge
key={opt.type}
variant="light"
color="edr-green"
size="lg"
radius="sm"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
</div>
<p className="flex items-center gap-2 font-medium text-muted-foreground">
<Building className="size-4" />
{profile.companyName}
</p>
</div>
</div>
))}
</Group>
) : (
<Group gap={6} c="edr-muted">
<Building size={16} />
<Text c="edr-muted" fw={500}>
{profile.companyType}
</Text>
</Group>
)}
</Stack>
</Group>
<Separator />
<Divider mb="lg" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column */}
<div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Company Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
</CardContent>
</Card>
<Grid gap="lg">
{/* Left Column */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
{/* Company Details */}
<Card>
<CardHeading
icon={
<Building2
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Company Details"
description="Business registration information"
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoItem
icon={<Globe size={16} />}
label="Location"
value={profile.companyLocation}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Address"
value={profile.companyAddress}
/>
<InfoItem
icon={<FileCheck size={16} />}
label="TIN Number"
value={profile.tinNumber}
/>
<InfoItem
icon={<ShieldCheck size={16} />}
label="FAN Number"
value={profile.fanNumber}
/>
<InfoItem
icon={<Mail size={16} />}
label="Email"
value={profile.companyEmail}
/>
<InfoItem
icon={<Phone size={16} />}
label="Phone"
value={profile.companyPhone}
/>
</SimpleGrid>
</Card>
{/* Personal Details (from ExternalProfile) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Profile Details
</CardTitle>
<CardDescription>Your linked user profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
</CardContent>
</Card>
</div>
{/* Key Personnel */}
<Card>
<CardHeading
icon={
<Briefcase
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Key Personnel"
description="Management and contact persons"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<PersonnelGroup color="edr-green" title="Contact Person">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</PersonnelGroup>
<PersonnelGroup color="edr-accent" title="General Manager">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</PersonnelGroup>
</SimpleGrid>
</Card>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</div>
</div>
</CardContent>
{/* Power of Attorney */}
{profile.poaName && (
<Card style={{ borderStyle: "dashed" }}>
<CardHeading
icon={
<UserCheck
size={20}
color="var(--mantine-color-edr-accent-6)"
/>
}
title="Power of Attorney"
description="Authorized representative details"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</SimpleGrid>
</Card>
)}
</Stack>
</Grid.Col>
{/* Power of Attorney */}
{profile.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</CardContent>
</Card>
{/* Right Column */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{/* Operating Roles */}
<Card>
<CardHeading
icon={
<BadgeCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Operating Roles"
description="Your registered freight roles and reference numbers"
/>
{roleOptions.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<Stack gap="md">
{roleOptions.map((opt) => {
const active = refByType.get(opt.type);
return (
<Group
key={opt.type}
justify="space-between"
wrap="nowrap"
align="center"
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
>
{opt.icon}
</ThemeIcon>
<Stack gap={2}>
<Text size="sm" fw={600} c="edr-text">
{opt.label}
</Text>
<Text
size="xs"
c="edr-muted"
ff={active ? "monospace" : undefined}
>
{active ? active.reference : "Not registered"}
</Text>
</Stack>
</Group>
{active ? (
<Badge
variant="light"
color={
active.status === "active"
? "edr-green"
: "edr-accent"
}
tt="capitalize"
>
{active.status}
</Badge>
) : (
<Button
component={Link}
to="/settings?tab=company"
size="xs"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
>
Add {opt.label}
</Button>
)}
</Group>
);
})}
</Stack>
)}
</div>
</Card>
{/* Right Column */}
<div className="flex flex-col gap-8">
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<a
href="/settings"
className="inline-flex h-9 items-center justify-center rounded-md bg-background px-4 text-sm font-medium text-foreground hover:bg-background/90"
>
Edit Settings
</a>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
{/* Secure Account */}
<Card
padding="xl"
style={{
background: "var(--mantine-color-edr-ink-6)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
top: 16,
right: 16,
opacity: 0.1,
}}
>
<ShieldCheck size={128} color="white" />
</Box>
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
<Title order={3} size="h4" c="white">
Secure Account
</Title>
<Text size="sm" c="gray.4">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</Text>
<Button
component={Link}
to="/settings"
variant="white"
color="dark"
mt="xs"
w="fit-content"
>
Edit Settings
</Button>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -1,39 +1,51 @@
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { ArrowRight, Mail, Phone } from "lucide-react";
import { useState } from "react";
import { type FormEvent, useState } from "react";
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
const EDR_LOGO = "/assets/edr-logo.png";
type LoginMethod = "email" | "phone";
const loginMethods: Array<{
value: LoginMethod;
label: string;
icon: typeof Mail;
placeholder: string;
}> = [
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
];
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState("");
const [countryCode, setCountryCode] = useState("+251");
const [phoneNumber, setPhoneNumber] = useState("");
const [countryCode] = useState("+251");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const currentMethod = loginMethods.find((item) => item.value === method)!;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setLoading(true);
try {
const loginId =
method === "email"
? identifier
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password });
if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null)
?.from?.pathname;
const from = (location.state as { from?: { pathname: string } } | null)?.from
?.pathname;
navigate(from ?? "/portal", { replace: true });
} else {
setError(result.error.message);
@@ -46,154 +58,105 @@ export default function LoginPage() {
};
return (
<AuthLayout
left={{
badge: "Welcome Back",
title: "Sign in to your freight operations account",
description:
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
}}
>
<Stack gap="xs" mb="lg">
<Box
w={48}
h={48}
bg="edr-soft"
className="flex items-center justify-center rounded-2xl"
>
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
<AuthShell>
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
<div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Welcome back
</Text>
<Text fz={15} c="edr-muted" mt={4}>
Enter your credentials to access your portal
</Text>
</Box>
</Stack>
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Enter your credentials to access your freight portal.
</p>
</div>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<SegmentedControl
value={method}
onChange={(v) => setMethod(v as LoginMethod)}
fullWidth
radius="md"
data={[
{
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Mail size={15} />
<Text size="sm">Email</Text>
</Group>
),
value: "email",
},
{
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Phone size={15} />
<Text size="sm">Phone</Text>
</Group>
),
value: "phone",
},
]}
/>
{method === "email" ? (
<TextInput
label="Email Address"
placeholder="name@company.com"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
disabled={loading}
/>
) : (
<PhoneInput
disabled={loading}
countryCode={{
value: countryCode,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setCountryCode(e.target.value),
}}
phone={{
value: phoneNumber,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setPhoneNumber(e.target.value),
}}
/>
)}
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={500} c="edr-text">
Password
</Text>
<Button
variant="transparent"
size="xs"
c="edr-green.6"
p={0}
h="auto"
fz={12}
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">Sign in method</label>
<div className="relative">
<select
value={method}
onChange={(event) => setMethod(event.target.value as LoginMethod)}
disabled={loading}
className={`${fieldClass} appearance-none pr-10`}
>
Forgot password?
</Button>
</Group>
<PasswordInput
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
{loginMethods.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
{currentMethod.label} <span className="text-red-500">*</span>
</label>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading}
className={fieldClass}
/>
</Box>
</div>
{error && (
<Alert color="red" variant="light" radius="md">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-800">
Password <span className="text-red-500">*</span>
</label>
<a href="#" className="text-xs font-semibold text-primary hover:underline">
Forgot password?
</a>
</div>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Enter your password"
disabled={loading}
className={`${fieldClass} pr-11`}
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
</Alert>
)}
</div>
) : null}
<Button
type="submit"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
>
Sign In
</Button>
<button type="submit" disabled={loading} className={primaryButtonClass}>
{loading ? "Signing in..." : "Sign In"}
</button>
<Text size="sm" c="edr-muted" ta="center">
Don't have an account?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
<p className="text-center text-sm text-gray-500">
Don&apos;t have an account?{" "}
<button
type="button"
onClick={() => navigate("/signup")}
className="font-semibold text-primary hover:underline"
>
Create an account
</Button>
</Text>
</Stack>
</button>
</p>
</div>
</form>
</AuthLayout>
</AuthShell>
);
}

View File

@@ -1,7 +1,6 @@
import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ArrowRight, UserPlus, X } from "lucide-react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
@@ -9,8 +8,9 @@ import { z } from "zod";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth";
import AuthLayout from "@/components/auth/AuthLayout";
import PhoneInput from "@/components/auth/PhoneInput";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
const EDR_LOGO = "/assets/edr-logo.png";
const passwordRequirements = [
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
@@ -20,11 +20,22 @@ const passwordRequirements = [
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
const ETHIOPIA_COUNTRY_CODE = "+251";
const isValidEthiopianMobile = (value: string) => {
const digits = value.replace(/\D/g, "");
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
return /^9\d{8}$/.test(normalized);
};
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
countryCode: z.string().min(1, "Country code is required"),
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
phone: z
.string()
.min(1, "Phone number is required")
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
userType: z.string(),
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
@@ -44,11 +55,16 @@ const userSchema = z
type FormData = z.infer<typeof userSchema>;
const errorText = (msg?: string) =>
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
export default function SignupPage() {
const navigate = useNavigate();
const { signup } = useAuth();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const {
register,
@@ -59,7 +75,7 @@ export default function SignupPage() {
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
countryCode: "+251",
countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "",
userType: userType.individual,
firstName: { en: "", am: "" },
@@ -73,7 +89,8 @@ export default function SignupPage() {
setError(null);
setLoading(true);
try {
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
const digits = data.phone.replace(/\D/g, "");
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
const payload: SignupPayload = {
email: data.email,
username: data.email,
@@ -102,145 +119,194 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? "";
return (
<AuthLayout
left={{
badge: "Smart Freight Operations",
title: "Create your freight operations account",
description:
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
features: [
"Real-time shipment tracking",
"Secure logistics management",
"Enterprise-grade operations",
"Multi-corridor freight monitoring",
],
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
}}
<AuthShell
tagline="Smart Freight Operations"
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
Create Account
</Text>
<Text fz={15} c="edr-muted" mt={4}>
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Create account
</h1>
<p className="text-sm leading-relaxed text-gray-500">
Register to access EDR Freight services.
</Text>
</Box>
</Stack>
</p>
</div>
{error && (
<Alert color="red" variant="light" radius="md" mb="md">
{error}
</Alert>
)}
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
First name <span className="text-red-500">*</span>
</label>
<input
placeholder="John"
disabled={loading}
className={fieldClass}
{...register("firstName.en")}
/>
{errorText(errors.firstName?.en?.message)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Last name <span className="text-red-500">*</span>
</label>
<input
placeholder="Doe"
disabled={loading}
className={fieldClass}
{...register("lastName.en")}
/>
{errorText(errors.lastName?.en?.message)}
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={2} spacing="md">
<TextInput
label="First Name"
placeholder="John"
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
placeholder="john@example.com"
disabled={loading}
error={errors.firstName?.en?.message}
{...register("firstName.en")}
className={fieldClass}
{...register("email")}
/>
<TextInput
label="Last Name"
placeholder="Doe"
disabled={loading}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
{errorText(errors.email?.message)}
</div>
<TextInput
label="Email Address"
placeholder="john@example.com"
type="email"
disabled={loading}
error={errors.email?.message}
{...register("email")}
/>
<div className="space-y-1.5">
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
Phone <span className="text-red-500">*</span>
</label>
<input type="hidden" {...register("countryCode")} />
<div
className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${
errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90"
}`}
>
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600">
{ETHIOPIA_COUNTRY_CODE}
</span>
<input
id="signup-phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
placeholder="0912345678"
maxLength={10}
disabled={loading}
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400"
{...register("phone", {
onChange: (event) => {
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10);
},
})}
/>
</div>
{errorText(errors.phone?.message)}
</div>
<PhoneInput
disabled={loading}
countryCode={{ ...register("countryCode") }}
phone={{ ...register("phone") }}
countryCodeError={errors.countryCode}
phoneError={errors.phone}
/>
<Box>
<PasswordInput
label="Password"
placeholder="Create a strong password"
disabled={loading}
error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 && (
<Stack gap={4} mt={8}>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
placeholder="Create a strong password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errorText(errors.password?.message)}
{passwordValue.length > 0 ? (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<Group key={req.label} gap={6} align="center" wrap="nowrap">
<ThemeIcon
size={16}
radius="xl"
variant={met ? "filled" : "light"}
color={met ? "edr-green" : "gray"}
<div key={req.label} className="flex items-center gap-2">
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
}`}
>
{met ? <Check size={10} /> : <X size={10} />}
</ThemeIcon>
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
</span>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
{req.label}
</Text>
</Group>
</span>
</div>
);
})}
</Stack>
)}
</Box>
</div>
) : null}
</div>
<PasswordInput
label="Confirm Password"
placeholder="Re-enter your password"
disabled={loading}
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Confirm password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showConfirm ? "text" : "password"}
placeholder="Re-enter your password"
disabled={loading}
className={`${fieldClass} pr-11`}
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirm((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showConfirm ? "Hide password" : "Show password"}
>
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errorText(errors.confirmPassword?.message)}
</div>
<Button
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
</div>
) : null}
<button
type="submit"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
>
Create Account
</Button>
{loading ? "Creating account..." : "Create Account"}
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
</button>
<Text size="sm" c="edr-muted" ta="center">
<p className="text-center text-sm text-gray-500">
Already have an account?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
<button
type="button"
onClick={() => navigate("/login")}
className="font-semibold text-primary hover:underline"
>
Sign In
</Button>
</Text>
</Stack>
</button>
</p>
</div>
</form>
</AuthLayout>
</AuthShell>
);
}

View File

@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -60,6 +61,8 @@ export function DraftBookingView({
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState("");
const [priceChangeModal, setPriceChangeModal] =
useState<SubmitBookingResponse | null>(null);
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo(
@@ -72,9 +75,11 @@ export function DraftBookingView({
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled:
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
!booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -82,8 +87,17 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
mutationFn: async (files: Record<string, File | File[] | null>) => {
if (booking.status === "CHANGES_REQUESTED") {
const result = await api.bookings.update.call({
id: booking.id,
dto: {},
documents: files,
});
return result.booking;
}
return api.bookings.uploadDocuments.call({ id: booking.id, files });
},
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -93,7 +107,20 @@ export function DraftBookingView({
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeModal(result);
return;
}
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
});
const confirmSubmitMutation = useMutation({
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
setPriceChangeModal(null);
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
@@ -155,7 +182,12 @@ export function DraftBookingView({
/>
<MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]}
mutations={[
uploadMutation,
submitMutation,
confirmSubmitMutation,
cancelMutation,
]}
/>
<StatusHero booking={booking}>
@@ -163,7 +195,9 @@ export function DraftBookingView({
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
@@ -260,6 +294,8 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
const allowReplace =
!isUploaded || booking.status === "CHANGES_REQUESTED";
return (
<DocRow
key={doc.key}
@@ -276,13 +312,19 @@ export function DraftBookingView({
isUploaded ? "verified" : selected ? "ready" : "missing"
}
action={
isUploaded ? (
isUploaded && !allowReplace ? (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
) : (
<>
{isUploaded && (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
)}
<input
ref={(el) => {
fileInputRefs.current[doc.key] = el;
@@ -318,7 +360,7 @@ export function DraftBookingView({
},
}}
>
{selected ? "Change" : "Add"}
{selected ? "Change" : isUploaded ? "Replace" : "Add"}
</Button>
{selected && (
<ActionIcon
@@ -375,6 +417,72 @@ export function DraftBookingView({
}
/>
<Modal
opened={priceChangeModal !== null}
onClose={() => setPriceChangeModal(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeModal && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeModal.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeModal.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeModal.totalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
{priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
<Stack gap={4}>
{priceChangeModal.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeModal(null)}
>
Review later
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -12,7 +12,11 @@ import { ActivityCard } from "./components/ActivityCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { CancelledBanner } from "./components/Notices";
import {
CancelledBanner,
ConsolidationPairedNotice,
ConsolidationWaitingBanner,
} from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
// Paired: a consolidation partner was found and the booking resumed the normal
// flow. Surface the "partner found" reassurance only in the early stages,
// before approval, so it doesn't linger for the rest of the booking's life.
const showPairedNotice =
!!booking.consolidationPartnerId &&
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
return (
<PageShell>
@@ -92,10 +103,16 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner
priceLabel={pricing ? priceTotal(pricing) : undefined}
/>
) : (
<StatusHero booking={booking} />
)}
{showPairedNotice && <ConsolidationPairedNotice />}
<ContractCard booking={booking} navigate={navigate} />
<BodyGrid
@@ -163,6 +180,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
}}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={payMutation.isPending}
error={
payMutation.isError

View File

@@ -1,5 +1,13 @@
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Link2,
PencilLine,
StickyNote,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
export function NoticeBanner({
@@ -186,6 +194,90 @@ export function CancelledBanner({
);
}
/**
* Shown to the customer while their booking is PENDING_CONSOLIDATION: it is
* waiting for another shipment to share the wagon. The price shown is this
* booking's own held amount — bookings and contracts are independent, so the
* partner's amount is never shown. Once a partner is found the backend moves
* the booking back to SUBMITTED and it continues the normal flow.
*/
export function ConsolidationWaitingBanner({
priceLabel,
}: {
priceLabel?: string;
}) {
return (
<Paper radius={16} p={20} bg="#FDF3E0" className="border border-[#F4D9A8]">
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap" miw={0}>
<div
className="flex shrink-0 items-center justify-center rounded-[13px] border border-[#F4D9A8]"
style={{ width: 46, height: 46, backgroundColor: "#fff", color: "#C77F12" }}
>
<Link2 size={24} />
</div>
<Box miw={0}>
<span
className="inline-flex rounded-full px-[10px] py-1 text-[10.5px] font-extrabold uppercase tracking-[0.3px] text-white"
style={{ backgroundColor: "#C77F12" }}
>
Waiting for a partner
</span>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
Your shipment is waiting to share a wagon
</Text>
<Text mt={2} fz="13px" c="#7A6A4E" className="leading-[1.45]">
Your cargo only fills part of a wagon, so were pairing it with
another shipment on the same route to share the space. As soon as a
matching shipment is found, your booking continues automatically
acceptance, approval and contract stay independent and yours alone.
</Text>
</Box>
</Group>
{priceLabel && (
<div
className="flex shrink-0 flex-col items-end rounded-xl border border-[#F4D9A8] px-[16px] py-[12px]"
style={{ backgroundColor: "#fff" }}
>
<Text fz="10.5px" fw={700} c="#B07A2A" tt="uppercase" className="tracking-[0.5px]">
Your price (held)
</Text>
<Text mt={3} fz="18px" fw={800} c="#10202F">
{priceLabel}
</Text>
</div>
)}
</Group>
</Paper>
);
}
/**
* A brief positive notice shown once a consolidation partner has been found and
* the booking has resumed the normal flow (SUBMITTED with a partner linked).
* Reassures the customer the wait ended; the booking proceeds independently.
*/
export function ConsolidationPairedNotice() {
return (
<div
className="flex items-start gap-3 rounded-[14px] border p-4"
style={{ borderColor: "#BFE6C9", backgroundColor: "#EAF7EE", color: "#1B7A3D" }}
>
<CheckCircle2 size={18} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<Text fz="13.5px" fw={800} c="#1B7A3D">
Consolidation partner found
</Text>
<Text fz="13px" c="#2E6B43" className="leading-[1.45]">
A matching shipment was found to share the wagon, so your booking is
back on track and now moving through review and approval as usual.
Nothing more is needed from you for now.
</Text>
</div>
</div>
);
}
export function MutationErrors({
mutations,
}: {

View File

@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
return () => clearInterval(interval);
}, [deadlineMs]);
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
return (
<SectionCard p={22}>
<SectionCard
p={22}
style={
remaining.expired
? undefined
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
}
>
<Group justify="space-between" align="center">
<CardTitle>Payment deadline</CardTitle>
<Group

View File

@@ -1,6 +1,6 @@
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { Smartphone, type LucideIcon } from "lucide-react";
import { useState } from "react";
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
import { Check, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { PaymentMethod } from "@/services/payments.service";
@@ -8,7 +8,10 @@ interface ProviderOption {
method: PaymentMethod;
label: string;
description: string;
icon: LucideIcon;
logo: string;
/** Currencies this provider settles in. */
currencies: string[];
accent: string;
}
// Only Telebirr and Waafi are enabled for now.
@@ -16,17 +19,32 @@ const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
label: "telebirr",
description: "Ethiopian mobile money",
icon: Smartphone,
description: "Ethiopian mobile money · ETB",
logo: "/assets/telebirr.jpeg",
currencies: ["ETB"],
accent: "#0A6F4D",
},
{
method: "WAAFI",
label: "Waafi",
description: "Djibouti mobile money",
icon: Smartphone,
description: "Djibouti mobile money · USD",
logo: "/assets/waafi.jpeg",
currencies: ["USD"],
accent: "#2E5B96",
},
];
/**
* Pick the provider that settles in the booking's currency. USD → Waafi,
* ETB → Telebirr. Falls back to the first provider when unknown.
*/
function providersForCurrency(currency?: string | null): ProviderOption[] {
const cur = currency?.trim().toUpperCase();
if (!cur) return PROVIDERS;
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
return matched.length > 0 ? matched : PROVIDERS;
}
function ProviderRow({
option,
selected,
@@ -36,55 +54,75 @@ function ProviderRow({
selected: boolean;
onSelect: () => void;
}) {
const Icon = option.icon;
return (
<Group
onClick={onSelect}
gap={12}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
gap={14}
wrap="nowrap"
align="center"
style={{
cursor: "pointer",
borderRadius: 12,
padding: "13px 14px",
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
backgroundColor: selected ? "#ECF6F1" : "#fff",
transition: "border-color .12s, background-color .12s",
borderRadius: 14,
padding: "14px 16px",
border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`,
backgroundColor: selected ? "#F6FBF8" : "#fff",
boxShadow: selected
? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)`
: "none",
transition: "border-color .14s, box-shadow .14s, background-color .14s",
}}
>
<Box
style={{
width: 40,
height: 40,
width: 52,
height: 52,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
color: selected ? "#fff" : "#475569",
borderRadius: 12,
overflow: "hidden",
border: "1px solid #EEF2F6",
backgroundColor: "#fff",
}}
>
<Icon size={19} />
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
</Box>
<Box style={{ flex: 1 }}>
<Text fz="14px" fw={700} c="#10202F">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
{option.label}
</Text>
<Text fz="12.5px" c="#9AA8B5">
<Text fz="12.5px" c="#7A8794" truncate>
{option.description}
</Text>
</Box>
<Box
style={{
width: 18,
height: 18,
width: 22,
height: 22,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
backgroundColor: selected ? "#0A6F4D" : "transparent",
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
border: `2px solid ${selected ? option.accent : "#CBD5E1"}`,
backgroundColor: selected ? option.accent : "transparent",
transition: "all .14s",
}}
/>
>
{selected && <Check size={13} color="#fff" strokeWidth={3} />}
</Box>
</Group>
);
}
@@ -93,6 +131,7 @@ export function PaymentMethodModal({
opened,
onClose,
amountLabel,
currency,
onConfirm,
processing,
error,
@@ -101,67 +140,126 @@ export function PaymentMethodModal({
onClose: () => void;
/** Human-readable total, e.g. "ETB 12,500". */
amountLabel?: string;
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
currency?: string | null;
onConfirm: (method: PaymentMethod) => void;
processing?: boolean;
error?: string | null;
}) {
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
const providers = useMemo(() => providersForCurrency(currency), [currency]);
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
// Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => {
if (!providers.some((p) => p.method === method)) {
setMethod(providers[0].method);
}
}, [providers, method]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size={460}
title={
<Stack gap={2}>
<Text fw={800} fz="17px" c="#10202F">
Choose a payment method
</Text>
{amountLabel && (
<Text fz="12.5px" c="#9AA8B5">
Amount due: {amountLabel}
</Text>
)}
</Stack>
}
radius={18}
size={480}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Stack gap={10}>
{PROVIDERS.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
{/* Header */}
<Box px={24} pt={24} pb={18}>
<Text fw={800} fz="19px" c="#10202F" lh={1.2}>
Complete your payment
</Text>
<Text mt={4} fz="13px" c="#7A8794">
Choose how you'd like to pay for this booking.
</Text>
{amountLabel && (
<Group
mt={16}
justify="space-between"
align="center"
px={16}
py={13}
style={{
borderRadius: 12,
background:
"linear-gradient(135deg, #FEF8EC 0%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Text fz="12.5px" fw={700} c="#B07D14" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Amount due
</Text>
<Text fz="20px" fw={800} c="#10202F">
{amountLabel}
</Text>
</Group>
)}
</Box>
{/* Provider options */}
<Box px={24} pb={4}>
<Text fz="11.5px" fw={700} c="#9AA8B5" tt="uppercase" mb={10} style={{ letterSpacing: 0.6 }}>
Payment method
</Text>
<Stack gap={10}>
{providers.map((option) => (
<ProviderRow
key={option.method}
option={option}
selected={method === option.method}
onSelect={() => setMethod(option.method)}
/>
))}
</Stack>
</Box>
{/* Footer */}
<Box px={24} pt={16} pb={22}>
{error && (
<Text fz="12.5px" c="#C0392B" fw={600}>
<Text fz="12.5px" c="#C0392B" fw={600} mb={10}>
{error}
</Text>
)}
<Button
fullWidth
mt={6}
radius={10}
color="edr-green"
disabled={processing}
loading={processing}
onClick={() => onConfirm(method)}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
<Text fz="11.5px" c="#9AA8B5" ta="center">
You'll be redirected to your provider to complete payment securely.
</Text>
</Stack>
<Group gap={6} align="center" justify="center" mb={12}>
<ShieldCheck size={14} color="#0A8A5F" />
<Text fz="11.5px" c="#7A8794">
Secured · you'll be redirected to your provider to pay
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Button
variant="default"
radius={12}
onClick={onClose}
disabled={processing}
styles={{
root: { height: 48, flex: "0 0 38%" },
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
}}
>
Cancel
</Button>
<Button
radius={12}
color="edr-green"
disabled={processing}
loading={processing}
onClick={() => onConfirm(method)}
styles={{
root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
</Button>
</Group>
</Box>
</Modal>
);
}

View File

@@ -61,7 +61,6 @@ export function ScheduleCard({
: "Rail only";
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
const assignedTrain: Row = {
label: "Assigned train",
value: booking.trainId ?? "Not yet assigned",
@@ -80,7 +79,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
{ label: "Consolidation", value: consolidation },
]
: [
statusRow,
@@ -88,7 +86,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
{ label: "Consolidation", value: consolidation },
];
return (

View File

@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
[
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
["Assigned train", booking.trainId ?? "Not yet assigned"],
],
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
];
return (

View File

@@ -1,12 +1,92 @@
import { Box, Group, Text } from "@mantine/core";
import { AlertTriangle, Check, FileText, History } from "lucide-react";
import {
AlertTriangle,
Check,
FileText,
History,
MapPin,
MoveRight,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative } from "../utils";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
const ACCENT = "#F2A516";
/** Origin → destination strip rendered above the progress tracker. */
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
const origin = yardLabel(booking.originYard);
const destination = yardLabel(booking.destinationYard);
return (
<Box
mb={22}
px={18}
py={14}
className="rounded-2xl"
style={{
background:
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
border: "1px solid #F2E4C4",
}}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<RouteEndpoint label="Origin" value={origin} />
<Box
className="flex items-center justify-center rounded-full shrink-0"
style={{
width: 34,
height: 34,
backgroundColor: "#fff",
border: `1px solid ${ACCENT}33`,
color: ACCENT,
}}
>
<MoveRight size={18} />
</Box>
<RouteEndpoint label="Destination" value={destination} alignRight />
</Group>
</Box>
);
}
function RouteEndpoint({
label,
value,
alignRight,
}: {
label: string;
value: string;
alignRight?: boolean;
}) {
return (
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
<Group
gap={5}
align="center"
wrap="nowrap"
justify={alignRight ? "flex-end" : "flex-start"}
>
<MapPin size={12} color={ACCENT} />
<Text
fz="10.5px"
fw={700}
c="#B07D14"
tt="uppercase"
className="tracking-[0.6px]"
>
{label}
</Text>
</Group>
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
{value}
</Text>
</Box>
);
}
export function StatusHero({
booking,
children,
@@ -91,6 +171,8 @@ export function StatusHero({
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
{!negative && <RouteStrip booking={booking} />}
{children ?? (
<ProgressTracker
current={cfg.stage}

View File

@@ -70,11 +70,11 @@ export function EstimateCard({
mb={4}
style={{
borderRadius: 6,
backgroundColor: "#F1F4F7",
backgroundColor: "#FEF6E6",
padding: "3px 7px",
fontSize: 11,
fontWeight: 700,
color: "#6B7C8E",
color: "#B07D14",
}}
>
est.

View File

@@ -15,6 +15,7 @@ import {
SimpleGrid,
Stack,
Switch,
Tabs,
Text,
Textarea,
TextInput,
@@ -36,7 +37,7 @@ import {
} from "lucide-react";
import { useMemo, useRef, type ReactNode } from "react";
import { Controller, useForm } from "react-hook-form";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
CountChip,
DocRow,
@@ -52,12 +53,33 @@ import {
type BookingFormValues,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
import { Step5CargoDetails } from "./new-booking-form/steps";
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
function yardNameFromBooking(
yard: { label?: string; code?: string; name?: string } | undefined | null,
const EDIT_SECTIONS = [
"service",
"route",
"cargo",
"schedule",
"documents",
"notes",
] as const;
type EditSection = (typeof EDIT_SECTIONS)[number];
function isEditSection(value: string | null): value is EditSection {
return EDIT_SECTIONS.includes(value as EditSection);
}
function yardIdFromBooking(
yard: Freight.IYard | null | undefined,
referenceData: Freight.BookingReferenceData,
): string {
return yard?.label ?? yard?.name ?? yard?.code ?? "";
if (yard?.id) return yard.id;
const label = yard?.label ?? "";
return (
referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? ""
);
}
/** Fallback container type for a size, used only when a booking row has no
@@ -108,14 +130,19 @@ function mapBookingToFormValues(
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
: "without_return",
originYard: yardNameFromBooking(booking.originYard),
destinationYard: yardNameFromBooking(booking.destinationYard),
originYard: yardIdFromBooking(booking.originYard, referenceData),
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
shippingLine: (booking as any).shippingLine?.name ?? "",
shippingLine: (booking as any).shippingLine?.id ?? "",
consolidationEnabled: booking.allowConsolidation ?? false,
paymentCurrency:
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
scheduledDate: booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: "",
notes: "",
containers: [],
} as BookingFormInputValues;
@@ -237,9 +264,20 @@ const DIRECTION_LABEL: Record<string, string> = {
export default function EditBookingPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const sectionParam = searchParams.get("section");
const activeSection: EditSection = isEditSection(sectionParam)
? sectionParam
: "service";
function setSection(section: EditSection) {
setSearchParams({ section });
window.scrollTo({ top: 0, behavior: "smooth" });
}
const bookingQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: id! },
@@ -269,18 +307,17 @@ export default function EditBookingPage() {
const updateMutation = useMutation({
mutationFn: async (payload: Partial<CreateBookingPayload>) => {
const result = await api.bookings.update.call({ id: id!, dto: payload });
// Upload any newly attached documents against the existing booking.
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({ id: id!, files: documents });
const newDocuments: BookingDocuments = {};
for (const [key, value] of Object.entries(documents)) {
if (value) newDocuments[key] = value;
}
return result;
return api.bookings.update.call({
id: id!,
dto: payload,
documents:
Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -304,9 +341,9 @@ export default function EditBookingPage() {
);
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.name === originYard);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const destination = referenceData?.yard.find(
(y) => y.name === destinationYard,
(y) => y.id === destinationYard,
);
return getRouteDirection(origin, destination);
}, [originYard, destinationYard, referenceData]);
@@ -314,7 +351,7 @@ export default function EditBookingPage() {
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
value: y.name,
value: y.id,
label: y.name,
country: y.country,
}));
@@ -322,10 +359,16 @@ export default function EditBookingPage() {
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
// Dedupe by name (the value the form keys on) so two lines sharing a name
// can't produce a duplicate Select option and crash Mantine.
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const setDocument = (key: string, file: File | null) => {
@@ -338,17 +381,8 @@ export default function EditBookingPage() {
};
const handleSubmit = form.handleSubmit((data) => {
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const cargoTypePath = data.cargoTypePath ?? [];
const cargoTypeId =
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
@@ -369,10 +403,16 @@ export default function EditBookingPage() {
)
: Number(data.cargoWeight || 0);
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
const selectedSvc = referenceData?.service.find(
(s) => s.id === data.serviceTypeId,
);
const apiPayload: Partial<CreateBookingPayload> = {
scheduledDate: new Date().toISOString().slice(0, 10),
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: undefined,
// Day-level pool: the customer edits only the day; the engine assigns the
// train, so trainScheduleId is not sent.
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -380,8 +420,8 @@ export default function EditBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originYardId: findYardId(data.originYard),
destinationYardId: findYardId(data.destinationYard),
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection:
direction === "EXPORT"
? "EXPORT"
@@ -391,9 +431,8 @@ export default function EditBookingPage() {
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
paymentCurrency: data.paymentCurrency,
allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -419,7 +458,7 @@ export default function EditBookingPage() {
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: findShippingLineId(data.shippingLine) }
? { shippingLineId: data.shippingLine }
: {}),
};
@@ -505,9 +544,34 @@ export default function EditBookingPage() {
</Alert>
)}
<Stack gap={36} mt="xl">
{/* ── Section 1: Service ── */}
<Stack gap="md">
{booking.status === "CHANGES_REQUESTED" && (
<Alert color="orange" icon={<AlertCircle size={16} />} radius="md" mt="lg">
<Text size="sm" fw={600}>
Staff requested changes
</Text>
<Text size="sm" mt={4}>
Update the sections below and save. Then return to the booking page to
resubmit for review.
</Text>
</Alert>
)}
<Tabs
value={activeSection}
onChange={(value) => value && setSection(value as EditSection)}
mt="xl"
>
<Tabs.List mb="lg" style={{ flexWrap: "wrap" }}>
<Tabs.Tab value="service">Service</Tabs.Tab>
<Tabs.Tab value="route">Route</Tabs.Tab>
<Tabs.Tab value="cargo">Cargo</Tabs.Tab>
<Tabs.Tab value="schedule">Schedule</Tabs.Tab>
<Tabs.Tab value="documents">Documents</Tabs.Tab>
<Tabs.Tab value="notes">Notes</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="service">
<Stack gap="md">
<SectionHeading
title="Service"
description="Select the service combination and configure trucking options."
@@ -548,6 +612,8 @@ export default function EditBookingPage() {
/>
</SimpleGrid>
<PaymentCurrencyField control={form.control} />
{(selectedService?.includesFirstMile ||
selectedService?.includesLastMile ||
selectedService?.includesCustoms) && (
@@ -648,10 +714,9 @@ export default function EditBookingPage() {
</Paper>
)}
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 3: Route ── */}
<Tabs.Panel value="route">
<Stack gap="md">
<SectionHeading
title="Route"
@@ -746,10 +811,9 @@ export default function EditBookingPage() {
/>
</Paper>
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 4: Cargo ── */}
<Tabs.Panel value="cargo">
<Box>
<Step5CargoDetails
form={form}
@@ -758,10 +822,13 @@ export default function EditBookingPage() {
isLoading={!referenceData}
/>
</Box>
</Tabs.Panel>
<Divider />
<Tabs.Panel value="schedule">
<StepScheduling form={form} referenceData={referenceData} />
</Tabs.Panel>
{/* ── Section 5: Documents ── */}
<Tabs.Panel value="documents">
<Stack gap="md">
<SectionHeading
title="Documents"
@@ -855,10 +922,9 @@ export default function EditBookingPage() {
</Box>
</Paper>
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 6: Notes ── */}
<Tabs.Panel value="notes">
<Stack gap="md">
<SectionHeading
title="Notes"
@@ -878,7 +944,8 @@ export default function EditBookingPage() {
)}
/>
</Stack>
</Stack>
</Tabs.Panel>
</Tabs>
{/* ── Submit ── */}
<Group

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
@@ -8,14 +8,44 @@ import {
Card,
Group,
Menu,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
FileEdit,
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
X,
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
"PAID",
"IN_TRANSIT",
"COMPLETED",
"DELIVERED",
]);
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
@@ -24,25 +54,86 @@ import {
usePagination,
} from "@edr/ui-common";
// ── Status badge ──────────────────────────────────────────────────────────────
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = {
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
};
const STATUS_FILTERS = [
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
{
key: "active",
label: "In progress",
statuses:
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
},
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
{
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
] as const;
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
const STAT_CARDS: Array<{
key: StatusFilterKey;
label: string;
icon: LucideIcon;
iconBg: string;
iconColor: string;
}> = [
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? {
bg: "#F1F4F7",
dot: "#94A3B8",
color: "#475569",
label: status.replace(/_/g, " "),
};
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
return (
<Group
gap={6}
@@ -51,7 +142,7 @@ function StatusBadge({ status }: { status: string }) {
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: cfg.bg,
backgroundColor: bg,
padding: "5px 11px",
}}
>
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: cfg.dot,
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
{cfg.label}
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
status,
id,
booking,
onNavigate,
}: {
status: string;
id: string;
booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
if (status === "DRAFT") {
return (
<Button
@@ -89,57 +180,39 @@ function PrimaryAction({
radius="md"
fw={700}
fz={13}
rightSection={<ArrowRight size={14} />}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
onClick={go}
>
Continue
</Button>
);
}
if (status === "AWAITING_PAYMENT") {
if (status === "CHANGES_REQUESTED") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
color="orange"
rightSection={<ArrowRight size={14} />}
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
Pay
Review changes
</Button>
);
}
if (status === "IN_TRANSIT") {
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Track
</Button>
);
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
View
</Button>
);
}
// ── Column header label ───────────────────────────────────────────────────────
function ColHeader({ label }: { label: string }) {
return (
<Text
@@ -155,20 +228,164 @@ function ColHeader({ label }: { label: string }) {
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
return data?.meta?.total;
}
function StatCard({
card,
active,
count,
onSelect,
}: {
card: (typeof STAT_CARDS)[number];
active: boolean;
count: number | undefined;
onSelect: () => void;
}) {
const Icon = card.icon;
return (
<Paper
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
p="md"
radius="lg"
withBorder
style={{
cursor: "pointer",
transition: "box-shadow 140ms ease, border-color 140ms ease",
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
}}
>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 42,
height: 42,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: card.iconBg,
color: card.iconColor,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} c="edr-text">
{count ?? "—"}
</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{card.label}
</Text>
</Box>
</Group>
</Paper>
);
}
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null,
);
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
const bookings = data?.items ?? [];
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const total = bookings.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
const filter: BookingListFilter = useMemo(
() => ({
statuses,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[statuses, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: undefined,
closed: undefined,
};
const allItems = data?.items ?? [];
const total = data?.meta?.total ?? allItems.length;
// Server handles status + pagination; reference search is applied on the page.
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allItems;
return allItems.filter((b) =>
[b.reference, b.originYard?.label, b.destinationYard?.label]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q)),
);
}, [allItems, query]);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const showEmpty =
!isLoading && !isError && rows.length === 0;
const columns: ColumnDef<Freight.IBooking>[] = [
{
@@ -178,8 +395,7 @@ export default function MyBookings() {
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const b = row.original;
const cargoLabel =
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box
@@ -217,7 +433,7 @@ export default function MyBookings() {
const b = row.original;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = b.scheduledDate ?? b.createdAt ?? "";
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
return (
<Box>
<Text fz={13} fw={600} c="edr-text">
@@ -245,7 +461,10 @@ export default function MyBookings() {
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
const b = row.original as Freight.IBooking & {
totalAmount?: number;
amount?: number;
};
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
@@ -267,24 +486,42 @@ export default function MyBookings() {
header: () => null,
cell: ({ row }) => {
const booking = row.original;
const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
{trackable && (
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
fw={700}
fz={13}
leftSection={<Train size={14} />}
onClick={() => setTrackingBooking(booking)}
>
Track
</Button>
)}
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View Details
View details
</Menu.Item>
{trackable && (
<Menu.Item
leftSection={<Train size={15} />}
onClick={() => setTrackingBooking(booking)}
>
Track shipment
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
@@ -293,8 +530,6 @@ export default function MyBookings() {
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
@@ -305,81 +540,112 @@ export default function MyBookings() {
Bookings
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Manage every cargo booking from draft to delivery.
Track every cargo booking from draft to delivery.
</Text>
</Box>
<Group gap={12}>
<Button variant="default" radius="md" leftSection={<Download size={16} />}>
Export
</Button>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New Booking
</Button>
</Group>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
{STAT_CARDS.map((card) => (
<StatCard
key={card.key}
card={card}
active={statusFilter === card.key}
count={cardCounts[card.key]}
onSelect={() => selectFilter(card.key)}
/>
))}
</SimpleGrid>
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
{/* Toolbar */}
<Group
justify="flex-end"
gap={8}
justify="space-between"
gap={12}
px={20}
py={14}
wrap="wrap"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<ArrowUpDown size={14} />}
>
Sort
</Button>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<Filter size={14} />}
>
Filter
</Button>
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
<TextInput
placeholder="Search reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
variant="transparent"
color="gray"
onClick={() => setQuery("")}
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
/>
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
allowDeselect={false}
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
aria-label="Filter by status"
/>
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}
</Text>
</Group>
{/* Empty state */}
{total === 0 && dataTableStatus === "success" ? (
{showEmpty ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600} c="edr-text">
No bookings yet
{query ? "No bookings match your search" : "No bookings here yet"}
</Text>
<Text size="xs" c="edr-muted" maw={320}>
You haven't made any booking requests yet. Create your first one to get started.
{query
? "Try a different reference or clear the search."
: "Create your first booking to get started."}
</Text>
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
{!query && (
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
)}
</Stack>
) : (
<DataTable
columns={columns}
data={paginatedData}
data={rows}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
@@ -391,6 +657,8 @@ export default function MyBookings() {
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
@@ -398,6 +666,20 @@ export default function MyBookings() {
)}
</Card>
</Stack>
<ShipmentTrackingModal
opened={trackingBooking !== null}
onClose={() => setTrackingBooking(null)}
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??
trackingBooking?.destinationYard?.code
}
/>
</Box>
);
}

View File

@@ -1,7 +1,9 @@
import { api } from "@/services/api";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
GeneratePriceResponse,
SubmitBookingResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,6 +37,7 @@ import {
getRouteDirection,
initialBookingFormValues,
stepFields,
type BookingDocuments,
type BookingFormValues,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
@@ -48,6 +51,8 @@ import {
StepScheduling,
} from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
);
}
const createMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const persistAndPriceMutation = useMutation({
mutationFn: async ({
payload,
mode,
existingBookingId,
}: {
payload: CreateBookingPayload;
mode: PriceModalMode;
existingBookingId: string | null;
}) => {
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
let bookingId = existingBookingId;
// Documents can't ride along with creation — upload them against the
// new booking id once it exists. Optional here; the booking detail page
// remains the catch-all for any docs the user skips.
const documents = form.getValues("documents") ?? {};
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
if (bookingId) {
await api.bookings.update.call({ id: bookingId, dto: payload, documents });
} else {
const booking = await api.bookings.create.call({ payload, documents });
bookingId = booking.id;
}
return booking;
const pricing = await api.bookings.generatePrice.call({ id: bookingId });
return { bookingId, pricing, mode };
},
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${booking.id}`);
},
});
const createAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {};
const hasDocs = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocs) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
return { bookingId: booking.id, pricing };
},
onSuccess: ({ bookingId, pricing }) => {
onSuccess: ({ bookingId, pricing, mode }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
setPricingPhase("ready");
setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
onError: () => {
setPricingPhase("idle");
},
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
await api.bookings.submit.call({ id: priceBookingId });
return api.bookings.submit.call({ id: priceBookingId });
},
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeResult(result);
return;
}
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
});
const confirmSubmitMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: () => {
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
const docValues = form.watch("documents") ?? {};
const hasDocuments = useMemo(
() =>
Object.values(docValues).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
),
[docValues],
);
const [pricingPhase, setPricingPhase] = useState<
"idle" | "generating" | "ready"
>("idle");
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
null,
);
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
null,
);
const [priceChangeResult, setPriceChangeResult] =
useState<SubmitBookingResponse | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
return;
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
@@ -232,13 +231,9 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const findContainerTypeId = (name: string): string => {
for (const group of containerGroups) {
const ct = group.types.find((t) => t.name === name);
@@ -265,7 +260,9 @@ export default function NewBookingPage() {
)!;
return {
scheduledDate: new Date().toISOString(),
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -273,12 +270,13 @@ export default function NewBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
paymentCurrency: "USD",
paymentCurrency: data.paymentCurrency,
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection: direction!,
cargoTypeId,
trainScheduleId: data.trainScheduleId,
// Day-level pool: the customer picks only a day (scheduledDate); the batch
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled,
@@ -307,31 +305,61 @@ export default function NewBookingPage() {
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: findShippingLineId(data.shippingLine) }
? { shippingLineId: data.shippingLine }
: {}),
...(cargoFreeText ? { cargoFreeText } : {}),
};
}
const handleDraftSubmit = form.handleSubmit((data) => {
const handleSaveDraft = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "draft",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const handleGeneratePrice = form.handleSubmit((data) => {
const handleSubmitBooking = form.handleSubmit((data) => {
if (!hasAllRequiredDocuments(data.documents)) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
setStep(6);
return;
}
try {
const apiPayload = buildApiPayload(data);
setPricingPhase("generating");
createAndPriceMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "submit",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const isPricing =
persistAndPriceMutation.isPending || confirmMutation.isPending;
function closePriceModal() {
setPriceModalMode(null);
if (priceModalMode === "draft" && priceBookingId) {
navigate(`/bookings/${priceBookingId}`);
}
}
function handleDraftModalOk() {
setPriceModalMode(null);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}
return (
<Box
style={{
@@ -376,14 +404,14 @@ export default function NewBookingPage() {
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleDraftSubmit}
onSubmit={(e) => e.preventDefault()}
>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
</Box>
{createMutation.isError && (
{persistAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
@@ -391,29 +419,11 @@ export default function NewBookingPage() {
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
Failed to save booking or generate price
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{createAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to generate price estimate
</Text>
<Text size="sm" mt={4} c="red.7">
{createAndPriceMutation.error instanceof Error
? createAndPriceMutation.error.message
{persistAndPriceMutation.error instanceof Error
? persistAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
@@ -450,17 +460,16 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId
? () => navigate(`/bookings/${priceBookingId}`)
: undefined
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmitBooking}
saveDraftPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "draft"
}
submitPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit"
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/>
)}
</Box>
@@ -501,51 +510,151 @@ export default function NewBookingPage() {
>
Continue
</Button>
) : pricingPhase === "idle" ? (
<Group>
<Button
type="submit"
form="new-booking-form"
variant={hasDocuments ? "outline" : "filled"}
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending
? "Saving Draft..."
: "Save as Draft"}
</Button>
{hasDocuments && (
<Button
type="button"
color="edr-green"
radius="md"
loading={createAndPriceMutation.isPending}
leftSection={
createAndPriceMutation.isPending ? undefined : (
<Send size={16} />
)
}
onClick={() => handleGeneratePrice()}
>
{createAndPriceMutation.isPending
? "Generating price…"
: "Submit"}
</Button>
)}
</Group>
) : pricingPhase === "generating" ? (
<Button type="button" color="edr-green" radius="md" loading>
Generating price estimate
) : (
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={handleSubmitBooking}
loading={isPricing}
>
Submit
</Button>
) : null}
)}
</Group>
</Box>
</form>
<Modal
opened={priceModalMode !== null && pricingData !== null}
onClose={closePriceModal}
title={
<Text fw={700}>
{priceModalMode === "submit"
? "Confirm booking submission"
: "Draft saved — price estimate"}
</Text>
}
radius="lg"
centered
size="md"
>
{pricingData && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceModalMode === "submit"
? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
: "Your booking has been saved as a draft. Here is the estimated price."}
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Group justify="space-between" pt="xs">
<Text fw={800} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group justify="flex-end" gap="sm" mt="md">
{priceModalMode === "submit" ? (
<>
<Button
variant="default"
radius="md"
onClick={closePriceModal}
disabled={confirmMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
>
Confirm & submit
</Button>
</>
) : (
<Button color="edr-green" radius="md" onClick={handleDraftModalOk}>
OK
</Button>
)}
</Group>
</Stack>
)}
</Modal>
<Modal
opened={priceChangeResult !== null}
onClose={() => setPriceChangeResult(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeResult && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeResult.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeResult.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeResult.totalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeResult(null)}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
import { Fragment } from "react";
import { STEPS } from "./schema";
const GREEN = "var(--mantine-color-edr-green-5)";
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
const BORDER = "var(--mantine-color-edr-border-0)";
const MUTED = "var(--mantine-color-edr-muted-0)";
const INK = "var(--mantine-color-edr-text-0)";
export function StepIndicator({ step }: { step: number }) {
return (
<div className="flex items-center">
{STEPS.map((item, index) => (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-1">
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
transition: "all 0.2s",
...(step > item.id
? {
backgroundColor: "var(--mantine-color-edr-green-5)",
color: "#fff",
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
}
: step === item.id
<div className="flex items-start">
{STEPS.map((item, index) => {
const done = step > item.id;
const active = step === item.id;
return (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
<div
style={{
width: 34,
height: 34,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 700,
flexShrink: 0,
transition: "all 0.2s",
...(done
? {
border: "2.5px solid var(--mantine-color-edr-green-5)",
color: "var(--mantine-color-edr-green-7)",
backgroundColor: "#fff",
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
color: "#fff",
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
}
: {
backgroundColor: "#fff",
color: "var(--mantine-color-edr-muted-0)",
border: "2px solid var(--mantine-color-edr-border-0)",
}),
}}
>
{step > item.id ? (
<Check style={{ width: 13, height: 13 }} />
) : (
item.id
)}
: active
? {
border: `2.5px solid ${GREEN}`,
color: GREEN_DEEP,
backgroundColor: "#fff",
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
}
: {
backgroundColor: "#fff",
color: MUTED,
border: `2px solid ${BORDER}`,
}),
}}
>
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
</div>
<span
style={{
fontSize: 11,
fontWeight: active ? 700 : 500,
textAlign: "center",
lineHeight: 1.2,
maxWidth: 72,
display: "none",
transition: "color 0.2s",
color: step >= item.id ? INK : MUTED,
}}
className="md:!block"
>
{item.short}
</span>
</div>
<span
style={{
fontSize: 10,
fontWeight: 500,
display: "none",
transition: "color 0.2s",
color:
step >= item.id
? "var(--mantine-color-edr-text-0)"
: "var(--mantine-color-edr-muted-0)",
}}
className="lg:!block"
>
{item.short}
</span>
</div>
{index < STEPS.length - 1 && (
<div
style={{
flex: 1,
height: 2,
borderRadius: 999,
margin: "0 6px",
marginBottom: 14,
transition: "background-color 0.3s",
backgroundColor:
step > item.id
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-0)",
}}
/>
)}
</Fragment>
))}
{index < STEPS.length - 1 && (
<div
style={{
flex: 1,
height: 3,
borderRadius: 999,
margin: "16px 8px 0",
transition: "background 0.3s",
background: done
? "linear-gradient(90deg, #0A8A5F, #12B981)"
: BORDER,
}}
/>
)}
</Fragment>
);
})}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Box, Text } from "@mantine/core";
import { Banknote, DollarSign } from "lucide-react";
import { Controller, type Control } from "react-hook-form";
import {
PAYMENT_CURRENCY_OPTIONS,
type BookingFormInputValues,
type BookingFormValues,
type PaymentCurrency,
} from "./schema";
import { OptionCard, OptionFieldError, StepLabel } from "./shared";
const CURRENCY_ICONS: Record<
PaymentCurrency,
{ icon: typeof DollarSign; bg: string; color: string }
> = {
USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
};
export function PaymentCurrencyField({
control,
}: {
control: Control<BookingFormInputValues, any, BookingFormValues>;
}) {
return (
<Box mt={24}>
<StepLabel>Payment currency</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Choose the currency for your freight quote and invoices.
</Text>
<Controller
name="paymentCurrency"
control={control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
const Icon = CURRENCY_ICONS[option.value].icon;
return (
<OptionCard
key={option.value}
selected={field.value === option.value}
onClick={() => field.onChange(option.value)}
icon={<Icon className="h-5 w-5" />}
iconBg={CURRENCY_ICONS[option.value].bg}
iconColor={CURRENCY_ICONS[option.value].color}
title={option.label}
description={option.description}
/>
);
})}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
</Box>
);
}

View File

@@ -14,9 +14,7 @@ export const STEPS = [
/**
* Shipment documents collected during booking creation. The fileKeys mirror
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
* here shows up as "Uploaded" on the booking detail page. All optional in this
* flow — the detail page remains the catch-all for uploading them later.
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey,
fileLabel,
helpText: null,
isRequired: false,
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents",
label: "Booking Documents",
description:
"Attach your shipment documents now, or skip and upload them later from the booking page.",
"Attach all four required shipment documents before submitting your booking.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record<string, File | File[] | null>;
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
export const PAYMENT_CURRENCY_OPTIONS: Array<{
value: PaymentCurrency;
label: string;
description: string;
}> = [
{
value: "USD",
label: "USD",
description: "US Dollar — international pricing and invoicing.",
},
{
value: "ETB",
label: "ETB",
description: "Ethiopian Birr — local pricing and invoicing.",
},
];
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
@@ -94,8 +113,9 @@ export const bookingFormSchema = z
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
scheduledDate: z.string().min(1, "Select a shipment date."),
trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]),
@@ -118,7 +138,10 @@ export const bookingFormSchema = z
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
}),
),
consolidationEnabled: z.boolean(),
// Consolidation is system-managed, not a customer choice. The backend only
// consolidates partial-wagon bookings, so this is always allowed; the
// customer neither sees nor toggles it.
consolidationEnabled: z.boolean().default(true),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
})
@@ -202,6 +225,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "",
serviceTypeId: "",
paymentCurrency: "USD",
firstMile: {
enabled: false,
pickUpAddress: "",
@@ -216,14 +240,13 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "",
shippingLine: "",
scheduledDate: "",
trainScheduleId: "",
cargoWeight: "",
cargoTypePath: [],
cargoFreeText: "",
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: false,
consolidationEnabled: true,
documents: {},
notes: "",
};
@@ -232,6 +255,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"],
2: [
"serviceTypeId",
"paymentCurrency",
"firstMile",
"lastMile",
"equipmentReturn",
@@ -244,14 +268,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"isRefrigerated",
"shippingLine",
],
4: [
"cargoType",
"cargoWeight",
"cargoTypePath",
"containers",
"consolidationEnabled",
],
5: ["scheduledDate", "trainScheduleId"],
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};
@@ -267,22 +285,33 @@ export interface WagonConfig {
type: "20ft" | "40ft";
}
/**
* Derive the trade direction from the origin/destination yard countries.
*
* Mirrors the backend's `deriveTradeDirection` exactly so the value the portal
* sends always matches what the API re-derives (the API rejects mismatches):
* - origin in Djibouti → IMPORT
* - destination in Djibouti (origin not) → EXPORT
* - everything else (e.g. Ethiopia↔Ethiopia)→ DOMESTIC
*
* Returns null only while a yard is still unselected, so the UI can wait.
*/
export function getRouteDirection(
origin: Freight.BookingReferenceYard | null | undefined,
dest: Freight.BookingReferenceYard | null | undefined,
): Freight.ScheduleTradeDirection | null {
if (!origin || !dest) return null;
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
return "DOMESTIC";
}
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
return "EXPORT";
}
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
const originCountry = origin.country?.trim();
const destCountry = dest.country?.trim();
if (originCountry === "Djibouti") {
return "IMPORT";
}
return null;
if (destCountry === "Djibouti" && originCountry !== "Djibouti") {
return "EXPORT";
}
return "DOMESTIC";
}
export function calcWagons(containers: ContainerConfig[]) {

View File

@@ -1,48 +1,167 @@
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
import {
Alert,
Box,
Combobox,
Group,
Input,
InputBase,
Paper,
Select,
Text,
Title,
useCombobox,
} from "@mantine/core";
import {
AlertTriangle,
Check,
CheckCircle2,
Info,
Loader,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
import type {
ControllerRenderProps,
FieldError as RhfFieldError,
} from "react-hook-form";
import type { BookingFormInputValues } from "./schema";
// Brand tokens (kept local so the form reads consistently with the booking
// detail page and the scheduling step).
const INK = "#10202F";
const MUTED = "#6B7C8E";
const GREEN = "#0EA371";
const GREEN_DARK = "#0A6F4D";
const BORDER = "#E6ECF2";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null;
return (
<Text size="xs" c="red" mt={4}>
<Text size="xs" c="red" mt={6}>
{error.message}
</Text>
);
}
/**
* Premium selectable option card with an icon tile, title, and description.
* Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
* `children` (legacy callers still work).
*/
export function OptionCard({
selected,
onClick,
disabled,
icon,
iconBg = "#ECF6F1",
iconColor = GREEN_DARK,
title,
description,
children,
}: {
selected: boolean;
onClick?: () => void;
disabled?: boolean;
children: ReactNode;
icon?: ReactNode;
iconBg?: string;
iconColor?: string;
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
disabled
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
style={{
position: "relative",
width: "100%",
textAlign: "left",
borderRadius: 16,
padding: 18,
cursor: disabled ? "not-allowed" : "pointer",
transition: "all 150ms ease",
border: `1.5px solid ${
disabled ? BORDER : selected ? GREEN : BORDER
}`,
background: disabled
? "#F6F8FA"
: selected
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
}`}
? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
: "#FFFFFF",
boxShadow: selected
? `0 0 0 1px ${GREEN}, 0 8px 20px rgba(14,163,113,0.10)`
: "0 1px 2px rgba(16,24,40,0.04)",
opacity: disabled ? 0.65 : 1,
}}
onMouseEnter={(e) => {
if (!disabled && !selected) {
e.currentTarget.style.borderColor = "#BFE3D2";
e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
}
}}
onMouseLeave={(e) => {
if (!disabled && !selected) {
e.currentTarget.style.borderColor = BORDER;
e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
}
}}
>
{selected && !disabled && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
<Check className="h-3 w-3 text-white" />
<span
style={{
position: "absolute",
right: 14,
top: 14,
display: "flex",
height: 22,
width: 22,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
background: GREEN,
boxShadow: "0 2px 6px rgba(14,163,113,0.45)",
}}
>
<Check style={{ width: 13, height: 13, color: "#fff" }} strokeWidth={3} />
</span>
)}
{/* Structured form (icon + title + description) */}
{(icon || title || description) && (
<Box>
{icon && (
<Box
style={{
marginBottom: 12,
display: "flex",
height: 42,
width: 42,
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
)}
{title && (
<Text fz={15} fw={800} c={INK}>
{title}
</Text>
)}
{description && (
<Text fz={12.5} c={MUTED} mt={3} style={{ lineHeight: 1.5 }}>
{description}
</Text>
)}
</Box>
)}
{children}
</button>
);
@@ -63,7 +182,7 @@ export function AlertBox({
};
const { color, icon } = map[tone];
return (
<Alert color={color} icon={icon} radius="md" fz="sm">
<Alert color={color} icon={icon} radius="lg" fz="sm">
{children}
</Alert>
);
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
return (
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
<Text
fz={11}
fw={700}
tt="uppercase"
c={MUTED}
style={{ letterSpacing: "0.07em" }}
>
{children}
</Text>
);
}
/**
* Card shell that wraps a step's body. Gives every step the same premium
* surface, padding, and an optional eyebrow.
*/
export function StepCard({
children,
eyebrow,
}: {
children: ReactNode;
eyebrow?: ReactNode;
}) {
return (
<Paper
radius={20}
p={{ base: "lg", sm: 28 }}
withBorder
bg="white"
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
>
{eyebrow}
{children}
</Paper>
);
}
export function StepHeader({
title,
description,
icon,
}: {
title: string;
description: string;
icon?: ReactNode;
}) {
return (
<div>
<Title order={3} className="tracking-tight">
{title}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div>
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
{icon && (
<Box
style={{
flexShrink: 0,
width: 44,
height: 44,
borderRadius: 13,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
color: GREEN_DARK,
}}
>
{icon}
</Box>
)}
<Box>
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
{title}
</Title>
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
{description}
</Text>
</Box>
</Group>
);
}
/** Shared Mantine input styling so every field in the form matches. */
export const fieldStyles = {
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
} as const;
export function SelectField({
field,
error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder,
disabled,
data,
leftSection,
}: {
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string;
disabled?: boolean;
data: string[] | { value: string; label: string }[];
leftSection?: ReactNode;
}) {
return (
<Select
@@ -122,6 +301,11 @@ export function SelectField({
onBlur={field.onBlur}
error={error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
leftSection={leftSection}
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
);
}
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
};
return (
<Input.Wrapper label={label} error={error?.message}>
<Combobox store={combobox} disabled={disabled}>
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
<Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
<Combobox.Target>
<InputBase
placeholder={placeholder}
disabled={disabled}
radius={10}
styles={fieldStyles}
value={searchQuery || selectedLabel}
onChange={(e) => {
onSearchChange(e.currentTarget.value);

View File

@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2 } from "lucide-react";
import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Shipment Documents"
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
description="Attach your shipment documents now, or skip and upload them later from the booking page."
/>
<Group
@@ -86,6 +87,6 @@ export function StepDocuments({ form }: { form: BookingForm }) {
/>
)}
/>
</div>
</StepCard>
);
}

View File

@@ -5,10 +5,9 @@ import {
Button,
Card,
Group,
Modal,
Stack,
Text,
useMantineTheme
useMantineTheme,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
@@ -46,17 +45,15 @@ interface DayData {
isToday: boolean;
isCurrentMonth: boolean;
isSelectedDate: boolean;
schedules: Freight.BookableScheduleItem[];
hasSchedule: boolean;
/** True when the route has at least one departure on this day. */
hasDeparture: boolean;
}
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId");
const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType");
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
[referenceData, destinationYardId],
);
const { data: bookableSchedules } = useQuery(
api.bookings.getBookableSchedules.queryOptions({
// Day-level pool: the customer picks a DAY, not a train. We only fetch which
// days have a departure — no capacity, no per-train detail. The batch engine
// assigns the train later, distributing the day's pool by priority.
const { data: availableDays } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
// Group all schedules per date — multiple departures per day are allowed.
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
// match the format used by the calendar day keys.
const schedulesByDate = useMemo(() => {
const map = new Map<string, Freight.BookableScheduleItem[]>();
if (bookableSchedules) {
for (const s of bookableSchedules) {
const dateKey = s.scheduleDate.slice(0, 10);
const existing = map.get(dateKey) ?? [];
map.set(dateKey, [...existing, s]);
}
}
return map;
}, [bookableSchedules]);
const selectedSchedule = useMemo(
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
[bookableSchedules, selectedScheduleId],
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const availableCount = useMemo(() => {
let count = 0;
schedulesByDate.forEach((schedules) => {
if (schedules.some((s) => s.remainingWagons > 0)) count++;
});
return count;
}, [schedulesByDate]);
const days = useMemo((): DayData[] => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
const schedules = schedulesByDate.get(dateString) ?? [];
return {
day: date.getDate(),
dateString,
isToday: isToday(date),
isCurrentMonth: isSameMonth(date, currentDate),
isSelectedDate: selectedDate === dateString,
schedules,
hasSchedule: schedules.length > 0,
hasDeparture: departureDays.has(dateString),
};
});
}, [currentDate, schedulesByDate, selectedDate]);
}, [currentDate, departureDays, selectedDate]);
const availableCount = useMemo(
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
[days],
);
const cargoSummary = useMemo(() => {
if (!cargoType) return "Not selected";
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7);
const handleDayClick = (day: DayData) => {
if (day.schedules.length > 1) {
setSelectedDayForModal(day);
} else if (day.schedules.length === 1) {
form.setValue("scheduledDate", day.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", day.schedules[0].id, {
shouldValidate: true,
});
}
};
const handleSelectScheduleFromModal = (scheduleId: string) => {
if (selectedDayForModal) {
form.setValue("scheduledDate", selectedDayForModal.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
setSelectedDayForModal(null);
}
if (!day.hasDeparture) return;
// Record only the day — no specific train is chosen.
form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
};
return (
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Stack gap={14} px={24} py={18}>
<Text fz={13} fw={600} c="edr-text.0">
{originYardId && destinationYardId
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
: "Select origin and destination to see available departures"}
</Text>
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
}}
>
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
<DayCell
key={di}
day={d}
onDayClick={handleDayClick}
/>
<DayCell key={di} day={d} onDayClick={handleDayClick} />
))}
</Box>
))}
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
value={cargoSummary}
/>
{selectedSchedule && selectedDate && (
{selectedDate && (
<Box
p={14}
style={{
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
c="edr-green.7"
style={{ letterSpacing: "0.08em" }}
>
SELECTED DEPARTURE
SELECTED DAY
</Text>
</Group>
<Text fw={800} fz={16} c="edr-text.0">
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Train
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
</Text>
</Group>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Wagons available
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
</Text>
</Group>
<Text fz={12.5} c="edr-muted">
Your train is confirmed by our freight desk after booking.
</Text>
</Stack>
</Box>
)}
@@ -380,71 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Stack>
</Box>
</Stack>
{/* ── Schedule Selection Modal ──────────────────────────── */}
<Modal
opened={!!selectedDayForModal}
onClose={() => setSelectedDayForModal(null)}
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
centered
size="sm"
styles={{
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
body: { padding: 24 },
}}
>
<Stack gap={12}>
<Text fz={13} c="edr-muted" fw={500}>
Choose a departure time
</Text>
{selectedDayForModal?.schedules.map((schedule) => (
<Button
key={schedule.id}
variant="outline"
fullWidth
onClick={() => handleSelectScheduleFromModal(schedule.id)}
style={{ height: 64, justifyContent: "flex-start" }}
styles={{
inner: { justifyContent: "flex-start" },
root: {
borderColor: theme.colors["edr-border"][0],
transition: "all 150ms ease",
"&:hover": {
borderColor: theme.colors["edr-green"][5],
backgroundColor: theme.colors["edr-soft"][0],
},
},
}}
>
<Group gap={16} w="100%">
<Box
style={{
width: 48,
height: 48,
borderRadius: theme.radius.md,
backgroundColor: theme.colors["edr-soft"][0],
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][5]} />
</Box>
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
<Text fw={700} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
{schedule.trainNumber && (
<Text fz={12} c="edr-muted">
Train {schedule.trainNumber}
</Text>
)}
</Stack>
</Group>
</Button>
))}
</Stack>
</Modal>
</Group>
);
}
@@ -454,7 +332,7 @@ interface DayCellProps {
onDayClick: (day: DayData) => void;
}
function DayCell({ day: d, onDayClick, }: DayCellProps) {
function DayCell({ day: d, onDayClick }: DayCellProps) {
const theme = useMantineTheme();
if (!d.isCurrentMonth) {
@@ -478,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
const cellBg = d.isSelectedDate
? theme.colors["edr-soft"][0]
: d.hasSchedule
: d.hasDeparture
? "#FFFFFF"
: "transparent";
const cellBorder = d.isSelectedDate
? `2px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule
: d.hasDeparture
? `1px solid ${theme.colors["edr-border"][0]}`
: "none";
return (
<Box
onClick={() => d.hasSchedule && onDayClick(d)}
onClick={() => d.hasDeparture && onDayClick(d)}
style={{
height: 92,
borderRadius: theme.radius.md,
@@ -501,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
display: "flex",
flexDirection: "column",
gap: 4,
cursor: d.hasSchedule ? "pointer" : "default",
cursor: d.hasDeparture ? "pointer" : "default",
transition: "all 150ms ease",
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
}}
onMouseEnter={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
}}
onMouseLeave={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
@@ -527,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
c={
d.isToday && !d.isSelectedDate
? "edr-green.6"
: d.hasSchedule
: d.hasDeparture
? "edr-text.0"
: "edr-muted"
}
@@ -552,45 +430,25 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
justifyContent: "center",
}}
>
<Check
size={14}
color="white"
strokeWidth={3}
/>
<Check size={14} color="white" strokeWidth={3} />
</Box>
)}
</Group>
{/* Schedule times */}
{d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
{d.schedules.slice(0, 2).map((s) => (
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
<Box
style={{
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
}}
/>
<Text
fz={12}
fw={700}
c="edr-text.0"
style={{ flex: 1, minWidth: 0 }}
>
{format(new Date(s.scheduleDate), "HH:mm")}
</Text>
</Group>
))}
{d.schedules.length > 2 && (
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
+{d.schedules.length - 2} more
</Text>
)}
</Stack>
{/* Availability marker — a single dot for days that have a departure.
No counts or capacity are shown: it's a day-level pool. */}
{d.hasDeparture && (
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
}}
/>
</Box>
)}
</Box>
);

View File

@@ -10,8 +10,11 @@ import {
AsyncComboboxField,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
} from "./shared";
import { FileSignature } from "lucide-react";
import { Stack } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -52,7 +55,6 @@ export function Step1ContractType({
);
const contractOptions = useMemo<PreviousContractOption[]>(() => {
console.log("Bookings data:", bookings);
if (!bookings) return [];
return bookings?.items
@@ -172,8 +174,8 @@ export function Step1ContractType({
form.setValue("containers", mappedContainers);
}
// ── Consolidation ───────────────────────────────────────────────────
form.setValue("consolidationEnabled", booking.allowConsolidation);
// Consolidation is system-managed (always allowed) — not copied from the
// previous booking and not customer-controllable.
// ── Scheduled date ──────────────────────────────────────────────────
if (booking.scheduledDate) {
@@ -182,10 +184,11 @@ export function Step1ContractType({
};
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileSignature size={22} />}
title="Contract Type"
description="New contract or renewal of an existing one."
description="Start a new contract or renew an existing one to reuse its details."
/>
<Controller
@@ -193,40 +196,33 @@ export function Step1ContractType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
selected={field.value === "new"}
icon={<FileText className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="New Contract"
description="Create a fresh freight contract from scratch."
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
form.setValue("previousContractRef", "");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<FileText className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-gray-500">
Create a new contract.
</p>
</OptionCard>
/>
<OptionCard
selected={field.value === "renewal"}
icon={<RefreshCw className="h-5 w-5" />}
iconBg="#EAF1FB"
iconColor="#2E5B96"
title="Contract Renewal"
description="Pick a previous reference to auto-fill historical parameters."
onClick={() => {
field.onChange("renewal");
form.clearErrors("contractType");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
<RefreshCw className="h-4 w-4 text-sky-600" />
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-gray-500">
Select a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -234,7 +230,7 @@ export function Step1ContractType({
/>
{contractType === "renewal" && (
<div className="space-y-3 pt-1">
<Stack gap={12} mt={22}>
{error && (
<AlertBox tone="error">
Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled.
</AlertBox>
)}
</div>
</Stack>
)}
</div>
</StepCard>
);
}

View File

@@ -1,9 +1,18 @@
import { Switch, TextInput } from "@mantine/core";
import { FileText, Train, Truck } from "lucide-react";
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
import {
fieldStyles,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field";
import type { Freight } from "@edr/types";
@@ -61,10 +70,11 @@ export function Step2ServiceType({
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Layers size={22} />}
title="Service Type"
description="Select the service combination and configure trucking options."
description="Choose the service combination, then configure your trucking options."
/>
<Controller
@@ -72,211 +82,225 @@ export function Step2ServiceType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => {
return (
<OptionCard
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
<Train className="h-4 w-4 text-indigo-600" />
</div>
<p className="font-semibold">{s.serviceName}</p>
<p className="mt-0.5 text-xs text-gray-500">
{s.description}
</p>
</OptionCard>
);
})}
.map((s) => (
<OptionCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
icon={<Train className="h-5 w-5" />}
iconBg="#EEF0FB"
iconColor="#4F46E5"
title={s.serviceName}
description={s.description}
/>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
<PaymentCurrencyField control={form.control} />
{showServiceSections && (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
<Stack gap={12} mt={24}>
<StepLabel>Trucking & customs options</StepLabel>
{/* First Mile */}
{includesFirstMile && (
<div className="p-4">
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
First Mile Pick-up
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="First Mile — Pick-up"
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Last Mile */}
{includesLastMile && (
<div className="p-4">
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Last Mile Delivery
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Last Mile — Delivery"
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Equipment Return */}
{includesLastMile && lastMileEnabled && (
<div className="p-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-gray-500">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onChange={(e) => {
field.onChange(
e.currentTarget.checked
? "with_return"
: "without_return",
);
}}
color="edr-green"
/>
</div>
)}
/>
</div>
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Equipment Return"
description={
field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."
}
checked={field.value === "with_return"}
onChange={(v) =>
field.onChange(v ? "with_return" : "without_return")
}
/>
)}
/>
)}
{/* Customs Clearing */}
{includesCustoms && (
<div className="p-4">
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Customs Clearing Service
</p>
<p className="mt-0.5 text-xs text-gray-500">
EDR handles customs documentation and clearance on
your behalf.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
</div>
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)}
</div>
</Stack>
)}
</div>
</StepCard>
);
}
function ServiceToggle({
icon,
title,
description,
checked,
onChange,
children,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
children?: ReactNode;
}) {
return (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
<Group gap={13} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: checked ? "#ECF6F1" : "#F1F4F7",
color: checked ? "#0A6F4D" : "#64748B",
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
style={{ flexShrink: 0 }}
/>
</Group>
{children}
</Box>
);
}

View File

@@ -1,6 +1,6 @@
import type { Freight } from "@edr/types";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -8,7 +8,7 @@ import {
type BookingFormValues,
getRouteDirection,
} from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared";
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -35,10 +35,17 @@ export function Step4Route({
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
// The form keys shipping line by name, so options are keyed by name too.
// Dedupe by name: if the reference data has two lines sharing a name, a
// duplicate option would crash Mantine's Select ("Duplicate options...").
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const originData = useMemo(() => {
@@ -63,7 +70,6 @@ export function Step4Route({
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
@@ -85,10 +91,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<RouteIcon size={22} />}
title="Route"
description="Select the origin and destination yards."
description="Choose the origin and destination yards for your shipment."
/>
{isLoading ? (
@@ -96,7 +103,7 @@ export function Step4Route({
) : (
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="originYard"
control={form.control}
@@ -153,56 +160,108 @@ export function Step4Route({
/>
)}
<Divider />
<Divider my={22} color="#EEF2F6" />
<div className="divide-y divide-gray-200">
<StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Flame className="h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-gray-500">
Applies a Hazard Surcharge to the final bill.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge to the final bill."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div>
<p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-gray-500">
Temperature-controlled transport applies a Refrigerator
Surcharge.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
</div>
</div>
</Stack>
</StepCard>
);
}
function ToggleRow({
icon,
iconBg,
iconColor,
title,
description,
checked,
onChange,
}: {
icon: React.ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<Group
justify="space-between"
align="center"
wrap="nowrap"
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
/>
</Group>
);
}

View File

@@ -5,7 +5,6 @@ import {
ActionIcon,
Button,
Skeleton,
InputLabel,
Text,
TextInput,
} from "@mantine/core";
@@ -17,9 +16,11 @@ import {
} from "./schema";
import {
AlertBox,
fieldStyles,
OptionCard,
OptionFieldError,
SelectField,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
if (isLoading) {
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
<div className="space-y-4">
<Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton height={96} radius="xl" />
<Skeleton height={96} radius="xl" />
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton height={96} radius="lg" />
<Skeleton height={96} radius="lg" />
</div>
<Skeleton height={40} radius="md" />
<Skeleton height={40} w="33%" radius="md" />
<Skeleton height={44} radius="md" />
<Skeleton height={44} w="33%" radius="md" />
</div>
</div>
</StepCard>
);
}
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
<div className="space-y-3">
<InputLabel>Cargo Type *</InputLabel>
<StepLabel>Cargo Type *</StepLabel>
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
icon={<Package className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="Containerized"
description="Pre-packed containerized cargo (20ft / 40ft)."
onClick={() => {
field.onChange("container");
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-gray-500">
Pre-packed containerized cargo (20ft / 40ft).
</p>
</OptionCard>
/>
<OptionCard
selected={cargoType === "bulk"}
icon={<Weight className="h-5 w-5" />}
iconBg="#FDF3E0"
iconColor="#C77F09"
title="General Cargo"
description="Bulk commodities or break-bulk cargo."
onClick={() => {
field.onChange("bulk");
form.setValue("containers", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-gray-500">
Bulk commodities or break-bulk cargo.
</p>
</OptionCard>
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius="md"
radius={10}
styles={fieldStyles}
min={0}
step={0.01}
/>
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
})()}
</>
)}
</div>
</StepCard>
);
}

View File

@@ -1,26 +1,46 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import {
Badge,
Box,
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Paper,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import { format } from "date-fns";
import {
Calendar,
CheckCircle2,
Circle,
ClipboardCheck,
FileText,
Package,
Pencil,
Route,
Send,
Truck,
} from "lucide-react";
import type { Freight } from "@/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
export const REVIEW_STEP_TARGETS = {
contract: 1,
service: 2,
route: 3,
cargo: 4,
schedule: 5,
documents: 6,
} as const;
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -28,113 +48,135 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
function OverviewSection({
icon,
title,
onEdit,
children,
}: {
icon: React.ReactNode;
title: string;
onEdit: () => void;
children: React.ReactNode;
}) {
return (
<Paper radius={16} p="lg" withBorder className="border-gray-200 bg-white">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Box
className="flex items-center justify-center rounded-lg"
style={{
width: 36,
height: 36,
backgroundColor: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
}}
>
{icon}
</Box>
<Text fw={700} size="sm" c="#10202F">
{title}
</Text>
</Group>
<Button
type="button"
variant="subtle"
color="edr-green"
size="compact-xs"
leftSection={<Pencil size={13} />}
onClick={onEdit}
>
Edit
</Button>
</Group>
{children}
</Paper>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" py={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" className="tracking-wide">
{label}
</Text>
<Text size="sm" fw={500} ta="right" maw="60%">
{value || "—"}
</Text>
</Group>
);
}
function ReadinessItem({
done,
label,
}: {
done: boolean;
label: string;
}) {
return (
<Group gap="sm" wrap="nowrap">
{done ? (
<CheckCircle2 size={18} className="shrink-0 text-emerald-600" />
) : (
<Circle size={18} className="shrink-0 text-gray-300" />
)}
<Text size="sm" c={done ? "dark" : "dimmed"}>
{label}
</Text>
</Group>
);
}
export function Step8Review({
form,
setStep,
direction,
referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
onSaveDraft,
onSubmit,
saveDraftPending = false,
submitPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
pricingPhase?: "idle" | "generating" | "ready";
pricingData?: GeneratePriceResponse | null;
onConfirm?: () => void;
onContinueLater?: () => void;
onAbort?: () => void;
confirmPending?: boolean;
abortPending?: boolean;
onSaveDraft?: () => void;
onSubmit?: () => void;
saveDraftPending?: boolean;
submitPending?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function CompactRow({
label,
value,
target,
}: {
label: string;
value: string;
target: number;
}) {
return (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
{label}
</Text>
<Text size="sm" fw={500} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(target)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
>
Edit
</button>
</div>
);
}
function CompactCard({
icon: Icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
<Group gap="xs" mb="xs" wrap="nowrap">
<Box c="edr-green">{Icon}</Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Group>
<Stack gap="xs">{children}</Stack>
</Card>
);
}
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.containerType || c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: Number(values.cargoWeight || 0);
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
const allDocsReady = hasAllRequiredDocuments(documents);
const cargoValue = (() => {
if (values.cargoType === "container") return containerSummary;
if (values.cargoType === "container") return "Container freight";
if (!referenceData) return "";
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
@@ -143,226 +185,312 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
const originYardName = referenceData?.yard.find(
(y) => y.id === values.originYard,
)?.name ?? values.originYard;
const originYardName =
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
values.originYard;
const destinationYardName = referenceData?.yard.find(
(y) => y.id === values.destinationYard,
)?.name ?? values.destinationYard;
const destinationYardName =
referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
values.destinationYard;
const scheduleLabel = values.scheduledDate
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
: "—";
const directionLabel = direction
? direction.charAt(0) + direction.slice(1).toLowerCase()
: "—";
return (
<Stack gap="md">
<Stack gap="lg">
<StepHeader
icon={<ClipboardCheck size={22} />}
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
description="Review your booking overview before sending it for EDR staff review."
/>
{/* Pricing Card - Prominent at top */}
{pricingPhase === "generating" && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
<Group justify="center" py="lg">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Generating price estimate
</Text>
</Group>
</Card>
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Stack gap="sm">
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
💳 Price Breakdown
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={2}>
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="xs" />
<Group justify="space-between" py={2}>
<Text fw={700} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Left — booking summary */}
<Stack gap="md" className="min-w-0 flex-1">
<Paper
radius={20}
p="lg"
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Text size="xs" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
Booking overview
</Text>
<Text fw={800} size="xl" c="#10202F">
{values.contractType === "new" ? "New Contract" : "Contract Renewal"}
</Text>
<Text size="sm" c="dimmed">
{serviceType?.name ?? "—"} · {originYardName} {destinationYardName}
</Text>
</Stack>
<Badge size="lg" variant="light" color="edr-green" radius="md">
{directionLabel}
</Badge>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")}
</Text>
</Paper>
<OverviewSection
icon={<Package size={18} />}
title="Contract & Service"
onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
>
<DetailRow
label="Contract"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
/>
{values.contractType === "renewal" && values.previousContractRef && (
<DetailRow label="Previous ref" value={values.previousContractRef} />
)}
<Group mt="md">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
className="flex-1"
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
className="flex-1"
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
Abort
</Button>
</Group>
</Stack>
</Card>
)}
{/* Review Details - Compact Cards Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
<CompactCard icon={<Package size={16} />} title="Contract & Service">
<CompactRow
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</CompactCard>
<CompactCard icon={<Route size={16} />} title="Route">
<CompactRow
label="Origin → Destination"
value={`${originYardName}${destinationYardName}`}
target={3}
/>
<CompactRow
label="Workflow"
value={
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Truck size={16} />} title="Logistics">
<CompactRow
label="First Mile"
value={
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Last Mile"
value={
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Equipment Return"
value={
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
}
target={2}
/>
<CompactRow
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Cargo Details">
<CompactRow
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
<CompactRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Containers">
<CompactRow
label="Count & Type"
value={containerSummary || "—"}
target={4}
/>
<CompactRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
target={4}
/>
</CompactCard>
<CompactCard icon={<FileText size={16} />} title="Documents">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
Attached
</Text>
<Text size="sm" fw={500}>
{docsAttached > 0
? `${docsAttached} of ${docsTotal}`
: "None"}
</Text>
</div>
<button
<DetailRow label="Service" value={serviceType?.name ?? ""} />
<DetailRow
label="Payment currency"
value={values.paymentCurrency ?? "USD"}
/>
<Button
type="button"
onClick={() => setStep(5)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
variant="subtle"
size="compact-xs"
color="gray"
mt={4}
onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
>
Edit
</button>
</div>
</CompactCard>
</SimpleGrid>
Edit service options
</Button>
</OverviewSection>
{/* Notes */}
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations…"
rows={2}
radius="md"
size="sm"
<OverviewSection
icon={<Route size={18} />}
title="Route"
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
>
<DetailRow
label="Corridor"
value={`${originYardName}${destinationYardName}`}
/>
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow label="Shipping line" value={values.shippingLine || "—"} />
<DetailRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
/>
</OverviewSection>
<OverviewSection
icon={<Truck size={18} />}
title="Logistics"
onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
>
<DetailRow
label="First mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
/>
<DetailRow
label="Last mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
/>
<DetailRow
label="Equipment return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
}
/>
<DetailRow
label="Customs clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Shipment date" value={scheduleLabel} />
</OverviewSection>
<OverviewSection
icon={<Package size={18} />}
title="Cargo"
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
>
<DetailRow label="Freight type" value={cargoValue} />
<DetailRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
/>
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM (t)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{values.containers
.filter((c) => +c.qty > 0)
.map((c, i) => (
<Table.Tr key={i}>
<Table.Td>{c.containerType || c.type}</Table.Td>
<Table.Td>{c.qty}</Table.Td>
<Table.Td>{c.vgm}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{containerSummary && (
<DetailRow label="Summary" value={containerSummary} />
)}
</OverviewSection>
<OverviewSection
icon={<FileText size={18} />}
title="Documents"
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
>
<Stack gap="xs">
{BOOKING_DOCS_SETTING.fields.map((field) => {
const file = documents[field.fileKey];
const attached = Array.isArray(file)
? file.length > 0
: Boolean(file);
const fileName = attached
? Array.isArray(file)
? file[0]?.name
: (file as File)?.name
: null;
return (
<Group key={field.fileKey} justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{attached ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : (
<Circle size={16} className="text-red-400 shrink-0" />
)}
<Text size="sm">{field.fileLabel}</Text>
</Group>
<Text size="xs" c={attached ? "dimmed" : "red"} className="truncate max-w-[45%]">
{fileName ?? "Missing"}
</Text>
</Group>
);
})}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
{docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
</Text>
</OverviewSection>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
label="Additional notes"
placeholder="Any special instructions for EDR operations…"
rows={3}
radius="md"
/>
)}
/>
)}
/>
</Stack>
{/* Right — sticky actions */}
<Box className="w-full shrink-0 lg:w-[340px] lg:sticky lg:top-24">
<Stack gap="md">
<Paper radius={20} p="lg" withBorder bg="white">
<Text fw={800} size="sm" mb="md" c="#10202F">
Submission readiness
</Text>
<Stack gap="sm">
<ReadinessItem done={Boolean(values.serviceTypeId)} label="Service configured" />
<ReadinessItem
done={Boolean(values.paymentCurrency)}
label="Payment currency selected"
/>
<ReadinessItem
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment day selected"
/>
<ReadinessItem
done={
values.cargoType === "container"
? values.containers.some((c) => +c.qty > 0)
: Boolean(values.cargoWeight)
}
label="Cargo details complete"
/>
<ReadinessItem
done={allDocsReady}
label="All 4 documents attached"
/>
</Stack>
</Paper>
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
{allDocsReady
? "Ready to submit. You'll review the price estimate before final submission."
: "Upload all four documents to enable submission."}
</Text>
<Stack gap="sm">
<Button
type="button"
color="edr-green"
radius="md"
fullWidth
size="md"
leftSection={<Send size={16} />}
onClick={onSubmit}
loading={submitPending}
disabled={!allDocsReady || submitPending}
>
Submit
</Button>
<Button
type="button"
variant="outline"
color="edr-green"
radius="md"
fullWidth
onClick={onSaveDraft}
loading={saveDraftPending}
disabled={submitPending}
>
Save as draft
</Button>
</Stack>
</Paper>
</Stack>
</Box>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,61 @@
import { Button, type ButtonProps } from "@mantine/core";
import { CreditCard } from "lucide-react";
import { Freight } from "@edr/types";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
import { priceTotal } from "../BookingDetailPage/utils";
import { useBookingPayment } from "./useBookingPayment";
interface PayNowButtonProps {
booking: Freight.IBooking;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/**
* Self-contained "Pay now" action: shows the payment-method modal in place
* instead of navigating to the booking detail page. Drop it into list rows,
* cards, or anywhere a payable booking surfaces.
*/
export function PayNowButton({
booking,
label = "Pay now",
size = "xs",
fullWidth,
}: PayNowButtonProps) {
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
return (
<>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
// Don't let a surrounding row-click handler fire.
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /payments/initiate, and redirects the browser to the provider (or the
* fallback checkout page). Reused by the booking detail page, the booking list,
* and the home page so "Pay now" behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
const [modalOpen, setModalOpen] = useState(false);
const mutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId, method }),
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId, method });
window.location.href = redirectUrl;
},
});
const open = () => setModalOpen(true);
const close = () => {
if (!mutation.isPending) {
setModalOpen(false);
mutation.reset();
}
};
const error = mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null;
return {
modalOpen,
open,
close,
processing: mutation.isPending,
error,
confirm: (method: PaymentMethod) => mutation.mutate(method),
};
}

View File

@@ -0,0 +1,752 @@
import { Box, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Clock,
Flag,
MapPin,
PackageX,
RefreshCw,
Train,
} from "lucide-react";
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import {
checkpointKindLabel,
corridorProgress,
isArrived,
isDispatched,
shipmentStatusLabel,
} from "./trackingStages";
const GREEN = "#0EA371";
const GREEN_DARK = "#0A6F4D";
const ACCENT = "#F2A516";
const INK = "#10202F";
const MUTED = "#6B7C8E";
interface ShipmentTrackingModalProps {
opened: boolean;
onClose: () => void;
bookingId: string;
bookingReference: string;
originLabel?: string;
destinationLabel?: string;
}
export function ShipmentTrackingModal({
opened,
onClose,
bookingId,
bookingReference,
originLabel,
destinationLabel,
}: ShipmentTrackingModalProps) {
const { data, isLoading, isError, refetch, isFetching } = useQuery({
...api.bookings.tracking.queryOptions({ input: { id: bookingId } }),
enabled: opened && Boolean(bookingId),
refetchInterval: opened ? 30_000 : false,
});
const hasSchedule = data?.hasSchedule ?? false;
return (
<Modal
opened={opened}
onClose={onClose}
centered
size={900}
radius={20}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ content: { overflow: "hidden" } }}
>
<Header
bookingReference={data?.bookingReference ?? bookingReference}
trainNumber={data?.trainNumber ?? null}
status={data?.scheduleStatus ?? null}
currentSequenceNo={data?.currentSequenceNo ?? -1}
onClose={onClose}
onRefresh={() => refetch()}
refreshing={isFetching}
/>
<Box px={28} py={24}>
{isLoading ? (
<Center mih={280}>
<Stack align="center" gap="sm">
<Loader color="edr-green" />
<Text fz="sm" c={MUTED}>
Locating your train
</Text>
</Stack>
</Center>
) : isError ? (
<ErrorState onRetry={() => refetch()} />
) : !hasSchedule ? (
<NotDispatchedState
origin={originLabel ?? "Origin"}
destination={destinationLabel ?? "Destination"}
/>
) : data ? (
<Stack gap={26}>
<SummaryBar data={data} />
<Corridor data={data} />
<CheckpointFeed data={data} />
</Stack>
) : null}
</Box>
</Modal>
);
}
// ── Header ────────────────────────────────────────────────────────────────────
function Header({
bookingReference,
trainNumber,
status,
currentSequenceNo,
onClose,
onRefresh,
refreshing,
}: {
bookingReference: string;
trainNumber: string | null;
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
onClose: () => void;
onRefresh: () => void;
refreshing: boolean;
}) {
return (
<Box
px={28}
py={22}
style={{
background:
"linear-gradient(120deg, #0C1A2B 0%, #123047 60%, #0A6F4D 140%)",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={14} align="center" wrap="nowrap">
<Box
style={{
width: 48,
height: 48,
borderRadius: 13,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255,255,255,0.12)",
color: "#fff",
}}
>
<Train size={24} />
</Box>
<Box>
<Text
fz="11px"
fw={700}
tt="uppercase"
c="#9FE9CC"
style={{ letterSpacing: 0.7 }}
>
Live shipment tracking
</Text>
<Text fz="20px" fw={800} c="#fff" lh={1.2}>
{bookingReference}
</Text>
{trainNumber && (
<Text fz="12px" c="#A9BBCB">
Train {trainNumber}
</Text>
)}
</Box>
</Group>
<Group gap={10} align="center" wrap="nowrap">
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
<RefreshCw size={16} />
</IconButton>
<IconButton title="Close" onClick={onClose}>
<span style={{ fontSize: 18, lineHeight: 1, fontWeight: 600 }}>×</span>
</IconButton>
</Group>
</Group>
</Box>
);
}
function IconButton({
children,
onClick,
title,
spinning,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
spinning?: boolean;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
style={{
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
border: "1px solid rgba(255,255,255,0.18)",
backgroundColor: "rgba(255,255,255,0.08)",
color: "#fff",
cursor: "pointer",
animation: spinning ? "edr-spin 0.9s linear infinite" : undefined,
}}
>
{children}
</button>
);
}
function HeaderStatusPill({
status,
currentSequenceNo,
}: {
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
}) {
const arrived = isArrived(status);
const moving = isDispatched(status);
const bg = arrived
? "rgba(14,163,113,0.22)"
: moving
? "rgba(242,165,22,0.20)"
: "rgba(255,255,255,0.12)";
const dot = arrived ? "#5BE3B0" : moving ? ACCENT : "#CBD5E1";
return (
<Group
gap={7}
align="center"
wrap="nowrap"
px={12}
py={7}
style={{ borderRadius: 999, backgroundColor: bg }}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: dot,
animation: moving ? "edr-pulse 1.4s ease-in-out infinite" : undefined,
}}
/>
<Text fz="12px" fw={700} c="#fff">
{shipmentStatusLabel(status, currentSequenceNo)}
</Text>
</Group>
);
}
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
const arrived = isArrived(data.scheduleStatus);
const items: Array<{ label: string; value: string; accent?: boolean }> = [
{
label: "Departed",
value: fmtTime(data.actualDepartureAt ?? data.scheduledDepartureAt),
},
{
label: arrived ? "Arrived" : "Est. arrival",
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
accent: !arrived,
},
{
label: "Stations",
value: `${Math.max(0, data.currentSequenceNo + (data.currentSequenceNo >= 0 ? 1 : 0))} / ${data.stations.length}`,
},
];
return (
<Group
gap={0}
wrap="nowrap"
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
overflow: "hidden",
}}
>
{items.map((it, i) => (
<Box
key={it.label}
style={{
flex: 1,
padding: "14px 16px",
borderLeft: i > 0 ? "1px solid #EEF2F6" : undefined,
background: it.accent ? "#FEFBF3" : "#FBFCFD",
}}
>
<Text
fz="10.5px"
fw={700}
tt="uppercase"
c={it.accent ? "#B07D14" : MUTED}
style={{ letterSpacing: 0.5 }}
>
{it.label}
</Text>
<Text fz="15px" fw={800} c={INK} mt={2}>
{it.value}
</Text>
</Box>
))}
</Group>
);
}
// ── Corridor: stations + train marker ──────────────────────────────────────────
function Corridor({ data }: { data: Freight.IBookingTracking }) {
const arrived = isArrived(data.scheduleStatus);
const moving = isDispatched(data.scheduleStatus);
const stations = data.stations;
const current = data.currentSequenceNo;
const progress = corridorProgress(stations.length, current, arrived);
// Map sequenceNo → latest checkpoint at that station for captions.
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
return (
<Box>
<Group gap={8} align="center" mb={16}>
<MapPin size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Where is your train
</Text>
<Text fz="12.5px" c={MUTED}>
· {progress}% of the route
</Text>
</Group>
{/* Horizontal rail */}
<Box style={{ position: "relative", paddingTop: 44, paddingBottom: 4 }}>
{/* base rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
right: 16,
height: 5,
borderRadius: 999,
background: "#EAF0F5",
}}
/>
{/* filled rail */}
<Box
style={{
position: "absolute",
top: 54,
left: 16,
width: `calc((100% - 32px) * ${progress / 100})`,
height: 5,
borderRadius: 999,
background: `linear-gradient(90deg, ${GREEN_DARK}, ${GREEN})`,
transition: "width 600ms ease",
}}
/>
{/* train marker riding the filled rail */}
<Box
style={{
position: "absolute",
top: 18,
left: `calc(16px + (100% - 32px) * ${progress / 100})`,
transform: "translateX(-50%)",
transition: "left 600ms ease",
zIndex: 3,
}}
>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: arrived
? `linear-gradient(135deg, ${GREEN}, ${GREEN_DARK})`
: `linear-gradient(135deg, ${ACCENT}, #D98A06)`,
color: "#fff",
boxShadow: "0 6px 16px rgba(16,24,40,0.20)",
border: "3px solid #fff",
animation: moving ? "edr-bob 1.8s ease-in-out infinite" : undefined,
}}
>
{arrived ? <CheckCircle2 size={18} /> : <Train size={18} />}
</Box>
</Box>
{/* station nodes */}
<Box
style={{
position: "relative",
display: "flex",
justifyContent: "space-between",
zIndex: 2,
}}
>
{stations.map((s, i) => {
const reached = arrived || (current >= 0 && i <= current);
const isCurrent = !arrived && i === current;
const isLast = i === stations.length - 1;
const cp = checkpointBySeq.get(s.sequenceNo);
return (
<StationNode
key={`${s.yardId}-${i}`}
label={s.label}
reached={reached}
isCurrent={isCurrent}
isEndpoint={i === 0 || isLast}
arrivedHere={isLast && arrived}
time={cp ? fmtTime(cp.occurredAt) : null}
align={i === 0 ? "left" : isLast ? "right" : "center"}
/>
);
})}
</Box>
</Box>
</Box>
);
}
function StationNode({
label,
reached,
isCurrent,
isEndpoint,
arrivedHere,
time,
align,
}: {
label: string;
reached: boolean;
isCurrent: boolean;
isEndpoint: boolean;
arrivedHere: boolean;
time: string | null;
align: "left" | "center" | "right";
}) {
const color = arrivedHere ? GREEN : isCurrent ? ACCENT : reached ? GREEN : "#CBD5E1";
return (
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
flex: isEndpoint ? "0 0 auto" : 1,
minWidth: 0,
maxWidth: 120,
}}
>
<Box
style={{
width: isCurrent ? 18 : 14,
height: isCurrent ? 18 : 14,
borderRadius: "50%",
background: "#fff",
border: `3px solid ${color}`,
boxShadow: isCurrent ? `0 0 0 4px ${ACCENT}22` : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
style={{
width: isCurrent ? 7 : 5,
height: isCurrent ? 7 : 5,
borderRadius: "50%",
background: color,
}}
/>
</Box>
<Text
fz="11.5px"
fw={reached ? 700 : 600}
c={reached ? INK : "#9AA8B5"}
mt={8}
ta={align}
truncate
style={{ maxWidth: 110 }}
title={label}
>
{label}
</Text>
{time && (
<Text fz="10px" c={MUTED} mt={1}>
{time}
</Text>
)}
</Box>
);
}
// ── Checkpoint feed ────────────────────────────────────────────────────────────
function CheckpointFeed({ data }: { data: Freight.IBookingTracking }) {
// Newest first.
const ordered = [...data.checkpoints].sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
return (
<Box
p={20}
style={{
borderRadius: 16,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Group gap={8} align="center" mb={ordered.length ? 16 : 0}>
<Clock size={15} color={GREEN_DARK} />
<Text fz="14px" fw={800} c={INK}>
Journey log
</Text>
</Group>
{ordered.length === 0 ? (
<Text fz="13px" c={MUTED}>
No checkpoints logged yet. Updates appear here as the train passes each
station along the corridor.
</Text>
) : (
<Box>
{ordered.map((cp, i) => {
const isLatest = i === 0;
const last = i === ordered.length - 1;
const Icon =
cp.kind === Freight.TrainCheckpointKind.Arrived
? CheckCircle2
: cp.kind === Freight.TrainCheckpointKind.Departed
? Flag
: Train;
return (
<Group key={cp.id} gap={14} wrap="nowrap" align="flex-start">
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
alignSelf: "stretch",
}}
>
<Box
style={{
width: 30,
height: 30,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#64748B",
flexShrink: 0,
}}
>
<Icon size={15} />
</Box>
{!last && (
<Box
style={{
flex: 1,
width: 2,
marginTop: 4,
marginBottom: 4,
background: "#E1E7EE",
}}
/>
)}
</Box>
<Box pb={last ? 0 : 16} style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="center" wrap="wrap">
<Text fz="13.5px" fw={700} c={INK}>
{cp.label ?? "Checkpoint"}
</Text>
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: isLatest ? "#ECF6F1" : "#F1F4F7",
color: isLatest ? GREEN_DARK : "#475569",
}}
>
{checkpointKindLabel(cp.kind)}
</Box>
{isLatest && (
<Box
component="span"
style={{
borderRadius: 999,
padding: "2px 9px",
fontSize: 10.5,
fontWeight: 700,
background: "#FEF6E6",
color: "#B07D14",
}}
>
Latest
</Box>
)}
</Group>
{cp.note && (
<Text fz="12.5px" c={MUTED} mt={2}>
{cp.note}
</Text>
)}
<Text fz="11.5px" c="#9AA8B5" mt={3}>
{fmtTime(cp.occurredAt)}
</Text>
</Box>
</Group>
);
})}
</Box>
)}
</Box>
);
}
// ── Empty / error states ───────────────────────────────────────────────────────
function NotDispatchedState({
origin,
destination,
}: {
origin: string;
destination: string;
}) {
return (
<Stack align="center" gap={6} py={40} ta="center">
<Box
style={{
width: 64,
height: 64,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FEF6E6",
color: ACCENT,
}}
>
<PackageX size={30} />
</Box>
<Text fz="18px" fw={800} c={INK} mt={4}>
Not on the rails yet
</Text>
<Text fz="13.5px" c={MUTED} maw={440}>
Your shipment from <b>{origin}</b> to <b>{destination}</b> hasn't been
assigned to a train. Live tracking begins the moment it's dispatched and
starts moving along the corridor.
</Text>
</Stack>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<Stack align="center" gap={8} py={40} ta="center">
<Box
style={{
width: 60,
height: 60,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEAE7",
color: "#C0392B",
}}
>
<AlertTriangle size={28} />
</Box>
<Text fz="16px" fw={800} c={INK}>
Couldn't load tracking
</Text>
<Text fz="13px" c={MUTED}>
Something went wrong fetching your shipment status.
</Text>
<button
type="button"
onClick={onRetry}
style={{
marginTop: 6,
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "9px 16px",
borderRadius: 10,
border: "1px solid #E6ECF2",
background: "#fff",
color: INK,
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
<RefreshCw size={15} /> Try again
</button>
</Stack>
);
}
// ── helpers ────────────────────────────────────────────────────────────────────
function fmtTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// keyframes (injected once)
if (
typeof document !== "undefined" &&
!document.getElementById("edr-tracking-kf")
) {
const style = document.createElement("style");
style.id = "edr-tracking-kf";
style.textContent = `
@keyframes edr-spin { to { transform: rotate(360deg); } }
@keyframes edr-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@keyframes edr-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
`;
document.head.appendChild(style);
}

View File

@@ -0,0 +1,64 @@
import { Freight } from "@edr/types";
const { TrainScheduleStatus } = Freight;
export function isArrived(
status?: Freight.TrainScheduleStatus | null,
): boolean {
return status === TrainScheduleStatus.Arrived;
}
export function isDispatched(
status?: Freight.TrainScheduleStatus | null,
): boolean {
return status === TrainScheduleStatus.Dispatched;
}
/** Human label for the schedule status, from the rider's point of view. */
export function shipmentStatusLabel(
status?: Freight.TrainScheduleStatus | null,
currentSequenceNo = -1,
): string {
switch (status) {
case TrainScheduleStatus.Arrived:
return "Arrived";
case TrainScheduleStatus.Dispatched:
return currentSequenceNo <= 0 ? "Departed" : "In transit";
case TrainScheduleStatus.Scheduled:
return "Scheduled";
case TrainScheduleStatus.Cancelled:
return "Cancelled";
case TrainScheduleStatus.Draft:
return "Preparing";
default:
return "Not dispatched";
}
}
/**
* 0100 progress across the corridor, derived from how many stations the train
* has reached. Arrived → 100. Not departed → 0.
*/
export function corridorProgress(
stationCount: number,
currentSequenceNo: number,
arrived: boolean,
): number {
if (arrived) return 100;
if (stationCount <= 1 || currentSequenceNo < 0) return 0;
const lastSeq = stationCount - 1;
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
}
/** Caption for a checkpoint kind. */
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
switch (kind) {
case Freight.TrainCheckpointKind.Departed:
return "Departed";
case Freight.TrainCheckpointKind.Arrived:
return "Arrived";
case Freight.TrainCheckpointKind.Passed:
default:
return "Passed";
}
}

View File

@@ -1,8 +1,16 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
import { Button } from "@edr/ui-common";
import {
Box,
Button,
Divider,
Loader,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertTriangle, CheckCircle2, FileSearch, FileText, Home, RotateCcw } from "lucide-react";
import { api } from "@/services/api";
function extractOrderId(): string | null {
@@ -13,6 +21,35 @@ function extractOrderId(): string | null {
return segments[segments.length - 1] ?? null;
}
function PaymentCard({ children }: { children: React.ReactNode }) {
return (
<Box
style={{
minHeight: "100dvh",
background: "#f8fafc",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<Box
style={{
width: "100%",
maxWidth: 460,
background: "#fff",
borderRadius: 24,
border: "1.5px solid #e5e7eb",
boxShadow: "0 4px 24px 0 rgba(0,0,0,0.07)",
overflow: "hidden",
}}
>
{children}
</Box>
</Box>
);
}
export default function CheckPaymentPage() {
const navigate = useNavigate();
const orderId = useMemo(() => extractOrderId(), []);
@@ -29,105 +66,307 @@ export default function CheckPaymentPage() {
if (!orderId) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
<div className="flex flex-col items-center gap-4">
<XCircle className="size-10 text-destructive" />
<p className="text-lg font-bold text-foreground">
No payment reference found
</p>
<Button
type="button"
variant="outline"
onClick={() => navigate("/bookings")}
>
Back to My Bookings
</Button>
</div>
</div>
</div>
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #f97316 0%, #fb923c 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<FileSearch size={42} color="#fff" />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
No payment reference
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8}>
We could not find a payment order to verify.
</Text>
</Box>
<Stack gap={10} p={32}>
<Button
fullWidth
size="md"
radius={12}
color="orange"
onClick={() => navigate("/bookings")}
styles={{ root: { height: 48, fontWeight: 700 } }}
>
Back to my bookings
</Button>
</Stack>
</PaymentCard>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
{isLoading && (
<div className="flex flex-col items-center gap-4">
<LoaderCircle className="size-10 animate-spin text-primary" />
<p className="text-lg font-semibold text-foreground">
Checking payment status
</p>
</div>
)}
if (isLoading) {
return (
<PaymentCard>
<Box style={{ padding: "64px 32px", textAlign: "center" }}>
<Loader size={48} color="edr-green" type="dots" mx="auto" mb={24} />
<Text fw={700} fz={18} c="#10202F">
Verifying your payment
</Text>
<Text fz={14} c="dimmed" mt={8}>
Please wait, this usually takes a few seconds.
</Text>
</Box>
</PaymentCard>
);
}
{isSuccess && (
<div className="flex flex-col items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-primary/10">
<CheckCircle2 className="size-8 text-primary" />
</div>
<p className="text-lg font-bold text-foreground">
Payment was successful!
</p>
<p className="text-sm text-muted-foreground">
Your booking has been confirmed and payment is complete.
</p>
if (isSuccess) {
return (
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #059669 0%, #0ea371 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.2)",
border: "2px solid rgba(255,255,255,0.35)",
margin: "0 auto 20px",
display: "flex",
}}
>
<CheckCircle2 size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Payment verified
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
Your booking is confirmed and payment is complete.
</Text>
</Box>
<Stack gap={0} p={32}>
<Box
style={{
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 14,
padding: "14px 18px",
}}
>
<Text fz={13.5} c="#14532d" lh={1.5} ta="center">
EDR staff will assign a train and you will be notified of any updates.
</Text>
</Box>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
type="button"
fullWidth
size="md"
radius={12}
color="edr-green"
leftSection={<FileText size={17} />}
onClick={() => navigate("/bookings")}
className="mt-2"
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Go to My Bookings
Go to my bookings
</Button>
</div>
)}
{!isLoading && data && !isSuccess && (
<div className="flex flex-col items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<p className="text-lg font-bold text-foreground">
Payment status: {data.status}
</p>
<p className="text-sm text-muted-foreground">
Please try again or contact support if the issue persists.
</p>
<Button
type="button"
variant="outline"
onClick={() => navigate("/bookings")}
className="mt-2"
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
>
Back to My Bookings
Back to home
</Button>
</div>
)}
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20}>
Questions?{" "}
<Text span c="edr-green" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</PaymentCard>
);
}
{isError && (
<div className="flex flex-col items-center gap-4">
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<p className="text-lg font-bold text-foreground">
Something went wrong
</p>
<p className="text-sm text-muted-foreground">
if (isError) {
return (
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Verification failed
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
We could not verify your payment status.
</Text>
</Box>
<Stack gap={0} p={32}>
<Box
style={{
background: "#fff7f7",
border: "1px solid #fecaca",
borderRadius: 14,
padding: "14px 18px",
}}
>
<Text fz={13.5} c="#7f1d1d" ta="center" lh={1.5}>
{error instanceof Error
? error.message
: "Failed to check payment status."}
</p>
: "An unexpected error occurred. Please try again or contact support."}
</Text>
</Box>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
type="button"
variant="outline"
fullWidth
size="md"
radius={12}
color="red"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
className="mt-2"
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Back to My Bookings
Back to my bookings
</Button>
</div>
)}
</div>
</div>
<Button
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
>
Back to home
</Button>
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20}>
Need help?{" "}
<Text span c="red.6" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</PaymentCard>
);
}
// Non-success status (e.g. PAY_FAIL, PENDING, etc.)
return (
<PaymentCard>
<Box
style={{
background: "linear-gradient(135deg, #d97706 0%, #f59e0b 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Payment incomplete
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
Status:{" "}
<Text span fw={700}>
{data?.status ?? "Unknown"}
</Text>
</Text>
</Box>
<Stack gap={0} p={32}>
<Box
style={{
background: "#fffbeb",
border: "1px solid #fde68a",
borderRadius: 14,
padding: "14px 18px",
}}
>
<Text fz={13.5} c="#78350f" ta="center" lh={1.5}>
Your payment did not complete successfully. Nothing has been charged.
You can retry from your booking page.
</Text>
</Box>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
fullWidth
size="md"
radius={12}
color="orange"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
>
Back to my bookings retry payment
</Button>
<Button
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
>
Back to home
</Button>
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20}>
Need help?{" "}
<Text span c="orange.7" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</PaymentCard>
);
}

View File

@@ -0,0 +1,171 @@
import {
Box,
Button,
Divider,
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
import { useNavigate } from "react-router-dom";
export default function PaymentFailurePage() {
const navigate = useNavigate();
return (
<Box
style={{
minHeight: "100dvh",
background: "linear-gradient(135deg, #fff7f7 0%, #f8fafc 60%, #fef2f2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<Box
style={{
width: "100%",
maxWidth: 460,
background: "#fff",
borderRadius: 24,
border: "1.5px solid #fecaca",
boxShadow:
"0 4px 24px 0 rgba(220,38,38,0.07), 0 1px 4px 0 rgba(0,0,0,0.04)",
overflow: "hidden",
}}
>
{/* Red header stripe */}
<Box
style={{
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.18)",
border: "2px solid rgba(255,255,255,0.3)",
margin: "0 auto 20px",
display: "flex",
}}
>
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Payment not completed
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
Nothing was charged your booking is still active.
</Text>
</Box>
{/* Body */}
<Stack gap={0} p={32}>
<Stack gap={16}>
<Box
style={{
background: "#fff7f7",
border: "1px solid #fecaca",
borderRadius: 14,
padding: "16px 20px",
}}
>
<Stack gap={10}>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#ef4444",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
Your payment was declined or cancelled. No charge was made.
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#ef4444",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
You can retry using the <strong>Pay now</strong> button on
your booking page.
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#ef4444",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
Contact support if the problem persists.
</Text>
</Group>
</Stack>
</Box>
</Stack>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
fullWidth
size="md"
radius={12}
color="red"
leftSection={<RotateCcw size={17} />}
onClick={() => navigate("/bookings")}
styles={{
root: { height: 48, fontWeight: 700, fontSize: 15 },
}}
>
Back to my bookings retry payment
</Button>
<Button
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{
root: { height: 44, fontWeight: 600, fontSize: 14 },
}}
>
Back to home
</Button>
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20} lh={1.5}>
Need help?{" "}
<Text span c="red.6" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,170 @@
import {
Box,
Button,
Divider,
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { CheckCircle2, FileText, Home } from "lucide-react";
import { useNavigate } from "react-router-dom";
export default function PaymentSuccessPage() {
const navigate = useNavigate();
return (
<Box
style={{
minHeight: "100dvh",
background: "linear-gradient(135deg, #f0fdf4 0%, #f8fafc 60%, #ecfdf5 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<Box
style={{
width: "100%",
maxWidth: 460,
background: "#fff",
borderRadius: 24,
border: "1.5px solid #d1fae5",
boxShadow:
"0 4px 24px 0 rgba(10,111,77,0.08), 0 1px 4px 0 rgba(0,0,0,0.04)",
overflow: "hidden",
}}
>
{/* Green header stripe */}
<Box
style={{
background: "linear-gradient(135deg, #059669 0%, #0ea371 100%)",
padding: "40px 32px 32px",
textAlign: "center",
}}
>
<ThemeIcon
size={80}
radius="xl"
style={{
background: "rgba(255,255,255,0.2)",
border: "2px solid rgba(255,255,255,0.35)",
margin: "0 auto 20px",
display: "flex",
}}
>
<CheckCircle2 size={42} color="#fff" strokeWidth={2} />
</ThemeIcon>
<Text fw={800} fz={24} c="#fff" lh={1.2}>
Payment successful
</Text>
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
Your payment has been received and confirmed.
</Text>
</Box>
{/* Body */}
<Stack gap={0} p={32}>
<Stack gap={16}>
<Box
style={{
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 14,
padding: "16px 20px",
}}
>
<Stack gap={10}>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#16a34a",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#14532d" lh={1.5}>
Your booking is now confirmed for scheduling.
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#16a34a",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#14532d" lh={1.5}>
A receipt will be sent to your registered email.
</Text>
</Group>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "#16a34a",
flexShrink: 0,
marginTop: 2,
}}
/>
<Text fz={13.5} c="#14532d" lh={1.5}>
EDR staff will process your booking and assign a train.
</Text>
</Group>
</Stack>
</Box>
</Stack>
<Divider my={24} color="#e5e7eb" />
<Stack gap={10}>
<Button
fullWidth
size="md"
radius={12}
color="edr-green"
leftSection={<FileText size={17} />}
onClick={() => navigate("/bookings")}
styles={{
root: { height: 48, fontWeight: 700, fontSize: 15 },
}}
>
Go to my bookings
</Button>
<Button
fullWidth
size="md"
radius={12}
variant="subtle"
color="gray"
leftSection={<Home size={17} />}
onClick={() => navigate("/")}
styles={{
root: { height: 44, fontWeight: 600, fontSize: 14 },
}}
>
Back to home
</Button>
</Stack>
<Text fz={12} c="dimmed" ta="center" mt={20} lh={1.5}>
Questions? Contact{" "}
<Text span c="edr-green" fw={600}>
support@edr.et
</Text>
</Text>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,143 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import {
Button,
Card,
Group,
SimpleGrid,
Text,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import RoleCard from "./RoleCard";
import { rolesForCompanyType } from "./companyRoles";
interface CompanyRolesCardProps {
profile: ProfileResponse;
}
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
const queryClient = useQueryClient();
const options = useMemo(
() => rolesForCompanyType(profile.companyType),
[profile.companyType],
);
// Roles already persisted (active + locked), keyed by type -> reference.
const activeByType = useMemo(() => {
const map = new Map<string, string>();
for (const p of profile.companyProfiles) map.set(p.type, p.reference);
return map;
}, [profile.companyProfiles]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = (type: string) => {
if (activeByType.has(type)) return; // add-only: active roles are locked
setSelected((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
};
const mutation = useMutation({
mutationFn: (types: string[]) =>
api.companies.addCompanyProfiles.call({ types }),
onSuccess: () => {
setSelected(new Set());
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
const handleSave = () => {
if (selected.size === 0) return;
mutation.mutate(Array.from(selected));
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Business Profile</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{profile.companyType === "customer"
? "Select the role(s) your company operates as — importer, exporter, or both."
: "Your company's operational role."}
</Text>
{options.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{options.map((opt) => {
const isActive = activeByType.has(opt.type);
return (
<RoleCard
key={opt.type}
label={opt.label}
description={opt.description}
icon={opt.icon}
selected={selected.has(opt.type)}
locked={isActive}
lockedNote={
isActive ? `Active · ${activeByType.get(opt.type)}` : undefined
}
onClick={() => toggle(opt.type)}
/>
);
})}
</SimpleGrid>
)}
{options.length > 0 && (
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Profile updated
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Failed to update profile
</Text>
</Group>
)}
</Group>
<Button
type="button"
leftSection={<Save size={16} />}
loading={mutation.isPending}
disabled={selected.size === 0}
onClick={handleSave}
>
{selected.size > 1 ? "Add Roles" : "Add Role"}
</Button>
</Group>
)}
</Card>
);
}

View File

@@ -0,0 +1,77 @@
import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core";
import { Building2 } from "lucide-react";
import RoleCard from "./RoleCard";
import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles";
interface OnboardingRoleSelectProps {
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */
value: string[];
onChange: (next: string[]) => void;
}
/**
* First (and only) thing shown in the Company Profile tab during onboarding.
* Importer / Exporter sit side by side and can both be picked; Freight
* Forwarder is a separate, mutually-exclusive choice below them. A valid
* selection reveals the company-profile fields.
*/
export default function OnboardingRoleSelect({
value,
onChange,
}: OnboardingRoleSelectProps) {
const selected = new Set(value);
const isForwarder = selected.has(FREIGHT_FORWARDER.type);
// Toggling a customer role drops any forwarder selection (mutually exclusive).
const toggleCustomerRole = (type: string) => {
const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type));
if (next.has(type)) next.delete(type);
else next.add(type);
onChange([...next]);
};
const toggleForwarder = () => {
onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]);
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>What does your company do?</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Pick Importer, Exporter, or both or register as a Freight Forwarder.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
<RoleCard
key={role.type}
label={role.label}
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleCustomerRole(role.type)}
/>
))}
</SimpleGrid>
<Divider
label="or"
labelPosition="center"
my="lg"
c="edr-muted"
styles={{ label: { textTransform: "uppercase", fontSize: 11 } }}
/>
<RoleCard
label={FREIGHT_FORWARDER.label}
description={FREIGHT_FORWARDER.description}
icon={FREIGHT_FORWARDER.icon}
selected={isForwarder}
onClick={toggleForwarder}
/>
</Card>
);
}

View File

@@ -0,0 +1,75 @@
import { Box, Group, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import { Check } from "lucide-react";
export interface RoleCardProps {
label: string;
description: string;
icon: React.ReactNode;
/** Highlighted because the user just selected it (toggleable). */
selected?: boolean;
/** Highlighted and non-interactive because it is already persisted. */
locked?: boolean;
/** Small note under the description, e.g. "Active · IM-00001". */
lockedNote?: string;
onClick?: () => void;
}
/**
* The selectable company-role card used by both the onboarding role picker and
* the add-only roles card in settings. Visual mirror of the onboarding
* account-type cards.
*/
export default function RoleCard({
label,
description,
icon,
selected = false,
locked = false,
lockedNote,
onClick,
}: RoleCardProps) {
const highlighted = selected || locked;
return (
<UnstyledButton
type="button"
onClick={locked ? undefined : onClick}
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
highlighted
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
: "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
} ${locked ? "cursor-default" : ""}`}
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant={highlighted ? "filled" : "light"}
color="edr-green"
className="shrink-0"
>
{icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{description}
</Text>
{lockedNote && (
<Text size="xs" c="edr-green" mt={6} fw={600}>
{lockedNote}
</Text>
)}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
</UnstyledButton>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -17,7 +17,12 @@ import {
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import type { ProfileResponse } from "@/types/profile";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type {
CreateCompanyPayload,
CompanyProfileInput,
} from "@/services/companies.service";
import CompanyRolesCard from "./CompanyRolesCard";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -52,6 +57,7 @@ export default function TabCompanyProfile({
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) {
@@ -91,8 +97,7 @@ export default function TabCompanyProfile({
const mutation = useMutation({
mutationFn: async (data: CompanyProfileFormData) => {
const payload: CreateCompanyPayload = {
companyType: "customer",
const base = {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
@@ -103,10 +108,21 @@ export default function TabCompanyProfile({
};
if (isCreate) {
// Importer/Exporter -> a "customer" company; Freight Forwarder is its
// own company type. Both drive the persisted CompanyProfile rows.
const companyType = selectedRoles.includes("freight_forwarder")
? "freight_forwarder"
: "customer";
const payload: CreateCompanyPayload = {
...base,
companyType,
companyProfiles: selectedRoles.map((type) => ({
type: type as CompanyProfileInput["type"],
})),
};
return api.companies.create.call(payload);
} else {
return api.companies.updateProfile.call(payload);
}
return api.companies.updateProfile.call(base);
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -118,14 +134,31 @@ export default function TabCompanyProfile({
},
});
const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
const onSubmit = (data: CompanyProfileFormData) => {
if (isCreate && selectedRoles.length === 0) return;
mutation.mutate(data);
};
// During onboarding the role selection gates the form: nothing else shows
// until the user picks Importer/Exporter or Freight Forwarder.
const showForm = !isCreate || selectedRoles.length > 0;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Stack gap="lg">
{isCreate ? (
<OnboardingRoleSelect
value={selectedRoles}
onChange={setSelectedRoles}
/>
) : (
profile && <CompanyRolesCard profile={profile} />
)}
{showForm && (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{isCreate
? "Enter your company registration details to get started"
@@ -251,6 +284,8 @@ export default function TabCompanyProfile({
</Group>
</Group>
</form>
</Card>
</Card>
)}
</Stack>
);
}

View File

@@ -1,4 +1,16 @@
import { useState } from "react";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common";
import {
Button,
Card,
Center,
Group,
Text,
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -8,18 +20,7 @@ import {
UploadCloud,
XCircle,
} from "lucide-react";
import {
Card,
Group,
Title,
Text,
Button,
Center,
} from "@mantine/core";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile";
import { useState } from "react";
interface TabDocumentsProps {
profile: ProfileResponse;
@@ -33,7 +34,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: "customer_documents" },
input: { code: "customer_file_documents" },
}),
);
@@ -45,6 +46,42 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
},
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleFilesChange = (next: Record<string, File | File[] | null>) => {
setDocumentFiles(next);
// Clear required-field errors for any field that now has a file.
setFieldErrors((prev) => {
if (Object.keys(prev).length === 0) return prev;
const updated = { ...prev };
for (const key of Object.keys(updated)) {
const v = next[key];
const hasValue = Array.isArray(v) ? v.length > 0 : v != null;
if (hasValue) delete updated[key];
}
return updated;
});
};
// Array-aware: an emptied multi-file field is `[]`, which must not count.
const hasFiles = Object.values(documentFiles).some((f) =>
Array.isArray(f) ? f.length > 0 : f != null,
);
const validateRequired = (): Record<string, string> => {
const errs: Record<string, string> = {};
for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) {
errs[field.fileKey] = `${field.fileLabel} is required`;
}
}
return errs;
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -67,7 +104,8 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={setDocumentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
/>
)}
@@ -100,13 +138,14 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
leftSection={<ArrowRight size={16} />}
loading={docUploadMutation.isPending}
onClick={() => {
const hasFiles = Object.values(documentFiles).some((f) => f !== null);
const validationErrors = validateRequired();
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
return;
}
if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
onContinue?.();
},
onSuccess: () => onContinue?.(),
});
} else {
onContinue?.();
@@ -120,7 +159,11 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
onClick={() => docUploadMutation.mutate(documentFiles)}
disabled={!hasFiles}
onClick={() => {
if (!hasFiles) return;
docUploadMutation.mutate(documentFiles);
}}
>
Upload Documents
</Button>

View File

@@ -0,0 +1,39 @@
import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react";
export interface RoleMeta {
type: string;
label: string;
description: string;
icon: React.ReactNode;
}
export const IMPORTER: RoleMeta = {
type: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
};
export const EXPORTER: RoleMeta = {
type: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
};
export const FREIGHT_FORWARDER: RoleMeta = {
type: "freight_forwarder",
label: "Freight Forwarder",
description: "Handle cargo on behalf of importers and exporters.",
icon: <Building2 size={22} />,
};
/** Importer / Exporter — the two roles a "customer" company can hold. */
export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER];
// dj_freight_forwarder and transporter are intentionally not exposed yet.
export function rolesForCompanyType(companyType: string): RoleMeta[] {
if (companyType === "customer") return CUSTOMER_ROLES;
if (companyType === "freight_forwarder") return [FREIGHT_FORWARDER];
return [];
}

View File

@@ -13,7 +13,9 @@ import {
BookingListFilter,
CreateBookingPayload,
GeneratePriceResponse,
SubmitBookingResponse,
} from "./bookings.service";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
paymentsService,
InitiatePaymentPayload,
@@ -36,6 +38,7 @@ import {
} from "@/types/dropdownSettings";
import type {
CompanyInfoResponse,
CompanyProfileResponse,
CreateCompanyPayload,
DashboardSummary,
} from "./companies.service";
@@ -126,14 +129,19 @@ export const api = {
"getDashboard",
companiesService.getDashboard,
),
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
"companies",
"addCompanyProfiles",
companiesService.addCompanyProfiles,
),
},
bookings: {
list: endpoint<BookingListFilter | void, PaginatedResponse<Freight.IBooking>>(
"bookings",
"list",
bookingsService.list,
),
list: endpoint<
BookingListFilter | void,
PaginatedResponse<Freight.IBooking>
>("bookings", "list", bookingsService.list),
get: endpoint<{ id: string }, Freight.IBooking>(
"bookings",
@@ -141,16 +149,29 @@ export const api = {
({ id }) => bookingsService.get(id),
),
create: endpoint<CreateBookingPayload, Freight.IBooking>(
tracking: endpoint<{ id: string }, Freight.IBookingTracking>(
"bookings",
"create",
bookingsService.create,
"tracking",
({ id }) => bookingsService.tracking(id),
),
create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking
>("bookings", "create", ({ payload, documents }) =>
bookingsService.create(payload, documents),
),
update: endpoint<
{ id: string; dto: Partial<CreateBookingPayload> },
{
id: string;
dto: Partial<CreateBookingPayload>;
documents?: BookingDocuments;
},
{ booking: Freight.IBooking; warnings: string[] }
>("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
>("bookings", "update", ({ id, dto, documents }) =>
bookingsService.update(id, dto, documents),
),
referenceData: endpoint<void, Freight.BookingReferenceData>(
"bookings",
@@ -174,12 +195,18 @@ export const api = {
({ id }) => bookingsService.generatePrice(id),
),
submit: endpoint<{ id: string }, Freight.IBooking>(
submit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"submit",
({ id }) => bookingsService.submit(id),
),
confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"confirmSubmit",
({ id }) => bookingsService.confirmSubmit(id),
),
uploadDocuments: endpoint<
{ id: string; files: Record<string, File | File[] | null> },
Freight.IBooking
@@ -196,8 +223,21 @@ export const api = {
getBookableSchedules: endpoint<
{ originYardId?: string; destinationYardId?: string },
Freight.BookableScheduleItem[]
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
>(
"train-scheduling",
"bookableSchedules",
({ originYardId, destinationYardId }) =>
bookingsService.getBookableSchedules({
originYardId,
destinationYardId,
}),
),
getAvailableDays: endpoint<
{ originYardId?: string; destinationYardId?: string },
string[]
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
},
@@ -263,19 +303,6 @@ export const api = {
({ entity }) => fileUploadSettingsService.getByEntity(entity),
),
create: endpoint<CreateFileUploadSettingDto, FileUploadSetting>(
"file-upload-settings",
"create",
(payload) => fileUploadSettingsService.create(payload),
),
update: endpoint<
{ id: string; dto: UpdateFileUploadSettingDto },
FileUploadSetting
>("file-upload-settings", "update", ({ id, dto }) =>
fileUploadSettingsService.update(id, dto),
),
remove: endpoint<{ id: string }, void>(
"file-upload-settings",
"remove",

View File

@@ -0,0 +1,88 @@
import type { CreateBookingPayload } from "./bookings.service";
import { BOOKING_DOCS_SETTING, type BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
function appendValue(formData: FormData, key: string, value: unknown) {
if (value === undefined || value === null) return;
if (typeof value === "boolean") {
formData.append(key, value ? "true" : "false");
return;
}
if (typeof value === "number") {
formData.append(key, String(value));
return;
}
if (typeof value === "string") {
formData.append(key, value);
return;
}
}
function appendContainers(
formData: FormData,
containers: NonNullable<CreateBookingPayload["containers"]>,
) {
containers.forEach((container, index) => {
formData.append(
`containers[${index}][containerTypeId]`,
container.containerTypeId,
);
formData.append(
`containers[${index}][quantity]`,
String(container.quantity),
);
formData.append(
`containers[${index}][vgmPerUnitTons]`,
String(container.vgmPerUnitTons),
);
});
}
function appendDocuments(
formData: FormData,
documents?: Record<string, File | File[] | null>,
) {
if (!documents) return;
for (const [key, fileOrFiles] of Object.entries(documents)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const file of fileOrFiles) {
formData.append(key, file);
}
} else {
formData.append(key, fileOrFiles);
}
}
}
/** Flatten a booking payload (and optional document files) into multipart FormData. */
export function buildBookingFormData(
payload: Partial<CreateBookingPayload>,
documents?: BookingDocuments,
): FormData {
const formData = new FormData();
const skipKeys = new Set(["containers", "freightShapeValidation"]);
for (const [key, value] of Object.entries(payload)) {
if (skipKeys.has(key)) continue;
appendValue(formData, key, value);
}
if (payload.containers?.length) {
appendContainers(formData, payload.containers);
}
appendDocuments(formData, documents);
return formData;
}
/** Returns true when every required booking document field has a file attached. */
export function hasAllRequiredDocuments(
documents: BookingDocuments | undefined | null,
): boolean {
const docs = documents ?? {};
return BOOKING_DOCS_SETTING.fields.every((field) => {
const value = docs[field.fileKey];
if (Array.isArray(value)) return value.length > 0;
return Boolean(value);
});
}

View File

@@ -1,6 +1,8 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import { buildBookingFormData } from "./booking-form-data";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
warnings: string[];
}
export interface SubmitBookingResponse {
bookingId: string;
status: string;
priceChanged: boolean;
previousTotalAmount?: number;
totalAmount: number;
currency: string;
lineItems?: PriceLineItem[];
message?: string;
}
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
@@ -54,6 +67,8 @@ export interface SignContractPayload {
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -71,8 +86,18 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const { data } = await client.post("/api/bookings", payload);
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
},
create: async (
payload: CreateBookingPayload,
documents?: BookingDocuments,
): Promise<Freight.IBooking> => {
const formData = buildBookingFormData(payload, documents);
const { data } = await client.post("/api/bookings", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data.booking;
},
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
@@ -82,8 +107,12 @@ export const bookingsService = {
update: async (
id: string,
payload: Partial<CreateBookingPayload>,
documents?: BookingDocuments,
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
const { data } = await client.patch(`/api/bookings/${id}`, payload);
const formData = buildBookingFormData(payload, documents);
const { data } = await client.patch(`/api/bookings/${id}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data;
},
@@ -101,11 +130,16 @@ export const bookingsService = {
return data.data;
},
submit: async (id: string): Promise<Freight.IBooking> => {
submit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
return data.data;
},
uploadDocuments: async (
id: string,
files: Record<string, File | File[] | null>,
@@ -161,4 +195,18 @@ export const bookingsService = {
);
return data.data;
},
/**
* Day-level pool: the days that have a departure on the route. The customer
* picks a day; the engine assigns the train. No capacity is returned.
*/
getAvailableDays: async (
query: Freight.AvailableDaysQuery = {},
): Promise<string[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
{ params: query },
);
return (data.data as Freight.AvailableDaysResponse).days;
},
};

View File

@@ -35,6 +35,18 @@ export interface CompanyResponse {
email: string | null;
website: string | null;
attributes: Record<string, any> | null;
companyProfiles?: CompanyProfileResponse[];
createdAt: string;
updatedAt: string;
}
export interface CompanyProfileResponse {
id: string;
type: string;
reference: string;
status: string;
businessLicense: string | null;
attributes: Record<string, any> | null;
createdAt: string;
updatedAt: string;
}
@@ -44,6 +56,11 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
businessLicense?: string;
}
export interface CreateCompanyPayload {
companyType?: string;
companyName: string;
@@ -57,6 +74,7 @@ export interface CreateCompanyPayload {
jobTitle?: string;
isPrimaryContact?: boolean;
attributes?: Record<string, any>;
companyProfiles?: CompanyProfileInput[];
}
export interface FreightVolumePoint {
@@ -124,6 +142,16 @@ export const companiesService = {
return unwrap(response.data);
},
addCompanyProfiles: async (payload: {
types: string[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
payload,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,

View File

@@ -1,6 +1,10 @@
import type { CompanyProfileResponse } from "@/services/companies.service";
export interface ProfileResponse {
companyId: string;
companyName: string;
companyType: string;
companyProfiles: CompanyProfileResponse[];
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;