DriverLicenses
This commit is contained in:
@ -3,6 +3,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { Layout } from "./components/Layout";
|
||||
import Dictionaries from "./pages/Dictionaries";
|
||||
import DriverForm from "./pages/DriverForm";
|
||||
import DriverLicenseForm from "./pages/DriverLicenseForm";
|
||||
import 'dayjs/locale/ru'
|
||||
|
||||
function App() {
|
||||
@ -14,6 +15,7 @@ function App() {
|
||||
<Route path="/" element={<div>a</div>} />
|
||||
<Route path="/dictionaries" element={<Dictionaries />} />
|
||||
<Route path="/drivers" element={<DriverForm />} />
|
||||
<Route path="/driversLicense" element={<DriverLicenseForm />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -48,6 +48,12 @@ export function Layout() {
|
||||
leftSection={<IconUser size="16px" stroke={1.5} />}
|
||||
active={location.pathname === "/drivers"}
|
||||
/>
|
||||
<NavLink
|
||||
onClick={() => navigate("/driversLicense")}
|
||||
label="Водители"
|
||||
leftSection={<IconUser size="16px" stroke={1.5} />}
|
||||
active={location.pathname === "/driversLicenseForm"}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
|
||||
660
src/pages/DriverLicenseForm.tsx
Normal file
660
src/pages/DriverLicenseForm.tsx
Normal file
@ -0,0 +1,660 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Box,
|
||||
Title,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Alert,
|
||||
Modal,
|
||||
TextInput,
|
||||
SimpleGrid,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Text,
|
||||
Badge,
|
||||
MultiSelect,
|
||||
InputBase,
|
||||
Popover,
|
||||
Collapse,
|
||||
Center,
|
||||
MantineProvider,
|
||||
} from '@mantine/core';
|
||||
import { DatePicker } from '@mantine/dates';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconAlertCircle, IconPencil, IconTrash, IconPlus, IconListDetails, IconCalendar, IconChevronDown, IconChevronUp } from '@tabler/icons-react';
|
||||
import { stringToSnils } from '../utils/format'
|
||||
|
||||
// --- ИНТЕРФЕЙСЫ ---
|
||||
export interface IDriver {
|
||||
id: number;
|
||||
fullname: string;
|
||||
snils: string;
|
||||
birthday: string;
|
||||
iin: string;
|
||||
license?: IDriverLicense[];
|
||||
}
|
||||
|
||||
export interface IDriverLicense {
|
||||
id: number;
|
||||
series_number: string;
|
||||
form_date: string;
|
||||
to_date: string;
|
||||
is_actual: boolean;
|
||||
driver_connection_id?: number;
|
||||
categories?: ICategoryWithConnection[];
|
||||
}
|
||||
|
||||
export interface ICategoryWithConnection {
|
||||
id: number;
|
||||
name: string;
|
||||
name_short: string;
|
||||
driver_license_connection_id?: number;
|
||||
}
|
||||
export interface IDriverLicenseCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
name_short: string;
|
||||
}
|
||||
|
||||
// --- ФУНКЦИИ ДЛЯ РАБОТЫ С API ---
|
||||
const API_BASE_URL = "https://api.jkhsakha.ru/is/fuel";
|
||||
|
||||
const handleApiResponse = async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(errorData.detail || errorData.message || 'Ошибка запроса к API');
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const fetchDictionary = async (directory: string) => {
|
||||
const response = await fetch(`${API_BASE_URL}/${directory}/?limit=1000`);
|
||||
return handleApiResponse(response);
|
||||
};
|
||||
|
||||
const createDictionaryItem = async (directory: string, data: any) => {
|
||||
const response = await fetch(`${API_BASE_URL}/${directory}/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleApiResponse(response);
|
||||
};
|
||||
|
||||
const updateDictionaryItem = async (directory: string, id: number, data: any) => {
|
||||
const response = await fetch(`${API_BASE_URL}/${directory}/${id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleApiResponse(response);
|
||||
};
|
||||
|
||||
const deleteDictionaryItem = async (directory: string, id: number) => {
|
||||
const response = await fetch(`${API_BASE_URL}/${directory}/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return handleApiResponse(response);
|
||||
};
|
||||
|
||||
// --- УТИЛИТЫ ДЛЯ РАБОТЫ С ДАТАМИ ---
|
||||
function dateToYYYYMMDD(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatToDDMMYYYY(date: Date): string {
|
||||
if (!date) return '';
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function parseYYYYMMDD(dateString: string): Date | null {
|
||||
if (!dateString || !/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
return null;
|
||||
}
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function dateToDDMMYYYY(dateString: string): string {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
if (year && month && day) {
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
return '';
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- КОМПОНЕНТ МАСКИРОВАННОГО ВВОДА ДАТЫ ---
|
||||
interface MaskedDateInputProps {
|
||||
value?: Date | null
|
||||
onChange: (value: Date | null) => void
|
||||
label?: string
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
error?: React.ReactNode
|
||||
maxDate?: Date
|
||||
}
|
||||
|
||||
function parseInputDDMMYYYY(input: string): Date | null {
|
||||
const [dd, mm, yyyy] = input.split(".")
|
||||
const day = parseInt(dd, 10)
|
||||
const month = parseInt(mm, 10) - 1
|
||||
const year = parseInt(yyyy, 10)
|
||||
|
||||
if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
|
||||
|
||||
const date = new Date(year, month, day);
|
||||
|
||||
if (date.getFullYear() === year && date.getMonth() === month && date.getDate() === day) {
|
||||
return date;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const MaskedDateInput = ({ value, onChange, label, required, placeholder = "ДД.MM.ГГГГ", error, maxDate }: MaskedDateInputProps) => {
|
||||
const [inputValue, setInputValue] = useState(value ? formatToDDMMYYYY(value) : "")
|
||||
const [opened, setOpened] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const formatted = formatToDDMMYYYY(value);
|
||||
if (formatted !== inputValue) {
|
||||
setInputValue(formatted)
|
||||
}
|
||||
} else {
|
||||
setInputValue('')
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 8);
|
||||
let formatted = digits;
|
||||
if (digits.length > 4) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
} else if (digits.length > 2) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
}
|
||||
setInputValue(formatted);
|
||||
|
||||
if (digits.length === 8) {
|
||||
const parsed = parseInputDDMMYYYY(formatted);
|
||||
if (parsed) onChange(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDatePick = (date: Date | null) => {
|
||||
if (!date) return;
|
||||
setInputValue(formatToDDMMYYYY(date));
|
||||
onChange(date);
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
label={label}
|
||||
required={required}
|
||||
component="input"
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
error={error}
|
||||
rightSection={
|
||||
<Popover opened={opened} trapFocus onChange={setOpened} position="bottom" shadow="md" width="auto">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="transparent" onClick={() => setOpened((o) => !o)}>
|
||||
<IconCalendar size={16} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<DatePicker locale="ru" value={value} onChange={handleDatePick} defaultLevel="month" maxDate={maxDate} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ГЛАВНЫЙ КОМПОНЕНТ СТРАНИЦЫ ---
|
||||
const DriversPageContent = () => {
|
||||
const [drivers, setDrivers] = useState<IDriver[]>([]);
|
||||
const [allCategories, setAllCategories] = useState<IDriverLicenseCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedDriverId, setExpandedDriverId] = useState<number | null>(null);
|
||||
|
||||
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
|
||||
const [licenseModalOpened, { open: openLicenseModal, close: closeLicenseModal }] = useDisclosure(false);
|
||||
const [confirmModalOpened, { open: openConfirmModal, close: closeConfirmModal }] = useDisclosure(false);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedDriver, setSelectedDriver] = useState<IDriver | null>(null);
|
||||
const [driverForNewLicense, setDriverForNewLicense] = useState<IDriver | null>(null);
|
||||
const [itemToDelete, setItemToDelete] = useState<{ type: 'driver' | 'license'; driverId: number; licenseId?: number } | null>(null);
|
||||
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [driversData, categoriesData] = await Promise.all([
|
||||
fetchDictionary('driver'),
|
||||
fetchDictionary('driver_license_category')
|
||||
]);
|
||||
setDrivers(driversData.results || driversData);
|
||||
setAllCategories(categoriesData.results || categoriesData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки данных:', err);
|
||||
setError('Не удалось загрузить данные. Пожалуйста, попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleOpenAddModal = () => {
|
||||
setIsEditing(false);
|
||||
setSelectedDriver(null);
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (driver: IDriver) => {
|
||||
setIsEditing(true);
|
||||
setSelectedDriver(driver);
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleOpenAddLicenseModal = (driver: IDriver) => {
|
||||
setDriverForNewLicense(driver);
|
||||
openLicenseModal();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (type: 'driver' | 'license', driverId: number, licenseId?: number) => {
|
||||
setItemToDelete({ type, driverId, licenseId });
|
||||
openConfirmModal();
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!itemToDelete) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (itemToDelete.type === 'driver') {
|
||||
const driverToDelete = drivers.find(d => d.id === itemToDelete.driverId);
|
||||
if (driverToDelete?.license) {
|
||||
for (const lic of driverToDelete.license) {
|
||||
await performLicenseDeletion(lic, driverToDelete.id);
|
||||
}
|
||||
}
|
||||
await deleteDictionaryItem('driver', itemToDelete.driverId);
|
||||
} else if (itemToDelete.type === 'license' && itemToDelete.licenseId) {
|
||||
const driver = drivers.find(d => d.id === itemToDelete.driverId);
|
||||
const license = driver?.license?.find(l => l.id === itemToDelete.licenseId);
|
||||
if (license && driver) {
|
||||
await performLicenseDeletion(license, driver.id);
|
||||
}
|
||||
}
|
||||
await fetchData();
|
||||
} catch(err: any) {
|
||||
console.error(`Ошибка удаления ${itemToDelete.type}:`, err);
|
||||
setError(err.message || `Не удалось удалить элемент.`);
|
||||
} finally {
|
||||
closeConfirmModal();
|
||||
setItemToDelete(null);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const performLicenseDeletion = async (license: IDriverLicense, driverId: number) => {
|
||||
if(license.categories) {
|
||||
for (const cat of license.categories) {
|
||||
if (cat.driver_license_connection_id) {
|
||||
await deleteDictionaryItem('driver_license_connection', cat.driver_license_connection_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
const driver = drivers.find(d => d.id === driverId);
|
||||
const lic = driver?.license?.find(l => l.id === license.id);
|
||||
if (lic?.driver_connection_id) {
|
||||
await deleteDictionaryItem('driver_connection', lic.driver_connection_id);
|
||||
}
|
||||
await deleteDictionaryItem('driver_license', license.id);
|
||||
};
|
||||
|
||||
const rows = drivers.map((driver) => {
|
||||
const isExpanded = expandedDriverId === driver.id;
|
||||
return (
|
||||
<React.Fragment key={driver.id}>
|
||||
<tr >
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<ActionIcon onClick={() => setExpandedDriverId(isExpanded ? null : driver.id)}>
|
||||
{isExpanded ? <IconChevronUp size={16}/> : <IconChevronDown size={16}/>}
|
||||
</ActionIcon>
|
||||
<Text size="sm" weight={500}>{driver.fullname}</Text>
|
||||
</Group>
|
||||
</td>
|
||||
<td>{stringToSnils(driver.snils)}</td>
|
||||
<td>{dateToDDMMYYYY(driver.birthday)}</td>
|
||||
<td>{driver.iin}</td>
|
||||
<td>
|
||||
<Group spacing="xs" noWrap>
|
||||
<Tooltip label="Редактировать">
|
||||
<ActionIcon color="blue" onClick={() => handleOpenEditModal(driver)}>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Удалить">
|
||||
<ActionIcon color="red" onClick={() => handleDeleteClick('driver', driver.id)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: 0, border: 0 }}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Box p="md" sx={(theme) => ({ backgroundColor: theme.colors.gray[0] })}>
|
||||
{driver.license && driver.license.length > 0 ? (
|
||||
driver.license.map(lic => (
|
||||
<Box key={lic.id} p="xs" mb="xs" sx={(theme) => ({ border: `1px solid ${theme.colors.gray[3]}`, borderRadius: theme.radius.sm, backgroundColor: theme.white })}>
|
||||
<Group position="apart">
|
||||
<div>
|
||||
<Text size="sm"><b>ВУ:</b> {lic.series_number}</Text>
|
||||
<Text size="xs"><b>Даты:</b> {dateToDDMMYYYY(lic.form_date)} - {dateToDDMMYYYY(lic.to_date)}</Text>
|
||||
<Group spacing="xs" mt={4}>
|
||||
{lic.categories?.map(cat => <Badge key={cat.id}>{cat.name_short}</Badge>)}
|
||||
</Group>
|
||||
</div>
|
||||
<ActionIcon color="red" onClick={() => handleDeleteClick('license', driver.id, lic.id)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" color="dimmed">Водительские удостоверения отсутствуют.</Text>
|
||||
)}
|
||||
<Group position="right" mt="sm">
|
||||
<Button variant="light" size="xs" leftIcon={<IconPlus size={14}/>} onClick={() => handleOpenAddLicenseModal(driver)}>
|
||||
Добавить ВУ
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
)
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LoadingOverlay visible={loading} overlayBlur={2} />
|
||||
<Group position="apart" mb="xl">
|
||||
<Title order={2}>Водители</Title>
|
||||
<Button leftIcon={<IconPlus size={16} />} onClick={handleOpenAddModal}>
|
||||
Добавить водителя
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{error && <Alert icon={<IconAlertCircle size="1rem" />} title="Ошибка!" color="red" mb="lg" withCloseButton onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
<Box sx={(theme) => ({ border: `1px solid ${theme.colors.gray[3]}`, borderRadius: theme.radius.sm, overflow: 'hidden' })}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ФИО</th>
|
||||
<th>СНИЛС</th>
|
||||
<th>Дата рождения</th>
|
||||
<th>ИНН</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Center p="xl"><Text color="dimmed">Водители не найдены.</Text></Center>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<DriverFormModal opened={modalOpened} onClose={closeModal} driver={selectedDriver} isEditing={isEditing} onSuccess={fetchData} />
|
||||
<AddLicenseFormModal opened={licenseModalOpened} onClose={closeLicenseModal} driver={driverForNewLicense} allCategories={allCategories} onSuccess={fetchData} />
|
||||
<Modal opened={confirmModalOpened} onClose={closeConfirmModal} title="Подтвердите удаление" centered size="sm">
|
||||
<Text size="sm">Вы уверены, что хотите удалить этот элемент? Это действие необратимо.</Text>
|
||||
<Group position="right" mt="xl">
|
||||
<Button variant="default" onClick={closeConfirmModal}>Отмена</Button>
|
||||
<Button color="red" onClick={handleConfirmDelete}>Удалить</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// --- КОМПОНЕНТ МОДАЛЬНОЙ ФОРМЫ (РЕДАКТИРОВАНИЕ/ДОБАВЛЕНИЕ ВОДИТЕЛЯ) ---
|
||||
interface DriverFormModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
driver: IDriver | null;
|
||||
isEditing: boolean;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const DriverFormModal = ({ opened, onClose, driver, isEditing, onSuccess }: DriverFormModalProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string|null>(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
fullname: '',
|
||||
snils: '',
|
||||
birthday: null as Date | null,
|
||||
iin: '',
|
||||
},
|
||||
validate: {
|
||||
fullname: (value) => (value.trim().length < 5 ? 'ФИО должно содержать минимум 5 символов' : null),
|
||||
snils: (value) => (/^\d{11}$/.test(value) ? null : 'СНИЛС должен состоять из 11 цифр'),
|
||||
iin: (value) => (/^\d{12}$/.test(value) ? null : 'ИНН должен состоять из 12 цифр'),
|
||||
birthday: (value) => (value ? null : 'Укажите дату рождения'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
if (isEditing && driver) {
|
||||
form.setValues({
|
||||
fullname: driver.fullname,
|
||||
snils: driver.snils,
|
||||
birthday: parseYYYYMMDD(driver.birthday),
|
||||
iin: driver.iin,
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
}
|
||||
}
|
||||
}, [isEditing, driver, opened]);
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
setLoading(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const driverPayload = {
|
||||
fullname: values.fullname,
|
||||
snils: values.snils,
|
||||
iin: values.iin,
|
||||
birthday: dateToYYYYMMDD(values.birthday),
|
||||
is_actual: true,
|
||||
};
|
||||
|
||||
if (isEditing && driver) {
|
||||
await updateDictionaryItem('driver', driver.id, driverPayload);
|
||||
} else {
|
||||
await createDictionaryItem('driver', driverPayload);
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка сохранения:', err);
|
||||
setFormError(err.message || 'Произошла неизвестная ошибка.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={isEditing ? "Редактировать водителя" : "Добавить водителя"} size="lg" centered>
|
||||
<Box component="form" onSubmit={form.onSubmit(handleSubmit)} sx={{ position: 'relative' }}>
|
||||
<LoadingOverlay visible={loading} />
|
||||
{formError && <Alert color="red" mb="md">{formError}</Alert>}
|
||||
|
||||
<Title order={4} mb="md">Данные водителя</Title>
|
||||
<SimpleGrid cols={2} breakpoints={[{ maxWidth: 'sm', cols: 1 }]}>
|
||||
<TextInput label="ФИО" required {...form.getInputProps('fullname')} />
|
||||
<TextInput label="СНИЛС" required {...form.getInputProps('snils')} maxLength={11} />
|
||||
<MaskedDateInput label="Дата рождения" required value={form.values.birthday} onChange={(date) => form.setFieldValue('birthday', date)} error={form.errors.birthday} maxDate={new Date()} />
|
||||
<TextInput label="ИНН" required {...form.getInputProps('iin')} maxLength={12}/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group position="right" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>Отмена</Button>
|
||||
<Button type="submit">{isEditing ? 'Сохранить изменения' : 'Добавить водителя'}</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// --- КОМПОНЕНТ МОДАЛЬНОЙ ФОРМЫ (ДОБАВЛЕНИЕ ВУ) ---
|
||||
interface AddLicenseFormModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
driver: IDriver | null;
|
||||
allCategories: IDriverLicenseCategory[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const AddLicenseFormModal = ({ opened, onClose, driver, allCategories, onSuccess }: AddLicenseFormModalProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string|null>(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
series_number: '',
|
||||
form_date: null as Date | null,
|
||||
to_date: null as Date | null,
|
||||
categories: [] as string[],
|
||||
},
|
||||
validate: {
|
||||
series_number: (value) => (value.trim().length < 6 ? 'Укажите серию и номер ВУ' : null),
|
||||
form_date: (value) => (value ? null : 'Укажите дату выдачи'),
|
||||
to_date: (value) => (value ? null : 'Укажите срок действия'),
|
||||
categories: (value) => (value.length === 0 ? 'Выберите хотя бы одну категорию' : null),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
form.reset();
|
||||
setFormError(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
if (!driver) return;
|
||||
setLoading(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const licensePayload = {
|
||||
series_number: values.series_number,
|
||||
form_date: dateToYYYYMMDD(values.form_date),
|
||||
to_date: dateToYYYYMMDD(values.to_date),
|
||||
is_actual: true,
|
||||
};
|
||||
const newLicense = await createDictionaryItem('driver_license', licensePayload);
|
||||
await createDictionaryItem('driver_connection', {
|
||||
driver_id: driver.id,
|
||||
driver_license_id: newLicense.id,
|
||||
});
|
||||
for (const categoryId of values.categories) {
|
||||
await createDictionaryItem('driver_license_connection', {
|
||||
driver_license_id: newLicense.id,
|
||||
driver_license_category_id: parseInt(categoryId, 10),
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка добавления ВУ:', err);
|
||||
setFormError(err.message || 'Произошла неизвестная ошибка при добавлении ВУ.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const categoryOptions = allCategories.map(cat => ({
|
||||
value: String(cat.id),
|
||||
label: `${cat.name_short} (${cat.name})`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={`Добавить ВУ для: ${driver?.fullname || ''}`} centered>
|
||||
<Box component="form" onSubmit={form.onSubmit(handleSubmit)} sx={{ position: 'relative' }}>
|
||||
<LoadingOverlay visible={loading} />
|
||||
{formError && <Alert color="red" mb="md">{formError}</Alert>}
|
||||
<SimpleGrid cols={1} spacing="md">
|
||||
<TextInput label="Серия и номер" required {...form.getInputProps('series_number')} />
|
||||
<MultiSelect label="Категории" placeholder="Выберите категории" data={categoryOptions} searchable required {...form.getInputProps('categories')} />
|
||||
<MaskedDateInput label="Дата выдачи" required value={form.values.form_date} onChange={(date) => form.setFieldValue('form_date', date)} error={form.errors.form_date} maxDate={new Date()} />
|
||||
<MaskedDateInput label="Срок действия до" required value={form.values.to_date} onChange={(date) => form.setFieldValue('to_date', date)} error={form.errors.to_date} />
|
||||
</SimpleGrid>
|
||||
<Group position="right" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>Отмена</Button>
|
||||
<Button type="submit">Добавить ВУ</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// --- КОМПОНЕНТ-ОБЕРТКА ДЛЯ СТИЛЕЙ ---
|
||||
const App = () => (
|
||||
<MantineProvider withGlobalStyles withNormalizeCSS>
|
||||
<DriversPageContent />
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user