mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user