Files
edr-platform/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
2026-06-23 15:02:25 +03:00

327 lines
8.5 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Loader2, Calendar } from "lucide-react";
import {
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";
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;
}
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;
}
values[field.name] = raw;
});
return values;
};
const FleetFormDialog = ({
open,
onOpenChange,
title,
fields,
initialRecord,
emptyValues,
isSubmitting,
selectOptionsLoading,
onSubmit,
}: FleetFormDialogProps) => {
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
}
}, [open, fields, emptyValues, initialRecord]);
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;
};
const handleSubmit = () => {
if (!validate()) return;
const payload = Object.fromEntries(
Object.entries(values)
.map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "")
return [key, undefined];
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">
<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;