Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-03 18:48:18 +00:00
106 changed files with 4924 additions and 876 deletions

View File

@@ -121,6 +121,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -330,7 +331,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
},
{
@@ -377,7 +378,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
},
],
@@ -569,6 +570,7 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
@@ -578,6 +580,7 @@ const App = () => {
<Routes>
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route
path="/dashboard"

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { Loader2, Calendar } from "lucide-react";
import { Loader2, ShieldCheck } from "lucide-react";
import {
Alert,
Badge,
Button,
Group,
Modal,
@@ -12,7 +14,6 @@ import {
Text,
Textarea,
TextInput,
ActionIcon,
} from "@mantine/core";
import {
@@ -20,6 +21,10 @@ import {
type FleetFormFieldDef,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import {
verifaydaService,
type FaydaCallbackMessage,
} from "@/services/verifayda.service";
export interface FleetFormDialogProps {
open: boolean;
@@ -31,8 +36,32 @@ export interface FleetFormDialogProps {
isSubmitting: boolean;
selectOptionsLoading?: boolean;
onSubmit: (values: Record<string, unknown>) => void;
/**
* Show a "Verify with Fayda" step: opens the eSignet popup and prefills
* firstName/lastName/email/phoneNumber/dateOfBirth from the verified
* identity, stamping faydaVerified + faydaSub on the payload.
*/
verifyWithFayda?: boolean;
}
// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase
// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering
// blank. Unknown/empty values fall through to undefined (field left untouched).
const normalizeGender = (raw?: string): string | undefined => {
const up = (raw ?? "").trim().toUpperCase();
if (up === "MALE" || up === "M") return "MALE";
if (up === "FEMALE" || up === "F") return "FEMALE";
return up ? "OTHER" : undefined;
};
// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input
// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars
// so the DOB field prefills instead of silently staying blank.
const normalizeBirthdate = (raw?: string): string | undefined => {
const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined;
};
const buildInitialValues = (
fields: FleetFormFieldDef[],
emptyValues: Record<string, unknown>,
@@ -72,9 +101,12 @@ const FleetFormDialog = ({
isSubmitting,
selectOptionsLoading,
onSubmit,
verifyWithFayda,
}: FleetFormDialogProps) => {
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [faydaLoading, setFaydaLoading] = useState(false);
const [faydaError, setFaydaError] = useState<string | null>(null);
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
@@ -87,10 +119,88 @@ const FleetFormDialog = ({
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
setFaydaError(null);
setFaydaLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
// Receive the ?code&state relayed by the /callback popup, exchange it for
// the verified identity, and prefill the matching form fields.
useEffect(() => {
if (!open || !verifyWithFayda) return;
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== "fayda-callback") return;
if (event.data.error) {
setFaydaLoading(false);
setFaydaError(event.data.errorDescription ?? event.data.error);
return;
}
if (!event.data.code || !event.data.state) return;
try {
const result = await verifaydaService.complete(event.data.code, event.data.state);
if (!result.verified) {
setFaydaError("Identity could not be verified");
return;
}
const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean);
const [firstName, ...rest] = nameParts;
const gender = normalizeGender(result.gender);
const dateOfBirth = normalizeBirthdate(result.birthdate);
setValues((current) => ({
...current,
...(firstName ? { firstName } : {}),
...(rest.length ? { lastName: rest.join(" ") } : {}),
...(result.email ? { email: result.email } : {}),
...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}),
...(dateOfBirth ? { dateOfBirth } : {}),
...(gender ? { gender } : {}),
faydaVerified: true,
...(result.iamUserId ? { faydaSub: result.iamUserId } : {}),
}));
setFaydaError(null);
} catch (err) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
(err instanceof Error ? err.message : "Verification failed");
setFaydaError(message);
} finally {
setFaydaLoading(false);
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [open, verifyWithFayda]);
const handleFaydaVerify = async () => {
setFaydaError(null);
setFaydaLoading(true);
try {
const { authorizationUrl } = await verifaydaService.start();
const popup = window.open(
authorizationUrl,
"fayda-verify",
"width=480,height=760,noopener=no",
);
if (!popup) {
setFaydaLoading(false);
setFaydaError("Pop-up blocked — allow pop-ups for this site and retry.");
}
// Loading stays on until the popup posts back; reopening the dialog resets it.
} catch (err) {
setFaydaLoading(false);
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
(err instanceof Error ? err.message : "Could not start verification");
setFaydaError(message);
}
};
const faydaVerified = values.faydaVerified === true;
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
[fields],
@@ -127,6 +237,12 @@ const FleetFormDialog = ({
const date = new Date(stringValue + "T00:00:00Z");
if (isNaN(date.getTime())) {
next[field.name] = `${field.label} is not a valid date`;
} else if (field.dateBound === "future") {
const startOfToday = new Date();
startOfToday.setUTCHours(0, 0, 0, 0);
if (date <= startOfToday) {
next[field.name] = `${field.label} must be in the future`;
}
} else if (date > new Date()) {
next[field.name] = `${field.label} cannot be in the future`;
}
@@ -147,6 +263,12 @@ const FleetFormDialog = ({
}, [fields]);
const handleSubmit = () => {
// Hard gate: a driver record cannot be saved until its identity is verified
// with Fayda. Mirrored server-side in DriversService.
if (verifyWithFayda && !faydaVerified) {
setFaydaError("Verify the driver's identity with Fayda before saving.");
return;
}
if (!validate()) return;
const payload = Object.fromEntries(
Object.entries(values)
@@ -167,6 +289,9 @@ const FleetFormDialog = ({
const renderField = (field: FleetFormFieldDef) => {
const value = values[field.name];
const error = errors[field.name];
// Fayda-owned identity fields (name/email/phone/DOB/gender) are populated
// only by verification and never hand-edited.
const isDisabled = Boolean(field.disabled || field.faydaLocked);
if (field.type === "select") {
return (
@@ -186,7 +311,7 @@ const FleetFormDialog = ({
}
error={error}
searchable
disabled={selectOptionsLoading}
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
@@ -216,7 +341,7 @@ const FleetFormDialog = ({
error={error}
searchable
clearable
disabled={selectOptionsLoading}
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
@@ -240,7 +365,7 @@ const FleetFormDialog = ({
}))
}
error={error}
disabled={field.disabled}
disabled={isDisabled}
/>
);
}
@@ -260,7 +385,7 @@ const FleetFormDialog = ({
}
error={error}
minRows={3}
disabled={field.disabled}
disabled={isDisabled}
/>
);
}
@@ -280,13 +405,8 @@ const FleetFormDialog = ({
}))
}
error={error}
disabled={field.disabled}
disabled={isDisabled}
description={field.description || "Select a date"}
rightSection={
<ActionIcon size="sm" variant="subtle" color="green">
<Calendar size={16} />
</ActionIcon>
}
size="sm"
radius="md"
styles={{
@@ -318,7 +438,7 @@ const FleetFormDialog = ({
}))
}
error={error}
disabled={field.disabled}
disabled={isDisabled}
/>
);
};
@@ -333,6 +453,40 @@ const FleetFormDialog = ({
centered
>
<Stack gap="md">
{verifyWithFayda && (
<Group justify="space-between" wrap="nowrap">
{faydaVerified ? (
<Badge
color="green"
variant="light"
size="lg"
leftSection={<ShieldCheck size={14} />}
>
Identity verified with Fayda
</Badge>
) : (
<Text size="sm" c="dimmed">
Identity must be verified with Fayda before this driver can be
saved.
</Text>
)}
<Button
variant={faydaVerified ? "default" : "light"}
color="edr-green"
size="xs"
leftSection={<ShieldCheck size={14} />}
loading={faydaLoading}
onClick={handleFaydaVerify}
>
{faydaVerified ? "Re-verify" : "Verify with Fayda"}
</Button>
</Group>
)}
{verifyWithFayda && faydaError && (
<Alert color="red" variant="light">
{faydaError}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{shortFields.map(renderField)}
</SimpleGrid>
@@ -345,6 +499,7 @@ const FleetFormDialog = ({
color="edr-green"
loading={isSubmitting}
onClick={handleSubmit}
disabled={verifyWithFayda && !faydaVerified}
>
Save
</Button>

View File

@@ -0,0 +1,207 @@
import { Center, Loader, Modal, Text, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
CircleDot,
Route,
Truck,
UserCheck,
UserMinus,
UserPlus,
} from "lucide-react";
import {
fleetHistoryService,
type FleetHistoryEvent,
} from "@/services/fleet-history.service";
import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetHistoryModalProps {
opened: boolean;
onClose: () => void;
entity: "driver" | "vehicle";
record: FleetRecord | null;
}
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => {
const r = asObj(record);
if (entity === "vehicle") {
return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim();
}
return `Driver history — ${[r.firstName, r.lastName]
.filter(Boolean)
.join(" ")}`.trim();
};
const mileLabel = (e: FleetHistoryEvent) =>
e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile";
const arrow = (from?: string | null, to?: string | null) =>
`${from ?? "—"}${to ?? "—"}`;
const metaStr = (e: FleetHistoryEvent, key: string) => {
const v = e.metadata?.[key];
return typeof v === "string" && v ? v : null;
};
function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") {
const vehiclePlate = metaStr(e, "vehiclePlate");
const driverName = metaStr(e, "driverName") ?? (e.label || null);
const bookingRef = metaStr(e, "bookingRef");
// Compose the detail line with whatever the current view doesn't already
// know: on a driver's timeline show which vehicle; always show the booking.
const detail = (extra?: string) =>
[
entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "",
bookingRef ? `Booking ${bookingRef}` : "",
extra ?? "",
]
.filter(Boolean)
.join(" · ");
switch (e.eventType) {
case "DRIVER_REGISTERED":
return {
icon: <UserCheck size={14} />,
title: "Driver registered",
text: e.toValue ? `Status: ${e.toValue}` : "",
};
case "VEHICLE_REGISTERED":
return {
icon: <Truck size={14} />,
title: "Vehicle registered",
text: e.toValue ? `Availability: ${e.toValue}` : "",
};
case "DRIVER_ASSIGNED":
return {
icon: <UserPlus size={14} />,
title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "DRIVER_UNASSIGNED":
return {
icon: <UserMinus size={14} />,
title:
entity === "vehicle"
? "Driver unassigned"
: "Unassigned from vehicle",
text:
entity === "vehicle"
? driverName
? `Driver ${driverName}`
: ""
: vehiclePlate
? `Vehicle ${vehiclePlate}`
: "",
};
case "VEHICLE_STATUS_CHANGED":
return {
icon: <CircleDot size={14} />,
title: "Status changed",
text: arrow(e.fromValue, e.toValue),
};
case "VEHICLE_AVAILABILITY_CHANGED":
return {
icon: <Activity size={14} />,
title: `Marked ${e.toValue ?? ""}`.trim(),
text: e.fromValue ? arrow(e.fromValue, e.toValue) : "",
};
case "MILE_VEHICLE_ASSIGNED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle assigned`,
text: detail(e.label ? `Status: ${e.label}` : ""),
};
case "MILE_VEHICLE_RELEASED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle released`,
text: detail(),
};
case "MILE_STATUS_CHANGED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)} status`,
text: detail(arrow(e.fromValue, e.toValue)),
};
default:
return { icon: <CircleDot size={14} />, title: e.eventType, text: "" };
}
}
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const FleetHistoryModal = ({
opened,
onClose,
entity,
record,
}: FleetHistoryModalProps) => {
const id = asObj(record).id ? String(asObj(record).id) : "";
const { data, isLoading } = useQuery({
queryKey: ["fleet-history", entity, id],
queryFn: () =>
entity === "vehicle"
? fleetHistoryService.vehicle(id)
: fleetHistoryService.driver(id),
enabled: opened && Boolean(id),
});
const events = data ?? [];
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>{titleFor(entity, record)}</Text>}
radius="lg"
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : events.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No history recorded yet. Activity appears here as this{" "}
{entity} is assigned, reassigned, or its status changes.
</Text>
) : (
<Timeline active={events.length} bulletSize={24} lineWidth={2}>
{events.map((e) => {
const d = describe(e, entity);
return (
<Timeline.Item key={e.id} bullet={d.icon} title={d.title}>
{d.text && (
<Text size="sm" c="dimmed">
{d.text}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(e.createdAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Modal>
);
};
export default FleetHistoryModal;

View File

@@ -1,4 +1,4 @@
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
@@ -11,6 +11,7 @@ export interface FleetRecordActionsProps {
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void;
layout?: "row" | "compact";
}
@@ -20,12 +21,16 @@ const FleetRecordActions = ({
onEdit,
onRemove,
onAssignDriver,
onHistory,
layout = "row",
}: FleetRecordActionsProps) => {
const navigate = useNavigate();
const removeLabel = config.removeActionLabel ?? "Delete";
const showDetail = Boolean(config.detailPath && "id" in record);
const isVehicle = config.slug === "vehicles";
const showHistory =
Boolean(onHistory) &&
(config.slug === "drivers" || config.slug === "vehicles");
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;
@@ -57,6 +62,14 @@ const FleetRecordActions = ({
>
Edit
</MenuItem>
{showHistory ? (
<MenuItem
onClick={() => onHistory?.(record)}
leftSection={<History size={14} strokeWidth={2} />}
>
History
</MenuItem>
) : null}
{showDetail ? (
<MenuItem
onClick={handleDetail}

View File

@@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core";
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
export type FleetColumnFormat = ColumnFormat | "statusBadge";
export type FleetColumnFormat = ColumnFormat | "statusBadge" | "verifiedBadge";
const optionLabelMap = new Map<string, Map<string, string>>();
@@ -20,6 +20,16 @@ export const formatFleetCell = (
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
if (format === "verifiedBadge") {
return value === true ? (
<Badge variant="light" color="green" size="sm" radius="md">
Verified
</Badge>
) : (
<Text size="sm" c="dimmed"></Text>
);
}
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
const getStatusColor = (st: string): string => {

View File

@@ -0,0 +1,93 @@
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
import { Check } from "lucide-react";
/** One stage of the last-mile delivery workflow. */
export interface LastMileStepState {
label: string;
done: boolean;
active: boolean;
/** Optional stamp/value shown next to the step (plate, time, distance…). */
detail?: string | null;
}
/**
* Compact 6-dot progress bar for a table row — filled = done, ringed = current,
* hollow = pending. Hover a dot for its label + stamp.
*/
export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) {
return (
<Group gap={4} wrap="nowrap">
{steps.map((s, i) => {
const color = s.done
? "var(--mantine-color-green-6)"
: s.active
? "var(--mantine-color-blue-5)"
: "var(--mantine-color-gray-4)";
return (
<Tooltip
key={i}
withArrow
label={`${s.label}${s.detail ? ` · ${s.detail}` : ""}`}
>
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: s.done ? color : "transparent",
border: `2px solid ${color}`,
boxShadow: s.active
? "0 0 0 2px var(--mantine-color-blue-1)"
: undefined,
display: "inline-block",
flex: "0 0 auto",
}}
/>
</Tooltip>
);
})}
</Group>
);
}
/**
* Vertical stepper for the detail view — completed steps bulleted + green, the
* current step highlighted, each showing its stamp/value when known.
*/
export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) {
const activeIndex = steps.findIndex((s) => s.active);
// Timeline highlights items with index < `active`; count of done steps drives it.
const doneCount = steps.filter((s) => s.done).length;
return (
<Timeline
active={activeIndex === -1 ? steps.length : doneCount}
bulletSize={22}
lineWidth={2}
color="green"
>
{steps.map((s, i) => (
<Timeline.Item
key={i}
bullet={s.done ? <Check size={12} /> : undefined}
title={
<Text size="sm" fw={s.active ? 600 : 500} c={s.active ? "blue" : undefined}>
{s.label}
</Text>
}
lineVariant={s.done ? "solid" : "dashed"}
>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{s.done ? "Done" : s.active ? "Current step" : "Pending"}
</Text>
{s.detail && (
<Text size="xs" c="dimmed">
{s.detail}
</Text>
)}
</Stack>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -0,0 +1,172 @@
import { useMemo, useState } from "react";
import { PackageCheck } from "lucide-react";
import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import type {
ImportLoadingBooking,
ImportLoadingBookingsResponse,
LoadingStatus,
} from "@/types/trainScheduling";
function ImportLoadingBookingRow({
booking,
selected,
onToggle,
}: {
booking: ImportLoadingBooking;
selected: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<PackageCheck size={14} />
<Text fw={600} size="sm">
{booking.reference ?? booking.id}
</Text>
<Badge
variant="light"
size="xs"
color={booking.loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{booking.loadingStatus}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{booking.customer ?? "Unknown customer"}
</Text>
<Text size="xs" c="dimmed">
{booking.weightTons}T
</Text>
</Stack>
</Group>
);
}
export function ImportLoadingConfirmationPanel({
scheduleId,
items,
isLoading,
}: {
scheduleId: string;
items: ImportLoadingBooking[];
isLoading?: boolean;
}) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const queryClient = useQueryClient();
const updateStatus = useMutation<
ImportLoadingBookingsResponse,
Error,
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus }
>({
...api.trainScheduling.updateImportLoadingStatus.mutationOptions(),
onSuccess: () => {
setSelectedIds([]);
queryClient.invalidateQueries({
queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }),
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Could not update loading status");
},
});
const toggle = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const allIds = useMemo(() => items.map((b) => b.id), [items]);
if (isLoading) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading import bookings
</Text>
</Group>
);
}
if (!items.length) {
return (
<Paper p="lg" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed" ta="center">
No paid import bookings with wagons allocated on this schedule
</Text>
</Paper>
);
}
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={500}>
Import bookings ({items.length})
</Text>
<Group gap="sm">
<Button variant="light" size="compact-sm" onClick={() => setSelectedIds(allIds)}>
Select all
</Button>
<Button variant="subtle" size="compact-sm" onClick={() => setSelectedIds([])}>
Clear
</Button>
</Group>
</Group>
<Stack gap="sm">
{items.map((booking) => (
<ImportLoadingBookingRow
key={booking.id}
booking={booking}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
/>
))}
</Stack>
<Group gap="sm">
<Button
color="edr-green"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "LOADED" })
}
>
Mark loaded
</Button>
<Button
variant="outline"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "UNLOADED" })
}
>
Mark unloaded
</Button>
</Group>
</Stack>
);
}

View File

@@ -14,7 +14,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
interface CreateWarehouseModalProps {
opened: boolean;
@@ -120,7 +120,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
placeholder="Modjo Open Warehouse"
required
value={form.name}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
onChange={(e) => { const v = lettersOnly(e.currentTarget.value); setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"

View File

@@ -48,6 +48,9 @@ export const formatDate = (value: string | null | undefined) => {
});
};
// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers.
export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, '');
export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;

View File

@@ -109,6 +109,8 @@ export const QUERY_KEYS = {
["train-scheduling", "unassigned", id] as const,
compositionRemovals: (id: string) =>
["train-scheduling", "removals", id] as const,
importLoadingBookings: (id: string) =>
["train-scheduling", "import-loading-bookings", id] as const,
},
FLEET: {

View File

@@ -324,6 +324,10 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
IMPORT_LOADING_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-status`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { Center, Loader, Stack, Text } from "@mantine/core";
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
/**
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
* http://localhost:5183/callback). Runs inside the verification popup:
* relays ?code&state (or ?error) to the window that opened it via
* postMessage, then closes itself. The opener performs the /complete call
* so the single-use session is only consumed once, in one place.
*/
const FaydaCallbackPage = () => {
const [standalone, setStandalone] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const message: FaydaCallbackMessage = {
type: "fayda-callback",
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
error: params.get("error") ?? undefined,
errorDescription: params.get("error_description") ?? undefined,
};
if (window.opener && window.opener !== window) {
(window.opener as Window).postMessage(message, window.location.origin);
window.close();
} else {
// Opened as a full-page redirect instead of a popup — nothing to relay to.
setStandalone(true);
}
}, []);
return (
<Center h="100vh">
<Stack align="center" gap="sm">
{standalone ? (
<>
<Text fw={600}>Verification window lost its parent page</Text>
<Text size="sm" c="dimmed">
Close this tab and restart the verification from the form.
</Text>
</>
) : (
<>
<Loader size="sm" />
<Text size="sm" c="dimmed">Completing Fayda verification</Text>
</>
)}
</Stack>
</Center>
);
};
export default FaydaCallbackPage;

View File

@@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
@@ -42,6 +43,7 @@ const FleetResourcePage = () => {
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const { viewMode, setViewMode } = useFleetViewMode(slug);
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
}}
onRemove={setRemoveTarget}
onAssignDriver={setAssigningDriver}
onHistory={setHistoryTarget}
/>
</div>
),
@@ -510,6 +513,7 @@ const FleetResourcePage = () => {
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
verifyWithFayda={Boolean(config.faydaVerification)}
/>
<Modal
@@ -580,6 +584,13 @@ const FleetResourcePage = () => {
</Group>
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
</Container>
);
};

View File

@@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [
{ label: "On leave", value: "ON_LEAVE" },
];
const DRIVER_GENDER_OPTIONS = [
{ label: "Male", value: "MALE" },
{ label: "Female", value: "FEMALE" },
{ label: "Other", value: "OTHER" },
];
export const driversConfig: FleetResourceConfig = {
slug: "drivers",
label: "Drivers",
@@ -29,24 +35,28 @@ export const driversConfig: FleetResourceConfig = {
options: DRIVER_STATUS_OPTIONS,
},
],
faydaVerification: true,
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
columns: [
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
{ id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 },
{ id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 },
{ id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 },
{ id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
{ id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 },
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
{ id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 },
],
formFields: [
{ name: "licenseNumber", label: "License Number", type: "text", required: true },
{ name: "firstName", label: "First Name", type: "text", required: true },
{ name: "lastName", label: "Last Name", type: "text", required: true },
{ name: "email", label: "Email", type: "email", required: true },
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true },
{ name: "licenseNumber", label: "Driver's License Number", type: "text", required: true },
{ name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true },
{ name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true },
{ name: "email", label: "Email", type: "email", required: true, faydaLocked: true },
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true },
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true },
{ name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true },
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" },
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
{ name: "address", label: "Address", type: "textarea" },
@@ -60,6 +70,7 @@ export const driversConfig: FleetResourceConfig = {
email: "",
phoneNumber: "",
dateOfBirth: "",
gender: "",
licenseExpiryDate: "",
status: "ACTIVE",
vehicleTypesAuthorized: [],

View File

@@ -33,13 +33,23 @@ export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
format?: ColumnFormat | "statusBadge" | "verifiedBadge";
size?: number;
}
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
/**
* Field is owned by the Fayda identity — populated only by verification and
* never hand-edited. Rendered disabled in the form.
*/
faydaLocked?: boolean;
/**
* Direction a `date` field is constrained to. "future" = must be after today
* (e.g. a license expiry); "past" (default) = cannot be in the future.
*/
dateBound?: "past" | "future";
}
export interface FleetListFilterDef {
@@ -73,6 +83,8 @@ export interface FleetResourceConfig {
cardCodeKey?: string;
cardSubtitleKey?: string;
searchKeys: string[];
/** Offer Fayda identity verification in the add/edit form (drivers). */
faydaVerification?: boolean;
}
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {

View File

@@ -6,6 +6,7 @@ import {
MoreHorizontal,
PackageCheck,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
@@ -879,10 +880,14 @@ const FirstMilePage = () => {
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
{nextStatus === "IN_TRANSIT" && !assigned
? "Assign a vehicle first"
: nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
</Menu.Item>
<Menu.Divider />
<Menu.Item
@@ -919,6 +924,13 @@ const FirstMilePage = () => {
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() => openInvoice(row.original)}
>
Generate Invoice
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -954,7 +966,7 @@ const FirstMilePage = () => {
}, [vehicleOptions]);
return (
<Stack gap="md">
<Stack gap="md" p="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">

View File

@@ -4,6 +4,7 @@ import {
Eye,
MoreHorizontal,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
@@ -49,6 +50,7 @@ import { driversService, type Driver } from "@/services/drivers.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
import { api } from "@/auth/http";
const formatPrice = (amount: number) =>
@@ -92,6 +94,58 @@ const vehicleLabel = (record: LastMileRecord) => {
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
const fmtStamp = (iso?: string | null) => {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
};
/**
* Derive the 6-step last-mile workflow state for a record. Step completion is
* read from the record + its pickup-ready (warehouse release) row:
* assign→vehicleId, arrived→release order issued, leave→releaseDate,
* in-transit/delivered→status, distance→exactKm.
*/
const computeLastMileSteps = (
record: LastMileRecord,
releaseRow?: ImportUnloadedItem,
): LastMileStepState[] => {
const exactKm = (record as { exactKm?: number | null }).exactKm;
// Truck arrival/leave live in the transient warehouse pickup-ready queue and
// vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED,
// treat both as done (the truck must have arrived + left to get there).
const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED";
const flags = [
record.status !== "PAYMENT_PENDING",
Boolean(record.vehicleId),
past || Boolean(releaseRow?.releaseOrderReference),
past || Boolean(releaseRow?.releaseDate),
past,
exactKm != null,
exactKm != null, // Generate Invoice — auto-generated when distance is saved
record.status === "DELIVERED",
];
// Current step = earliest incomplete one.
const activeIdx = flags.findIndex((f) => !f);
const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"];
const details: (string | null)[] = [
null,
record.vehicle?.plateNumber ?? null,
releaseRow?.releaseOrderReference ?? null,
fmtStamp(releaseRow?.releaseDate),
null,
exactKm != null ? `${exactKm} KM` : null,
exactKm != null ? "Invoice ready" : null,
fmtStamp(releaseRow?.deliveredAt),
];
return labels.map((label, i) => ({
label,
done: flags[i],
active: i === activeIdx,
detail: details[i],
}));
};
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
@@ -955,7 +1009,24 @@ const LastMilePage = () => {
const delivered = row.original.status === "DELIVERED";
const releaseRow =
pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original));
const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival";
// Gate on PERSISTENT state (status/vehicle/distance), not the truck
// arrival/leave signals — those live in the warehouse queue and vanish
// once the item is released, so they can't gate the status advance.
const status = row.original.status;
const hasDistance = row.original.exactKm != null;
// Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a
// vehicle), IN_TRANSIT→Delivered (needs distance/invoice).
const canAdvance =
status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance);
const canAssignStep = !assigned && status !== "DELIVERED";
const canDistance = status === "IN_TRANSIT";
// Truck arrival/leaving are independent — each driven only by its own
// warehouse state: arrive once assigned & not arrived, leave once
// arrived & not departed.
const canArrive = assigned && !releaseRow?.releaseOrderReference;
const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate;
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Menu position="bottom-end" width={200} withinPortal>
@@ -967,15 +1038,17 @@ const LastMilePage = () => {
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowRight size={15} />}
disabled={!nextStatus}
disabled={!nextStatus || !canAdvance}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
{nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Truck size={15} />}
disabled={assigned || delivered}
disabled={!canAssignStep}
onClick={() => openAssign(row.original.id)}
>
Assign
@@ -989,10 +1062,17 @@ const LastMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!assigned}
disabled={!canArrive}
onClick={() => openTruckArrival(row.original)}
>
{truckArrivalLabel}
Truck Arrival
</Menu.Item>
<Menu.Item
leftSection={<Truck size={15} />}
disabled={!canLeave}
onClick={() => openTruckArrival(row.original)}
>
Truck Leaving
</Menu.Item>
<Menu.Divider />
<Menu.Item
@@ -1003,11 +1083,18 @@ const LastMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
disabled={delivered}
disabled={!canDistance}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() => openInvoice(row.original)}
>
Generate Invoice
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -1044,7 +1131,7 @@ const LastMilePage = () => {
}, [vehicleOptions, pickupReadyByBooking]);
return (
<Stack gap="md">
<Stack gap="md" p="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -1320,6 +1407,18 @@ const LastMilePage = () => {
>
<Stack gap="md">
{activeRecord && <BookingInfo record={activeRecord} />}
{activeRecord && (
<Card withBorder padding="md" radius="md">
<Text fw={600} size="sm" mb="sm">Delivery steps</Text>
<LastMileStepper
steps={computeLastMileSteps(
activeRecord,
pickupReadyByBooking.get(activeRecord.bookingId) ??
pickupReadyByBooking.get(bookingRef(activeRecord)),
)}
/>
</Card>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
</Group>

View File

@@ -45,6 +45,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -145,6 +146,13 @@ export default function TrainScheduleV2DetailPage() {
},
});
const importLoadingQuery = useQuery(
api.trainScheduling.importLoadingBookings.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
}),
);
const eligibleFilters = useMemo(
() =>
schedule
@@ -955,6 +963,23 @@ export default function TrainScheduleV2DetailPage() {
]}
/>
{schedule?.direction === "IMPORT" ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Import loading confirmation</Text>
<Text size="sm" c="dimmed">
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
is tracking only it does not block dispatch.
</Text>
<ImportLoadingConfirmationPanel
scheduleId={scheduleId as string}
items={importLoadingQuery.data?.items ?? []}
isLoading={importLoadingQuery.isLoading}
/>
</Stack>
</Paper>
) : null}
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">

View File

@@ -14,7 +14,17 @@ import {
Text,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
ChevronDown,
ChevronRight,
Eye,
FileText,
History,
PackageOpen,
ShieldCheck,
Truck,
} from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
@@ -36,6 +46,7 @@ import {
useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
AutoUnloadExportDjiboutiResult,
ExportTrain,
@@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
const autoUnload = useAutoUnloadExportAtDjibouti();
const generateInterchange = useGenerateInterchangeDocument();
const qc = useQueryClient();
const secureGatePass = useMutation({
mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId),
onSuccess: () =>
qc.invalidateQueries({
queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'],
}),
});
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(null);
@@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() {
.map((doc) => [doc.scheduleId as string, doc]),
);
const secureGate = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
await secureGatePass.mutateAsync(train.scheduleId);
toast({
title: 'Gate pass secured',
description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Could not secure gate pass',
description: getErrorMessage(error),
});
} finally {
setBusyScheduleId(null);
}
};
const unloadTrain = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
@@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() {
>
Open
</Button>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<ShieldCheck size={14} />}
loading={busyScheduleId === train.scheduleId && secureGatePass.isPending}
onClick={() => secureGate(train)}
>
Secure Gate Pass
</Button>
<Button
size="compact-xs"
color="green"
@@ -356,7 +404,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Truck size={14} />
)
}
loading={busyScheduleId === train.scheduleId}
loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
onClick={() => unloadTrain(train)}
>
Auto Unload Export Items

View File

@@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
@@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() {
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
);
const warehousesQuery = useWarehouses();
@@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() {
return (
<PageContainer>
<PageHeader
title="Warehouse Inventory"
title={
direction === 'IMPORT'
? 'Import Terminal Inventory'
: direction === 'EXPORT'
? 'Export Terminal Inventory'
: 'Warehouse Inventory'
}
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
action={
<Group gap="xs">

View File

@@ -33,7 +33,7 @@ import {
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
import { extractErrorMessage } from '@/components/warehouses/options';
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
const FREIGHT = [
{ value: 'CONTAINER', label: 'Container' },
@@ -253,7 +253,7 @@ function AllocationRules() {
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
const value = lettersOnly(e.currentTarget.value);
setForm((f) => ({ ...f, name: value }));
}}
/>
@@ -569,7 +569,7 @@ function FeeRules() {
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
const value = lettersOnly(e.currentTarget.value);
setForm((f) => ({ ...f, name: value }));
}}
/>

View File

@@ -49,6 +49,8 @@ import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -370,6 +372,25 @@ export const api = {
QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId),
),
importLoadingBookings: endpoint<{ id: string }, ImportLoadingBookingsResponse>(
"train-scheduling",
"import-loading-bookings",
({ id }) => trainSchedulingService.getImportLoadingBookings(id),
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id),
),
updateImportLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
ImportLoadingBookingsResponse
>(
"train-scheduling",
"update-import-loading-status",
({ id, bookingIds, loadingStatus }) =>
trainSchedulingService.updateImportLoadingStatus(id, { bookingIds, loadingStatus }),
undefined,
({ id }) => [QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id)],
),
// ── Mutations ──────────────────────────────────────────────────────────
runAllocation: endpoint<
{ scheduleId: string },

View File

@@ -26,6 +26,8 @@ export interface Driver {
address?: string | null;
emergencyContact?: string | null;
notes?: string | null;
faydaVerified?: boolean;
faydaSub?: string | null;
totalTrips: number;
rating: number;
createdAt: string;

View File

@@ -0,0 +1,38 @@
import { api as apiClient } from "../auth/http";
export type FleetEventType =
| "DRIVER_REGISTERED"
| "VEHICLE_REGISTERED"
| "DRIVER_ASSIGNED"
| "DRIVER_UNASSIGNED"
| "VEHICLE_STATUS_CHANGED"
| "VEHICLE_AVAILABILITY_CHANGED"
| "MILE_VEHICLE_ASSIGNED"
| "MILE_VEHICLE_RELEASED"
| "MILE_STATUS_CHANGED";
export interface FleetHistoryEvent {
id: string;
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
/** Timeline of fleet events for a driver or a vehicle (newest first). */
export const fleetHistoryService = {
driver: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/drivers/${id}/history`)
.then((r) => r.data),
vehicle: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/vehicles/${id}/history`)
.then((r) => r.data),
};

View File

@@ -16,6 +16,8 @@ import type {
ImportDjiboutiActionPayload,
ImportDjiboutiLoadList,
ImportDjiboutiOperation,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -312,6 +314,26 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getImportLoadingBookings: async (
scheduleId: string,
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.get<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_BOOKINGS(scheduleId),
);
return unwrap(response.data);
},
updateImportLoadingStatus: async (
scheduleId: string,
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.patch<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_STATUS(scheduleId),
payload,
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {

View File

@@ -0,0 +1,46 @@
import { api as apiClient } from '../auth/http';
export interface FaydaStartResponse {
authorizationUrl: string;
}
export interface FaydaCompleteResult {
purpose: 'LOGIN' | 'VERIFY';
verified: boolean;
fullName?: string;
email?: string;
phoneNumber?: string;
/** ISO yyyy-MM-dd */
birthdate?: string;
gender?: string;
iamUserId?: string;
userDataSaved?: boolean;
}
/** Message posted from the /callback popup back to the opener window. */
export interface FaydaCallbackMessage {
type: 'fayda-callback';
code?: string;
state?: string;
error?: string;
errorDescription?: string;
}
export const verifaydaService = {
/** Returns the eSignet authorize URL to open in a popup. */
start: () =>
apiClient
.post<FaydaStartResponse>('/fayda/verification/start', {
purpose: 'VERIFY',
platform: 'WEB',
})
.then((r) => r.data),
/** Exchange the callback code+state for the verified identity attributes. */
complete: (code: string, state: string) =>
apiClient
.get<FaydaCompleteResult>(
`/fayda/verification/complete?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
)
.then((r) => r.data),
};

View File

@@ -479,6 +479,21 @@ export interface ImportDjiboutiDocumentRecord {
notes?: string | null;
}
export type LoadingStatus = "LOADED" | "UNLOADED";
export interface ImportLoadingBooking {
id: string;
reference: string | null;
customer: string | null;
weightTons: number;
loadingStatus: LoadingStatus;
}
export interface ImportLoadingBookingsResponse {
count: number;
items: ImportLoadingBooking[];
}
export interface ImportDjiboutiOperation {
trainScheduleId: string;
trainNumber: string | null;

View File

@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
containerId?: string;
goodsId?: string;
status?: InventoryStatus;
direction?: 'IMPORT' | 'EXPORT';
search?: string;
dateFrom?: string;
dateTo?: string;