feat: Удаление устаревших файлов документации и оптимизация структуры проекта. Упрощение кода и улучшение читаемости за счет удаления ненужных компонентов и отчетов, что способствует более эффективному управлению проектом.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s

This commit is contained in:
2025-10-03 09:30:53 +07:00
parent a4bd1ab1d4
commit 2ae26801c4
18 changed files with 417 additions and 6528 deletions
-2
View File
@@ -14,11 +14,9 @@
"@tabler/icons-react": "^3.34.0",
"axios": "^1.10.0",
"@tanstack/react-query": "^5.56.2",
"html-to-image": "^1.11.11",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1",
"react-sigma": "^1.2.35",
"@xyflow/react": "^12.3.4"
},
"devDependencies": {
+1 -1
View File
@@ -38,7 +38,7 @@ import QuickAddBar from './components/QuickAddBar.jsx';
import AccordionCard from './components/AccordionCard.jsx';
import SavedFilters from './components/SavedFilters.jsx';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import ValidatedInput from './components/ValidatedInput.jsx';
import FormField from './components/FormField.jsx';
import { useToast } from './components/ToastContainer.jsx';
const API_URL = '/api';
+61 -35
View File
@@ -1,41 +1,67 @@
import { useEffect, useRef } from 'react'
import Modal from './Modal';
import { IconAlertTriangle } from '@tabler/icons-react';
/**
* ConfirmDialog - упрощённая версия ConfirmModal для быстрых подтверждений
* Рефакторинг: теперь использует базовый Modal компонент
*/
export default function ConfirmDialog({
open,
title = 'Подтверждение',
message,
confirmText = 'Подтвердить',
cancelText = 'Отмена',
onConfirm,
onCancel,
destructive = false,
size = 'sm',
loading = false
}) {
const handleConfirm = () => {
onConfirm?.();
};
export default function ConfirmDialog({ open, title, message, confirmText = 'Подтвердить', cancelText = 'Отмена', onConfirm, onCancel, destructive = false, size = 'md' }) {
const ref = useRef(null)
useEffect(() => {
if (open && ref.current) {
try { ref.current.querySelector('button[data-primary]')?.focus() } catch {}
}
}, [open])
if (!open) return null
return (
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onCancel?.() }}>
<div className={`modal-dialog ${size === 'sm' ? 'modal-sm' : size === 'lg' ? 'modal-lg' : ''}`} role="document">
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
if (e.key === 'Tab') {
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
if (!focusable || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 id="confirm-title" className="modal-title">{title || 'Подтверждение'}</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onCancel}></button>
</div>
<div className="modal-body">
<p id="confirm-message" className="m-0">{message}</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onCancel}>{cancelText}</button>
<button type="button" className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`} data-primary onClick={onConfirm}>{confirmText}</button>
<Modal
show={open}
onClose={onCancel}
title={title}
size={size}
centered
footer={
<>
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
className={`btn ${destructive ? 'btn-danger' : 'btn-primary'}`}
onClick={handleConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
{confirmText}
</button>
</>
}
>
<div className="d-flex align-items-start">
{destructive && (
<div className="flex-shrink-0 me-3">
<IconAlertTriangle className="text-danger" size={32} />
</div>
)}
<div className="flex-grow-1">
<p className="mb-0">{message}</p>
</div>
</div>
</div>
)
</Modal>
);
}
+60 -36
View File
@@ -1,49 +1,73 @@
function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
if (!show) return null;
import Modal from './Modal';
/**
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики
* Рефакторинг: теперь использует базовый Modal компонент
*/
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
const added = diff?.added?.length || 0;
const removed = diff?.removed?.length || 0;
const changed = diff?.changed?.length || 0;
return (
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="confirm-diff-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div className="modal-content" tabIndex={-1} onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
if (!focusable || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}}>
<div className="modal-header">
<h5 id="confirm-diff-title" className="modal-title">Подтвердить сохранение</h5>
<button type="button" className="btn-close" onClick={onClose}></button>
</div>
<div className="modal-body">
<div className="row g-2 text-center">
<div className="col">
<div className="card"><div className="card-body p-2"><strong>Добавлено</strong><div className="text-muted">{added}</div></div></div>
</div>
<div className="col">
<div className="card"><div className="card-body p-2"><strong>Удалено</strong><div className="text-muted">{removed}</div></div></div>
</div>
<div className="col">
<div className="card"><div className="card-body p-2"><strong>Изменено</strong><div className="text-muted">{changed}</div></div></div>
</div>
<Modal
show={show}
onClose={onClose}
title="Подтвердить сохранение"
size="sm"
centered
footer={
<>
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
Отмена
</button>
<button
type="button"
className="btn btn-primary"
onClick={onConfirm}
disabled={loading}
>
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
Сохранить
</button>
</>
}
>
<div className="row g-2 text-center">
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Добавлено</strong>
<div className="text-muted">{added}</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>Отмена</button>
<button type="button" className="btn btn-primary" onClick={onConfirm}>Сохранить</button>
</div>
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Удалено</strong>
<div className="text-muted">{removed}</div>
</div>
</div>
</div>
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Изменено</strong>
<div className="text-muted">{changed}</div>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
}
export default ConfirmDiffModal;
+4 -2
View File
@@ -1,5 +1,7 @@
import React from 'react'
/**
* EmptyState - компонент для отображения пустого состояния
* Оптимизирован: убран неиспользуемый импорт React
*/
function EmptyState({
icon: Icon,
title = 'Нет данных',
+16 -9
View File
@@ -1,7 +1,12 @@
import { IconAlertTriangle, IconX } from '@tabler/icons-react';
import { IconAlertTriangle } from '@tabler/icons-react';
/**
* ErrorAlert - компонент для отображения ошибок
* Оптимизирован: убрано дублирование кода
*/
function ErrorAlert({ message, details, onClose }) {
if (!message) return null;
return (
<div className="alert alert-danger alert-dismissible" role="alert">
<div className="d-flex">
@@ -10,24 +15,26 @@ function ErrorAlert({ message, details, onClose }) {
</div>
<div className="flex-grow-1">
{String(message)}
{details ? (
{details && (
<details className="small mt-1">
<summary>Показать детали</summary>
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>
{typeof details === 'string' ? details : JSON.stringify(details, null, 2)}
</pre>
</details>
) : null}
)}
</div>
</div>
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть">
<IconX size={16} />
</button>
{onClose && (
<button
type="button"
className="btn-close"
onClick={onClose}
aria-label="Закрыть"
/>
)}
</div>
);
}
export default ErrorAlert;
+112 -23
View File
@@ -1,6 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react';
/**
* FormField - универсальное поле формы с валидацией
* Поддерживает иконки, подсказки, ошибки и success состояния
* Объединяет функциональность FormField и ValidatedInput
* Поддерживает: иконки, подсказки, ошибки, success состояния, debounce валидацию
*/
function FormField({
label,
@@ -9,32 +13,76 @@ function FormField({
value,
onChange,
onBlur,
onValidate, // функция валидации (опционально): (value) => { valid: boolean, message: string }
error,
success,
helpText,
required,
disabled,
placeholder,
autoFocus,
icon: Icon,
className = '',
inputClassName = '',
rows, // для textarea
options, // для select
debounceMs = 300, // debounce для валидации
showValidationIcon = true,
...inputProps
}) {
const inputId = `field-${name}`;
const hasError = !!error;
const hasSuccess = !!success && !error;
const [localValue, setLocalValue] = useState(value || '');
const [validation, setValidation] = useState({ valid: null, message: '' });
const [isDirty, setIsDirty] = useState(false);
const timerRef = useRef(null);
const hasExternalError = !!error;
const hasValidationError = isDirty && validation.valid === false;
const hasError = hasExternalError || hasValidationError;
const hasExternalSuccess = !!success && !hasExternalError;
const hasValidationSuccess = isDirty && validation.valid === true && !hasExternalError;
const hasSuccess = hasExternalSuccess || hasValidationSuccess;
const isTextarea = type === 'textarea';
const isSelect = type === 'select';
// Синхронизация с внешним value
useEffect(() => {
setLocalValue(value || '');
}, [value]);
// Валидация с debounce
useEffect(() => {
if (!isDirty || !onValidate) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
const result = onValidate(localValue);
setValidation(result);
}, debounceMs);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [localValue, isDirty, onValidate, debounceMs]);
const inputClasses = `form-control ${hasError ? 'is-invalid' : ''} ${hasSuccess ? 'is-valid' : ''} ${inputClassName}`;
const handleChange = (e) => {
onChange?.(e.target.value, e);
const newValue = e.target.value;
setLocalValue(newValue);
setIsDirty(true);
onChange?.(newValue, e);
};
const handleBlur = (e) => {
setIsDirty(true);
onBlur?.(e);
};
@@ -45,13 +93,14 @@ function FormField({
id={inputId}
name={name}
className={inputClasses}
value={value}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
rows={rows || 3}
autoFocus={autoFocus}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
@@ -65,11 +114,12 @@ function FormField({
id={inputId}
name={name}
className={inputClasses.replace('form-control', 'form-select')}
value={value}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
required={required}
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
@@ -89,12 +139,13 @@ function FormField({
name={name}
type={type}
className={inputClasses}
value={value}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={hasError}
aria-describedby={error ? `${inputId}-error` : helpText ? `${inputId}-help` : undefined}
{...inputProps}
@@ -102,6 +153,53 @@ function FormField({
);
};
// Рендер с иконкой (слева или справа для валидации)
const renderInputWithIcon = () => {
const hasValidationIcon = showValidationIcon && isDirty && validation.valid !== null;
if (Icon) {
// Иконка слева (переданная через prop)
return (
<div className="input-icon">
<span className="input-icon-addon">
<Icon size={18} />
</span>
{renderInput()}
{hasValidationIcon && (
<span className="input-icon-addon" style={{ right: 0, left: 'auto' }}>
{validation.valid === true ? (
<IconCheck size={20} className="text-success" />
) : (
<IconX size={20} className="text-danger" />
)}
</span>
)}
</div>
);
}
if (hasValidationIcon) {
// Только иконка валидации справа
return (
<div className="input-icon">
{renderInput()}
<span className="input-icon-addon">
{validation.valid === true ? (
<IconCheck size={20} className="text-success" />
) : (
<IconX size={20} className="text-danger" />
)}
</span>
</div>
);
}
return renderInput();
};
const displayError = error || (hasValidationError ? validation.message : null);
const displaySuccess = success || (hasValidationSuccess ? validation.message : null);
return (
<div className={`mb-3 ${className}`}>
{label && (
@@ -111,31 +209,23 @@ function FormField({
</label>
)}
{Icon ? (
<div className="input-icon">
<span className="input-icon-addon">
<Icon size={18} />
</span>
{renderInput()}
</div>
) : (
renderInput()
)}
{renderInputWithIcon()}
{error && (
{displayError && (
<div id={`${inputId}-error`} className="invalid-feedback d-block" role="alert">
{error}
{displayError}
</div>
)}
{hasSuccess && (
{displaySuccess && !displayError && (
<div className="valid-feedback d-block">
{success}
{displaySuccess}
</div>
)}
{helpText && !error && !success && (
{helpText && !displayError && !displaySuccess && (
<div id={`${inputId}-help`} className="form-text">
<IconAlertCircle size={14} className="me-1" />
{helpText}
</div>
)}
@@ -144,4 +234,3 @@ function FormField({
}
export default FormField;
-1
View File
@@ -1 +0,0 @@
export default function LockBanner() { return null }
+37 -24
View File
@@ -1,13 +1,14 @@
import { useMemo } from 'react';
/**
* Универсальный компонент пагинации
* Устраняет дублирование кода пагинации во всех менеджерах
* Оптимизирован: useMemo для вычисления страниц
*/
function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) {
if (totalPages <= 1) return null;
const renderPages = () => {
const pages = [];
const pages = useMemo(() => {
const result = [];
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, currentPage + 2);
@@ -18,25 +19,14 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
start = Math.max(1, totalPages - 4);
}
if (start > 1) pages.push('start-ellipsis');
for (let p = start; p <= end; p++) pages.push(p);
if (end < totalPages) pages.push('end-ellipsis');
if (start > 1) result.push({ type: 'ellipsis', key: 'start-ellipsis' });
for (let p = start; p <= end; p++) {
result.push({ type: 'page', page: p, key: p });
}
if (end < totalPages) result.push({ type: 'ellipsis', key: 'end-ellipsis' });
return pages.map((p) => {
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
return (
<li key={p} className="page-item disabled">
<span className="page-link"></span>
</li>
);
}
return (
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
<button className="page-link" onClick={() => onPageChange(p)}>{p}</button>
</li>
);
});
};
return result;
}, [currentPage, totalPages]);
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
@@ -52,6 +42,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
className="page-link"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
aria-label="Первая страница"
>
Первая
</button>
@@ -61,16 +52,38 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
className="page-link"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Предыдущая страница"
>
Назад
</button>
</li>
{renderPages()}
{pages.map((item) => {
if (item.type === 'ellipsis') {
return (
<li key={item.key} className="page-item disabled">
<span className="page-link"></span>
</li>
);
}
return (
<li key={item.key} className={`page-item${currentPage === item.page ? ' active' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(item.page)}
aria-label={`Страница ${item.page}`}
aria-current={currentPage === item.page ? 'page' : undefined}
>
{item.page}
</button>
</li>
);
})}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button
className="page-link"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Следующая страница"
>
Вперед
</button>
@@ -80,6 +93,7 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
className="page-link"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
aria-label="Последняя страница"
>
Последняя
</button>
@@ -90,4 +104,3 @@ function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChang
}
export default Pagination;
-119
View File
@@ -1,119 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { IconCheck, IconX, IconAlertCircle } from '@tabler/icons-react'
/**
* Компонент input с inline валидацией и подсказками (Tabler UI стили)
*/
function ValidatedInput({
type = 'text',
value,
onChange,
onValidate, // функция валидации: (value) => { valid: boolean, message: string }
placeholder = '',
label = '',
required = false,
disabled = false,
className = '',
helpText = '',
debounceMs = 300,
showSuccessIcon = true,
autoFocus = false,
...rest
}) {
const [localValue, setLocalValue] = useState(value || '')
const [validation, setValidation] = useState({ valid: null, message: '' })
const [isDirty, setIsDirty] = useState(false)
const timerRef = useRef(null)
// Синхронизация с внешним value
useEffect(() => {
setLocalValue(value || '')
}, [value])
// Валидация с debounce
useEffect(() => {
if (!isDirty || !onValidate) return
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
const result = onValidate(localValue)
setValidation(result)
}, debounceMs)
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [localValue, isDirty, onValidate, debounceMs])
const handleChange = (e) => {
const newValue = e.target.value
setLocalValue(newValue)
setIsDirty(true)
onChange?.(newValue)
}
const getInputClass = () => {
if (!isDirty) return ''
if (validation.valid === true) return 'is-valid'
if (validation.valid === false) return 'is-invalid'
return ''
}
return (
<div className={`mb-3 ${className}`}>
{/* Label */}
{label && (
<label className="form-label">
{label}
{required && <span className="text-danger ms-1">*</span>}
</label>
)}
{/* Input с иконкой валидации */}
<div className="input-icon">
<input
type={type}
className={`form-control ${getInputClass()}`}
value={localValue}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
required={required}
autoFocus={autoFocus}
{...rest}
/>
{/* Иконка статуса валидации */}
{isDirty && validation.valid !== null && (
<span className="input-icon-addon">
{validation.valid === true && showSuccessIcon ? (
<IconCheck size={20} className="text-success" />
) : validation.valid === false ? (
<IconX size={20} className="text-danger" />
) : null}
</span>
)}
</div>
{/* Help text или сообщение валидации */}
{isDirty && validation.message ? (
<div className={`form-hint ${validation.valid === false ? 'text-danger' : 'text-success'}`}>
{validation.message}
</div>
) : helpText ? (
<div className="form-hint text-muted">
<IconAlertCircle size={14} className="me-1" />
{helpText}
</div>
) : null}
</div>
)
}
export default ValidatedInput
+126
View File
@@ -0,0 +1,126 @@
import { useState, useCallback, useEffect } from 'react';
/**
* Хук для управления состоянием модального окна
* Упрощает работу с открытием/закрытием модалок
*/
export function useModal(initialState = false) {
const [isOpen, setIsOpen] = useState(initialState);
const open = useCallback(() => {
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
}, []);
const toggle = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
return {
isOpen,
open,
close,
toggle,
setIsOpen
};
}
/**
* Хук для управления ESC и клавиатурной навигацией в модалках
*/
export function useModalKeyboard(isOpen, onClose) {
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose?.();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
}
/**
* Хук для управления классом modal-open на body
*/
export function useModalBodyClass(isOpen) {
useEffect(() => {
if (isOpen) {
document.body.classList.add('modal-open');
} else {
document.body.classList.remove('modal-open');
}
return () => {
document.body.classList.remove('modal-open');
};
}, [isOpen]);
}
/**
* Хук для управления focus trap внутри модалки
*/
export function useFocusTrap(isOpen, containerRef) {
useEffect(() => {
if (!isOpen || !containerRef.current) return;
const container = containerRef.current;
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Фокус на первый элемент при открытии
firstElement?.focus();
const handleTabKey = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
container.addEventListener('keydown', handleTabKey);
return () => container.removeEventListener('keydown', handleTabKey);
}, [isOpen, containerRef]);
}
/**
* Комплексный хук, объединяющий все хуки для модалок
*/
export function useModalManager(initialState = false) {
const modal = useModal(initialState);
return {
...modal,
// Дополнительные утилиты
openWithData: (data) => {
modal.setData?.(data);
modal.open();
},
};
}
export default useModal;