mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
327 lines
8.5 KiB
TypeScript
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;
|