diff --git a/frontend/src/components/ConfirmModal.jsx b/frontend/src/components/ConfirmModal.jsx
new file mode 100644
index 0000000..2f0f1b1
--- /dev/null
+++ b/frontend/src/components/ConfirmModal.jsx
@@ -0,0 +1,71 @@
+import Modal from './Modal';
+import { IconAlertTriangle } from '@tabler/icons-react';
+
+/**
+ * ConfirmModal - модальное окно подтверждения действия
+ * Для критичных операций (удаление, отмена и т.д.)
+ */
+function ConfirmModal({
+ show,
+ onClose,
+ onConfirm,
+ title = 'Подтверждение',
+ message,
+ confirmLabel = 'Подтвердить',
+ cancelLabel = 'Отмена',
+ variant = 'danger', // primary, danger, warning, success
+ icon: Icon = IconAlertTriangle,
+ loading = false,
+ showIcon = true
+}) {
+ const handleConfirm = () => {
+ onConfirm?.();
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+ {showIcon && Icon && (
+
+
+
+ )}
+
+ {typeof message === 'string' ? (
+
{message}
+ ) : (
+ message
+ )}
+
+
+
+ );
+}
+
+export default ConfirmModal;
+
diff --git a/frontend/src/components/FormField.jsx b/frontend/src/components/FormField.jsx
new file mode 100644
index 0000000..d2c014c
--- /dev/null
+++ b/frontend/src/components/FormField.jsx
@@ -0,0 +1,147 @@
+/**
+ * FormField - универсальное поле формы с валидацией
+ * Поддерживает иконки, подсказки, ошибки и success состояния
+ */
+function FormField({
+ label,
+ name,
+ type = 'text',
+ value,
+ onChange,
+ onBlur,
+ error,
+ success,
+ helpText,
+ required,
+ disabled,
+ placeholder,
+ icon: Icon,
+ className = '',
+ inputClassName = '',
+ rows, // для textarea
+ options, // для select
+ ...inputProps
+}) {
+ const inputId = `field-${name}`;
+ const hasError = !!error;
+ const hasSuccess = !!success && !error;
+ const isTextarea = type === 'textarea';
+ const isSelect = type === 'select';
+
+ const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
+
+ const handleChange = (e) => {
+ onChange?.(e.target.value, e);
+ };
+
+ const handleBlur = (e) => {
+ onBlur?.(e);
+ };
+
+ const renderInput = () => {
+ if (isTextarea) {
+ return (
+
+ );
+ }
+
+ if (isSelect) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+ {label && (
+
+ )}
+
+ {Icon ? (
+
+
+
+
+ {renderInput()}
+
+ ) : (
+ renderInput()
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {hasSuccess && (
+
+ {success}
+
+ )}
+
+ {helpText && !error && !success && (
+
+ {helpText}
+
+ )}
+
+ );
+}
+
+export default FormField;
+
diff --git a/frontend/src/components/FormModal.jsx b/frontend/src/components/FormModal.jsx
new file mode 100644
index 0000000..858ce40
--- /dev/null
+++ b/frontend/src/components/FormModal.jsx
@@ -0,0 +1,72 @@
+import Modal from './Modal';
+
+/**
+ * FormModal - модальное окно с формой
+ * Автоматически обрабатывает submit и отображает кнопки действий
+ */
+function FormModal({
+ show,
+ onClose,
+ onSubmit,
+ title,
+ children,
+ submitLabel = 'Сохранить',
+ cancelLabel = 'Отмена',
+ submitIcon: SubmitIcon,
+ cancelIcon: CancelIcon,
+ loading = false,
+ submitVariant = 'primary',
+ disabled = false,
+ ...modalProps
+}) {
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit?.(e);
+ };
+
+ const handleKeyDown = (e) => {
+ // Ctrl+Enter или Cmd+Enter для быстрого submit
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+ handleSubmit(e);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ {...modalProps}
+ >
+
+
+ );
+}
+
+export default FormModal;
+
diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx
new file mode 100644
index 0000000..d41a13d
--- /dev/null
+++ b/frontend/src/components/Modal.jsx
@@ -0,0 +1,108 @@
+import { useEffect, useRef } from 'react';
+import { IconX } from '@tabler/icons-react';
+
+/**
+ * Универсальный Modal компонент
+ * Использует нативный Tabler UI стиль
+ */
+function Modal({
+ show,
+ onClose,
+ title,
+ children,
+ footer,
+ size = 'md', // sm, md, lg, xl
+ backdrop = true,
+ keyboard = true,
+ scrollable = false,
+ centered = false,
+ className = ''
+}) {
+ const modalRef = useRef(null);
+ const backdropRef = useRef(null);
+
+ // Управление классом modal-open на body
+ useEffect(() => {
+ if (show) {
+ document.body.classList.add('modal-open');
+ // Trap focus внутри модалки
+ modalRef.current?.focus();
+ } else {
+ document.body.classList.remove('modal-open');
+ }
+
+ return () => {
+ document.body.classList.remove('modal-open');
+ };
+ }, [show]);
+
+ // Обработка ESC
+ useEffect(() => {
+ if (keyboard && show) {
+ const handleEsc = (e) => {
+ if (e.key === 'Escape') onClose?.();
+ };
+ document.addEventListener('keydown', handleEsc);
+ return () => document.removeEventListener('keydown', handleEsc);
+ }
+ }, [keyboard, show, onClose]);
+
+ if (!show) return null;
+
+ const handleBackdropClick = (e) => {
+ if (backdrop && e.target === backdropRef.current) {
+ onClose?.();
+ }
+ };
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+
+ {title && (
+
+
{title}
+
+
+ )}
+
+
+ {children}
+
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+
+
+ >
+ );
+}
+
+export default Modal;
+
diff --git a/frontend/src/hooks/useDebounce.js b/frontend/src/hooks/useDebounce.js
new file mode 100644
index 0000000..63d061e
--- /dev/null
+++ b/frontend/src/hooks/useDebounce.js
@@ -0,0 +1,39 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * useDebounce - hook для debounce значения
+ * Полезен для поиска и других операций, где нужно отложить выполнение
+ *
+ * @param {any} value - значение для debounce
+ * @param {number} delay - задержка в миллисекундах (по умолчанию 300)
+ * @returns {any} - debounced значение
+ *
+ * Пример использования:
+ * const [searchTerm, setSearchTerm] = useState('');
+ * const debouncedSearchTerm = useDebounce(searchTerm, 500);
+ *
+ * useEffect(() => {
+ * // API запрос с debounced значением
+ * fetchResults(debouncedSearchTerm);
+ * }, [debouncedSearchTerm]);
+ */
+function useDebounce(value, delay = 300) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ // Устанавливаем таймер для обновления значения
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ // Очищаем таймер при изменении value или delay
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
+
+export default useDebounce;
+
diff --git a/frontend/src/hooks/useForm.js b/frontend/src/hooks/useForm.js
new file mode 100644
index 0000000..123f48b
--- /dev/null
+++ b/frontend/src/hooks/useForm.js
@@ -0,0 +1,135 @@
+import { useState, useCallback } from 'react';
+
+/**
+ * useForm - hook для управления формами с валидацией
+ *
+ * @param {Object} initialValues - начальные значения полей
+ * @param {Object} validationSchema - схема валидации {fieldName: validatorFn}
+ * @returns {Object} - {values, errors, touched, isSubmitting, handlers}
+ *
+ * Пример использования:
+ * const { values, errors, handleChange, handleBlur, handleSubmit } = useForm(
+ * { email: '', password: '' },
+ * {
+ * email: (value) => !value ? 'Email обязателен' : null,
+ * password: (value) => value.length < 6 ? 'Минимум 6 символов' : null
+ * }
+ * );
+ */
+function useForm(initialValues, validationSchema = {}) {
+ const [values, setValues] = useState(initialValues);
+ const [errors, setErrors] = useState({});
+ const [touched, setTouched] = useState({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Валидация одного поля
+ const validateField = useCallback((name, value) => {
+ if (!validationSchema[name]) return null;
+
+ const validator = validationSchema[name];
+ const error = validator(value, values);
+
+ return error;
+ }, [validationSchema, values]);
+
+ // Обработчик изменения поля
+ const handleChange = useCallback((name, value) => {
+ setValues(prev => ({ ...prev, [name]: value }));
+
+ // Валидация при изменении, если поле уже было touched
+ if (touched[name]) {
+ const error = validateField(name, value);
+ setErrors(prev => ({ ...prev, [name]: error }));
+ }
+ }, [touched, validateField]);
+
+ // Обработчик blur (потеря фокуса)
+ const handleBlur = useCallback((name) => {
+ setTouched(prev => ({ ...prev, [name]: true }));
+
+ const value = values[name];
+ const error = validateField(name, value);
+ setErrors(prev => ({ ...prev, [name]: error }));
+ }, [values, validateField]);
+
+ // Валидация всех полей
+ const validate = useCallback(() => {
+ const newErrors = {};
+ let isValid = true;
+
+ Object.keys(validationSchema).forEach(name => {
+ const error = validateField(name, values[name]);
+ if (error) {
+ newErrors[name] = error;
+ isValid = false;
+ }
+ });
+
+ setErrors(newErrors);
+ return isValid;
+ }, [validationSchema, values, validateField]);
+
+ // Обработчик submit
+ const handleSubmit = useCallback(async (onSubmit) => {
+ setIsSubmitting(true);
+
+ // Отмечаем все поля как touched
+ const allTouched = Object.keys(values).reduce((acc, key) => {
+ acc[key] = true;
+ return acc;
+ }, {});
+ setTouched(allTouched);
+
+ const isValid = validate();
+
+ if (isValid) {
+ try {
+ await onSubmit(values);
+ } catch (error) {
+ console.error('Form submission error:', error);
+ throw error;
+ } finally {
+ setIsSubmitting(false);
+ }
+ } else {
+ setIsSubmitting(false);
+ }
+ }, [values, validate]);
+
+ // Сброс формы
+ const reset = useCallback(() => {
+ setValues(initialValues);
+ setErrors({});
+ setTouched({});
+ setIsSubmitting(false);
+ }, [initialValues]);
+
+ // Установка значения поля программно
+ const setValue = useCallback((name, value) => {
+ setValues(prev => ({ ...prev, [name]: value }));
+ }, []);
+
+ // Установка ошибки поля программно
+ const setError = useCallback((name, error) => {
+ setErrors(prev => ({ ...prev, [name]: error }));
+ }, []);
+
+ return {
+ values,
+ errors,
+ touched,
+ isSubmitting,
+ handleChange,
+ handleBlur,
+ handleSubmit,
+ validate,
+ reset,
+ setValue,
+ setError,
+ setValues,
+ setErrors
+ };
+}
+
+export default useForm;
+