Files
edr-platform/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
Hagernesh cf8a2e928d fix(warehouses): detention groups by canonical truck type
Join truck_types via vehicles.truck_type_id (normalized legacy
vehicle_type only as fallback) so type renames can't unmatch detention
rules and FK-less vehicles keep billing.
2026-07-24 11:55:05 +00:00

652 lines
22 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Loader2, ShieldCheck } from "lucide-react";
import {
Alert,
Badge,
Button,
Group,
Modal,
NumberInput,
Radio,
Select,
MultiSelect,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import {
FLEET_SELECT_NONE,
type FleetFormFieldDef,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import {
verifaydaService,
type FaydaCallbackMessage,
} from "@/services/verifayda.service";
export interface FleetFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
fields: FleetFormFieldDef[];
initialRecord?: FleetRecord | null;
emptyValues: Record<string, unknown>;
isSubmitting: boolean;
selectOptionsLoading?: boolean;
onSubmit: (values: Record<string, unknown>) => void;
/**
* 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>,
record?: FleetRecord | null,
): Record<string, unknown> => {
const values: Record<string, unknown> = { ...emptyValues };
if (!record) return values;
fields.forEach((field) => {
const raw = (record as unknown as Record<string, unknown>)[field.name];
if (raw === null || raw === undefined) {
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
return;
}
// For selects, snap the record value onto a real option even if its casing
// drifted (e.g. an API/seed value of "Available" vs the "AVAILABLE" option).
// Otherwise the Select renders blank and a required field fails on submit.
if (field.type === "select" && field.options?.length) {
const match = field.options.find(
(o) => String(o.value).toLowerCase() === String(raw).toLowerCase(),
);
values[field.name] = match ? match.value : raw;
return;
}
values[field.name] = raw;
});
return values;
};
const FleetFormDialog = ({
open,
onOpenChange,
title,
fields,
initialRecord,
emptyValues,
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
// dynamic select options finish loading). Re-seeding on those would wipe the
// user's in-progress edits (e.g. a changed Current Yard / status) the moment
// the yard or wagon-type options resolve.
const recordId =
initialRecord && "id" in initialRecord ? String(initialRecord.id) : null;
useEffect(() => {
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
setFaydaError(null);
setFaydaLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
// Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
// _hasTrailer) for the value already on the record. Without this, editing a
// rigid truck would show a Trailer Plate field until the type is re-picked.
// Only scratch keys are written, so a stored one-off capacity is never
// clobbered by the type's default; re-deriving from the live value is
// idempotent, so this is safe to run again when the options finally load.
useEffect(() => {
if (!open) return;
setValues((current) => {
const scratch: Record<string, unknown> = {};
fields.forEach((field) => {
if (!field.onOptionSelected) return;
const selected = field.options?.find((o) => o.value === current[field.name]);
if (!selected) return;
Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
if (key.startsWith("_")) scratch[key] = value;
});
});
return Object.keys(scratch).length ? { ...current, ...scratch } : current;
});
}, [open, fields]);
// 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;
/**
* Fields the current answers actually apply to — a rigid truck type (Casoni)
* has no trailer, so its plate field disappears. Honoured in three places, not
* just here: a hidden field must also skip validation (an invisible "required"
* error blocks submit with nothing to fix) and must submit an explicit null
* (so switching to a rigid type CLEARS the stored trailer plate rather than
* stranding it on the row).
*/
const visibleFields = useMemo(
() =>
fields.filter((field) => {
if (
field.hideWhen &&
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
) {
return false;
}
if (
field.showWhen &&
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
) {
return false;
}
if (field.showIf && !field.showIf(values)) return false;
return true;
}),
[fields, values],
);
const hiddenFieldNames = useMemo(() => {
const visible = new Set(visibleFields.map((f) => f.name));
return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
}, [fields, visibleFields]);
const shortFields = useMemo(
() => visibleFields.filter((f) => f.type !== "textarea"),
[visibleFields],
);
const longFields = useMemo(
() => visibleFields.filter((f) => f.type === "textarea"),
[visibleFields],
);
const validate = () => {
const next: Record<string, string> = {};
visibleFields.forEach((field) => {
const value = values[field.name];
const stringValue =
typeof value === "string" ? value.trim() : String(value ?? "");
if (
field.required &&
(stringValue === "" || stringValue === FLEET_SELECT_NONE)
) {
next[field.name] = `${field.label} is required`;
}
// Validate date format (YYYY-MM-DD) - DatePicker ensures this
if (
field.type === "date" &&
stringValue &&
stringValue !== FLEET_SELECT_NONE
) {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(stringValue)) {
next[field.name] = `${field.label} must be a valid date`;
} else {
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`;
}
}
}
// Format check (e.g. plate numbers). Skipped for an empty optional field —
// "required" above already owns the empty case. Upper-cased to match the
// server, which stores plates upper-case.
if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) {
const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase();
if (!field.pattern.regex.test(candidate)) {
next[field.name] = field.pattern.message;
}
}
});
setErrors(next);
return Object.keys(next).length === 0;
};
// Field types keyed by name, so the submit payload can coerce each value to the
// type the API expects (number columns come back from the API as strings like
// "24.00", which the DTO's @IsNumber rejects on an otherwise-unchanged save).
const fieldTypeByName = useMemo(() => {
const map: Record<string, FleetFormFieldDef["type"]> = {};
fields.forEach((f) => (map[f.name] = f.type));
return map;
}, [fields]);
// Emptying one of these means "unset the column", so it submits an explicit
// null instead of being dropped from the payload like other empty fields.
const clearableByName = useMemo(() => {
const map: Record<string, boolean> = {};
fields.forEach((f) => (map[f.name] = Boolean(f.clearable)));
return map;
}, [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;
// Derived fields are never edited, so form state for them can be stale (or
// seeded from the record) — recompute before building the payload.
const submitted: Record<string, unknown> = { ...values };
fields.forEach((field) => {
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
});
// A field the answers hid no longer applies to this record — send an explicit
// null so the column is unset, instead of leaving a stale value behind.
hiddenFieldNames.forEach((name) => {
submitted[name] = null;
});
const payload = Object.fromEntries(
Object.entries(submitted)
// `_`-prefixed keys are form-local scratch written by `onOptionSelected`
// (e.g. _hasTrailer, which drives visibility). The API validates with
// forbidNonWhitelisted, so an undeclared key would 400 the whole save.
.filter(([key]) => !key.startsWith("_"))
.map(([key, value]) => {
if (hiddenFieldNames.includes(key)) return [key, null];
if (value === FLEET_SELECT_NONE || value === "" || value == null)
return [key, clearableByName[key] ? null : undefined];
if (fieldTypeByName[key] === "number") {
const num = Number(value);
return [key, Number.isNaN(num) ? undefined : num];
}
return [key, value];
})
.filter(([, value]) => value !== undefined),
);
onSubmit(payload);
};
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);
// Computed from other fields (e.g. the import run implied by the export
// run) — read-only, and recomputed here rather than read from form state.
if (field.derivedValue) {
return (
<TextInput
key={field.name}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={field.derivedValue(values)}
readOnly
variant="filled"
error={error}
/>
);
}
if (field.type === "radio") {
return (
<Radio.Group
key={field.name}
label={field.label}
value={value == null ? "" : String(value)}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next }))
}
error={error}
>
<Group gap="lg" mt={6}>
{(field.options ?? []).map((o) => (
<Radio key={o.value} value={o.value} label={o.label} disabled={isDisabled} />
))}
</Group>
</Radio.Group>
);
}
if (field.type === "select") {
return (
<Select
key={field.name}
label={field.label}
description={field.description}
placeholder={field.placeholder}
data={field.options ?? []}
value={
value == null || value === ""
? field.noneOption
? FLEET_SELECT_NONE
: null
: String(value)
}
onChange={(next) =>
setValues((current) => {
const patch = field.onOptionSelected
? field.onOptionSelected(
field.options?.find((o) => o.value === next),
current,
)
: {};
return { ...current, [field.name]: next ?? "", ...patch };
})
}
error={error}
searchable
clearable={field.clearable}
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : undefined
}
/>
);
}
if (field.type === "multiselect") {
const arrayValue = Array.isArray(value)
? value
: typeof value === "string" && value
? [value]
: [];
return (
<MultiSelect
key={field.name}
label={field.label}
placeholder={field.placeholder || "Select options"}
data={field.options ?? []}
value={arrayValue.map(String)}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next }))
}
error={error}
searchable
clearable
disabled={selectOptionsLoading || isDisabled}
rightSection={
selectOptionsLoading ? (
<Loader2 size={14} className="animate-spin" />
) : undefined
}
/>
);
}
if (field.type === "number") {
return (
<NumberInput
key={field.name}
label={field.label}
placeholder={field.placeholder}
value={value === "" || value == null ? "" : Number(value)}
onChange={(next) =>
setValues((current) => ({
...current,
[field.name]: next === "" ? "" : next,
}))
}
error={error}
disabled={isDisabled}
/>
);
}
if (field.type === "textarea") {
return (
<Textarea
key={field.name}
label={field.label}
placeholder={field.placeholder}
value={String(value ?? "")}
onChange={(e) =>
setValues((current) => ({
...current,
[field.name]: e.currentTarget?.value,
}))
}
error={error}
minRows={3}
disabled={isDisabled}
/>
);
}
if (field.type === "date") {
return (
<TextInput
key={field.name}
type="date"
label={field.label}
placeholder={field.placeholder || "YYYY-MM-DD"}
value={typeof value === "string" ? value.slice(0, 10) : ""}
onChange={(e) =>
setValues((current) => ({
...current,
[field.name]: e.currentTarget?.value ?? "",
}))
}
error={error}
disabled={isDisabled}
description={field.description || "Select a date"}
size="sm"
radius="md"
styles={{
input: {
borderColor: "var(--mantine-color-gray-3)",
backgroundColor: "var(--mantine-color-gray-0)",
transition: "all 200ms ease",
"&:focus": {
borderColor: "var(--mantine-color-green-5)",
backgroundColor: "var(--mantine-color-gray-1)",
},
},
}}
/>
);
}
return (
<TextInput
key={field.name}
label={field.label}
placeholder={field.placeholder}
description={field.description}
value={String(value ?? "")}
onChange={(e) =>
setValues((current) => ({
...current,
[field.name]: e.currentTarget?.value,
}))
}
error={error}
disabled={isDisabled}
/>
);
};
return (
<Modal
opened={open}
onClose={() => onOpenChange(false)}
title={<Text fw={600}>{title}</Text>}
size="lg"
radius="lg"
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>
{longFields.map(renderField)}
<Group justify="flex-end" gap="sm" mt="sm">
<Button variant="default" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={isSubmitting}
onClick={handleSubmit}
disabled={verifyWithFayda && !faydaVerified}
>
Save
</Button>
</Group>
</Stack>
</Modal>
);
};
export default FleetFormDialog;