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

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 { FC } from 'react'
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from 'react-router-dom'
import { TemplateProvider } from "../contexts/TemplateContext"; import { TemplateProvider } from '../contexts/TemplateContext'
import { ElementsCreation } from "../pages/ElementsCreation"; import { ElementsCreation } from '../pages/ElementsCreation'
import { ProtocolCreation } from "../pages/ProtocolCreation"; import { ProtocolCreation } from '../pages/ProtocolCreation'
import { TemplateSetup } from "../pages/TemplateSetup"; import { TemplateSetup } from '../pages/TemplateSetup'
import { Layout } from "./Layout"; import { Layout } from './Layout'
const App: FC = () => { const App: FC = () => {
return ( return (
@@ -12,13 +12,23 @@ const App: FC = () => {
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/templates" replace />} /> <Route index element={<Navigate to="/templates" replace />} />
<Route path="templates" element={<TemplateSetup />} /> <Route path="templates/" element={<TemplateSetup />} />
<Route path="elements" element={<ElementsCreation />} /> <Route
<Route path="create-protocol" element={<ProtocolCreation />} /> path="templates/:templateId/edit"
element={<TemplateSetup />}
/>
<Route
path="templates/:templateId/elements"
element={<ElementsCreation />}
/>
<Route
path="templates/:templateId/protocols"
element={<ProtocolCreation />}
/>
</Route> </Route>
</Routes> </Routes>
</TemplateProvider> </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 { FC } from "react"; import { Outlet } from 'react-router-dom'
import { Link, Outlet, useLocation } from "react-router-dom";
const Layout: FC = () => { const Layout: FC = () => {
const location = useLocation();
return ( return (
<div className="h-screen flex flex-col bg-gray-50"> <div className="flex h-screen 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>
{/* Основной контент */} {/* Основной контент */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
); )
}; }
export default Layout; export default Layout

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
useState, useState,
} from 'react' } from 'react'
import { VariableSizeGrid } from 'react-window' import { VariableSizeGrid } from 'react-window'
import { useFormulaAutoSave } from '../../hooks/useFormulaAutoSave'
import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine' import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine'
import { MergedCell } from '../../types/template' import { MergedCell } from '../../types/template'
@@ -80,6 +81,7 @@ const findMergedCellInfo = (
interface DualSpreadsheetProps { interface DualSpreadsheetProps {
templateData?: Record<string, Record<string, any>> templateData?: Record<string, Record<string, any>>
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
templateId?: string // Добавляем ID шаблона для сохранения
} }
// Кэшированные данные ячеек для оптимизации // Кэшированные данные ячеек для оптимизации
@@ -147,9 +149,19 @@ const Cell = memo(
} }
const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${ 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 = { let cellStyle: CSSProperties = {
...style, ...style,
@@ -185,7 +197,7 @@ const Cell = memo(
backgroundColor: isSelected backgroundColor: isSelected
? 'hsl(var(--accent))' ? 'hsl(var(--accent))'
: isModified : isModified
? '#fefce8' ? '#fef3c7'
: 'white', : 'white',
} }
} }
@@ -276,6 +288,12 @@ interface FormulaBarProps {
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void onBlur: (e: React.FocusEvent<HTMLInputElement>) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
formulaBarRef: (el: HTMLInputElement | null) => void formulaBarRef: (el: HTMLInputElement | null) => void
// Обновляем props для сохранения
isSaving?: boolean
hasUnsavedChanges?: boolean
lastSaveError?: string | null
onManualSave?: () => void
onClearError?: () => void
} }
const FormulaBar = memo( const FormulaBar = memo(
@@ -289,9 +307,12 @@ const FormulaBar = memo(
onBlur, onBlur,
onKeyDown, onKeyDown,
formulaBarRef, formulaBarRef,
isSaving = false,
hasUnsavedChanges = false,
lastSaveError = null,
onManualSave,
onClearError,
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => { }: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
// Теперь FormulaBar всегда отображается, так как он один
return ( return (
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1"> <div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -341,7 +362,7 @@ const FormulaBar = memo(
}} }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{isCalculating && ( {(isCalculating || isSaving) && (
<div className="flex items-center text-muted-foreground"> <div className="flex items-center text-muted-foreground">
<svg <svg
className="h-3.5 w-3.5 animate-spin" 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" 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> ></path>
</svg> </svg>
<span className="ml-1 text-xs">
{isSaving ? 'Сохранение...' : 'Расчет...'}
</span>
</div> </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> </div>
</div> </div>
@@ -414,6 +522,19 @@ const CellRenderer = ({
? '' // Не показывать вычисленное значение во время редактирования ? '' // Не показывать вычисленное значение во время редактирования
: cellData?.displayValue || '' : cellData?.displayValue || ''
// Отладка для измененных ячеек
if (cellData?.isModified) {
console.log(
`🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
{
cellKey,
isModified: cellData.isModified,
displayValue,
rawValue,
},
)
}
// Получаем информацию об объединенных ячейках только для листа отчета // Получаем информацию об объединенных ячейках только для листа отчета
const mergedCellInfo = const mergedCellInfo =
sheetType === 'report' && mergedCells sheetType === 'report' && mergedCells
@@ -565,29 +686,28 @@ const Spreadsheet = ({
> >
<div <div
ref={(el) => (containerRefs.current[sheetType] = el)} 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} onKeyDown={handleKeyDown}
tabIndex={0} 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 */} {/* Spreadsheet Body */}
<div className="flex flex-1"> <div className="flex flex-1">
{/* Row Headers */} {/* Row Headers */}
<div className="flex-shrink-0 bg-muted"> <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 <div
ref={(el) => (rowHeaderRefs.current[sheetType] = el)} ref={(el) => (rowHeaderRefs.current[sheetType] = el)}
className="overflow-y-hidden" className="overflow-y-hidden"
@@ -675,6 +795,7 @@ const Spreadsheet = ({
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
templateData = {}, templateData = {},
mergedCells = [], mergedCells = [],
templateId,
}) => { }) => {
const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine( const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine(
{ {
@@ -693,6 +814,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
} | null>(null) } | null>(null)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования
const [isInitialized, setIsInitialized] = useState(false)
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>( const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
() => ({ () => ({
@@ -729,6 +851,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
useEffect(() => { useEffect(() => {
const reportTemplate = templateData?.[SHEET_NAMES.report] const reportTemplate = templateData?.[SHEET_NAMES.report]
// Минимальная отладка templateData
if (!templateData || Object.keys(templateData).length === 0) {
console.log('⚠️ templateData пустой')
}
if (reportTemplate) { if (reportTemplate) {
// Очищаем старые значения // Очищаем старые значения
initialTemplateValues.current = {} initialTemplateValues.current = {}
@@ -736,6 +863,13 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
Object.entries(reportTemplate).forEach(([cellName, value]) => { Object.entries(reportTemplate).forEach(([cellName, value]) => {
initialTemplateValues.current[cellName] = String(value) initialTemplateValues.current[cellName] = String(value)
}) })
console.log(
'✅ Загружено значений шаблона:',
Object.keys(initialTemplateValues.current).length,
)
} else {
console.log('⚠️ DualSpreadsheet: Данные шаблона отчета не найдены')
} }
}, [templateData]) }, [templateData])
@@ -793,6 +927,19 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const cellName = getColumnLabel(col) + (row + 1) const cellName = getColumnLabel(col) + (row + 1)
const templateValue = initialTemplateValues.current[cellName] ?? '' const templateValue = initialTemplateValues.current[cellName] ?? ''
// Отладка только для измененных ячеек
if (
sheetType === 'report' &&
templateValue &&
rawValue !== templateValue
) {
console.log(`🔧 Измененная ячейка ${cellName}:`, {
templateValue,
rawValue,
isModified: true,
})
}
// Нормализуем значения для сравнения // Нормализуем значения для сравнения
const normalizeValue = (value: any): string => { const normalizeValue = (value: any): string => {
if (value === null || value === undefined) return '' if (value === null || value === undefined) return ''
@@ -810,6 +957,15 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
normalizedTemplate !== '' && normalizedTemplate !== '' &&
normalizedRaw !== normalizedTemplate normalizedRaw !== normalizedTemplate
// Отладка только для измененных ячеек
if (sheetType === 'report' && isModified) {
console.log(`🔍 Измененная ячейка ${cellName}:`, {
templateValue,
rawValue,
isModified,
})
}
cachedData[cellKey] = { cachedData[cellKey] = {
displayValue, displayValue,
rawValue, rawValue,
@@ -888,6 +1044,24 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
(save: boolean) => { (save: boolean) => {
if (save && selectedCell) { if (save && selectedCell) {
const sheetName = SHEET_NAMES[activeSheet] 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( multiSheetEngine.setCellValueWithoutRecalc(
sheetName, sheetName,
selectedCell.row, selectedCell.row,
@@ -1093,6 +1267,85 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
R: 0.5, 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 --- // --- Spreadsheet Component ---
useEffect(() => { useEffect(() => {
@@ -1150,8 +1403,13 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
formulaBarRef={(el) => { formulaBarRef={(el) => {
formulaBarRef.current = 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 <Spreadsheet
sheetType="report" sheetType="report"
columnWidths={columnWidths.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 React, { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import { Template } from '../../types/template' import { Template } from '../../types/template'
import DualSpreadsheet from '../DualSpreadsheet/DualSpreadsheet' import DualSpreadsheet from '../DualSpreadsheet/DualSpreadsheet'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Separator } from '../ui/separator'
interface TemplateEditorProps { interface TemplateEditorProps {
template: Template template: Template
onSave: (template: Template) => void
onCancel: () => void
onBack: () => void onBack: () => void
} }
export const TemplateEditor: React.FC<TemplateEditorProps> = ({ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
template, template,
onSave,
onCancel,
onBack, onBack,
}) => { }) => {
const [editedTemplate, setEditedTemplate] = useState<Template>(template) const [editedTemplate, setEditedTemplate] = useState<Template>(template)
@@ -24,13 +35,17 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
template.excelData || {}, template.excelData || {},
) )
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [dataVersion, setDataVersion] = useState(0) // Для принудительного обновления const [dataVersion, setDataVersion] = useState(0) // Для принудительного
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate() const navigate = useNavigate()
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] const file = event.target.files?.[0]
if (!file) return
if (!file) {
console.log('No file selected')
return
}
setIsLoading(true) setIsLoading(true)
const reader = new FileReader() const reader = new FileReader()
@@ -134,12 +149,8 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
reader.readAsArrayBuffer(file) reader.readAsArrayBuffer(file)
} }
const handleSave = () => {
onSave(editedTemplate)
}
const handleCreateElements = () => { const handleCreateElements = () => {
navigate('/elements') navigate(`/templates/${editedTemplate.id}/elements`)
} }
return ( return (
@@ -166,44 +177,74 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Wrench className="h-3.5 w-3.5" /> <Wrench className="h-3.5 w-3.5" />
Создать элементы Элементы интерфейса
</Button>
<Button variant="outline" size="sm" onClick={onCancel}>
Отмена
</Button> </Button>
<Button <Button
variant="outline"
size="sm" size="sm"
onClick={handleSave} onClick={() =>
navigate(`/templates/${editedTemplate.id}/protocols`)
}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Save className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
Сохранить Создать протокол
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{/* Панель загрузки Excel */} {/* Компактная панель загрузки Excel и инструментов */}
<div className="flex-shrink-0 border-b bg-muted/30 px-4 py-2"> <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 justify-between">
<div className="flex items-center gap-3"> {/* Левая часть - панель инструментов стилизации */}
<span className="text-xs font-medium text-muted-foreground"> <div className="flex items-center gap-0.5 rounded-lg border bg-background p-0.5">
Excel шаблон: {/* Группа текстового форматирования */}
</span> <div className="flex items-center">
{editedTemplate.excelFile ? ( <Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-2 py-1"> <Bold className="h-3 w-3" />
<Upload className="h-3 w-3 text-emerald-600" /> </Button>
<span className="text-xs text-emerald-700"> <Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
{editedTemplate.excelFile.name} <Italic className="h-3 w-3" />
</span> </Button>
</div> <Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
) : ( <Underline className="h-3 w-3" />
<div className="text-xs text-muted-foreground"> </Button>
Файл не загружен </div>
</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>
<div className="flex items-center gap-3">
{/* Правая часть - загрузка Excel */}
<div className="flex items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -211,27 +252,47 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
onChange={handleFileUpload} onChange={handleFileUpload}
className="hidden" 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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="flex h-7 items-center gap-2" className="h-7 rounded-md px-2"
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
<> <div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
<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" />
<Upload className="h-3 w-3" />
<span className="text-xs">
{editedTemplate.excelFile
? 'Заменить файл'
: 'Загрузить Excel'}
</span>
</>
)} )}
</Button> </Button>
</div> </div>
@@ -244,6 +305,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
key={dataVersion} key={dataVersion}
templateData={{ L: excelData }} templateData={{ L: excelData }}
mergedCells={editedTemplate.mergedCells} mergedCells={editedTemplate.mergedCells}
templateId={`template-${editedTemplate.id}`}
/> />
</div> </div>
</div> </div>

View File

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

View File

@@ -1,27 +1,27 @@
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from 'class-variance-authority'
import * as React from "react"; import * as React from 'react'
import { cn } from "@/lib/utils"; import { cn } from '../../lib/utils'
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: "text-foreground", outline: 'text-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} },
); )
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <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 React, {
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils'; createContext,
import { Template, TemplateElement } from '../types/template'; ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils'
import { Template, TemplateElement } from '../types/template'
interface TemplateContextType { interface TemplateContextType {
templates: Template[]; templates: Template[]
addTemplate: (template: Template) => void; addTemplate: (template: Template) => void
updateTemplate: (template: Template) => void; updateTemplate: (template: Template) => void
deleteTemplate: (templateId: string) => void; deleteTemplate: (templateId: string) => void
getTemplate: (templateId: string) => Template | undefined; getTemplate: (templateId: string) => Template | undefined
} }
const TemplateContext = createContext<TemplateContextType | undefined>(undefined); const TemplateContext = createContext<TemplateContextType | undefined>(
undefined,
)
export const useTemplateContext = () => { export const useTemplateContext = () => {
const context = useContext(TemplateContext); const context = useContext(TemplateContext)
if (!context) { 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 { interface TemplateProviderProps {
children: ReactNode; children: ReactNode
} }
// Ключ для localStorage
const TEMPLATES_STORAGE_KEY = 'protoc_templates'
// Функция создания элемента названия протокола // Функция создания элемента названия протокола
function createProtocolNameElement(): TemplateElement { function createProtocolNameElement(): TemplateElement {
return { return {
@@ -33,7 +44,9 @@ function createProtocolNameElement(): TemplateElement {
label: 'Название протокола', label: 'Название протокола',
placeholder: 'Введите название протокола', placeholder: 'Введите название протокола',
required: true, required: true,
targetCells: [{ sheet: 'L', cell: 'A1', displayName: 'Название протокола' }], targetCells: [
{ sheet: 'L', cell: 'A1', displayName: 'Название протокола' },
],
options: [], options: [],
order: 0, order: 0,
layout: { layout: {
@@ -43,7 +56,7 @@ function createProtocolNameElement(): TemplateElement {
height: 80, height: 80,
zIndex: 1, zIndex: 1,
}, },
}; }
} }
// Функция миграции шаблона // Функция миграции шаблона
@@ -52,63 +65,106 @@ function migrateTemplate(template: any): Template {
...template, ...template,
elements: template.elements?.map(migrateTemplateElement) || [], elements: template.elements?.map(migrateTemplateElement) || [],
layoutSettings: template.layoutSettings || getDefaultLayoutSettings(), 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 }) => { // Функция загрузки шаблонов из localStorage
const [templates, setTemplates] = useState<Template[]>([]); 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 addTemplate = (template: Template) => {
// Проверяем есть ли уже элемент названия протокола // Проверяем есть ли уже элемент названия протокола
const hasProtocolName = template.elements.some(el => const hasProtocolName = template.elements.some(
el.name === 'protocolName' || el.label === 'Название протокола' (el) => el.name === 'protocolName' || el.label === 'Название протокола',
); )
let templateWithProtocolName = template; let templateWithProtocolName = template
if (!hasProtocolName) { if (!hasProtocolName) {
// Добавляем элемент названия протокола как первый элемент // Добавляем элемент названия протокола как первый элемент
const protocolNameElement = createProtocolNameElement(); const protocolNameElement = createProtocolNameElement()
templateWithProtocolName = { templateWithProtocolName = {
...template, ...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); const migratedTemplate = migrateTemplate(templateWithProtocolName)
setTemplates(prev => [...prev, migratedTemplate]); setTemplates((prev) => [...prev, migratedTemplate])
}; }
const updateTemplate = (updatedTemplate: Template) => { const updateTemplate = (updatedTemplate: Template) => {
console.log('updateTemplate called with:', updatedTemplate); console.log('updateTemplate called with:', updatedTemplate)
console.log('Elements count:', updatedTemplate.elements.length); console.log('Elements count:', updatedTemplate.elements.length)
const migratedTemplate = migrateTemplate(updatedTemplate); const migratedTemplate = migrateTemplate(updatedTemplate)
console.log('Migrated template:', migratedTemplate); console.log('Migrated template:', migratedTemplate)
console.log('Migrated elements count:', migratedTemplate.elements.length); console.log('Migrated elements count:', migratedTemplate.elements.length)
setTemplates(prev => prev.map(t => setTemplates((prev) =>
t.id === migratedTemplate.id ? migratedTemplate : t prev.map((t) => (t.id === migratedTemplate.id ? migratedTemplate : t)),
)); )
}; }
const deleteTemplate = (templateId: string) => { 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 getTemplate = (templateId: string) => {
const template = templates.find(t => t.id === templateId); const template = templates.find((t) => t.id === templateId)
return template ? migrateTemplate(template) : undefined; return template ? migrateTemplate(template) : undefined
}; }
return ( return (
<TemplateContext.Provider value={{ <TemplateContext.Provider
templates: templates.map(migrateTemplate), // Применяем миграцию при получении value={{
addTemplate, templates: templates.map(migrateTemplate), // Применяем миграцию при получении
updateTemplate, addTemplate,
deleteTemplate, updateTemplate,
getTemplate, deleteTemplate,
}}> getTemplate,
}}
>
{children} {children}
</TemplateContext.Provider> </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', 'gi',
) )
const before = code const before = code
code = code.replace(funcRegex, (match, p1, offset) => { code = code.replace(funcRegex, (match, p1) => {
// p1 - это название функции, нужно сохранить символ перед функцией если он есть // p1 - это название функции, нужно сохранить символ перед функцией если он есть
const prefix = match.substring(0, match.indexOf(p1)) const prefix = match.substring(0, match.indexOf(p1))
return `${prefix}__functions__.${funcName}(` return `${prefix}__functions__.${funcName}(`

View File

@@ -1,6 +1,6 @@
import { Plus, Wrench } from 'lucide-react' import { Plus, Wrench } from 'lucide-react'
import { FC, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { ElementConstructor } from '../../../../components/TemplateManager/ElementConstructor' import { ElementConstructor } from '../../../../components/TemplateManager/ElementConstructor'
import { Button } from '../../../../components/ui/button' import { Button } from '../../../../components/ui/button'
import { import {
@@ -19,11 +19,25 @@ import {
export const ElementsCreation: FC = () => { export const ElementsCreation: FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { templateId } = useParams<{ templateId: string }>()
const { templates, updateTemplate } = useTemplateContext() const { templates, updateTemplate } = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>( const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
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) => { const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template) setSelectedTemplate(template)
} }
@@ -112,10 +126,6 @@ export const ElementsCreation: FC = () => {
setSelectedTemplate(updatedTemplate) setSelectedTemplate(updatedTemplate)
} }
const handleBack = () => {
setSelectedTemplate(null)
}
// Настройки отображения по умолчанию // Настройки отображения по умолчанию
const defaultLayoutSettings: FormLayoutSettings = { const defaultLayoutSettings: FormLayoutSettings = {
gridSize: 20, 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="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 justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" onClick={handleBack}> <Button variant="ghost" onClick={() => navigate(`/templates`)}>
Назад Назад
</Button> </Button>
<div> <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, Settings,
Wrench, Wrench,
} from 'lucide-react' } from 'lucide-react'
import { FC, useMemo, useState } from 'react' import { FC, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { StandardsSelector } from '../../../../components/StandardsElement/StandardsSelector' import { StandardsSelector } from '../../../../components/StandardsElement/StandardsSelector'
import { Badge } from '../../../../components/ui/badge' import { Badge } from '../../../../components/ui/badge'
import { Button } from '../../../../components/ui/button' import { Button } from '../../../../components/ui/button'
@@ -525,24 +525,39 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
export const ProtocolCreation: FC = () => { export const ProtocolCreation: FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { templateId } = useParams<{ templateId?: string }>()
const { templates } = useTemplateContext() const { templates } = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>( const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
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) => { const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template) setSelectedTemplate(template)
// Обновляем URL с ID выбранного шаблона
navigate(`/create-protocol/${template.id}`)
} }
const handleProtocolSave = (data: Record<string, any>) => { const handleProtocolSave = (data: Record<string, any>) => {
console.log('Сохранение протокола:', data) console.log('Сохранение протокола:', data)
// Здесь будет логика сохранения протокола // Здесь будет логика сохранения протокола
alert(`Протокол "${data.protocolName}" сохранен!`) alert(`Протокол "${data.protocolName}" сохранен!`)
navigate('/') navigate('/templates')
} }
const handleBack = () => { const handleBack = () => {
setSelectedTemplate(null) setSelectedTemplate(null)
// Возвращаемся к списку шаблонов
navigate('/templates')
} }
if (selectedTemplate) { if (selectedTemplate) {

View File

@@ -1,6 +1,20 @@
import { FC } from 'react'; import { FC, useEffect } from 'react'
import { TemplateManager } from '../../../../components/TemplateManager/TemplateManager'; import { useParams } from 'react-router-dom'
import { TemplateManager } from '../../../../components/TemplateManager/TemplateManager'
import { useTemplateContext } from '../../../../contexts/TemplateContext'
export const TemplateSetup: FC = () => { 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 { ElementsCreation } from './ElementsCreation'
import { Home } from "./Home"; import { NoMatch } from './NoMatch'
import { NoMatch } from "./NoMatch"; import { ProtocolCreation } from './ProtocolCreation'
import { ProtocolCreation } from "./ProtocolCreation"; import { TemplateSetup } from './TemplateSetup'
import { TemplateSetup } from "./TemplateSetup";
export { ElementsCreation, Home, NoMatch, ProtocolCreation, 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 файл отчета, какие ячейки с каким значением туда надо вставить.