Ручные правки
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||||
|
<Underline className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||||
Файл не загружен
|
|
||||||
|
{/* Группа выравнивания */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||||
|
<AlignLeft className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||||
|
<AlignCenter className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
|
||||||
|
<AlignRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</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">
|
</div>
|
||||||
|
|
||||||
|
{/* Правая часть - загрузка 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
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 items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1 space-y-1">
|
||||||
<CardTitle className="text-lg">{template.name}</CardTitle>
|
<CardTitle className="text-lg leading-tight">
|
||||||
|
{template.name}
|
||||||
|
</CardTitle>
|
||||||
{template.description && (
|
{template.description && (
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="text-sm">
|
||||||
{template.description}
|
{template.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 ml-2">
|
|
||||||
<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 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>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-0">
|
||||||
<div className="space-y-2 text-sm text-gray-600">
|
<div className="mb-4 flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<Upload className="h-4 w-4" />
|
<FileText className="h-3 w-3" />
|
||||||
<span>
|
<span>{template.excelFile ? '✓' : '✗'}</span>
|
||||||
{template.excelFile ? 'Excel файл загружен' : 'Excel файл не загружен'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<Settings className="h-4 w-4" />
|
<Wrench className="h-3 w-3" />
|
||||||
<span>{template.elements.length} элементов интерфейса</span>
|
<span>{template.elements.length}</span>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
|
||||||
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="flex gap-2 mt-4">
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
console.log('Navigating to protocols:', template.id)
|
||||||
|
navigate(`/templates/${template.id}/protocols`)
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!template.excelFile || template.elements.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
{template.excelFile && template.elements.length > 0
|
||||||
|
? 'Создать протокол'
|
||||||
|
: 'Требует настройки'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => handleTemplateSelect(template)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
console.log(
|
||||||
|
'Navigating to template edit:',
|
||||||
|
template.id,
|
||||||
|
)
|
||||||
|
navigate(`/templates/${template.id}/edit`)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Предпросмотр
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
|
Шаблон
|
||||||
</Button>
|
</Button>
|
||||||
</div> */}
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/templates/${template.id}/elements`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wrench className="mr-1 h-3 w-3" />
|
||||||
|
Элементы
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
</DialogContent>
|
||||||
};
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
value={{
|
||||||
templates: templates.map(migrateTemplate), // Применяем миграцию при получении
|
templates: templates.map(migrateTemplate), // Применяем миграцию при получении
|
||||||
addTemplate,
|
addTemplate,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
getTemplate,
|
getTemplate,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</TemplateContext.Provider>
|
</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',
|
'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}(`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
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) {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
1
temp
1
temp
@@ -1 +0,0 @@
|
|||||||
Так теперь продолжаем делать нашу систему. У нас должно быть изначально меню, где мы можем создавать шаблоны на создание протоколов. Мы даем в этом меню ему имя. Далее мы можем создавать шаблон, а это продгрузка excel файла, который будет шаблон (заполняться строчки левого меню отчета). И можем проводить рассчеты справа и изменять ячейки отчета. Также ниже от этих двух таблиц должны быть конструктор элементов которые тоже привяжутся к имени этого шаблона, там мы сможем создавать просто элементы который будут видны в интерфейсы, давать им имена и ячейку в которую будут вноситься данные при выборе в этих элементах (это будут например, элемент ввода текста, кнопки, радиокнопки, выпадающий список и тп). Тут важно интерфейс общий сделать чтобы удобно можно было добавлять новый вид элементов. Вот это как бы меню конструктор для создания отчетов, потом когда мы выбираем этот шаблон из списка, мы видим все наши элементы, мы туда вводим инофрмацию, у нас посчитывается ячейки что мы указали изменяемыми в левом отчете, и отправляется информация на бэк что за excel файл отчета, какие ячейки с каким значением туда надо вставить.
|
|
||||||
Reference in New Issue
Block a user