From abc2ee44bd65d0b6454eef50072a21ba43a10f1e Mon Sep 17 00:00:00 2001 From: tlartem Date: Tue, 22 Jul 2025 08:34:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D1=81=D0=B5=20=D0=BA=20=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 62 +++--- .../DualSpreadsheet/DualSpreadsheet.tsx | 5 +- src/component/DualSpreadsheet/types.ts | 1 + src/component/ui/toast.tsx | 176 ++++++++++++++++++ src/lib/hooks/useToast.tsx | 154 +++++++++++++++ src/page/ProtocolCreation/Page.tsx | 72 +++---- src/service/fileApiService.ts | 39 ++-- src/type/template.ts | 5 + src/widget/template/ui/TemplateCard.tsx | 16 +- 9 files changed, 440 insertions(+), 90 deletions(-) create mode 100644 src/component/ui/toast.tsx create mode 100644 src/lib/hooks/useToast.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 7ae64f3..a2c15cf 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,8 +1,13 @@ -import { FC } from 'react' +import { FC, useEffect } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { TemplateProvider } from '@/entitiy/template/model/TemplateContext' +import { + setGlobalToastContext, + ToastProvider, + useToast, +} from '@/lib/hooks/useToast' import { ElementsCreation } from '@/page/ElementsCreation' import { ProtocolCreation } from '@/page/ProtocolCreation' import { TemplateEditPage } from '@/page/TemplateEditPage' @@ -11,29 +16,44 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Layout } from './Layout' const queryClient = new QueryClient() + +// Компонент для инициализации глобального контекста toast +const ToastInitializer: FC = () => { + const toast = useToast() + + useEffect(() => { + setGlobalToastContext(toast) + }, [toast]) + + return null +} + const App: FC = () => { return ( - - - }> - } /> - } /> - } - /> - } - /> - } - /> - - - + + + + + }> + } /> + } /> + } + /> + } + /> + } + /> + + + + ) } diff --git a/src/component/DualSpreadsheet/DualSpreadsheet.tsx b/src/component/DualSpreadsheet/DualSpreadsheet.tsx index 1a7ffcf..05bbe19 100644 --- a/src/component/DualSpreadsheet/DualSpreadsheet.tsx +++ b/src/component/DualSpreadsheet/DualSpreadsheet.tsx @@ -37,6 +37,7 @@ const DualSpreadsheet: FC = ({ mergedCells = [], templateId, onEngineReady, + enableAutoSave = true, }) => { // Мемоизируем конфигурацию листов const sheetsConfig = useMemo( @@ -722,9 +723,9 @@ const DualSpreadsheet: FC = ({ () => ({ templateId, autoSaveDelay: 2000, - enableAutoSave: true, + enableAutoSave, }), - [templateId] + [templateId, enableAutoSave] ) // Мемоизируем getAllModifiedCells для предотвращения частых ререндеров хука автосохранения diff --git a/src/component/DualSpreadsheet/types.ts b/src/component/DualSpreadsheet/types.ts index cb85b46..e25bc2f 100644 --- a/src/component/DualSpreadsheet/types.ts +++ b/src/component/DualSpreadsheet/types.ts @@ -38,6 +38,7 @@ export interface DualSpreadsheetProps { mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек templateId?: string // Добавляем ID шаблона для сохранения onEngineReady?: (engine: any) => void // Коллбэк, который отдаёт родителю методы движка + enableAutoSave?: boolean // Возможность отключить автосохранение } export interface CellProps { diff --git a/src/component/ui/toast.tsx b/src/component/ui/toast.tsx new file mode 100644 index 0000000..5a62fe6 --- /dev/null +++ b/src/component/ui/toast.tsx @@ -0,0 +1,176 @@ +import { X } from 'lucide-react' +import { FC, useEffect, useState } from 'react' + +export type ToastType = 'success' | 'error' | 'warning' | 'info' + +export interface Toast { + id: string + type: ToastType + title?: string + message: string + duration?: number +} + +interface ToastProps { + toast: Toast + onRemove: (id: string) => void +} + +const ToastComponent: FC = ({ toast, onRemove }) => { + const [isVisible, setIsVisible] = useState(false) + const [isRemoving, setIsRemoving] = useState(false) + + useEffect(() => { + // Показываем уведомление с анимацией + setIsVisible(true) + + // Автоматическое скрытие + const duration = toast.duration || 5000 + const timer = setTimeout(() => { + handleRemove() + }, duration) + + return () => clearTimeout(timer) + }, []) + + const handleRemove = () => { + setIsRemoving(true) + setTimeout(() => { + onRemove(toast.id) + }, 300) // Время анимации + } + + const getTypeStyles = () => { + switch (toast.type) { + case 'success': + return 'bg-green-50 border-green-200 text-green-800' + case 'error': + return 'bg-red-50 border-red-200 text-red-800' + case 'warning': + return 'bg-yellow-50 border-yellow-200 text-yellow-800' + case 'info': + return 'bg-blue-50 border-blue-200 text-blue-800' + default: + return 'bg-gray-50 border-gray-200 text-gray-800' + } + } + + const getIconColor = () => { + switch (toast.type) { + case 'success': + return 'text-green-600' + case 'error': + return 'text-red-600' + case 'warning': + return 'text-yellow-600' + case 'info': + return 'text-blue-600' + default: + return 'text-gray-600' + } + } + + const getIcon = () => { + switch (toast.type) { + case 'success': + return ( + + + + ) + case 'error': + return ( + + + + ) + case 'warning': + return ( + + + + ) + case 'info': + return ( + + + + ) + } + } + + return ( +
+
+
{getIcon()}
+
+ {toast.title && ( +

{toast.title}

+ )} +
+ {toast.message} +
+
+
+ +
+
+
+ ) +} + +interface ToastContainerProps { + toasts: Toast[] + onRemove: (id: string) => void +} + +export const ToastContainer: FC = ({ + toasts, + onRemove, +}) => { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map(toast => ( +
+ +
+ ))} +
+ ) +} diff --git a/src/lib/hooks/useToast.tsx b/src/lib/hooks/useToast.tsx new file mode 100644 index 0000000..9504ec6 --- /dev/null +++ b/src/lib/hooks/useToast.tsx @@ -0,0 +1,154 @@ +import { Toast, ToastContainer, ToastType } from '@/component/ui/toast' +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, + useState, +} from 'react' + +interface ToastContextType { + showToast: ( + message: string, + type?: ToastType, + options?: { title?: string; duration?: number } + ) => void + removeToast: (id: string) => void + // Методы для быстрого доступа к разным типам уведомлений + success: ( + message: string, + options?: { title?: string; duration?: number } + ) => void + error: ( + message: string, + options?: { title?: string; duration?: number } + ) => void + warning: ( + message: string, + options?: { title?: string; duration?: number } + ) => void + info: ( + message: string, + options?: { title?: string; duration?: number } + ) => void +} + +const ToastContext = createContext(null) + +export const ToastProvider: FC = ({ children }) => { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback( + ( + message: string, + type: ToastType = 'info', + options: { title?: string; duration?: number } = {} + ) => { + const id = Math.random().toString(36).substr(2, 9) + const newToast: Toast = { + id, + message, + type, + title: options.title, + duration: options.duration, + } + + setToasts(prev => [...prev, newToast]) + }, + [] + ) + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(toast => toast.id !== id)) + }, []) + + // Методы для быстрого доступа + const success = useCallback( + (message: string, options?: { title?: string; duration?: number }) => { + showToast(message, 'success', options) + }, + [showToast] + ) + + const error = useCallback( + (message: string, options?: { title?: string; duration?: number }) => { + showToast(message, 'error', options) + }, + [showToast] + ) + + const warning = useCallback( + (message: string, options?: { title?: string; duration?: number }) => { + showToast(message, 'warning', options) + }, + [showToast] + ) + + const info = useCallback( + (message: string, options?: { title?: string; duration?: number }) => { + showToast(message, 'info', options) + }, + [showToast] + ) + + const value: ToastContextType = { + showToast, + removeToast, + success, + error, + warning, + info, + } + + return ( + + {children} + + + ) +} + +export const useToast = (): ToastContextType => { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + return context +} + +// Глобальное переопределение alert +let globalToastContext: ToastContextType | null = null + +export const setGlobalToastContext = (context: ToastContextType) => { + globalToastContext = context +} + +// Переопределяем глобальный alert +if (typeof window !== 'undefined') { + const originalAlert = window.alert + window.alert = (message: string) => { + if (globalToastContext) { + // Определяем тип уведомления по содержанию сообщения + const lowerMessage = message.toLowerCase() + if (lowerMessage.includes('ошибка') || lowerMessage.includes('error')) { + globalToastContext.error(message) + } else if ( + lowerMessage.includes('сохранен') || + lowerMessage.includes('успешно') + ) { + globalToastContext.success(message) + } else if ( + lowerMessage.includes('предупреждение') || + lowerMessage.includes('warning') + ) { + globalToastContext.warning(message) + } else { + globalToastContext.info(message) + } + } else { + // Fallback на обычный alert, если контекст недоступен + originalAlert(message) + } + } +} diff --git a/src/page/ProtocolCreation/Page.tsx b/src/page/ProtocolCreation/Page.tsx index 62b37ef..0d436fd 100644 --- a/src/page/ProtocolCreation/Page.tsx +++ b/src/page/ProtocolCreation/Page.tsx @@ -17,17 +17,10 @@ import { Textarea } from '@/component/ui/textarea' import { useTemplateContext } from '@/entitiy/template/model/TemplateContext' import { cellAddressToCoordinates } from '@/lib/cell-utils' import { getElementDefinition } from '@/lib/element-registry' +import { useToast } from '@/lib/hooks/useToast' import { getLatestFileForTemplate } from '@/service/fileApiService' import { Template, TemplateElement } from '@/type/template' -import { - ArrowLeft, - Download, - FileText, - Grid, - Save, - Settings, - Wrench, -} from 'lucide-react' +import { ArrowLeft, FileText, Grid, Save, Settings, Wrench } from 'lucide-react' import { FC, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' @@ -371,6 +364,7 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { const [formData, setFormData] = useState>({}) const [showSpreadsheet, setShowSpreadsheet] = useState(false) const engineRef = useRef(null) + const toast = useToast() const handleFieldChange = (elementId: string, value: any) => { setFormData(prev => ({ @@ -443,7 +437,7 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { try { if (!engineRef.current) { - alert('Движок ещё не инициализирован') + toast.error('Движок ещё не инициализирован') return } @@ -469,7 +463,9 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { cells.forEach((tc, idx) => { const cellValue = getStandardById(value[idx] as string)?.name || '' const { row, col } = cellAddressToCoordinates(tc.cell) - if (tc.sheet === 'Calculations') alert('Лист Calculations') + if (tc.sheet === 'Calculations') { + toast.warning('Обнаружен лист Calculations') + } const sheetName = tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L' engineRef.current.setCellValueWithoutRecalc( @@ -564,7 +560,7 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { // Получаем последний файл для шаблона const latestFile = await getLatestFileForTemplate(template.id) if (!latestFile) { - alert('Не найден исходный файл Excel для шаблона') + toast.error('Не найден исходный файл Excel для шаблона') return } @@ -574,7 +570,7 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { template_id: template.id, } - // console.log('[DBG] POST body', body) + console.log('[DBG] POST body', body) const resp = await fetch('/api/protocols/', { method: 'POST', @@ -583,20 +579,12 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { }) if (!resp.ok) throw new Error(`HTTP ${resp.status}`) - - const result = await resp.json() - // console.log('✅ Протокол создан, ID:', result.protocol_id) } catch (err) { console.error('Ошибка при создании протокола', err) - alert('Ошибка при создании протокола') + toast.error('Ошибка при создании протокола') } } - const handleExport = () => { - // console.log('Экспорт в Excel:', { formData }) - alert('Функция экспорта будет реализована в следующих версиях') - } - const canvasSize = useMemo(() => { const PADDING_Y = 200 const baseWidth = 1200 @@ -619,29 +607,23 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { } }, [template.elements]) - // Проверяем есть ли кнопка сохранения (есть ли название протокола) - const protocolNameElement = template.elements.find( - el => el.name === 'protocolName' || el.label === 'Название протокола' - ) - // const canSave = protocolNameElement - // ? !!formData[protocolNameElement.id]?.trim() - // : false const canSave = true return (
- {/* Заголовок */} -
+
-
- -
-

- Создание протокола: {template.name} -

+
+ +

Создание протокола

+ + + {template.name} +
@@ -652,10 +634,6 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { {showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'} -
@@ -745,7 +724,7 @@ export const ProtocolCreation: FC = () => { const [selectedTemplate, setSelectedTemplate] = useState