Ручные правки

This commit is contained in:
2025-07-20 06:35:39 +03:00
parent 30a5526de2
commit 339bb52840
22 changed files with 1616 additions and 373 deletions

View File

@@ -1,10 +1,10 @@
import { FC } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { TemplateProvider } from "../contexts/TemplateContext";
import { ElementsCreation } from "../pages/ElementsCreation";
import { ProtocolCreation } from "../pages/ProtocolCreation";
import { TemplateSetup } from "../pages/TemplateSetup";
import { Layout } from "./Layout";
import { FC } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { TemplateProvider } from '../contexts/TemplateContext'
import { ElementsCreation } from '../pages/ElementsCreation'
import { ProtocolCreation } from '../pages/ProtocolCreation'
import { TemplateSetup } from '../pages/TemplateSetup'
import { Layout } from './Layout'
const App: FC = () => {
return (
@@ -12,13 +12,23 @@ const App: FC = () => {
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/templates" replace />} />
<Route path="templates" element={<TemplateSetup />} />
<Route path="elements" element={<ElementsCreation />} />
<Route path="create-protocol" element={<ProtocolCreation />} />
<Route path="templates/" element={<TemplateSetup />} />
<Route
path="templates/:templateId/edit"
element={<TemplateSetup />}
/>
<Route
path="templates/:templateId/elements"
element={<ElementsCreation />}
/>
<Route
path="templates/:templateId/protocols"
element={<ProtocolCreation />}
/>
</Route>
</Routes>
</TemplateProvider>
);
};
)
}
export default App;
export default App

View File

