Files
edr-platform/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
2026-07-03 09:10:05 +00:00

481 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Loader2, Calendar, ShieldCheck } from "lucide-react";
import {
Alert,
Badge,
Button,
Group,
Modal,
NumberInput,
Select,
MultiSelect,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ActionIcon,
} 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;
}
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]);
// 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;
setValues((current) => ({
...current,
...(firstName ? { firstName } : {}),
...(rest.length ? { lastName: rest.join(" ") } : {}),
...(result.email ? { email: result.email } : {}),
...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}),
...(result.birthdate ? { dateOfBirth: result.birthdate } : {}),
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],
);
const longFields = useMemo(
() => fields.filter((f) => f.type === "textarea"),
[fields],
);
const validate = () => {
const next: Record<string, string> = {};
fields.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 (date > new Date()) {
next[field.name] = `${field.label} cannot be in the future`;
}
}
}
});
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]);
const handleSubmit = () => {
if (!validate()) return;
const payload = Object.fromEntries(
Object.entries(values)
.map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "")
return [key, 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];
if (field.type === "select") {
return (
<Select
key={field.name}
label={field.label}
data={field.options ?? []}
value={
value == null || value === ""
? field.noneOption
? FLEET_SELECT_NONE
: null
: String(value)
}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
}
error={error}
searchable
disabled={selectOptionsLoading}
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}
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={field.disabled}
/>
);
}
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={field.disabled}
/>
);
}
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={field.disabled}
description={field.description || "Select a date"}
rightSection={
<ActionIcon size="sm" variant="subtle" color="green">
<Calendar size={16} />
</ActionIcon>
}
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={field.disabled}
/>
);
};
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">
Verify the driver's identity with Fayda to prefill their details.
</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}
>
Save
</Button>
</Group>
</Stack>
</Modal>
);
};
export default FleetFormDialog;