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; isSubmitting: boolean; selectOptionsLoading?: boolean; onSubmit: (values: Record) => 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, record?: FleetRecord | null, ): Record => { const values: Record = { ...emptyValues }; if (!record) return values; fields.forEach((field) => { const raw = (record as unknown as Record)[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>({}); const [errors, setErrors] = useState>({}); const [faydaLoading, setFaydaLoading] = useState(false); const [faydaError, setFaydaError] = useState(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 = {}; 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) => { 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 = {}; 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 = {}; 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 = {}; 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 = { ...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 ( ); } if (field.type === "radio") { return ( setValues((current) => ({ ...current, [field.name]: next })) } error={error} > {(field.options ?? []).map((o) => ( ))} ); } if (field.type === "select") { return (