@@ -1,65 +1,15 @@
import { FileText, Settings, Wrench } from "lucide-react";
import { FC } from "react";
import { Link, Outlet, useLocation } from "react-router-dom";
import { FC } from 'react'
import { Outlet } from 'react-router-dom'
const Layout: FC = () => {
const location = useLocation();
return (
<div className="h-screen flex flex-col bg-gray-50">
{/* Верхняя навигация */}
<nav className="bg-white border-b border-gray-200 px-6 py-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<h1 className="text-xl font-bold text-gray-900">Protoc</h1>
<div className="flex items-center gap-4">
<Link
to="/templates"
className={`flex items-center gap-2 px-3 py-1 rounded-md text-sm font-medium transition-colors ${
location.pathname === '/templates'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Settings className="h-4 w-4" />
Настройка шаблонов
</Link>
<Link
to="/elements"
className={`flex items-center gap-2 px-3 py-1 rounded-md text-sm font-medium transition-colors ${
location.pathname === '/elements'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Wrench className="h-4 w-4" />
Создание элементов
</Link>
<Link
to="/create-protocol"
className={`flex items-center gap-2 px-3 py-1 rounded-md text-sm font-medium transition-colors ${
location.pathname === '/create-protocol'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<FileText className="h-4 w-4" />
Создание протокола
</Link>
</div>
</div>
</div>
</nav>
<div className="flex h-screen flex-col bg-gray-50">
{/* Основной контент */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
};
)
}
export default Layout;
export default Layout

View File

@@ -16,7 +16,7 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
required: false,
targetCells: [],
},
Editor: ({ config, onChange }) => (
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>

View File

@@ -17,7 +17,7 @@ export const numberDefinition: ElementDefinition<NumberConfig, number> = {
required: false,
targetCells: [],
},
Editor: ({ config, onChange }) => (
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>

View File

@@ -17,7 +17,7 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
required: false,
targetCells: [],
},
Editor: ({ config, onChange }) => (
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>

View File

@@ -17,7 +17,7 @@ export const textareaDefinition: ElementDefinition<TextareaConfig, string> = {
required: false,
targetCells: [],
},
Editor: ({ config, onChange }) => (
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют
</div>

View File

@@ -10,6 +10,7 @@ import {
useState,
} from 'react'
import { VariableSizeGrid } from 'react-window'
import { useFormulaAutoSave } from '../../hooks/useFormulaAutoSave'
import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine'
import { MergedCell } from '../../types/template'
@@ -80,6 +81,7 @@ const findMergedCellInfo = (
interface DualSpreadsheetProps {
templateData?: Record<string, Record<string, any>>
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
templateId?: string // Добавляем ID шаблона для сохранения
}
// Кэшированные данные ячеек для оптимизации
@@ -147,9 +149,19 @@ const Cell = memo(
}
const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${
isModified ? 'bg-yellow-50' : ''
isModified ? 'bg-yellow-400 !important' : ''
}`
// Отладка стилей для измененных ячеек
if (isModified) {
console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, {
className,
isModified,
isSelected,
displayValue,
})
}
// Для объединенных ячеек нужно рассчитать правильные размеры
let cellStyle: CSSProperties = {
...style,
@@ -185,7 +197,7 @@ const Cell = memo(
backgroundColor: isSelected
? 'hsl(var(--accent))'
: isModified
? '#fefce8'
? '#fef3c7'
: 'white',
}
}
@@ -276,6 +288,12 @@ interface FormulaBarProps {
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
formulaBarRef: (el: HTMLInputElement | null) => void
// Обновляем props для сохранения
isSaving?: boolean
hasUnsavedChanges?: boolean
lastSaveError?: string | null
onManualSave?: () => void
onClearError?: () => void
}
const FormulaBar = memo(
@@ -289,9 +307,12 @@ const FormulaBar = memo(
onBlur,
onKeyDown,
formulaBarRef,
isSaving = false,
hasUnsavedChanges = false,
lastSaveError = null,
onManualSave,
onClearError,
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
// Теперь FormulaBar всегда отображается, так как он один
return (
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex items-center space-x-2">
@@ -341,7 +362,7 @@ const FormulaBar = memo(
}}
onKeyDown={onKeyDown}
/>
{isCalculating && (
{(isCalculating || isSaving) && (
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5 animate-spin"
@@ -362,8 +383,95 @@ const FormulaBar = memo(
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="ml-1 text-xs">
{isSaving ? 'Сохранение...' : 'Расчет...'}
</span>
</div>
)}
{/* Ошибка сохранения */}
{lastSaveError && (
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
<svg
className="h-3 w-3 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span
className="text-xs text-destructive"
title={lastSaveError}
>
Ошибка сохранения
</span>
{onClearError && (
<button
onClick={onClearError}
className="ml-1 text-destructive/60 hover:text-destructive"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
{/* Кнопка ручного сохранения */}
{onManualSave && (
<button
onClick={onManualSave}
disabled={isSaving}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
hasUnsavedChanges
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: lastSaveError
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-muted text-muted-foreground'
} disabled:opacity-50`}
title={
lastSaveError
? 'Повторить сохранение'
: hasUnsavedChanges
? 'Есть несохраненные изменения'
: 'Все изменения сохранены'
}
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{lastSaveError
? 'Повторить'
: hasUnsavedChanges
? 'Сохранить'
: 'Сохранено'}
</button>
)}
</div>
</div>
</div>
@@ -414,6 +522,19 @@ const CellRenderer = ({
? '' // Не показывать вычисленное значение во время редактирования
: cellData?.displayValue || ''
// Отладка для измененных ячеек
if (cellData?.isModified) {
console.log(
`🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
{
cellKey,
isModified: cellData.isModified,
displayValue,
rawValue,
},
)
}
// Получаем информацию об объединенных ячейках только для листа отчета
const mergedCellInfo =
sheetType === 'report' && mergedCells
@@ -565,29 +686,28 @@ const Spreadsheet = ({
>
<div
ref={(el) => (containerRefs.current[sheetType] = el)}
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card shadow-sm"
className="flex h-full flex-col overflow-hidden border bg-card shadow-sm"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Header */}
<div className="flex-shrink-0 border-b bg-muted/50 px-3 py-1">
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${
sheetType === 'report' ? 'bg-primary' : 'bg-emerald-500'
}`}
></div>
<h3 className="text-xs font-medium text-muted-foreground">
{sheetType === 'report' ? 'Отчет' : 'Расчеты'}
</h3>
</div>
</div>
{/* Spreadsheet Body */}
<div className="flex flex-1">
{/* Row Headers */}
<div className="flex-shrink-0 bg-muted">
<div className="flex h-7 w-10 items-center justify-center border-b border-r border-border" />
<div className="relative flex h-7 w-10 items-center justify-center border-b border-r border-border">
{/* Компактное обозначение листа */}
<div className="absolute inset-0 flex items-end justify-end p-0.5">
<span
className={`flex h-3 w-3 items-center justify-center rounded-sm text-[9px] font-medium text-white ${
sheetType === 'report'
? 'bg-primary/70'
: 'bg-emerald-500/70'
}`}
>
{SHEET_NAMES[sheetType]}
</span>
</div>
</div>
<div
ref={(el) => (rowHeaderRefs.current[sheetType] = el)}
className="overflow-y-hidden"
@@ -675,6 +795,7 @@ const Spreadsheet = ({
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
templateData = {},
mergedCells = [],
templateId,
}) => {
const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine(
{
@@ -693,6 +814,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
} | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования
const [isInitialized, setIsInitialized] = useState(false)
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
() => ({
@@ -729,6 +851,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
useEffect(() => {
const reportTemplate = templateData?.[SHEET_NAMES.report]
// Минимальная отладка templateData
if (!templateData || Object.keys(templateData).length === 0) {
console.log('⚠️ templateData пустой')
}
if (reportTemplate) {
// Очищаем старые значения
initialTemplateValues.current = {}
@@ -736,6 +863,13 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
Object.entries(reportTemplate).forEach(([cellName, value]) => {
initialTemplateValues.current[cellName] = String(value)
})
console.log(
'✅ Загружено значений шаблона:',
Object.keys(initialTemplateValues.current).length,
)
} else {
console.log('⚠️ DualSpreadsheet: Данные шаблона отчета не найдены')
}
}, [templateData])
@@ -793,6 +927,19 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const cellName = getColumnLabel(col) + (row + 1)
const templateValue = initialTemplateValues.current[cellName] ?? ''
// Отладка только для измененных ячеек
if (
sheetType === 'report' &&
templateValue &&
rawValue !== templateValue
) {
console.log(`🔧 Измененная ячейка ${cellName}:`, {
templateValue,
rawValue,
isModified: true,
})
}
// Нормализуем значения для сравнения
const normalizeValue = (value: any): string => {
if (value === null || value === undefined) return ''
@@ -810,6 +957,15 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
normalizedTemplate !== '' &&
normalizedRaw !== normalizedTemplate
// Отладка только для измененных ячеек
if (sheetType === 'report' && isModified) {
console.log(`🔍 Измененная ячейка ${cellName}:`, {
templateValue,
rawValue,
isModified,
})
}
cachedData[cellKey] = {
displayValue,
rawValue,
@@ -888,6 +1044,24 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
(save: boolean) => {
if (save && selectedCell) {
const sheetName = SHEET_NAMES[activeSheet]
const cellName =
getColumnLabel(selectedCell.col) + (selectedCell.row + 1)
const oldValue = multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col,
)
const templateValue = initialTemplateValues.current[cellName] ?? ''
console.log('💾 Сохранение ячейки:', {
cellName,
oldValue,
newValue: editingValue,
templateValue,
isChanged: oldValue !== editingValue,
isModifiedFromTemplate: templateValue !== editingValue,
})
multiSheetEngine.setCellValueWithoutRecalc(
sheetName,
selectedCell.row,
@@ -1093,6 +1267,85 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
R: 0.5,
})
// Используем новый хук автосохранения
const {
isSaving,
hasUnsavedChanges,
lastSaveError,
manualSave,
clearError,
loadSavedFormulas,
} = useFormulaAutoSave(
multiSheetEngine.getAllModifiedCells,
multiSheetEngine.isCalculating,
revision,
{
templateId,
autoSaveDelay: 2000,
enableAutoSave: true,
},
)
// Загружаем сохраненные формулы при инициализации
useEffect(() => {
if (!templateId || isInitialized) return
// Проверяем, есть ли начальные данные
const hasInitialData =
Object.keys(templateData).length > 0 &&
Object.values(templateData).some((sheet) => Object.keys(sheet).length > 0)
// Всегда загружаем сохраненные данные, если они есть
const savedFormulas = loadSavedFormulas()
if (savedFormulas && Object.keys(savedFormulas).length > 0) {
console.log('🔄 Загружаем сохраненные формулы в движок:', savedFormulas)
if (hasInitialData) {
// Если есть начальные данные, СЛИВАЕМ их с сохраненными изменениями
console.log('📋 Сливаем данные шаблона с сохраненными изменениями')
// Создаем объединенные данные: шаблон + сохраненные изменения
const mergedData: Record<string, Record<string, any>> = {}
// Копируем данные шаблона
Object.entries(templateData).forEach(([sheetName, sheetData]) => {
mergedData[sheetName] = { ...sheetData }
})
// Применяем сохраненные изменения поверх шаблона
Object.entries(savedFormulas).forEach(([sheetName, sheetData]) => {
if (!mergedData[sheetName]) {
mergedData[sheetName] = {}
}
Object.entries(sheetData).forEach(([cellAddress, value]) => {
mergedData[sheetName][cellAddress] = value
})
})
console.log('📊 Загружаем объединенные данные (шаблон + изменения)')
multiSheetEngine.loadData(mergedData)
} else {
// Если нет начальных данных, используем только сохраненные
multiSheetEngine.loadData(savedFormulas)
}
console.log('✅ Сохраненные формулы загружены в движок')
} else if (hasInitialData) {
console.log('📋 Используем только начальные данные шаблона')
} else {
console.log('⚠️ Нет данных для загрузки')
}
setIsInitialized(true)
}, [
templateId,
templateData,
loadSavedFormulas,
multiSheetEngine,
isInitialized,
])
// --- Spreadsheet Component ---
useEffect(() => {
@@ -1150,8 +1403,13 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
formulaBarRef={(el) => {
formulaBarRef.current = el
}}
isSaving={isSaving}
hasUnsavedChanges={hasUnsavedChanges}
lastSaveError={lastSaveError}
onManualSave={manualSave}
onClearError={clearError}
/>
<div className="flex min-w-0 flex-1 gap-1 overflow-hidden p-1">
<div className="flex min-w-0 flex-1 gap-1 overflow-hidden">
<Spreadsheet
sheetType="report"
columnWidths={columnWidths.report}

View File

@@ -1,22 +1,33 @@
import { ArrowLeft, Save, Upload, Wrench } from 'lucide-react'
import {
AlignCenter,
AlignLeft,
AlignRight,
ArrowLeft,
Bold,
FileText,
Grid,
Italic,
Palette,
Type,
Underline,
Upload,
Wrench,
} from 'lucide-react'
import React, { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import * as XLSX from 'xlsx'
import { Template } from '../../types/template'
import DualSpreadsheet from '../DualSpreadsheet/DualSpreadsheet'
import { Button } from '../ui/button'
import { Separator } from '../ui/separator'
interface TemplateEditorProps {
template: Template
onSave: (template: Template) => void
onCancel: () => void
onBack: () => void
}
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
template,
onSave,
onCancel,
onBack,
}) => {
const [editedTemplate, setEditedTemplate] = useState<Template>(template)
@@ -24,13 +35,17 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
template.excelData || {},
)
const [isLoading, setIsLoading] = useState(false)
const [dataVersion, setDataVersion] = useState(0) // Для принудительного обновления
const [dataVersion, setDataVersion] = useState(0) // Для принудительного
const fileInputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (!file) {
console.log('No file selected')
return
}
setIsLoading(true)
const reader = new FileReader()
@@ -134,12 +149,8 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
reader.readAsArrayBuffer(file)
}
const handleSave = () => {
onSave(editedTemplate)
}
const handleCreateElements = () => {
navigate('/elements')
navigate(`/templates/${editedTemplate.id}/elements`)
}
return (
@@ -166,44 +177,74 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
className="flex items-center gap-2"
>
<Wrench className="h-3.5 w-3.5" />
Создать элементы
</Button>
<Button variant="outline" size="sm" onClick={onCancel}>
Отмена
Элементы интерфейса
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSave}
onClick={() =>
navigate(`/templates/${editedTemplate.id}/protocols`)
}
className="flex items-center gap-2"
>
<Save className="h-3.5 w-3.5" />
Сохранить
<FileText className="h-3.5 w-3.5" />
Создать протокол
</Button>
</div>
</div>
</div>
{/* Панель загрузки Excel */}
{/* Компактная панель загрузки Excel и инструментов */}
<div className="flex-shrink-0 border-b bg-muted/30 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-muted-foreground">
Excel шаблон:
</span>
{editedTemplate.excelFile ? (
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-2 py-1">
<Upload className="h-3 w-3 text-emerald-600" />
<span className="text-xs text-emerald-700">
{editedTemplate.excelFile.name}
</span>
</div>
) : (
<div className="text-xs text-muted-foreground">
Файл не загружен
</div>
)}
{/* Левая часть - панель инструментов стилизации */}
<div className="flex items-center gap-0.5 rounded-lg border bg-background p-0.5">
{/* Группа текстового форматирования */}
<div className="flex items-center">
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Bold className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Italic className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Underline className="h-3 w-3" />
</Button>
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
{/* Группа выравнивания */}
<div className="flex items-center">
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<AlignLeft className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<AlignCenter className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<AlignRight className="h-3 w-3" />
</Button>
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
{/* Группа цвета и стилей */}
<div className="flex items-center">
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Palette className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Type className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Grid className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex items-center gap-3">
{/* Правая часть - загрузка Excel */}
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
@@ -211,27 +252,47 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
onChange={handleFileUpload}
className="hidden"
/>
{/* Статус файла - всегда отображается */}
<div
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 ${
editedTemplate.excelFile
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<FileText
className={`h-3 w-3 ${
editedTemplate.excelFile
? 'text-emerald-600'
: 'text-gray-400'
}`}
/>
<span
className={`max-w-32 truncate text-xs font-medium ${
editedTemplate.excelFile
? 'text-emerald-700'
: 'text-gray-500'
}`}
>
{editedTemplate.excelFile
? editedTemplate.excelFile.name
: 'Файл не выбран'}
</span>
</div>
{/* Кнопка загрузки - всегда отображается */}
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="flex h-7 items-center gap-2"
className="h-7 rounded-md px-2"
disabled={isLoading}
>
{isLoading ? (
<>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="text-xs">Загрузка...</span>
</>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<>
<Upload className="h-3 w-3" />
<span className="text-xs">
{editedTemplate.excelFile
? 'Заменить файл'
: 'Загрузить Excel'}
</span>
</>
<Upload className="h-3 w-3" />
)}
</Button>
</div>
@@ -244,6 +305,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
key={dataVersion}
templateData={{ L: excelData }}
mergedCells={editedTemplate.mergedCells}
templateId={`template-${editedTemplate.id}`}
/>
</div>
</div>

View File

@@ -1,27 +1,62 @@
import { FileText, Plus, Settings, Upload } from 'lucide-react';
import React, { useState } from 'react';
import { useTemplateContext } from '../../contexts/TemplateContext';
import { Template } from '../../types/template';
import { Button } from '../ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
import { Input } from '../ui/input';
import { TemplateEditor } from './TemplateEditor';
import { FileText, Plus, Settings, Trash2, Wrench } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTemplateContext } from '../../contexts/TemplateContext'
import { Template } from '../../types/template'
import { Button } from '../ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '../ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog'
import { Input } from '../ui/input'
import { TemplateEditor } from './TemplateEditor'
interface TemplateManagerProps {
onTemplateSelect?: (template: Template) => void;
onTemplateSelect?: (template: Template) => void
templateId?: string
}
export const TemplateManager: React.FC<TemplateManagerProps> = () => {
const { templates, addTemplate, updateTemplate } = useTemplateContext();
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [newTemplateName, setNewTemplateName] = useState('');
const [newTemplateDescription, setNewTemplateDescription] = useState('');
export const TemplateManager: React.FC<TemplateManagerProps> = ({
templateId,
}) => {
const navigate = useNavigate()
const { templates, addTemplate, updateTemplate, deleteTemplate } =
useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null,
)
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isEditMode, setIsEditMode] = useState(false)
const [newTemplateName, setNewTemplateName] = useState('')
const [newTemplateDescription, setNewTemplateDescription] = useState('')
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
null,
)
// Автоматически открываем редактирование шаблона, если передан templateId
useEffect(() => {
if (templateId) {
const template = templates.find((t) => t.id === templateId)
if (template) {
setSelectedTemplate(template)
setIsEditMode(true)
}
}
}, [templateId, templates])
const handleCreateTemplate = () => {
if (!newTemplateName.trim()) return;
if (!newTemplateName.trim()) return
const newTemplate: Template = {
id: Date.now().toString(),
@@ -30,67 +65,54 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
elements: [],
createdAt: new Date(),
updatedAt: new Date(),
};
}
addTemplate(newTemplate);
setSelectedTemplate(newTemplate);
setIsEditMode(true);
setIsCreateDialogOpen(false);
setNewTemplateName('');
setNewTemplateDescription('');
};
addTemplate(newTemplate)
setSelectedTemplate(newTemplate)
setIsEditMode(true)
setIsCreateDialogOpen(false)
setNewTemplateName('')
setNewTemplateDescription('')
}
const handleTemplateUpdate = (updatedTemplate: Template) => {
updateTemplate(updatedTemplate);
setSelectedTemplate(updatedTemplate);
};
updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate)
}
// const handleTemplateDelete = (templateId: string) => {
// deleteTemplate(templateId);
// if (selectedTemplate?.id === templateId) {
// setSelectedTemplate(null);
// setIsEditMode(false);
// }
// };
// const handleTemplateSelect = (template: Template) => {
// setSelectedTemplate(template);
// setIsEditMode(false);
// onTemplateSelect?.(template);
// };
const handleEditTemplate = (template: Template) => {
setSelectedTemplate(template);
setIsEditMode(true);
};
const handleDeleteTemplate = (template: Template) => {
deleteTemplate(template.id)
setTemplateToDelete(null)
}
if (selectedTemplate && isEditMode) {
return (
<TemplateEditor
template={selectedTemplate}
onSave={handleTemplateUpdate}
onCancel={() => setIsEditMode(false)}
onBack={() => {
setSelectedTemplate(null);
setIsEditMode(false);
setSelectedTemplate(null)
setIsEditMode(false)
navigate('/templates')
}}
/>
);
)
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="mx-auto max-w-7xl p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Настройка шаблонов</h1>
<p className="text-gray-600 mt-2">Создание и управление шаблонами протоколов</p>
<h1 className="text-3xl font-bold text-gray-900">Protoc</h1>
<p className="mt-2 text-gray-600">
Создание протоколов с помощью шаблонов
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать шаблон
Создать
</Button>
</DialogTrigger>
@@ -111,7 +133,7 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Описание (опционально)</label>
<label className="text-sm font-medium">Описание</label>
<Input
placeholder="Введите описание шаблона"
value={newTemplateDescription}
@@ -120,13 +142,13 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Отмена
</Button>
<Button
<Button
onClick={handleCreateTemplate}
disabled={!newTemplateName.trim()}
>
@@ -138,74 +160,158 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
</div>
{templates.length === 0 ? (
<div className="text-center py-12">
<FileText className="h-16 w-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет созданных шаблонов</h3>
<p className="text-gray-600 mb-6">Создайте первый шаблон для начала работы</p>
<div className="py-12 text-center">
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Нет созданных шаблонов
</h3>
<p className="mb-6 text-gray-600">
Создайте первый шаблон для начала работы
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
<Plus className="mr-2 h-4 w-4" />
Создать шаблон
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{templates.map((template) => (
<Card key={template.id} className="hover:shadow-lg transition-shadow cursor-pointer">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{template.name}</CardTitle>
{template.description && (
<CardDescription className="mt-1">
{template.description}
</CardDescription>
)}
</div>
<div className="flex gap-1 ml-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{templates.map((template) => {
return (
<Card
key={template.id}
className="overflow-hidden transition-shadow hover:shadow-md"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-1">
<CardTitle className="text-lg leading-tight">
{template.name}
</CardTitle>
{template.description && (
<CardDescription className="text-sm">
{template.description}
</CardDescription>
)}
</div>
<Button
variant="ghost"
size="icon"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleEditTemplate(template);
e.stopPropagation()
setTemplateToDelete(template)
}}
>
<Settings className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4" />
<span>
{template.excelFile ? 'Excel файл загружен' : 'Excel файл не загружен'}
</span>
<div className="flex items-center justify-between pt-2">
<div className="text-xs text-muted-foreground">
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
</div>
<div className="text-xs text-muted-foreground">
Обновлен: {template.updatedAt.toLocaleDateString('ru-RU')}
</div>
</div>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<span>{template.elements.length} элементов интерфейса</span>
</CardHeader>
<CardContent className="pt-0">
<div className="mb-4 flex items-center gap-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
<span>{template.excelFile ? '✓' : '✗'}</span>
</div>
<div className="flex items-center gap-1">
<Wrench className="h-3 w-3" />
<span>{template.elements.length}</span>
</div>
</div>
<div className="text-xs text-gray-500 mt-2">
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
<div className="space-y-2">
<Button
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation()
console.log('Navigating to protocols:', template.id)
navigate(`/templates/${template.id}/protocols`)
}}
disabled={
!template.excelFile || template.elements.length === 0
}
>
<FileText className="mr-2 h-4 w-4" />
{template.excelFile && template.elements.length > 0
? 'Создать протокол'
: 'Требует настройки'}
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation()
console.log(
'Navigating to template edit:',
template.id,
)
navigate(`/templates/${template.id}/edit`)
}}
>
<Settings className="mr-1 h-3 w-3" />
Шаблон
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation()
navigate(`/templates/${template.id}/elements`)
}}
>
<Wrench className="mr-1 h-3 w-3" />
Элементы
</Button>
</div>
</div>
</div>
{/* <div className="flex gap-2 mt-4">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleTemplateSelect(template)}
>
Предпросмотр
</Button>
</div> */}
</CardContent>
</Card>
))}
</CardContent>
</Card>
)
})}
</div>
)}
{/* Диалог подтверждения удаления */}
<Dialog
open={!!templateToDelete}
onOpenChange={() => setTemplateToDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить шаблон</DialogTitle>
<DialogDescription>
Вы уверены, что хотите удалить шаблон "{templateToDelete?.name}"?
Это действие нельзя будет отменить.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setTemplateToDelete(null)}>
Отмена
</Button>
<Button
variant="destructive"
onClick={() =>
templateToDelete && handleDeleteTemplate(templateToDelete)
}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
};
)
}

View File

@@ -1,27 +1,27 @@
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from "@/lib/utils";
import { cn } from '../../lib/utils'
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
);
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
)
}
export { Badge, badgeVariants };
export { Badge, badgeVariants }

View File

@@ -1,29 +1,40 @@
import React, { createContext, ReactNode, useContext, useState } from 'react';
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils';
import { Template, TemplateElement } from '../types/template';
import React, {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils'
import { Template, TemplateElement } from '../types/template'
interface TemplateContextType {
templates: Template[];
addTemplate: (template: Template) => void;
updateTemplate: (template: Template) => void;
deleteTemplate: (templateId: string) => void;
getTemplate: (templateId: string) => Template | undefined;
templates: Template[]
addTemplate: (template: Template) => void
updateTemplate: (template: Template) => void
deleteTemplate: (templateId: string) => void
getTemplate: (templateId: string) => Template | undefined
}
const TemplateContext = createContext<TemplateContextType | undefined>(undefined);
const TemplateContext = createContext<TemplateContextType | undefined>(
undefined,
)
export const useTemplateContext = () => {
const context = useContext(TemplateContext);
const context = useContext(TemplateContext)
if (!context) {
throw new Error('useTemplateContext must be used within a TemplateProvider');
throw new Error('useTemplateContext must be used within a TemplateProvider')
}
return context;
};
return context
}
interface TemplateProviderProps {
children: ReactNode;
children: ReactNode
}
// Ключ для localStorage
const TEMPLATES_STORAGE_KEY = 'protoc_templates'
// Функция создания элемента названия протокола
function createProtocolNameElement(): TemplateElement {
return {
@@ -33,7 +44,9 @@ function createProtocolNameElement(): TemplateElement {
label: 'Название протокола',
placeholder: 'Введите название протокола',
required: true,
targetCells: [{ sheet: 'L', cell: 'A1', displayName: 'Название протокола' }],
targetCells: [
{ sheet: 'L', cell: 'A1', displayName: 'Название протокола' },
],
options: [],
order: 0,
layout: {
@@ -43,7 +56,7 @@ function createProtocolNameElement(): TemplateElement {
height: 80,
zIndex: 1,
},
};
}
}
// Функция миграции шаблона
@@ -52,63 +65,106 @@ function migrateTemplate(template: any): Template {
...template,
elements: template.elements?.map(migrateTemplateElement) || [],
layoutSettings: template.layoutSettings || getDefaultLayoutSettings(),
};
createdAt: template.createdAt ? new Date(template.createdAt) : new Date(),
updatedAt: template.updatedAt ? new Date(template.updatedAt) : new Date(),
}
}
export const TemplateProvider: React.FC<TemplateProviderProps> = ({ children }) => {
const [templates, setTemplates] = useState<Template[]>([]);
// Функция загрузки шаблонов из localStorage
function loadTemplatesFromStorage(): Template[] {
try {
const stored = localStorage.getItem(TEMPLATES_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
return Array.isArray(parsed) ? parsed.map(migrateTemplate) : []
}
} catch (error) {
console.error('Ошибка при загрузке шаблонов из localStorage:', error)
}
return []
}
// Функция сохранения шаблонов в localStorage
function saveTemplatesToStorage(templates: Template[]): void {
try {
localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(templates))
} catch (error) {
console.error('Ошибка при сохранении шаблонов в localStorage:', error)
}
}
export const TemplateProvider: React.FC<TemplateProviderProps> = ({
children,
}) => {
const [templates, setTemplates] = useState<Template[]>(() => {
// Загружаем шаблоны из localStorage при инициализации
return loadTemplatesFromStorage()
})
// Сохраняем шаблоны в localStorage при каждом изменении
useEffect(() => {
saveTemplatesToStorage(templates)
}, [templates])
const addTemplate = (template: Template) => {
// Проверяем есть ли уже элемент названия протокола
const hasProtocolName = template.elements.some(el =>
el.name === 'protocolName' || el.label === 'Название протокола'
);
let templateWithProtocolName = template;
const hasProtocolName = template.elements.some(
(el) => el.name === 'protocolName' || el.label === 'Название протокола',
)
let templateWithProtocolName = template
if (!hasProtocolName) {
// Добавляем элемент названия протокола как первый элемент
const protocolNameElement = createProtocolNameElement();
const protocolNameElement = createProtocolNameElement()
templateWithProtocolName = {
...template,
elements: [protocolNameElement, ...template.elements.map(el => ({ ...el, order: (el.order || 0) + 1 }))]
};
elements: [
protocolNameElement,
...template.elements.map((el) => ({
...el,
order: (el.order || 0) + 1,
})),
],
}
}
const migratedTemplate = migrateTemplate(templateWithProtocolName);
setTemplates(prev => [...prev, migratedTemplate]);
};
const migratedTemplate = migrateTemplate(templateWithProtocolName)
setTemplates((prev) => [...prev, migratedTemplate])
}
const updateTemplate = (updatedTemplate: Template) => {
console.log('updateTemplate called with:', updatedTemplate);
console.log('Elements count:', updatedTemplate.elements.length);
const migratedTemplate = migrateTemplate(updatedTemplate);
console.log('Migrated template:', migratedTemplate);
console.log('Migrated elements count:', migratedTemplate.elements.length);
setTemplates(prev => prev.map(t =>
t.id === migratedTemplate.id ? migratedTemplate : t
));
};
console.log('updateTemplate called with:', updatedTemplate)
console.log('Elements count:', updatedTemplate.elements.length)
const migratedTemplate = migrateTemplate(updatedTemplate)
console.log('Migrated template:', migratedTemplate)
console.log('Migrated elements count:', migratedTemplate.elements.length)
setTemplates((prev) =>
prev.map((t) => (t.id === migratedTemplate.id ? migratedTemplate : t)),
)
}
const deleteTemplate = (templateId: string) => {
setTemplates(prev => prev.filter(t => t.id !== templateId));
};
setTemplates((prev) => prev.filter((t) => t.id !== templateId))
}
const getTemplate = (templateId: string) => {
const template = templates.find(t => t.id === templateId);
return template ? migrateTemplate(template) : undefined;
};
const template = templates.find((t) => t.id === templateId)
return template ? migrateTemplate(template) : undefined
}
return (
<TemplateContext.Provider value={{
templates: templates.map(migrateTemplate), // Применяем миграцию при получении
addTemplate,
updateTemplate,
deleteTemplate,
getTemplate,
}}>
<TemplateContext.Provider
value={{
templates: templates.map(migrateTemplate), // Применяем миграцию при получении
addTemplate,
updateTemplate,
deleteTemplate,
getTemplate,
}}
>
{children}
</TemplateContext.Provider>
);
};
)
}

View File

@@ -0,0 +1,313 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface SavedFormulaData {
templateId?: string
formulaData: Record<string, Record<string, any>>
timestamp: string
version: number
}
export interface UseFormulaAutoSaveOptions {
templateId?: string
autoSaveDelay?: number // задержка автосохранения в мс
enableAutoSave?: boolean
}
// Ключ для localStorage
const getStorageKey = (templateId: string) => {
console.log('🔑 Получен templateId:', templateId)
return `formula_data_${templateId}`
}
/**
* Хук для автосохранения формул электронных таблиц в localStorage
*
* Функции:
* - Автоматическое сохранение с дебаунсом при изменениях
* - Ручное сохранение по требованию
* - Отслеживание состояния сохранения
* - Сохранение в localStorage (заглушка для API)
*/
export const useFormulaAutoSave = (
getAllModifiedCells: () => Record<string, Record<string, any>>,
isCalculating: boolean,
revision: number,
options: UseFormulaAutoSaveOptions = {},
) => {
const { templateId, autoSaveDelay = 2000, enableAutoSave = true } = options
// Состояние
const [lastSavedRevision, setLastSavedRevision] = useState(0)
const [isSaving, setIsSaving] = useState(false)
const [lastSaveError, setLastSaveError] = useState<string | null>(null)
const [savedData, setSavedData] = useState<SavedFormulaData | null>(null)
// Refs
const saveTimeoutRef = useRef<number | null>(null)
const versionCounter = useRef(1)
// Загрузка данных из localStorage при инициализации
useEffect(() => {
if (!templateId) return
try {
const storageKey = getStorageKey(templateId)
const savedDataStr = localStorage.getItem(storageKey)
if (savedDataStr) {
const parsedData: SavedFormulaData = JSON.parse(savedDataStr)
setSavedData(parsedData)
versionCounter.current = parsedData.version + 1
console.log(
'📂 Загружены сохраненные формулы (версия:',
parsedData.version,
')',
)
}
} catch (error) {
console.warn('⚠️ Ошибка при загрузке данных из localStorage:', error)
}
}, [templateId])
// Основная функция сохранения в localStorage
const performSave = useCallback(
async (data: Record<string, Record<string, any>>) => {
if (Object.keys(data).length === 0) {
console.log('📭 Нет данных для сохранения')
return
}
if (!templateId) {
console.warn('⚠️ Не указан templateId для сохранения формул')
return
}
setIsSaving(true)
setLastSaveError(null)
try {
// Имитируем задержку API для реалистичности
await new Promise((resolve) =>
setTimeout(resolve, 200 + Math.random() * 300),
)
// Иногда имитируем ошибку (2% вероятность)
if (Math.random() < 0.02) {
throw new Error('Ошибка записи в хранилище')
}
const savedData: SavedFormulaData = {
templateId,
formulaData: data,
timestamp: new Date().toISOString(),
version: versionCounter.current++,
}
// Сохраняем в localStorage
const storageKey = getStorageKey(templateId)
localStorage.setItem(storageKey, JSON.stringify(savedData))
setSavedData(savedData)
setLastSavedRevision(revision)
console.log(
'💾 Сохранено формул:',
Object.values(data).reduce(
(sum, sheet) => sum + Object.keys(sheet).length,
0,
),
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Неизвестная ошибка'
setLastSaveError(errorMessage)
console.error(
'❌ Ошибка при сохранении формул в localStorage:',
errorMessage,
)
// Не обновляем lastSavedRevision при ошибке, чтобы попытаться снова
} finally {
setIsSaving(false)
}
},
[templateId, revision],
)
// Автосохранение с дебаунсом
const scheduleAutoSave = useCallback(() => {
console.log('⏰ scheduleAutoSave вызван, проверяем условия:', {
enableAutoSave,
isCalculating,
isSaving,
templateId,
})
if (!enableAutoSave || isCalculating || isSaving || !templateId) {
console.log('❌ Автосохранение отменено по условиям')
return
}
// Очищаем предыдущий таймер
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
console.log('🔄 Очищен предыдущий таймер автосохранения')
}
console.log(`⏳ Запланировано автосохранение через ${autoSaveDelay}ms`)
saveTimeoutRef.current = setTimeout(() => {
console.log('🎯 Выполняем отложенное автосохранение')
const currentData = getAllModifiedCells()
console.log('📋 Данные для автосохранения:', currentData)
performSave(currentData)
}, autoSaveDelay)
}, [
enableAutoSave,
isCalculating,
isSaving,
templateId,
autoSaveDelay,
getAllModifiedCells,
performSave,
])
// Эффект для автосохранения при изменениях
useEffect(() => {
console.log('🔍 Проверка условий автосохранения:', {
enableAutoSave,
revision,
lastSavedRevision,
isCalculating,
isSaving,
templateId,
shouldSave:
enableAutoSave &&
revision > lastSavedRevision &&
!isCalculating &&
!isSaving,
})
if (
enableAutoSave &&
revision > lastSavedRevision &&
!isCalculating &&
!isSaving
) {
console.log('✅ Планируем автосохранение...')
scheduleAutoSave()
} else {
console.log('⏸️ Автосохранение пропущено по условиям')
}
}, [
enableAutoSave,
revision,
lastSavedRevision,
isCalculating,
isSaving,
scheduleAutoSave,
])
// Ручное сохранение
const manualSave = useCallback(() => {
console.log('🖱️ Ручное сохранение вызвано')
// Отменяем автосохранение если оно запланировано
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = null
console.log('⏹️ Отменено запланированное автосохранение')
}
const currentData = getAllModifiedCells()
console.log('📋 Данные для сохранения:', currentData)
if (Object.keys(currentData).length === 0) {
console.log('📭 Нет измененных данных для сохранения')
return
}
performSave(currentData)
}, [getAllModifiedCells, performSave])
// Функция загрузки сохраненных формул из localStorage
const loadSavedFormulas = useCallback((): Record<
string,
Record<string, any>
> | null => {
if (!templateId) return null
try {
const storageKey = getStorageKey(templateId)
const savedDataStr = localStorage.getItem(storageKey)
if (savedDataStr) {
const parsedData: SavedFormulaData = JSON.parse(savedDataStr)
console.log('📂 Формулы загружены из localStorage:', {
templateId,
version: parsedData.version,
timestamp: parsedData.timestamp,
})
return parsedData.formulaData
}
return null
} catch (error) {
console.error(
'❌ Ошибка при загрузке сохраненных формул из localStorage:',
error,
)
return null
}
}, [templateId])
// Функция очистки сохраненных данных
const clearSavedFormulas = useCallback(() => {
if (!templateId) return
try {
const storageKey = getStorageKey(templateId)
localStorage.removeItem(storageKey)
setSavedData(null)
setLastSavedRevision(0)
versionCounter.current = 1
console.log('🗑️ Сохраненные формулы очищены:', { templateId })
} catch (error) {
console.error('❌ Ошибка при очистке сохраненных формул:', error)
}
}, [templateId])
// Очистка таймаута при размонтировании
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
// Вычисляемые значения
const isDataChanged = revision > lastSavedRevision
const hasUnsavedChanges = isDataChanged && !isSaving
return {
// Состояние
isSaving,
isDataChanged,
hasUnsavedChanges,
lastSaveError,
lastSavedRevision,
savedData,
// Методы
manualSave,
loadSavedFormulas,
clearSavedFormulas,
// Утилиты
getCurrentData: getAllModifiedCells,
clearError: () => setLastSaveError(null),
}
}

View File

@@ -157,7 +157,7 @@ export class Cell {
'gi',
)
const before = code
code = code.replace(funcRegex, (match, p1, offset) => {
code = code.replace(funcRegex, (match, p1) => {
// p1 - это название функции, нужно сохранить символ перед функцией если он есть
const prefix = match.substring(0, match.indexOf(p1))
return `${prefix}__functions__.${funcName}(`

View File

@@ -1,6 +1,6 @@
import { Plus, Wrench } from 'lucide-react'
import { FC, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FC, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ElementConstructor } from '../../../../components/TemplateManager/ElementConstructor'
import { Button } from '../../../../components/ui/button'
import {
@@ -19,11 +19,25 @@ import {
export const ElementsCreation: FC = () => {
const navigate = useNavigate()
const { templateId } = useParams<{ templateId: string }>()
const { templates, updateTemplate } = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null,
)
// Автоматически выбираем шаблон, если передан templateId
useEffect(() => {
if (templateId) {
const template = templates.find((t) => t.id === templateId)
if (template) {
setSelectedTemplate(template)
} else {
// Если шаблон не найден, перенаправляем на страницу со списком шаблонов
navigate('/templates', { replace: true })
}
}
}, [templateId, templates, navigate])
const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template)
}
@@ -112,10 +126,6 @@ export const ElementsCreation: FC = () => {
setSelectedTemplate(updatedTemplate)
}
const handleBack = () => {
setSelectedTemplate(null)
}
// Настройки отображения по умолчанию
const defaultLayoutSettings: FormLayoutSettings = {
gridSize: 20,
@@ -128,7 +138,7 @@ export const ElementsCreation: FC = () => {
<div className="sticky top-0 z-50 border-b border-gray-200 bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={handleBack}>
<Button variant="ghost" onClick={() => navigate(`/templates`)}>
Назад
</Button>
<div>

View File

@@ -1,3 +0,0 @@
import Home from "./ui/Page/Page";
export { Home };

View File

@@ -1,18 +0,0 @@
import { FC } from "react";
import { DualSpreadsheet } from "../../../../components";
// import { exampleCalculationsData, exampleTemplateData } from "../../../../lib/template-data";
const Home: FC = () => {
const initialData = {
Report: {},
Calculations: {}
};
return (
<div className="h-screen bg-gray-50">
<DualSpreadsheet templateData={initialData} />
</div>
);
};
export default Home;

View File

@@ -8,8 +8,8 @@ import {
Settings,
Wrench,
} from 'lucide-react'
import { FC, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FC, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { StandardsSelector } from '../../../../components/StandardsElement/StandardsSelector'
import { Badge } from '../../../../components/ui/badge'
import { Button } from '../../../../components/ui/button'
@@ -525,24 +525,39 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
export const ProtocolCreation: FC = () => {
const navigate = useNavigate()
const { templateId } = useParams<{ templateId?: string }>()
const { templates } = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null,
)
// Автоматически выбираем шаблон если передан ID в URL
useEffect(() => {
if (templateId && templates.length > 0) {
const template = templates.find((t) => t.id === templateId)
if (template) {
setSelectedTemplate(template)
}
}
}, [templateId, templates])
const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template)
// Обновляем URL с ID выбранного шаблона
navigate(`/create-protocol/${template.id}`)
}
const handleProtocolSave = (data: Record<string, any>) => {
console.log('Сохранение протокола:', data)
// Здесь будет логика сохранения протокола
alert(`Протокол "${data.protocolName}" сохранен!`)
navigate('/')
navigate('/templates')
}
const handleBack = () => {
setSelectedTemplate(null)
// Возвращаемся к списку шаблонов
navigate('/templates')
}
if (selectedTemplate) {

View File

@@ -1,6 +1,20 @@
import { FC } from 'react';
import { TemplateManager } from '../../../../components/TemplateManager/TemplateManager';
import { FC, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { TemplateManager } from '../../../../components/TemplateManager/TemplateManager'
import { useTemplateContext } from '../../../../contexts/TemplateContext'
export const TemplateSetup: FC = () => {
return <TemplateManager />;
};
const { templateId } = useParams<{ templateId: string }>()
const { templates } = useTemplateContext()
useEffect(() => {
if (templateId) {
const template = templates.find((t) => t.id === templateId)
if (template) {
console.log('Opening template for editing:', template)
}
}
}, [templateId, templates])
return <TemplateManager templateId={templateId} />
}

View File

@@ -1,8 +1,6 @@
import { ElementsCreation } from "./ElementsCreation";
import { Home } from "./Home";
import { NoMatch } from "./NoMatch";
import { ProtocolCreation } from "./ProtocolCreation";
import { TemplateSetup } from "./TemplateSetup";
export { ElementsCreation, Home, NoMatch, ProtocolCreation, TemplateSetup };
import { ElementsCreation } from './ElementsCreation'
import { NoMatch } from './NoMatch'
import { ProtocolCreation } from './ProtocolCreation'
import { TemplateSetup } from './TemplateSetup'
export { ElementsCreation, NoMatch, ProtocolCreation, TemplateSetup }

View File

@@ -0,0 +1,381 @@
import {
FormulaApiConfig,
FormulaApiError,
FormulaApiEvent,
FormulaHistoryRequest,
FormulaHistoryResponse,
LoadFormulasRequest,
LoadFormulasResponse,
SaveFormulasRequest,
SaveFormulasResponse,
SyncStatus,
TemplateFormulaData,
} from '../types/formula-api'
/**
* Сервис для работы с API формул электронных таблиц
* Содержит заглушки для будущего API интерфейса
*/
class FormulaApiService {
private config: FormulaApiConfig
private eventListeners: Map<string, Array<(event: FormulaApiEvent) => void>> =
new Map()
private storage: Map<string, TemplateFormulaData> = new Map() // Заглушка локального хранилища
private versionCounter = 1
constructor(config: FormulaApiConfig) {
this.config = config
}
/**
* Сохранение формул на сервер
*/
async saveFormulas(
request: SaveFormulasRequest,
): Promise<SaveFormulasResponse> {
this.emitEvent({ type: 'SAVE_START', templateId: request.templateId })
try {
// Имитация задержки API
await this.delay(300, 700)
// Иногда имитируем ошибку (5% вероятность)
if (Math.random() < 0.05) {
throw new Error('Ошибка соединения с сервером')
}
// Преобразуем данные из движка в наш формат
const templateData: TemplateFormulaData = {
templateId: request.templateId,
version: this.versionCounter++,
sheets: {},
timestamp: request.timestamp,
}
// Конвертируем данные каждого листа
Object.entries(request.formulaData).forEach(([sheetName, cellData]) => {
templateData.sheets[sheetName] = {
sheetName,
cells: cellData,
metadata: {
rowCount: 90,
columnCount: 20,
lastCalculated: new Date().toISOString(),
},
}
})
// Сохраняем в заглушку локального хранилища
this.storage.set(request.templateId, templateData)
const response: SaveFormulasResponse = {
success: true,
templateId: request.templateId,
version: templateData.version,
timestamp: templateData.timestamp,
message: 'Формулы успешно сохранены',
}
// Здесь будет настоящий API вызов:
/*
const response = await fetch(`${this.config.baseUrl}/api/formulas/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config.timeout || 10000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: SaveFormulasResponse = await response.json()
*/
this.emitEvent({
type: 'SAVE_SUCCESS',
templateId: request.templateId,
version: response.version,
})
return response
} catch (error) {
const apiError: FormulaApiError = {
code: 'SAVE_FAILED',
message: error instanceof Error ? error.message : 'Неизвестная ошибка',
timestamp: new Date().toISOString(),
}
this.emitEvent({
type: 'SAVE_ERROR',
templateId: request.templateId,
error: apiError,
})
throw apiError
}
}
/**
* Загрузка сохраненных формул
*/
async loadFormulas(
request: LoadFormulasRequest,
): Promise<LoadFormulasResponse> {
this.emitEvent({ type: 'LOAD_START', templateId: request.templateId })
try {
// Имитация задержки API
await this.delay(200, 500)
// Получаем данные из заглушки
const templateData = this.storage.get(request.templateId)
if (!templateData) {
return {
success: true,
notFound: true,
message: 'Сохраненные формулы не найдены',
}
}
// Здесь будет настоящий API вызов:
/*
const url = new URL(`${this.config.baseUrl}/api/formulas/${request.templateId}`)
if (request.version) {
url.searchParams.set('version', request.version.toString())
}
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
signal: AbortSignal.timeout(this.config.timeout || 10000),
})
if (response.status === 404) {
return {
success: true,
notFound: true,
message: 'Сохраненные формулы не найдены',
}
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: LoadFormulasResponse = await response.json()
*/
const response: LoadFormulasResponse = {
success: true,
data: templateData,
message: 'Формулы успешно загружены',
}
this.emitEvent({
type: 'LOAD_SUCCESS',
templateId: request.templateId,
version: templateData.version,
})
return response
} catch (error) {
const apiError: FormulaApiError = {
code: 'LOAD_FAILED',
message: error instanceof Error ? error.message : 'Ошибка загрузки',
timestamp: new Date().toISOString(),
}
this.emitEvent({
type: 'LOAD_ERROR',
templateId: request.templateId,
error: apiError,
})
throw apiError
}
}
/**
* Получение истории изменений формул
*/
async getFormulaHistory(
request: FormulaHistoryRequest,
): Promise<FormulaHistoryResponse> {
try {
// Имитация задержки API
await this.delay(300, 600)
// Заглушка истории
const response: FormulaHistoryResponse = {
success: true,
templateId: request.templateId,
history: [],
totalCount: 0,
hasMore: false,
}
// Здесь будет настоящий API вызов:
/*
const url = new URL(`${this.config.baseUrl}/api/formulas/${request.templateId}/history`)
if (request.limit) url.searchParams.set('limit', request.limit.toString())
if (request.offset) url.searchParams.set('offset', request.offset.toString())
if (request.dateFrom) url.searchParams.set('dateFrom', request.dateFrom)
if (request.dateTo) url.searchParams.set('dateTo', request.dateTo)
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
signal: AbortSignal.timeout(this.config.timeout || 10000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: FormulaHistoryResponse = await response.json()
*/
return response
} catch (error) {
throw {
code: 'HISTORY_FAILED',
message:
error instanceof Error ? error.message : 'Ошибка получения истории',
timestamp: new Date().toISOString(),
} as FormulaApiError
}
}
/**
* Получение статуса синхронизации
*/
async getSyncStatus(templateId: string): Promise<SyncStatus> {
try {
// Имитация задержки API
await this.delay(100, 300)
const localData = this.storage.get(templateId)
const status: SyncStatus = {
templateId,
isUpToDate: true,
localVersion: localData?.version || 0,
remoteVersion: localData?.version || 0,
conflictsCount: 0,
lastSyncTime: localData?.timestamp,
}
// Здесь будет настоящий API вызов:
/*
const response = await fetch(`${this.config.baseUrl}/api/formulas/${templateId}/sync-status`, {
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
signal: AbortSignal.timeout(this.config.timeout || 5000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const status: SyncStatus = await response.json()
*/
this.emitEvent({ type: 'SYNC_STATUS_CHANGED', status })
return status
} catch (error) {
throw {
code: 'SYNC_STATUS_FAILED',
message:
error instanceof Error ? error.message : 'Ошибка проверки статуса',
timestamp: new Date().toISOString(),
} as FormulaApiError
}
}
/**
* Подписка на события API
*/
addEventListener(
eventType: string,
listener: (event: FormulaApiEvent) => void,
): void {
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, [])
}
this.eventListeners.get(eventType)!.push(listener)
}
/**
* Отписка от событий API
*/
removeEventListener(
eventType: string,
listener: (event: FormulaApiEvent) => void,
): void {
const listeners = this.eventListeners.get(eventType)
if (listeners) {
const index = listeners.indexOf(listener)
if (index > -1) {
listeners.splice(index, 1)
}
}
}
/**
* Испускание события
*/
private emitEvent(event: FormulaApiEvent): void {
const listeners = this.eventListeners.get(event.type) || []
listeners.forEach((listener) => {
try {
listener(event)
} catch (error) {
console.error('Ошибка в обработчике события API:', error)
}
})
}
/**
* Имитация задержки API
*/
private async delay(min: number, max: number): Promise<void> {
const delay = min + Math.random() * (max - min)
await new Promise((resolve) => setTimeout(resolve, delay))
}
/**
* Очистка ресурсов
*/
dispose(): void {
this.eventListeners.clear()
this.storage.clear()
}
/**
* Получение конфигурации (используем config)
*/
getConfig(): FormulaApiConfig {
return this.config
}
}
// Создаем синглтон экземпляр сервиса
const formulaApiService = new FormulaApiService({
baseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001',
apiKey: import.meta.env.VITE_API_KEY,
timeout: 10000,
retryAttempts: 3,
retryDelay: 1000,
})
export default formulaApiService
export { FormulaApiService }

92
src/types/formula-api.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* Типы для API формул электронных таблиц
*/
export interface FormulaApiConfig {
baseUrl: string
apiKey?: string
timeout?: number
}
export interface FormulaApiError {
code: string
message: string
timestamp: string
}
export interface FormulaApiEvent {
type:
| 'SAVE_START'
| 'SAVE_SUCCESS'
| 'SAVE_ERROR'
| 'LOAD_START'
| 'LOAD_SUCCESS'
| 'LOAD_ERROR'
templateId: string
version?: number
error?: FormulaApiError
}
export interface SaveFormulasRequest {
templateId: string
formulaData: Record<string, Record<string, any>>
timestamp: string
}
export interface SaveFormulasResponse {
success: boolean
templateId: string
version: number
timestamp: string
message: string
}
export interface LoadFormulasRequest {
templateId: string
version?: number
}
export interface LoadFormulasResponse {
success: boolean
notFound?: boolean
data?: TemplateFormulaData
message: string
}
export interface FormulaHistoryRequest {
templateId: string
limit?: number
}
export interface FormulaHistoryResponse {
success: boolean
history: Array<{
version: number
timestamp: string
description?: string
}>
}
export interface SyncStatus {
templateId: string
lastSync: string
isOnline: boolean
pendingChanges: number
}
export interface TemplateFormulaData {
templateId: string
version: number
sheets: Record<string, SheetFormulaData>
timestamp: string
}
export interface SheetFormulaData {
sheetName: string
cells: Record<string, any>
metadata: {
rowCount: number
columnCount: number
lastCalculated: string
}
}