Compare commits

...

2 Commits

Author SHA1 Message Date
4e70e067f7 Merge branch 'master' of https://git.jkhsakha.ru/HotamovFO/Web-Fuel 2025-09-05 10:42:22 +09:00
08dfec677a DriverLicenses 2025-09-05 10:42:16 +09:00
3 changed files with 668 additions and 0 deletions

View File

@ -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>

View File

@ -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 />

View 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;