Compare commits
2 Commits
e0c70ccbe9
...
4e70e067f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e70e067f7 | |||
| 08dfec677a |
@ -3,6 +3,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import Dictionaries from "./pages/Dictionaries";
|
import Dictionaries from "./pages/Dictionaries";
|
||||||
import DriverForm from "./pages/DriverForm";
|
import DriverForm from "./pages/DriverForm";
|
||||||
|
import DriverLicenseForm from "./pages/DriverLicenseForm";
|
||||||
import 'dayjs/locale/ru'
|
import 'dayjs/locale/ru'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -14,6 +15,7 @@ function App() {
|
|||||||
<Route path="/" element={<div>a</div>} />
|
<Route path="/" element={<div>a</div>} />
|
||||||
<Route path="/dictionaries" element={<Dictionaries />} />
|
<Route path="/dictionaries" element={<Dictionaries />} />
|
||||||
<Route path="/drivers" element={<DriverForm />} />
|
<Route path="/drivers" element={<DriverForm />} />
|
||||||
|
<Route path="/driversLicense" element={<DriverLicenseForm />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -48,6 +48,12 @@ export function Layout() {
|
|||||||
leftSection={<IconUser size="16px" stroke={1.5} />}
|
leftSection={<IconUser size="16px" stroke={1.5} />}
|
||||||
active={location.pathname === "/drivers"}
|
active={location.pathname === "/drivers"}
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
onClick={() => navigate("/driversLicense")}
|
||||||
|
label="Водители"
|
||||||
|
leftSection={<IconUser size="16px" stroke={1.5} />}
|
||||||
|
active={location.pathname === "/driversLicenseForm"}
|
||||||
|
/>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
<Outlet />
|
<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