все к лучшему

This commit is contained in:
2025-07-22 08:34:05 +03:00
parent 3a1bb42aee
commit abc2ee44bd
9 changed files with 440 additions and 90 deletions

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<TemplateProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/templates" replace />} />
<Route path="templates/" element={<TemplatesOverviewPage />} />
<Route
path="templates/:templateId/edit"
element={<TemplateEditPage />}
/>
<Route
path="templates/:templateId/elements"
element={<ElementsCreation />}
/>
<Route
path="templates/:templateId/protocols"
element={<ProtocolCreation />}
/>
</Route>
</Routes>
</TemplateProvider>
<ToastProvider>
<ToastInitializer />
<TemplateProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/templates" replace />} />
<Route path="templates/" element={<TemplatesOverviewPage />} />
<Route
path="templates/:templateId/edit"
element={<TemplateEditPage />}
/>
<Route
path="templates/:templateId/elements"
element={<ElementsCreation />}
/>
<Route
path="templates/:templateId/protocols"
element={<ProtocolCreation />}
/>
</Route>
</Routes>
</TemplateProvider>
</ToastProvider>
</QueryClientProvider>
)
}

View File

@@ -37,6 +37,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
mergedCells = [],
templateId,
onEngineReady,
enableAutoSave = true,
}) => {
// Мемоизируем конфигурацию листов
const sheetsConfig = useMemo(
@@ -722,9 +723,9 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
() => ({
templateId,
autoSaveDelay: 2000,
enableAutoSave: true,
enableAutoSave,
}),
[templateId]
[templateId, enableAutoSave]
)
// Мемоизируем getAllModifiedCells для предотвращения частых ререндеров хука автосохранения

View File

@@ -38,6 +38,7 @@ export interface DualSpreadsheetProps {
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
templateId?: string // Добавляем ID шаблона для сохранения
onEngineReady?: (engine: any) => void // Коллбэк, который отдаёт родителю методы движка
enableAutoSave?: boolean // Возможность отключить автосохранение
}
export interface CellProps {

176
src/component/ui/toast.tsx Normal file
View File

@@ -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<ToastProps> = ({ 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 (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
)
case 'error':
return (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)
case 'warning':
return (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
)
case 'info':
return (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
)
}
}
return (
<div
className={`
mb-3 transform rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out
${getTypeStyles()}
${
isVisible && !isRemoving
? 'translate-x-0 opacity-100'
: '-translate-x-full opacity-0'
}
`}
>
<div className="flex items-start">
<div className={`flex-shrink-0 ${getIconColor()}`}>{getIcon()}</div>
<div className="ml-3 flex-1">
{toast.title && (
<h3 className="text-sm font-medium">{toast.title}</h3>
)}
<div className={`text-sm ${toast.title ? 'mt-1' : ''}`}>
{toast.message}
</div>
</div>
<div className="ml-4 flex flex-shrink-0">
<button
onClick={handleRemove}
className={`
inline-flex rounded-md p-1.5 transition-colors
${getIconColor()} hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-black/20
`}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}
interface ToastContainerProps {
toasts: Toast[]
onRemove: (id: string) => void
}
export const ToastContainer: FC<ToastContainerProps> = ({
toasts,
onRemove,
}) => {
if (toasts.length === 0) return null
return (
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex w-full max-w-sm flex-col-reverse">
{toasts.map(toast => (
<div key={toast.id} className="pointer-events-auto">
<ToastComponent toast={toast} onRemove={onRemove} />
</div>
))}
</div>
)
}

154
src/lib/hooks/useToast.tsx Normal file
View File

@@ -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<ToastContextType | null>(null)
export const ToastProvider: FC<PropsWithChildren> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([])
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 (
<ToastContext.Provider value={value}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
)
}
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)
}
}
}

View File

