Ручные правки
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import { ArrowLeft, Save, Upload, Wrench } from 'lucide-react'
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
ArrowLeft,
|
||||
Bold,
|
||||
FileText,
|
||||
Grid,
|
||||
Italic,
|
||||
Palette,
|
||||
Type,
|
||||
Underline,
|
||||
Upload,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { Template } from '../../types/template'
|
||||
import DualSpreadsheet from '../DualSpreadsheet/DualSpreadsheet'
|
||||
import { Button } from '../ui/button'
|
||||
import { Separator } from '../ui/separator'
|
||||
|
||||
interface TemplateEditorProps {
|
||||
template: Template
|
||||
onSave: (template: Template) => void
|
||||
onCancel: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
template,
|
||||
onSave,
|
||||
onCancel,
|
||||
onBack,
|
||||
}) => {
|
||||
const [editedTemplate, setEditedTemplate] = useState<Template>(template)
|
||||
@@ -24,13 +35,17 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
template.excelData || {},
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [dataVersion, setDataVersion] = useState(0) // Для принудительного обновления
|
||||
const [dataVersion, setDataVersion] = useState(0) // Для принудительного
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file) {
|
||||
console.log('No file selected')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const reader = new FileReader()
|
||||
@@ -134,12 +149,8 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editedTemplate)
|
||||
}
|
||||
|
||||
const handleCreateElements = () => {
|
||||
navigate('/elements')
|
||||
navigate(`/templates/${editedTemplate.id}/elements`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -166,44 +177,74 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Создать элементы
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
Отмена
|
||||
Элементы интерфейса
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
onClick={() =>
|
||||
navigate(`/templates/${editedTemplate.id}/protocols`)
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Сохранить
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Создать протокол
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Панель загрузки Excel */}
|
||||
{/* Компактная панель загрузки Excel и инструментов */}
|
||||
<div className="flex-shrink-0 border-b bg-muted/30 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Excel шаблон:
|
||||
</span>
|
||||
{editedTemplate.excelFile ? (
|
||||
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-2 py-1">
|
||||
<Upload className="h-3 w-3 text-emerald-600" />
|
||||
<span className="text-xs text-emerald-700">
|
||||
{editedTemplate.excelFile.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Файл не загружен
|
||||
</div>
|
||||
)}
|
||||
{/* Левая часть - панель инструментов стилизации */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg border bg-background p-0.5">
|
||||
{/* Группа текстового форматирования */}
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<Bold className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<Italic className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<Underline className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||
|
||||
{/* Группа выравнивания */}
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<AlignCenter className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<AlignRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||
|
||||
{/* Группа цвета и стилей */}
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<Palette className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<Type className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||
<Grid className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* Правая часть - загрузка Excel */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -211,27 +252,47 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Статус файла - всегда отображается */}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 ${
|
||||
editedTemplate.excelFile
|
||||
? 'border-emerald-200 bg-emerald-50'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<FileText
|
||||
className={`h-3 w-3 ${
|
||||
editedTemplate.excelFile
|
||||
? 'text-emerald-600'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`max-w-32 truncate text-xs font-medium ${
|
||||
editedTemplate.excelFile
|
||||
? 'text-emerald-700'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{editedTemplate.excelFile
|
||||
? editedTemplate.excelFile.name
|
||||
: 'Файл не выбран'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка загрузки - всегда отображается */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex h-7 items-center gap-2"
|
||||
className="h-7 rounded-md px-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span className="text-xs">Загрузка...</span>
|
||||
</>
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-3 w-3" />
|
||||
<span className="text-xs">
|
||||
{editedTemplate.excelFile
|
||||
? 'Заменить файл'
|
||||
: 'Загрузить Excel'}
|
||||
</span>
|
||||
</>
|
||||
<Upload className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -244,6 +305,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
key={dataVersion}
|
||||
templateData={{ L: excelData }}
|
||||
mergedCells={editedTemplate.mergedCells}
|
||||
templateId={`template-${editedTemplate.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,62 @@
|
||||
import { FileText, Plus, Settings, Upload } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTemplateContext } from '../../contexts/TemplateContext';
|
||||
import { Template } from '../../types/template';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { TemplateEditor } from './TemplateEditor';
|
||||
import { FileText, Plus, Settings, Trash2, Wrench } from 'lucide-react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTemplateContext } from '../../contexts/TemplateContext'
|
||||
import { Template } from '../../types/template'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '../ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog'
|
||||
import { Input } from '../ui/input'
|
||||
import { TemplateEditor } from './TemplateEditor'
|
||||
|
||||
interface TemplateManagerProps {
|
||||
onTemplateSelect?: (template: Template) => void;
|
||||
onTemplateSelect?: (template: Template) => void
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
export const TemplateManager: React.FC<TemplateManagerProps> = () => {
|
||||
const { templates, addTemplate, updateTemplate } = useTemplateContext();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [newTemplateName, setNewTemplateName] = useState('');
|
||||
const [newTemplateDescription, setNewTemplateDescription] = useState('');
|
||||
export const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
templateId,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { templates, addTemplate, updateTemplate, deleteTemplate } =
|
||||
useTemplateContext()
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null,
|
||||
)
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isEditMode, setIsEditMode] = useState(false)
|
||||
const [newTemplateName, setNewTemplateName] = useState('')
|
||||
const [newTemplateDescription, setNewTemplateDescription] = useState('')
|
||||
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
// Автоматически открываем редактирование шаблона, если передан templateId
|
||||
useEffect(() => {
|
||||
if (templateId) {
|
||||
const template = templates.find((t) => t.id === templateId)
|
||||
if (template) {
|
||||
setSelectedTemplate(template)
|
||||
setIsEditMode(true)
|
||||
}
|
||||
}
|
||||
}, [templateId, templates])
|
||||
|
||||
const handleCreateTemplate = () => {
|
||||
if (!newTemplateName.trim()) return;
|
||||
if (!newTemplateName.trim()) return
|
||||
|
||||
const newTemplate: Template = {
|
||||
id: Date.now().toString(),
|
||||
@@ -30,67 +65,54 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
|
||||
elements: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
addTemplate(newTemplate);
|
||||
setSelectedTemplate(newTemplate);
|
||||
setIsEditMode(true);
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewTemplateName('');
|
||||
setNewTemplateDescription('');
|
||||
};
|
||||
addTemplate(newTemplate)
|
||||
setSelectedTemplate(newTemplate)
|
||||
setIsEditMode(true)
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewTemplateName('')
|
||||
setNewTemplateDescription('')
|
||||
}
|
||||
|
||||
const handleTemplateUpdate = (updatedTemplate: Template) => {
|
||||
updateTemplate(updatedTemplate);
|
||||
setSelectedTemplate(updatedTemplate);
|
||||
};
|
||||
updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
}
|
||||
|
||||
// const handleTemplateDelete = (templateId: string) => {
|
||||
// deleteTemplate(templateId);
|
||||
// if (selectedTemplate?.id === templateId) {
|
||||
// setSelectedTemplate(null);
|
||||
// setIsEditMode(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleTemplateSelect = (template: Template) => {
|
||||
// setSelectedTemplate(template);
|
||||
// setIsEditMode(false);
|
||||
// onTemplateSelect?.(template);
|
||||
// };
|
||||
|
||||
const handleEditTemplate = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsEditMode(true);
|
||||
};
|
||||
const handleDeleteTemplate = (template: Template) => {
|
||||
deleteTemplate(template.id)
|
||||
setTemplateToDelete(null)
|
||||
}
|
||||
|
||||
if (selectedTemplate && isEditMode) {
|
||||
return (
|
||||
<TemplateEditor
|
||||
template={selectedTemplate}
|
||||
onSave={handleTemplateUpdate}
|
||||
onCancel={() => setIsEditMode(false)}
|
||||
onBack={() => {
|
||||
setSelectedTemplate(null);
|
||||
setIsEditMode(false);
|
||||
setSelectedTemplate(null)
|
||||
setIsEditMode(false)
|
||||
navigate('/templates')
|
||||
}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Настройка шаблонов</h1>
|
||||
<p className="text-gray-600 mt-2">Создание и управление шаблонами протоколов</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Protoc</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Создание протоколов с помощью шаблонов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Создать шаблон
|
||||
Создать
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -111,7 +133,7 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Описание (опционально)</label>
|
||||
<label className="text-sm font-medium">Описание</label>
|
||||
<Input
|
||||
placeholder="Введите описание шаблона"
|
||||
value={newTemplateDescription}
|
||||
@@ -120,13 +142,13 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleCreateTemplate}
|
||||
disabled={!newTemplateName.trim()}
|
||||
>
|
||||
@@ -138,74 +160,158 @@ export const TemplateManager: React.FC<TemplateManagerProps> = () => {
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет созданных шаблонов</h3>
|
||||
<p className="text-gray-600 mb-6">Создайте первый шаблон для начала работы</p>
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Нет созданных шаблонов
|
||||
</h3>
|
||||
<p className="mb-6 text-gray-600">
|
||||
Создайте первый шаблон для начала работы
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать шаблон
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<Card key={template.id} className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{template.name}</CardTitle>
|
||||
{template.description && (
|
||||
<CardDescription className="mt-1">
|
||||
{template.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((template) => {
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="overflow-hidden transition-shadow hover:shadow-md"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-1">
|
||||
<CardTitle className="text-lg leading-tight">
|
||||
{template.name}
|
||||
</CardTitle>
|
||||
{template.description && (
|
||||
<CardDescription className="text-sm">
|
||||
{template.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditTemplate(template);
|
||||
e.stopPropagation()
|
||||
setTemplateToDelete(template)
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>
|
||||
{template.excelFile ? 'Excel файл загружен' : 'Excel файл не загружен'}
|
||||
</span>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Обновлен: {template.updatedAt.toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{template.elements.length} элементов интерфейса</span>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="mb-4 flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>{template.excelFile ? '✓' : '✗'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span>{template.elements.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log('Navigating to protocols:', template.id)
|
||||
navigate(`/templates/${template.id}/protocols`)
|
||||
}}
|
||||
disabled={
|
||||
!template.excelFile || template.elements.length === 0
|
||||
}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
{template.excelFile && template.elements.length > 0
|
||||
? 'Создать протокол'
|
||||
: 'Требует настройки'}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log(
|
||||
'Navigating to template edit:',
|
||||
template.id,
|
||||
)
|
||||
navigate(`/templates/${template.id}/edit`)
|
||||
}}
|
||||
>
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Шаблон
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/templates/${template.id}/elements`)
|
||||
}}
|
||||
>
|
||||
<Wrench className="mr-1 h-3 w-3" />
|
||||
Элементы
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
Предпросмотр
|
||||
</Button>
|
||||
</div> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Диалог подтверждения удаления */}
|
||||
<Dialog
|
||||
open={!!templateToDelete}
|
||||
onOpenChange={() => setTemplateToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Удалить шаблон</DialogTitle>
|
||||
<DialogDescription>
|
||||
Вы уверены, что хотите удалить шаблон "{templateToDelete?.name}"?
|
||||
Это действие нельзя будет отменить.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setTemplateToDelete(null)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
templateToDelete && handleDeleteTemplate(templateToDelete)
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react';
|
||||
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils';
|
||||
import { Template, TemplateElement } from '../types/template';
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils'
|
||||
import { Template, TemplateElement } from '../types/template'
|
||||
|
||||
interface TemplateContextType {
|
||||
templates: Template[];
|
||||
addTemplate: (template: Template) => void;
|
||||
updateTemplate: (template: Template) => void;
|
||||
deleteTemplate: (templateId: string) => void;
|
||||
getTemplate: (templateId: string) => Template | undefined;
|
||||
templates: Template[]
|
||||
addTemplate: (template: Template) => void
|
||||
updateTemplate: (template: Template) => void
|
||||
deleteTemplate: (templateId: string) => void
|
||||
getTemplate: (templateId: string) => Template | undefined
|
||||
}
|
||||
|
||||
const TemplateContext = createContext<TemplateContextType | undefined>(undefined);
|
||||
const TemplateContext = createContext<TemplateContextType | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
export const useTemplateContext = () => {
|
||||
const context = useContext(TemplateContext);
|
||||
const context = useContext(TemplateContext)
|
||||
if (!context) {
|
||||
throw new Error('useTemplateContext must be used within a TemplateProvider');
|
||||
throw new Error('useTemplateContext must be used within a TemplateProvider')
|
||||
}
|
||||
return context;
|
||||
};
|
||||
return context
|
||||
}
|
||||
|
||||
interface TemplateProviderProps {
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Ключ для localStorage
|
||||
const TEMPLATES_STORAGE_KEY = 'protoc_templates'
|
||||
|
||||
// Функция создания элемента названия протокола
|
||||
function createProtocolNameElement(): TemplateElement {
|
||||
return {
|
||||
@@ -33,7 +44,9 @@ function createProtocolNameElement(): TemplateElement {
|
||||
label: 'Название протокола',
|
||||
placeholder: 'Введите название протокола',
|
||||
required: true,
|
||||
targetCells: [{ sheet: 'L', cell: 'A1', displayName: 'Название протокола' }],
|
||||
targetCells: [
|
||||
{ sheet: 'L', cell: 'A1', displayName: 'Название протокола' },
|
||||
],
|
||||
options: [],
|
||||
order: 0,
|
||||
layout: {
|
||||
@@ -43,7 +56,7 @@ function createProtocolNameElement(): TemplateElement {
|
||||
height: 80,
|
||||
zIndex: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Функция миграции шаблона
|
||||
@@ -52,63 +65,106 @@ function migrateTemplate(template: any): Template {
|
||||
...template,
|
||||
elements: template.elements?.map(migrateTemplateElement) || [],
|
||||
layoutSettings: template.layoutSettings || getDefaultLayoutSettings(),
|
||||
};
|
||||
createdAt: template.createdAt ? new Date(template.createdAt) : new Date(),
|
||||
updatedAt: template.updatedAt ? new Date(template.updatedAt) : new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
export const TemplateProvider: React.FC<TemplateProviderProps> = ({ children }) => {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
// Функция загрузки шаблонов из localStorage
|
||||
function loadTemplatesFromStorage(): Template[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(TEMPLATES_STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
return Array.isArray(parsed) ? parsed.map(migrateTemplate) : []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке шаблонов из localStorage:', error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Функция сохранения шаблонов в localStorage
|
||||
function saveTemplatesToStorage(templates: Template[]): void {
|
||||
try {
|
||||
localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(templates))
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении шаблонов в localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export const TemplateProvider: React.FC<TemplateProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [templates, setTemplates] = useState<Template[]>(() => {
|
||||
// Загружаем шаблоны из localStorage при инициализации
|
||||
return loadTemplatesFromStorage()
|
||||
})
|
||||
|
||||
// Сохраняем шаблоны в localStorage при каждом изменении
|
||||
useEffect(() => {
|
||||
saveTemplatesToStorage(templates)
|
||||
}, [templates])
|
||||
|
||||
const addTemplate = (template: Template) => {
|
||||
// Проверяем есть ли уже элемент названия протокола
|
||||
const hasProtocolName = template.elements.some(el =>
|
||||
el.name === 'protocolName' || el.label === 'Название протокола'
|
||||
);
|
||||
|
||||
let templateWithProtocolName = template;
|
||||
const hasProtocolName = template.elements.some(
|
||||
(el) => el.name === 'protocolName' || el.label === 'Название протокола',
|
||||
)
|
||||
|
||||
let templateWithProtocolName = template
|
||||
if (!hasProtocolName) {
|
||||
// Добавляем элемент названия протокола как первый элемент
|
||||
const protocolNameElement = createProtocolNameElement();
|
||||
const protocolNameElement = createProtocolNameElement()
|
||||
templateWithProtocolName = {
|
||||
...template,
|
||||
elements: [protocolNameElement, ...template.elements.map(el => ({ ...el, order: (el.order || 0) + 1 }))]
|
||||
};
|
||||
elements: [
|
||||
protocolNameElement,
|
||||
...template.elements.map((el) => ({
|
||||
...el,
|
||||
order: (el.order || 0) + 1,
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const migratedTemplate = migrateTemplate(templateWithProtocolName);
|
||||
setTemplates(prev => [...prev, migratedTemplate]);
|
||||
};
|
||||
|
||||
const migratedTemplate = migrateTemplate(templateWithProtocolName)
|
||||
setTemplates((prev) => [...prev, migratedTemplate])
|
||||
}
|
||||
|
||||
const updateTemplate = (updatedTemplate: Template) => {
|
||||
console.log('updateTemplate called with:', updatedTemplate);
|
||||
console.log('Elements count:', updatedTemplate.elements.length);
|
||||
|
||||
const migratedTemplate = migrateTemplate(updatedTemplate);
|
||||
console.log('Migrated template:', migratedTemplate);
|
||||
console.log('Migrated elements count:', migratedTemplate.elements.length);
|
||||
|
||||
setTemplates(prev => prev.map(t =>
|
||||
t.id === migratedTemplate.id ? migratedTemplate : t
|
||||
));
|
||||
};
|
||||
console.log('updateTemplate called with:', updatedTemplate)
|
||||
console.log('Elements count:', updatedTemplate.elements.length)
|
||||
|
||||
const migratedTemplate = migrateTemplate(updatedTemplate)
|
||||
console.log('Migrated template:', migratedTemplate)
|
||||
console.log('Migrated elements count:', migratedTemplate.elements.length)
|
||||
|
||||
setTemplates((prev) =>
|
||||
prev.map((t) => (t.id === migratedTemplate.id ? migratedTemplate : t)),
|
||||
)
|
||||
}
|
||||
|
||||
const deleteTemplate = (templateId: string) => {
|
||||
setTemplates(prev => prev.filter(t => t.id !== templateId));
|
||||
};
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== templateId))
|
||||
}
|
||||
|
||||
const getTemplate = (templateId: string) => {
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
return template ? migrateTemplate(template) : undefined;
|
||||
};
|
||||
const template = templates.find((t) => t.id === templateId)
|
||||
return template ? migrateTemplate(template) : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateContext.Provider value={{
|
||||
templates: templates.map(migrateTemplate), // Применяем миграцию при получении
|
||||
addTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
getTemplate,
|
||||
}}>
|
||||
<TemplateContext.Provider
|
||||
value={{
|
||||
templates: templates.map(migrateTemplate), // Применяем миграцию при получении
|
||||
addTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
getTemplate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TemplateContext.Provider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
313
src/hooks/useFormulaAutoSave.ts
Normal file
313
src/hooks/useFormulaAutoSave.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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}(`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import Home from "./ui/Page/Page";
|
||||
|
||||
export { Home };
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
381
src/services/formulaApiService.ts
Normal file
381
src/services/formulaApiService.ts
Normal 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
92
src/types/formula-api.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user