все к лучшему
This commit is contained in:
@@ -1,8 +1,13 @@
|
|||||||
import { FC } from 'react'
|
import { FC, useEffect } from 'react'
|
||||||
|
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
import { TemplateProvider } from '@/entitiy/template/model/TemplateContext'
|
import { TemplateProvider } from '@/entitiy/template/model/TemplateContext'
|
||||||
|
import {
|
||||||
|
setGlobalToastContext,
|
||||||
|
ToastProvider,
|
||||||
|
useToast,
|
||||||
|
} from '@/lib/hooks/useToast'
|
||||||
import { ElementsCreation } from '@/page/ElementsCreation'
|
import { ElementsCreation } from '@/page/ElementsCreation'
|
||||||
import { ProtocolCreation } from '@/page/ProtocolCreation'
|
import { ProtocolCreation } from '@/page/ProtocolCreation'
|
||||||
import { TemplateEditPage } from '@/page/TemplateEditPage'
|
import { TemplateEditPage } from '@/page/TemplateEditPage'
|
||||||
@@ -11,29 +16,44 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { Layout } from './Layout'
|
import { Layout } from './Layout'
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
// Компонент для инициализации глобального контекста toast
|
||||||
|
const ToastInitializer: FC = () => {
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalToastContext(toast)
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const App: FC = () => {
|
const App: FC = () => {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TemplateProvider>
|
<ToastProvider>
|
||||||
<Routes>
|
<ToastInitializer />
|
||||||
<Route path="/" element={<Layout />}>
|
<TemplateProvider>
|
||||||
<Route index element={<Navigate to="/templates" replace />} />
|
<Routes>
|
||||||
<Route path="templates/" element={<TemplatesOverviewPage />} />
|
<Route path="/" element={<Layout />}>
|
||||||
<Route
|
<Route index element={<Navigate to="/templates" replace />} />
|
||||||
path="templates/:templateId/edit"
|
<Route path="templates/" element={<TemplatesOverviewPage />} />
|
||||||
element={<TemplateEditPage />}
|
<Route
|
||||||
/>
|
path="templates/:templateId/edit"
|
||||||
<Route
|
element={<TemplateEditPage />}
|
||||||
path="templates/:templateId/elements"
|
/>
|
||||||
element={<ElementsCreation />}
|
<Route
|
||||||
/>
|
path="templates/:templateId/elements"
|
||||||
<Route
|
element={<ElementsCreation />}
|
||||||
path="templates/:templateId/protocols"
|
/>
|
||||||
element={<ProtocolCreation />}
|
<Route
|
||||||
/>
|
path="templates/:templateId/protocols"
|
||||||
</Route>
|
element={<ProtocolCreation />}
|
||||||
</Routes>
|
/>
|
||||||
</TemplateProvider>
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</TemplateProvider>
|
||||||
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
mergedCells = [],
|
mergedCells = [],
|
||||||
templateId,
|
templateId,
|
||||||
onEngineReady,
|
onEngineReady,
|
||||||
|
enableAutoSave = true,
|
||||||
}) => {
|
}) => {
|
||||||
// Мемоизируем конфигурацию листов
|
// Мемоизируем конфигурацию листов
|
||||||
const sheetsConfig = useMemo(
|
const sheetsConfig = useMemo(
|
||||||
@@ -722,9 +723,9 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
() => ({
|
() => ({
|
||||||
templateId,
|
templateId,
|
||||||
autoSaveDelay: 2000,
|
autoSaveDelay: 2000,
|
||||||
enableAutoSave: true,
|
enableAutoSave,
|
||||||
}),
|
}),
|
||||||
[templateId]
|
[templateId, enableAutoSave]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Мемоизируем getAllModifiedCells для предотвращения частых ререндеров хука автосохранения
|
// Мемоизируем getAllModifiedCells для предотвращения частых ререндеров хука автосохранения
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface DualSpreadsheetProps {
|
|||||||
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
|
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
|
||||||
templateId?: string // Добавляем ID шаблона для сохранения
|
templateId?: string // Добавляем ID шаблона для сохранения
|
||||||
onEngineReady?: (engine: any) => void // Коллбэк, который отдаёт родителю методы движка
|
onEngineReady?: (engine: any) => void // Коллбэк, который отдаёт родителю методы движка
|
||||||
|
enableAutoSave?: boolean // Возможность отключить автосохранение
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CellProps {
|
export interface CellProps {
|
||||||
|
|||||||
176
src/component/ui/toast.tsx
Normal file
176
src/component/ui/toast.tsx
Normal 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
154
src/lib/hooks/useToast.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,17 +17,10 @@ import { Textarea } from '@/component/ui/textarea'
|
|||||||
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
||||||
import { cellAddressToCoordinates } from '@/lib/cell-utils'
|
import { cellAddressToCoordinates } from '@/lib/cell-utils'
|
||||||
import { getElementDefinition } from '@/lib/element-registry'
|
import { getElementDefinition } from '@/lib/element-registry'
|
||||||
|
import { useToast } from '@/lib/hooks/useToast'
|
||||||
import { getLatestFileForTemplate } from '@/service/fileApiService'
|
import { getLatestFileForTemplate } from '@/service/fileApiService'
|
||||||
import { Template, TemplateElement } from '@/type/template'
|
import { Template, TemplateElement } from '@/type/template'
|
||||||
import {
|
import { ArrowLeft, FileText, Grid, Save, Settings, Wrench } from 'lucide-react'
|
||||||
ArrowLeft,
|
|
||||||
Download,
|
|
||||||
FileText,
|
|
||||||
Grid,
|
|
||||||
Save,
|
|
||||||
Settings,
|
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { FC, useEffect, useMemo, useRef, useState } from 'react'
|
import { FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
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 [formData, setFormData] = useState<Record<string, any>>({})
|
||||||
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
||||||
const engineRef = useRef<any>(null)
|
const engineRef = useRef<any>(null)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const handleFieldChange = (elementId: string, value: any) => {
|
const handleFieldChange = (elementId: string, value: any) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -443,7 +437,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!engineRef.current) {
|
if (!engineRef.current) {
|
||||||
alert('Движок ещё не инициализирован')
|
toast.error('Движок ещё не инициализирован')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +463,9 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
cells.forEach((tc, idx) => {
|
cells.forEach((tc, idx) => {
|
||||||
const cellValue = getStandardById(value[idx] as string)?.name || ''
|
const cellValue = getStandardById(value[idx] as string)?.name || ''
|
||||||
const { row, col } = cellAddressToCoordinates(tc.cell)
|
const { row, col } = cellAddressToCoordinates(tc.cell)
|
||||||
if (tc.sheet === 'Calculations') alert('Лист Calculations')
|
if (tc.sheet === 'Calculations') {
|
||||||
|
toast.warning('Обнаружен лист Calculations')
|
||||||
|
}
|
||||||
const sheetName =
|
const sheetName =
|
||||||
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
||||||
engineRef.current.setCellValueWithoutRecalc(
|
engineRef.current.setCellValueWithoutRecalc(
|
||||||
@@ -564,7 +560,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
// Получаем последний файл для шаблона
|
// Получаем последний файл для шаблона
|
||||||
const latestFile = await getLatestFileForTemplate(template.id)
|
const latestFile = await getLatestFileForTemplate(template.id)
|
||||||
if (!latestFile) {
|
if (!latestFile) {
|
||||||
alert('Не найден исходный файл Excel для шаблона')
|
toast.error('Не найден исходный файл Excel для шаблона')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +570,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
template_id: template.id,
|
template_id: template.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('[DBG] POST body', body)
|
console.log('[DBG] POST body', body)
|
||||||
|
|
||||||
const resp = await fetch('/api/protocols/', {
|
const resp = await fetch('/api/protocols/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -583,20 +579,12 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
|
||||||
const result = await resp.json()
|
|
||||||
// console.log('✅ Протокол создан, ID:', result.protocol_id)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при создании протокола', err)
|
console.error('Ошибка при создании протокола', err)
|
||||||
alert('Ошибка при создании протокола')
|
toast.error('Ошибка при создании протокола')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
// console.log('Экспорт в Excel:', { formData })
|
|
||||||
alert('Функция экспорта будет реализована в следующих версиях')
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvasSize = useMemo(() => {
|
const canvasSize = useMemo(() => {
|
||||||
const PADDING_Y = 200
|
const PADDING_Y = 200
|
||||||
const baseWidth = 1200
|
const baseWidth = 1200
|
||||||
@@ -619,29 +607,23 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
}
|
}
|
||||||
}, [template.elements])
|
}, [template.elements])
|
||||||
|
|
||||||
// Проверяем есть ли кнопка сохранения (есть ли название протокола)
|
|
||||||
const protocolNameElement = template.elements.find(
|
|
||||||
el => el.name === 'protocolName' || el.label === 'Название протокола'
|
|
||||||
)
|
|
||||||
// const canSave = protocolNameElement
|
|
||||||
// ? !!formData[protocolNameElement.id]?.trim()
|
|
||||||
// : false
|
|
||||||
const canSave = true
|
const canSave = true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-gray-50">
|
<div className="flex h-screen flex-col bg-gray-50">
|
||||||
{/* Заголовок */}
|
<div className="flex-shrink-0 border-b bg-card px-4 py-2">
|
||||||
<div className="shrink-0 border-b border-gray-200 bg-white p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" onClick={onBack}>
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Назад
|
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div className="flex items-center space-x-2">
|
||||||
<h1 className="text-xl font-semibold text-gray-800">
|
<FileText className="h-5 w-5" />
|
||||||
Создание протокола: {template.name}
|
<h1 className="text-lg font-semibold">Создание протокола</h1>
|
||||||
</h1>
|
<span className="text-sm text-muted-foreground">—</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{template.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -652,10 +634,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
<Grid className="mr-2 h-4 w-4" />
|
<Grid className="mr-2 h-4 w-4" />
|
||||||
{showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'}
|
{showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={handleExport}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Экспорт в Excel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={!canSave}>
|
<Button onClick={handleSave} disabled={!canSave}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Сохранить протокол
|
Сохранить протокол
|
||||||
@@ -673,6 +651,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
mergedCells={template.mergedCells || []}
|
mergedCells={template.mergedCells || []}
|
||||||
templateId={template.id}
|
templateId={template.id}
|
||||||
onEngineReady={engine => (engineRef.current = engine)}
|
onEngineReady={engine => (engineRef.current = engine)}
|
||||||
|
enableAutoSave={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -745,7 +724,7 @@ export const ProtocolCreation: FC = () => {
|
|||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
const toast = useToast()
|
||||||
// Автоматически выбираем шаблон если передан ID в URL
|
// Автоматически выбираем шаблон если передан ID в URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (templateId && templates.length > 0) {
|
if (templateId && templates.length > 0) {
|
||||||
@@ -757,10 +736,7 @@ export const ProtocolCreation: FC = () => {
|
|||||||
}, [templateId, templates])
|
}, [templateId, templates])
|
||||||
|
|
||||||
const handleProtocolSave = (data: Record<string, any>) => {
|
const handleProtocolSave = (data: Record<string, any>) => {
|
||||||
// console.log('Сохранение протокола:', data)
|
toast.success(`Протокол "${data.protocolName}" сохранен!`)
|
||||||
// Здесь будет логика сохранения протокола
|
|
||||||
alert(`Протокол "${data.protocolName}" сохранен!`)
|
|
||||||
navigate('/templates')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { parseCellRange } from '@/component/DualSpreadsheet/utils'
|
||||||
|
import { MergedCell } from '@/type/template'
|
||||||
|
|
||||||
export interface ExcelParseResult {
|
export interface ExcelParseResult {
|
||||||
fileId: string // ID загруженного файла
|
fileId: string // ID загруженного файла
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
mergedCells: Array<{ from: string; to: string; value: any }>
|
mergedCells: MergedCell[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiUploadResponse {
|
export interface ApiUploadResponse {
|
||||||
@@ -67,11 +70,18 @@ export async function parseExcelFile(
|
|||||||
|
|
||||||
// Преобразуем ответ API в формат, ожидаемый приложением
|
// Преобразуем ответ API в формат, ожидаемый приложением
|
||||||
const data = result.cells.cells
|
const data = result.cells.cells
|
||||||
const mergedCells = result.cells.merged.map(merge => ({
|
const mergedCells: MergedCell[] = result.cells.merged.map(merge => {
|
||||||
from: merge.from,
|
const range = parseCellRange(`${merge.from}:${merge.to}`)
|
||||||
to: merge.to,
|
return {
|
||||||
value: data[merge.from] || '',
|
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 {
|
return {
|
||||||
fileId: result.file_id, // Добавляем fileId в возвращаемые данные
|
fileId: result.file_id, // Добавляем fileId в возвращаемые данные
|
||||||
@@ -159,11 +169,18 @@ export async function getFileData(fileId: string): Promise<ExcelParseResult> {
|
|||||||
|
|
||||||
// Преобразуем данные в формат, ожидаемый приложением
|
// Преобразуем данные в формат, ожидаемый приложением
|
||||||
const data = result.file.cells.cells
|
const data = result.file.cells.cells
|
||||||
const mergedCells = result.file.cells.merged.map(merge => ({
|
const mergedCells: MergedCell[] = result.file.cells.merged.map(merge => {
|
||||||
from: merge.from,
|
const range = parseCellRange(`${merge.from}:${merge.to}`)
|
||||||
to: merge.to,
|
return {
|
||||||
value: data[merge.from] || '',
|
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 {
|
return {
|
||||||
fileId: result.file.id,
|
fileId: result.file.id,
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export interface MergedCell {
|
|||||||
from: string // начальная ячейка (например, "A1")
|
from: string // начальная ячейка (например, "A1")
|
||||||
to: string // конечная ячейка (например, "C3")
|
to: string // конечная ячейка (например, "C3")
|
||||||
value: any // значение объединенной ячейки
|
value: any // значение объединенной ячейки
|
||||||
|
// Координаты диапазона (0-based), необходимые для рендера
|
||||||
|
startRow: number
|
||||||
|
startCol: number
|
||||||
|
endRow: number
|
||||||
|
endCol: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Типы шаблонов
|
// Типы шаблонов
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
selectionMode && 'hover:cursor-pointer',
|
selectionMode && 'hover:cursor-pointer',
|
||||||
selectionMode &&
|
selectionMode &&
|
||||||
isSelected &&
|
isSelected &&
|
||||||
'border border-primary ring-2 ring-primary ring-offset-1',
|
'border-2 border-primary ring-4 ring-primary/30 ring-offset-2',
|
||||||
'hover:border-primary hover:shadow-lg'
|
'hover:border-2 hover:border-primary hover:shadow-lg'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
@@ -91,20 +91,20 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
className="h-9 flex-1"
|
||||||
disabled={selectionMode || !isConfigured}
|
disabled={selectionMode || !isConfigured}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
navigate(`/templates/${template.id}/protocols`)
|
navigate(`/templates/${template.id}/protocols`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileText className="mr-2 h-3 w-3" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
{isConfigured ? 'Создать протокол' : 'Требует настройки'}
|
{isConfigured ? 'Создать протокол' : 'Требует настройки'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0"
|
className="h-9 w-9 p-0"
|
||||||
disabled={selectionMode}
|
disabled={selectionMode}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -112,12 +112,12 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
}}
|
}}
|
||||||
aria-label="Редактировать шаблон"
|
aria-label="Редактировать шаблон"
|
||||||
>
|
>
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0"
|
className="h-9 w-9 p-0"
|
||||||
disabled={selectionMode}
|
disabled={selectionMode}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -125,7 +125,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
}}
|
}}
|
||||||
aria-label="Настроить элементы"
|
aria-label="Настроить элементы"
|
||||||
>
|
>
|
||||||
<Wrench className="h-3 w-3" />
|
<Wrench className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user