mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
652 lines
22 KiB
TypeScript
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;
|