This commit is contained in:
23 changed files with 5879 additions and 0 deletions

24
src/App.tsx Normal file
View File

@ -0,0 +1,24 @@
import { MantineProvider } from "@mantine/core";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import Dictionaries from "./pages/Dictionaries";
import DriverForm from "./pages/DriverForm";
import 'dayjs/locale/ru'
function App() {
return (
<MantineProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<div>a</div>} />
<Route path="/dictionaries" element={<Dictionaries />} />
<Route path="/drivers" element={<DriverForm />} />
</Route>
</Routes>
</BrowserRouter>
</MantineProvider>
);
}
export default App;

22
src/api/api.ts Normal file
View File

@ -0,0 +1,22 @@
import axios from "axios";
const API_BASE_URL = "https://api.jkhsakha.ru/is/fuel";
export const fetchDictionary = async (directory: string) => {
const response = await axios.get(`${API_BASE_URL}/${directory}?limit=100`);
return response.data;
};
export const createDictionaryItem = async (directory: string, data: any) => {
const response = await axios.post(`${API_BASE_URL}/${directory}`, data);
return response.data;
};
export const updateDictionaryItem = async (directory: string, id: number, data: any) => {
const response = await axios.patch(`${API_BASE_URL}/${directory}/${id}`, data);
return response.data;
};
export const deleteDictionaryItem = async (directory: string, id: number) => {
await axios.delete(`${API_BASE_URL}/${directory}/${id}`);
};

View File