@@ -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<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [formData, setFormData] = useState<Record<string, any>>({})
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
const engineRef = useRef<any>(null)
const toast = useToast()
const handleFieldChange = (elementId: string, value: any) => {
setFormData(prev => ({
@@ -443,7 +437,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
try {
if (!engineRef.current) {
alert('Движок ещё не инициализирован')
toast.error('Движок ещё не инициализирован')
return
}
@@ -469,7 +463,9 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ 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<ProtocolFormProps> = ({ template, onSave, onBack }) => {
// Получаем последний файл для шаблона
const latestFile = await getLatestFileForTemplate(template.id)
if (!latestFile) {
alert('Не найден исходный файл Excel для шаблона')
toast.error('Не найден исходный файл Excel для шаблона')
return
}
@@ -574,7 +570,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ 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<ProtocolFormProps> = ({ 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<ProtocolFormProps> = ({ 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 (
<div className="flex h-screen flex-col bg-gray-50">
{/* Заголовок */}
<div className="shrink-0 border-b border-gray-200 bg-white p-4">
<div className="flex-shrink-0 border-b bg-card px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Назад
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold text-gray-800">
Создание протокола: {template.name}
</h1>
<div className="flex items-center space-x-2">
<FileText className="h-5 w-5" />
<h1 className="text-lg font-semibold">Создание протокола</h1>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">
{template.name}
</span>
</div>
</div>
<div className="flex gap-2">
@@ -652,10 +634,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
<Grid className="mr-2 h-4 w-4" />
{showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'}
</Button>
<Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
Экспорт в Excel
</Button>
<Button onClick={handleSave} disabled={!canSave}>
<Save className="mr-2 h-4 w-4" />
Сохранить протокол
@@ -673,6 +651,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
mergedCells={template.mergedCells || []}
templateId={template.id}
onEngineReady={engine => (engineRef.current = engine)}
enableAutoSave={false}
/>
</div>
@@ -745,7 +724,7 @@ export const ProtocolCreation: FC = () => {
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null
)
const toast = useToast()
// Автоматически выбираем шаблон если передан ID в URL
useEffect(() => {
if (templateId && templates.length > 0) {
@@ -757,10 +736,7 @@ export const ProtocolCreation: FC = () => {
}, [templateId, templates])
const handleProtocolSave = (data: Record<string, any>) => {
// console.log('Сохранение протокола:', data)
// Здесь будет логика сохранения протокола
alert(`Протокол "${data.protocolName}" сохранен!`)
navigate('/templates')
toast.success(`Протокол "${data.protocolName}" сохранен!`)
}
const handleBack = () => {

View File

@@ -1,7 +1,10 @@
import { parseCellRange } from '@/component/DualSpreadsheet/utils'
import { MergedCell } from '@/type/template'
export interface ExcelParseResult {
fileId: string // ID загруженного файла
data: Record<string, any>
mergedCells: Array<{ from: string; to: string; value: any }>
mergedCells: MergedCell[]
}
export interface ApiUploadResponse {
@@ -67,11 +70,18 @@ export async function parseExcelFile(
// Преобразуем ответ API в формат, ожидаемый приложением
const data = result.cells.cells
const mergedCells = result.cells.merged.map(merge => ({
from: merge.from,
to: merge.to,
value: data[merge.from] || '',
}))
const mergedCells: MergedCell[] = result.cells.merged.map(merge => {
const range = parseCellRange(`${merge.from}:${merge.to}`)
return {
from: merge.from,
to: merge.to,
value: data[merge.from] ?? '',
startRow: range?.startRow ?? 0,
startCol: range?.startCol ?? 0,
endRow: range?.endRow ?? 0,
endCol: range?.endCol ?? 0,
}
})
return {
fileId: result.file_id, // Добавляем fileId в возвращаемые данные
@@ -159,11 +169,18 @@ export async function getFileData(fileId: string): Promise<ExcelParseResult> {
// Преобразуем данные в формат, ожидаемый приложением
const data = result.file.cells.cells
const mergedCells = result.file.cells.merged.map(merge => ({
from: merge.from,
to: merge.to,
value: data[merge.from] || '',
}))
const mergedCells: MergedCell[] = result.file.cells.merged.map(merge => {
const range = parseCellRange(`${merge.from}:${merge.to}`)
return {
from: merge.from,
to: merge.to,
value: data[merge.from] ?? '',
startRow: range?.startRow ?? 0,
startCol: range?.startCol ?? 0,
endRow: range?.endRow ?? 0,
endCol: range?.endCol ?? 0,
}
})
return {
fileId: result.file.id,

View File

@@ -57,6 +57,11 @@ export interface MergedCell {
from: string // начальная ячейка (например, "A1")
to: string // конечная ячейка (например, "C3")
value: any // значение объединенной ячейки
// Координаты диапазона (0-based), необходимые для рендера
startRow: number
startCol: number
endRow: number
endCol: number
}
// Типы шаблонов

View File

@@ -35,8 +35,8 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
selectionMode && 'hover:cursor-pointer',
selectionMode &&
isSelected &&
'border border-primary ring-2 ring-primary ring-offset-1',
'hover:border-primary hover:shadow-lg'
'border-2 border-primary ring-4 ring-primary/30 ring-offset-2',
'hover:border-2 hover:border-primary hover:shadow-lg'
)}
>
{selectionMode && (
@@ -91,20 +91,20 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
className="flex-1"
className="h-9 flex-1"
disabled={selectionMode || !isConfigured}
onClick={e => {
e.stopPropagation()
navigate(`/templates/${template.id}/protocols`)
}}
>
<FileText className="mr-2 h-3 w-3" />
<FileText className="mr-2 h-4 w-4" />
{isConfigured ? 'Создать протокол' : 'Требует настройки'}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
className="h-9 w-9 p-0"
disabled={selectionMode}
onClick={e => {
e.stopPropagation()
@@ -112,12 +112,12 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
}}
aria-label="Редактировать шаблон"
>
<Settings className="h-3 w-3" />
<Settings className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
className="h-9 w-9 p-0"
disabled={selectionMode}
onClick={e => {
e.stopPropagation()
@@ -125,7 +125,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
}}
aria-label="Настроить элементы"
>
<Wrench className="h-3 w-3" />
<Wrench className="h-4 w-4" />
</Button>
</div>
</CardContent>