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

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 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>
) : (
<div className="text-xs text-muted-foreground">
Файл не загружен
<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 className="flex items-center gap-3">
</div>
{/* Правая часть - загрузка 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>
</>
) : (
<>
<Upload className="h-3 w-3" />
<span className="text-xs">
{editedTemplate.excelFile
? 'Заменить файл'
: 'Загрузить Excel'}
</span>
</>
)}
</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}
@@ -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="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">
<CardTitle className="text-lg">{template.name}</CardTitle>
<div className="flex-1 space-y-1">
<CardTitle className="text-lg leading-tight">
{template.name}
</CardTitle>
{template.description && (
<CardDescription className="mt-1">
<CardDescription className="text-sm">
{template.description}
</CardDescription>
)}
</div>
<div className="flex gap-1 ml-2">
<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 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>
</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>
<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-2">
<Settings className="h-4 w-4" />
<span>{template.elements.length} элементов интерфейса</span>
</div>
<div className="text-xs text-gray-500 mt-2">
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
<div className="flex items-center gap-1">
<Wrench className="h-3 w-3" />
<span>{template.elements.length}</span>
</div>
</div>
{/* <div className="flex gap-2 mt-4">
<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={() => handleTemplateSelect(template)}
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>
</div> */}
<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>
</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 === 'Название протокола'
);
const hasProtocolName = template.elements.some(
(el) => el.name === 'protocolName' || el.label === 'Название протокола',
)
let templateWithProtocolName = template;
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);
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);
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
));
};
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={{
<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
}
}

1
temp
View File

@@ -1 +0,0 @@
Так теперь продолжаем делать нашу систему. У нас должно быть изначально меню, где мы можем создавать шаблоны на создание протоколов. Мы даем в этом меню ему имя. Далее мы можем создавать шаблон, а это продгрузка excel файла, который будет шаблон (заполняться строчки левого меню отчета). И можем проводить рассчеты справа и изменять ячейки отчета. Также ниже от этих двух таблиц должны быть конструктор элементов которые тоже привяжутся к имени этого шаблона, там мы сможем создавать просто элементы который будут видны в интерфейсы, давать им имена и ячейку в которую будут вноситься данные при выборе в этих элементах (это будут например, элемент ввода текста, кнопки, радиокнопки, выпадающий список и тп). Тут важно интерфейс общий сделать чтобы удобно можно было добавлять новый вид элементов. Вот это как бы меню конструктор для создания отчетов, потом когда мы выбираем этот шаблон из списка, мы видим все наши элементы, мы туда вводим инофрмацию, у нас посчитывается ячейки что мы указали изменяемыми в левом отчете, и отправляется информация на бэк что за excel файл отчета, какие ячейки с каким значением туда надо вставить.