@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import { Modal, Button, TextInput, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { fetchDictionary } from "../api/api"; // Импорт API запроса
interface Props {
opened: boolean;
onClose: () => void;
onSubmit: (values: any) => void;
initialValues: any;
fields: { name: string; label: string; type: "text" | "select"; options?: any[] }[];
}
const DictionaryModal: React.FC<Props> = ({ opened, onClose, onSubmit, initialValues, fields }) => {
const form = useForm({ initialValues });
const [fuelTypes, setFuelTypes] = useState<{ value: string; label: string }[]>([]);
// Загружаем виды топлива при открытии модального окна
useEffect(() => {
if (opened) {
fetchDictionary("fuel_types").then((data) => {
const options = data.map((item: any) => ({
value: String(item.id), // Преобразуем ID в строку
label: item.name,
}));
setFuelTypes(options);
});
}
}, [opened]);
// Обновляем форму при изменении initialValues
useEffect(() => {
const updatedValues = { ...initialValues };
// Приводим id_fuel_type к строке, если он есть
if (updatedValues.id_fuel_type !== undefined) {
updatedValues.id_fuel_type = String(updatedValues.id_fuel_type);
}
form.setValues(updatedValues);
}, [initialValues]);
return (
<Modal opened={opened} onClose={onClose} title="Добавить/Редактировать" centered>
<form onSubmit={form.onSubmit(onSubmit)}>
{fields.map((field) =>
field.type === "text" ? (
<TextInput key={field.name} label={field.label} {...form.getInputProps(field.name)} />
) : (
<Select
key={field.name}
label={field.label}
data={field.name === "id_fuel_type" ? fuelTypes : field.options || []} // Используем API-данные для "Вид топлива"
value={form.values[field.name] || ""} // Указываем явно значение
{...form.getInputProps(field.name)}
/>
)
)}
<Button fullWidth mt="md" type="submit">
Сохранить
</Button>
</form>
</Modal>
);
};
export default DictionaryModal;

View File

@ -0,0 +1,17 @@
import {TextInput, Button}from '@mantine/core';
import { useState } from 'react';
import axios from 'axios';
const DictionaryPage = () => {
const [inputValue, setInputValue] = useState("")
const saveDataHandler = (value: String) => {
}
return (
<>
<TextInput label="Введите название" value={inputValue}
onChange={(event) => setInputValue(event.currentTarget.value)}/>
<Button onClick={() => saveDataHandler(inputValue)}>Сохранить</Button>
</>
)
}
export default DictionaryPage;

View File

@ -0,0 +1,125 @@
import { useState, useEffect } from "react";
import { Table, ActionIcon, Text, Modal, Button, Pagination } from "@mantine/core";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { fetchDictionary } from "../api/api";
interface Props {
data: any[];
onEdit: (item: any) => void;
onDelete: (id: number) => void;
}
const columnNames: Record<string, string> = {
name: "Наименование",
name_short: "Аббревиатура",
id_fuel_type: "Вид топлива",
};
const DictionaryTable: React.FC<Props> = ({ data, onEdit, onDelete }) => {
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const [selectedItem, setSelectedItem] = useState<any | null>(null);
const [fuelTypesMap, setFuelTypesMap] = useState<Map<string, string>>(new Map());
// Пагинация
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Загружаем виды топлива
useEffect(() => {
fetchDictionary("fuel_types").then((fuelTypes) => {
const map = new Map(fuelTypes.map((fuel: any) => [String(fuel.id), fuel.name]));
setFuelTypesMap(map);
});
}, []);
const openDeleteModal = (item: any) => {
setSelectedItem(item);
setDeleteModalOpened(true);
};
const handleDelete = () => {
if (selectedItem) {
onDelete(selectedItem.id);
setDeleteModalOpened(false);
setSelectedItem(null);
}
};
// Фильтрация данных для текущей страницы
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedData = data.slice(startIndex, startIndex + itemsPerPage);
return (
<>
<Table striped highlightOnHover withColumnBorders>
<Table.Thead>
<Table.Tr>
{data.length > 0 ? (
Object.keys(data[0]).map((key) => (
<Table.Th key={key} style={{ textAlign: "left" }}>
{columnNames[key] || key}
</Table.Th>
))
) : (
<Table.Th></Table.Th>
)}
{data.length > 0 && <Table.Th style={{ textAlign: "left" }}>Действия</Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{paginatedData.length > 0 ? (
paginatedData.map((item) => (
<Table.Tr key={item.id}>
{Object.keys(item).map((key, idx) => (
<Table.Td key={idx}>
{key === "id_fuel_type" ? fuelTypesMap.get(String(item[key])) || "Неизвестно" : String(item[key])}
</Table.Td>
))}
<Table.Td>
<ActionIcon color="blue" onClick={() => onEdit(item)} style={{ marginRight: "10px" }}>
<IconEdit />
</ActionIcon>
<ActionIcon color="red" onClick={() => openDeleteModal(item)}>
<IconTrash />
</ActionIcon>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={data.length > 0 ? Object.keys(data[0]).length + 1 : 1} style={{ textAlign: "center" }}>
<Text c="dimmed">Нет записей</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{/* Пагинация */}
{data.length > itemsPerPage && (
<Pagination
total={Math.ceil(data.length / itemsPerPage)}
value={currentPage}
onChange={setCurrentPage}
mt="md"
position="center"
/>
)}
{/* Модальное окно подтверждения удаления */}
<Modal opened={deleteModalOpened} onClose={() => setDeleteModalOpened(false)} title="Подтверждение удаления" centered>
<Text>Вы уверены, что хотите удалить <b>{selectedItem?.name || "элемент"}</b>?</Text>
<div style={{ marginTop: "20px", display: "flex", justifyContent: "flex-end" }}>
<Button variant="outline" onClick={() => setDeleteModalOpened(false)} style={{ marginRight: "10px" }}>
Отмена
</Button>
<Button color="red" onClick={handleDelete}>
Удалить
</Button>
</div>
</Modal>
</>
);
};
export default DictionaryTable;

57
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,57 @@
import { AppShell, Burger, Group, Text, NavLink } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { IconHome2, IconGauge, IconActivity, IconUser } from "@tabler/icons-react";
export function Layout() {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const navigate = useNavigate();
const location = useLocation();
return (
<AppShell
header={{ height: 60 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
<img src="/fuel.png" width={50} />
<Text size="xl" fw={900} variant="gradient" gradient={{ from: "blue", to: "cyan", deg: 90 }}>
Топливо и Транспорт
</Text>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<NavLink
onClick={() => navigate("/")}
label="Главная"
leftSection={<IconHome2 size="1rem" stroke={1.5} />}
active={location.pathname === "/"}
/>
<NavLink
onClick={() => navigate("/dictionaries")}
label="Справочники"
leftSection={<IconGauge size="1rem" stroke={1.5} />}
active={location.pathname === "/dictionaries"}
/>
<NavLink
onClick={() => navigate("/drivers")}
label="Водители"
leftSection={<IconUser size="1rem" stroke={1.5} />}
active={location.pathname === "/drivers"}
/>
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@ -0,0 +1,10 @@
import { InputBase } from "@mantine/core"
import { IMaskInput } from 'react-imask';
const MaskedDateInput = () => {
return (
<InputBase component={IMaskInput} mask="00.00.0000" placeholder="ДД.ММ.ГГГГ"/>
)
}

11
src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

103
src/pages/Dictionaries.tsx Normal file
View File

@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import { Button, Select } from "@mantine/core";
import DictionaryTable from "../components/DictionaryTable";
import DictionaryModal from "../components/DictionaryModal";
import { fetchDictionary, createDictionaryItem, updateDictionaryItem, deleteDictionaryItem } from "../api/api";
const dictionaryOptions = [
{ value: "fuel_types", label: "Вид топлива" },
{ value: "fuel", label: "Наименование топлива" },
{ value: "unit", label: "Единица измерения" },
{ value: "vehicle_class", label: "Категория транспорта" },
{ value: "vehicle_type", label: "Тип транспорта" },
{ value: "vehicle_activity", label: "Вид деятельности транспорта" },
{ value: "driver_license_category", label: "Категория водительских удостоверений" },
];
const fieldMappings: Record<string, any[]> = {
fuel_types: [{ name: "name", label: "Наименование", type: "text" }],
fuel: [
{ name: "name", label: "Наименование", type: "text" },
{ name: "id_fuel_type", label: "Вид топлива", type: "select", options: [] },
],
unit: [
{ name: "name", label: "Наименование", type: "text" },
{ name: "name_short", label: "Аббревиатура", type: "text" },
],
vehicle_class: [{ name: "name", label: "Наименование", type: "text" }],
vehicle_type: [{ name: "name", label: "Наименование", type: "text" }],
vehicle_activity: [{ name: "name", label: "Наименование", type: "text" }],
driver_license_category: [
{ name: "name", label: "Наименование", type: "text" },
{ name: "name_short", label: "Аббревиатура", type: "text" },
],
};
const Dictionaries = () => {
const [selectedDictionary, setSelectedDictionary] = useState("fuel_types");
const [data, setData] = useState<any[]>([]);
const [modalOpened, setModalOpened] = useState(false);
const [currentItem, setCurrentItem] = useState<any | null>(null);
useEffect(() => {
fetchDictionary(selectedDictionary).then(setData);
}, [selectedDictionary]);
const handleAdd = async (values: any) => {
const newItem = await createDictionaryItem(selectedDictionary, values);
setData((prev) => [...prev, newItem]); // Обновляем локальное состояние
setModalOpened(false);
};
const handleEdit = async (values: any) => {
if (!currentItem) return;
const updatedItem = await updateDictionaryItem(selectedDictionary, currentItem.id, values);
setData((prev) => prev.map((item) => (item.id === currentItem.id ? updatedItem : item))); // Обновляем локальное состояние
setModalOpened(false);
setCurrentItem(null);
};
const handleDelete = async (id: number) => {
await deleteDictionaryItem(selectedDictionary, id);
setData((prev) => prev.filter((item) => item.id !== id)); // Обновляем локальное состояние
};
const getEmptyInitialValues = () => {
return fieldMappings[selectedDictionary]?.reduce((acc, field) => {
acc[field.name] = "";
return acc;
}, {} as Record<string, string>);
};
return (
<div>
<h3>Справочники</h3>
<Select
data={dictionaryOptions}
value={selectedDictionary}
onChange={setSelectedDictionary}
allowDeselect={false}
searchable
nothingFoundMessage="Не найдено..."
maxDropdownHeight={300}
/><br/>
<Button onClick={() => { setCurrentItem(null); setModalOpened(true); }}>Добавить</Button><br/><br/>
<DictionaryTable
data={data}
onEdit={(item) => { setCurrentItem(item); setModalOpened(true); }}
onDelete={handleDelete}
/>
<DictionaryModal
opened={modalOpened}
onClose={() => setModalOpened(false)}
onSubmit={currentItem ? handleEdit : handleAdd}
initialValues={currentItem || getEmptyInitialValues()}
fields={fieldMappings[selectedDictionary] || []}
/>
</div>
);
};
export default Dictionaries;

259
src/pages/DriverForm.tsx Normal file
View File

@ -0,0 +1,259 @@
import 'dayjs/locale/ru';
import { useState, useEffect } from "react";
import {
Button,
TextInput,
Table,
ActionIcon,
Modal,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useForm } from "@mantine/form";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import {
fetchDictionary,
createDictionaryItem,
updateDictionaryItem,
deleteDictionaryItem,
} from "../api/api";
const DriverForm = () => {
const [drivers, setDrivers] = useState<any[]>([]);
const [licenseCategories, setLicenseCategories] = useState<{ value: string; label: string }[]>([]);
const [selectedDriver, setSelectedDriver] = useState<any | null>(null);
const [selectedLicense, setSelectedLicense] = useState<any | null>(null);
const [modalOpened, setModalOpened] = useState(false);
const [licenseModalOpened, setLicenseModalOpened] = useState(false);
const [dateValue, setDateValue] = useState('');
useEffect(() => {
fetchDictionary("driver_license_category").then((data) => {
const options = data.map((item: any) => ({
value: String(item.id),
label: item.name_short, // Берем короткое название (например, "B", "C", "D")
}));
setLicenseCategories(options);
});
fetchDictionary("driver").then(setDrivers);
}, []);
const driverForm = useForm({
initialValues: {
fullname: "Попов Спиридон Семенович",
snils: "11122233344",
birthday: null,
iin: "123456789123",
},
});
const licenseForm = useForm({
initialValues: {
driverId: "",
series_number: "",
form_date: "",
to_date: "",
categories: [],
frontPhoto: null,
backPhoto: null,
},
});
function dateToYYYYMMDD(date) {
if (!(date instanceof Date) || isNaN(date)) {
return ""; // Handle invalid Date objects
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
const handleAddDriver = async (values: any) => {
const date = new Date(Date.parse(values.birthday))
const newValues = {
fullname: values.fullname,
snils: values.snils,
birthday: dateToYYYYMMDD(date),
iin: values.iin
}
if (selectedDriver) {
const updatedDriver = await updateDictionaryItem("driver", selectedDriver.id, newValues);
setDrivers((prev) => prev.map((d) => (d.id === selectedDriver.id ? updatedDriver : d)));
} else {
const newDriver = await createDictionaryItem("driver", newValues);
setDrivers((prev) => [...prev, newDriver]);
}
setModalOpened(false);
driverForm.reset();
setSelectedDriver(null);
};
const handleDeleteDriver = async (id: number) => {
await deleteDictionaryItem("driver", id);
setDrivers((prev) => prev.filter((driver) => driver.id !== id));
};
const handleEditDriver = (driver: any) => {
driverForm.setValues(driver);
setSelectedDriver(driver);
setModalOpened(true);
};
const handleAddLicense = async (values: any) => {
if (selectedLicense) {
const updatedLicense = await updateDictionaryItem("driver_license", selectedLicense.id, values);
setDrivers((prev) =>
prev.map((driver) =>
driver.id === values.driverId
? {
...driver,
license: driver.license.map((lic) => (lic.id === selectedLicense.id ? updatedLicense : lic)),
}
: driver
)
);
} else {
const newLicense = await createDictionaryItem("driver_license", values);
const updatedDriver = await updateDictionaryItem("driver_connection", {
driver_id: values.driverId,
driver_license_id: newLicense.id,
});
setDrivers((prev) =>
prev.map((driver) =>
driver.id === values.driverId
? { ...driver, license: [...(driver.license || []), newLicense] }
: driver
)
);
}
setLicenseModalOpened(false);
licenseForm.reset();
setSelectedLicense(null);
};
const handleDeleteLicense = async (driverId: number, licenseId: number) => {
await deleteDictionaryItem("driver_license", licenseId);
setDrivers((prev) =>
prev.map((driver) =>
driver.id === driverId ? { ...driver, license: driver.license.filter((lic) => lic.id !== licenseId) } : driver
)
);
};
function parseDDMMYYYY(dateString: string): Date | null {
const [day, month, year] = dateString.split('.').map(Number);
if (
isNaN(day) ||
isNaN(month) ||
isNaN(year) ||
month < 1 ||
month > 12 ||
year < 1000 || // Prevent extremely early dates
year > 9999
) {
return null; // Invalid input
}
return new Date(year, month - 1, day); // Month is 0-indexed in Date
}
return (
<div>
<h2>Водители</h2>
<Button onClick={() => setModalOpened(true)}>Добавить водителя</Button>
<Table striped highlightOnHover withColumnBorders mt="md">
<Table.Thead>
<Table.Tr>
<Table.Th>ФИО</Table.Th>
<Table.Th>СНИЛС</Table.Th>
<Table.Th>Дата рождения</Table.Th>
<Table.Th>ИНН</Table.Th>
<Table.Th>Действия</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{drivers.map((driver) => (
<Table.Tr key={driver.id}>
<Table.Td>{driver.fullname}</Table.Td>
<Table.Td>{driver.snils}</Table.Td>
<Table.Td>{driver.birthday}</Table.Td>
<Table.Td>{driver.iin}</Table.Td>
<Table.Td>
<ActionIcon color="blue" onClick={() => handleEditDriver(driver)} style={{ marginRight: "10px" }}>
<IconEdit />
</ActionIcon>
<ActionIcon color="red" onClick={() => handleDeleteDriver(driver.id)}>
<IconTrash />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<h2>Водительские права</h2>
<Button onClick={() => setLicenseModalOpened(true)}>Добавить права</Button>
<Table striped highlightOnHover withColumnBorders mt="md">
<Table.Thead>
<Table.Tr>
<Table.Th>ФИО</Table.Th>
<Table.Th>Серия и номер</Table.Th>
<Table.Th>Дата выдачи</Table.Th>
<Table.Th>Срок действия</Table.Th>
<Table.Th>Категории</Table.Th>
<Table.Th>Действия</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{drivers.flatMap((driver) =>
driver.license?.map((license) => (
<Table.Tr key={license.id}>
<Table.Td>{driver.fullname}</Table.Td>
<Table.Td>{license.series_number}</Table.Td>
<Table.Td>{license.form_date}</Table.Td>
<Table.Td>{license.to_date}</Table.Td>
<Table.Td>{license.categories?.map((c) => c.name_short).join(", ")}</Table.Td>
<Table.Td>
<ActionIcon color="red" onClick={() => handleDeleteLicense(driver.id, license.id)}>
<IconTrash />
</ActionIcon>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
<Modal opened={modalOpened} onClose={() => setModalOpened(false)} title="Добавить/Редактировать водителя">
<form onSubmit={driverForm.onSubmit(handleAddDriver)}>
<TextInput label="ФИО" {...driverForm.getInputProps("fullname")} required />
<TextInput label="СНИЛС" {...driverForm.getInputProps("snils")} required />
<TextInput label="ИНН" {...driverForm.getInputProps("iin")} required />
<DateInput
label="Дата рождения"
placeholder="ДД.MM.ГГГГ"
defaultLevel='month'
valueFormat='DD.MM.YYYY'
required
dateParser={parseDDMMYYYY}
locale="ru"
{...driverForm.getInputProps("birthday")}
/>
<Button fullWidth mt="md" type="submit">
Сохранить
</Button>
</form>
</Modal>
</div>
);
};
export default DriverForm;

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />