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; 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; } 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]); // 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; 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 = {}; 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 = {}; 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 (