добился какой то работы с API

This commit is contained in:
2025-07-20 15:25:51 +03:00
parent 0d00245bf2
commit 71b5ea4e7b
21 changed files with 1910 additions and 1037 deletions

View File

@@ -40,8 +40,7 @@
"react-rnd": "^10.5.2", "react-rnd": "^10.5.2",
"react-window": "^1.8.11", "react-window": "^1.8.11",
"redux": "^4.2.1", "redux": "^4.2.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1"
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.37", "@types/react": "^18.0.37",

View File

@@ -1,5 +1,6 @@
import { useFormulaAutoSave } from '@/hooks/useFormulaAutoSave'
import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine' import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine'
import { useSheetAutoSave } from '@/hooks/useSheetAutoSave'
import { getFileData, getLatestFileForTemplate } from '@/services/fileApiSevice'
import { produce } from 'immer' import { produce } from 'immer'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -22,15 +23,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
mergedCells = [], mergedCells = [],
templateId, templateId,
}) => { }) => {
const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine( const { revision, updateRevision, setTemplateData, ...multiSheetEngine } =
{ useMultiSheetEngine({
sheets: [ sheets: [
{ name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT }, { name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT },
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT }, { name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
], ],
initialData: templateData || {}, initialData: templateData || {},
} })
)
const [activeSheet, setActiveSheet] = useState<SheetType>('report') const [activeSheet, setActiveSheet] = useState<SheetType>('report')
const [selectedCell, setSelectedCell] = useState<{ const [selectedCell, setSelectedCell] = useState<{
@@ -40,6 +40,9 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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 [isInitialized, setIsInitialized] = useState(false)
const [baseFileData, setBaseFileData] = useState<
Record<string, Record<string, any>>
>({}) // Базовые данные файла
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>( const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
() => ({ () => ({
@@ -74,14 +77,26 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const lastRevision = useRef<number>(-1) const lastRevision = useRef<number>(-1)
useEffect(() => { useEffect(() => {
// Используем baseFileData как источник базовых значений для сравнения
const reportBaseData = baseFileData?.[SHEET_NAMES.report]
if (reportBaseData && Object.keys(reportBaseData).length > 0) {
// Очищаем старые значения
initialTemplateValues.current = {}
Object.entries(reportBaseData).forEach(([cellName, value]) => {
initialTemplateValues.current[cellName] = String(value)
})
console.log(
'✅ Загружено базовых значений файла для сравнения:',
Object.keys(initialTemplateValues.current).length
)
} else {
// Fallback: используем templateData, если baseFileData еще не загружен
const reportTemplate = templateData?.[SHEET_NAMES.report] const reportTemplate = templateData?.[SHEET_NAMES.report]
// Минимальная отладка templateData if (reportTemplate && Object.keys(reportTemplate).length > 0) {
if (!templateData || Object.keys(templateData).length === 0) {
console.log('⚠️ templateData пустой')
}
if (reportTemplate) {
// Очищаем старые значения // Очищаем старые значения
initialTemplateValues.current = {} initialTemplateValues.current = {}
@@ -90,13 +105,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
}) })
console.log( console.log(
'✅ Загружено значений шаблона:', '✅ Загружено значений templateData для сравнения (fallback):',
Object.keys(initialTemplateValues.current).length Object.keys(initialTemplateValues.current).length
) )
} else { } else {
console.log('⚠️ DualSpreadsheet: Данные шаблона отчета не найдены') console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения')
} }
}, [templateData]) }
}, [baseFileData, templateData])
useEffect(() => { useEffect(() => {
if (selectedCell && activeSheet) { if (selectedCell && activeSheet) {
@@ -150,16 +166,16 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const displayValue = allCellValues[cellKey] const displayValue = allCellValues[cellKey]
const cellName = getColumnLabel(col) + (row + 1) const cellName = getColumnLabel(col) + (row + 1)
const templateValue = initialTemplateValues.current[cellName] ?? '' const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
// Отладка только для измененных ячеек // Отладка только для измененных ячеек
if ( if (
sheetType === 'report' && sheetType === 'report' &&
templateValue && baseFileValue &&
rawValue !== templateValue rawValue !== baseFileValue
) { ) {
console.log(`🔧 Измененная ячейка ${cellName}:`, { console.log(`🔧 Измененная ячейка ${cellName}:`, {
templateValue, baseFileValue,
rawValue, rawValue,
isModified: true, isModified: true,
}) })
@@ -174,18 +190,18 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
return str return str
} }
const normalizedTemplate = normalizeValue(templateValue) const normalizedBaseFile = normalizeValue(baseFileValue)
const normalizedRaw = normalizeValue(rawValue) const normalizedRaw = normalizeValue(rawValue)
const isModified = const isModified =
sheetType === 'report' && sheetType === 'report' &&
normalizedTemplate !== '' && normalizedBaseFile !== '' &&
normalizedRaw !== normalizedTemplate normalizedRaw !== normalizedBaseFile
// Отладка только для измененных ячеек // Отладка только для измененных ячеек
if (sheetType === 'report' && isModified) { if (sheetType === 'report' && isModified) {
console.log(`🔍 Измененная ячейка ${cellName}:`, { console.log(`🔍 Измененная ячейка ${cellName}:`, {
templateValue, baseFileValue,
rawValue, rawValue,
isModified, isModified,
}) })
@@ -276,15 +292,15 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
selectedCell.row, selectedCell.row,
selectedCell.col selectedCell.col
) )
const templateValue = initialTemplateValues.current[cellName] ?? '' const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
console.log('💾 Сохранение ячейки:', { console.log('💾 Сохранение ячейки:', {
cellName, cellName,
oldValue, oldValue,
newValue: editingValue, newValue: editingValue,
templateValue, baseFileValue,
isChanged: oldValue !== editingValue, isChanged: oldValue !== editingValue,
isModifiedFromTemplate: templateValue !== editingValue, isModifiedFromBaseFile: baseFileValue !== editingValue,
}) })
multiSheetEngine.setCellValueWithoutRecalc( multiSheetEngine.setCellValueWithoutRecalc(
@@ -494,12 +510,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
// Используем новый хук автосохранения // Используем новый хук автосохранения
const { const {
isSaving, isSaving,
isLoading,
hasUnsavedChanges, hasUnsavedChanges,
lastSaveError, lastSaveError,
manualSave, manualSave,
clearError, clearError,
loadSavedFormulas, loadSavedSheets,
} = useFormulaAutoSave( loadSheets, // Добавляем асинхронную функцию загрузки
} = useSheetAutoSave(
multiSheetEngine.getAllModifiedCells, multiSheetEngine.getAllModifiedCells,
multiSheetEngine.isCalculating, multiSheetEngine.isCalculating,
revision, revision,
@@ -510,6 +528,19 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
} }
) )
// Отладка templateId
useEffect(() => {
console.log('🆔 Текущий templateId в DualSpreadsheet:', {
templateId,
type: typeof templateId,
isValid: templateId
? /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
templateId
)
: false,
})
}, [templateId])
// Используем хук для перетаскивания разделителя // Используем хук для перетаскивания разделителя
const handleDividerDrag = useCallback((newLeftPercentage: number) => { const handleDividerDrag = useCallback((newLeftPercentage: number) => {
// Используем requestAnimationFrame для более плавного обновления // Используем requestAnimationFrame для более плавного обновления
@@ -527,31 +558,93 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
useEffect(() => { useEffect(() => {
if (!templateId || isInitialized) return if (!templateId || isInitialized) return
// Проверяем, есть ли начальные данные // Асинхронная функция для загрузки и слияния данных
const hasInitialData = const initializeData = async () => {
let finalBaseData: Record<string, Record<string, any>> = {}
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
if (templateId) {
try {
console.log('🔍 Ищем файлы для templateId:', templateId)
const latestFile = await getLatestFileForTemplate(templateId)
if (latestFile) {
console.log(
'📄 Найден файл:',
latestFile.name,
'ID:',
latestFile.id
)
const fileData = await getFileData(latestFile.id)
// TODO: Пока getFileData возвращает пустые данные, используем templateData
// Когда API будет готов, здесь будут реальные данные файла
if (Object.keys(fileData.data).length > 0) {
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
setBaseFileData(finalBaseData)
console.log('📄 Загружены базовые данные файла:', finalBaseData)
} else {
console.log(
'⚠️ getFileData вернул пустые данные, используем templateData'
)
}
} else {
console.log('⚠️ Файл не найден для templateId:', templateId)
}
} catch (error) {
console.error('❌ Ошибка при загрузке данных файла:', error)
}
}
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
const hasTemplateData =
Object.keys(templateData).length > 0 && Object.keys(templateData).length > 0 &&
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0) Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
// Всегда загружаем сохраненные данные, если они есть if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
const savedFormulas = loadSavedFormulas() finalBaseData = templateData
setBaseFileData(finalBaseData)
console.log(
'📄 Используем templateData как базовые данные:',
finalBaseData
)
setTemplateData(templateData)
}
if (savedFormulas && Object.keys(savedFormulas).length > 0) { // 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
console.log('🔄 Загружаем сохраненные формулы в движок:', savedFormulas) try {
console.log('🔍 Загружаем пользовательские изменения из API...')
const apiSheets = await loadSheets()
if (hasInitialData) { // Преобразуем листы API в формат данных
// Если есть начальные данные, СЛИВАЕМ их с сохраненными изменениями const userChanges: Record<string, Record<string, any>> = {}
console.log('📋 Сливаем данные шаблона с сохраненными изменениями') Object.entries(apiSheets).forEach(([sheetName, sheet]) => {
if (sheet.cells && Object.keys(sheet.cells).length > 0) {
userChanges[sheetName] = sheet.cells
}
})
// Создаем объединенные данные: шаблон + сохраненные изменения console.log(
'📂 Загружены пользовательские изменения:',
Object.keys(userChanges)
)
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
if (Object.keys(userChanges).length > 0) {
console.log(
'🔄 Объединяем базовые данные с пользовательскими изменениями'
)
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
const mergedData: Record<string, Record<string, any>> = {} const mergedData: Record<string, Record<string, any>> = {}
// Копируем данные шаблона // Копируем базовые данные файла как основу
Object.entries(templateData).forEach(([sheetName, sheetData]) => { Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
mergedData[sheetName] = { ...sheetData } mergedData[sheetName] = { ...sheetData }
}) })
// Применяем сохраненные изменения поверх шаблона // Применяем пользовательские изменения поверх базовых данных
Object.entries(savedFormulas).forEach(([sheetName, sheetData]) => { Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
if (!mergedData[sheetName]) { if (!mergedData[sheetName]) {
mergedData[sheetName] = {} mergedData[sheetName] = {}
} }
@@ -560,26 +653,41 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
}) })
}) })
console.log('📊 Загружаем объединенные данные (шаблон + изменения)') console.log(
'📊 Загружаем объединенные данные (базовые + пользовательские)'
)
multiSheetEngine.loadData(mergedData) multiSheetEngine.loadData(mergedData)
} else { } else if (Object.keys(finalBaseData).length > 0) {
// Если нет начальных данных, используем только сохраненные console.log(
multiSheetEngine.loadData(savedFormulas) '📋 Используем только базовые данные (нет пользовательских изменений)'
} )
multiSheetEngine.loadData(finalBaseData)
console.log('✅ Сохраненные формулы загружены в движок')
} else if (hasInitialData) {
console.log('📋 Используем только начальные данные шаблона')
} else { } else {
console.log('⚠️ Нет данных для загрузки') console.log('⚠️ Нет данных для загрузки')
} }
} catch (error) {
console.error(
'❌ Ошибка при загрузке пользовательских изменений:',
error
)
// В случае ошибки используем только базовые данные
if (Object.keys(finalBaseData).length > 0) {
console.log('📋 Ошибка API, используем только базовые данные')
multiSheetEngine.loadData(finalBaseData)
}
}
setIsInitialized(true) setIsInitialized(true)
}
initializeData()
}, [ }, [
templateId, templateId,
templateData, templateData,
loadSavedFormulas, loadSheets,
multiSheetEngine, multiSheetEngine,
setTemplateData,
isInitialized, isInitialized,
]) ])
@@ -598,6 +706,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
formulaBarRef.current = el formulaBarRef.current = el
}} }}
isSaving={isSaving} isSaving={isSaving}
isLoading={isLoading}
hasUnsavedChanges={hasUnsavedChanges} hasUnsavedChanges={hasUnsavedChanges}
lastSaveError={lastSaveError} lastSaveError={lastSaveError}
onManualSave={manualSave} onManualSave={manualSave}

View File

@@ -14,6 +14,7 @@ export const FormulaBar = memo(
onKeyDown, onKeyDown,
formulaBarRef, formulaBarRef,
isSaving = false, isSaving = false,
isLoading = false,
hasUnsavedChanges = false, hasUnsavedChanges = false,
lastSaveError = null, lastSaveError = null,
onManualSave, onManualSave,
@@ -68,7 +69,7 @@ export const FormulaBar = memo(
}} }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{(isCalculating || isSaving) && ( {(isCalculating || isSaving || isLoading) && (
<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"
@@ -90,7 +91,11 @@ export const FormulaBar = memo(
></path> ></path>
</svg> </svg>
<span className="ml-1 text-xs"> <span className="ml-1 text-xs">
{isSaving ? 'Сохранение...' : 'Расчет...'} {isSaving
? 'Сохранение...'
: isLoading
? 'Загрузка...'
: 'Расчет...'}
</span> </span>
</div> </div>
)} )}

View File

@@ -72,6 +72,7 @@ export interface FormulaBarProps {
formulaBarRef: (el: HTMLInputElement | null) => void formulaBarRef: (el: HTMLInputElement | null) => void
// Обновляем props для сохранения // Обновляем props для сохранения
isSaving?: boolean isSaving?: boolean
isLoading?: boolean
hasUnsavedChanges?: boolean hasUnsavedChanges?: boolean
lastSaveError?: string | null lastSaveError?: string | null
onManualSave?: () => void onManualSave?: () => void

View File

@@ -11,6 +11,7 @@ import {
CellTarget, CellTarget,
ElementType, ElementType,
FormLayoutSettings, FormLayoutSettings,
Template,
TemplateElement, TemplateElement,
} from '@/types/template' } from '@/types/template'
import { import {
@@ -18,10 +19,8 @@ import {
Droplets, Droplets,
Edit3, Edit3,
Gauge, Gauge,
LayoutGrid,
Plus, Plus,
Radio, Radio,
Settings,
Thermometer, Thermometer,
Trash2, Trash2,
Zap, Zap,
@@ -64,10 +63,10 @@ const CellTargetEditor: React.FC<{
const handleUpdateTarget = ( const handleUpdateTarget = (
index: number, index: number,
field: keyof CellTarget, field: keyof CellTarget,
value: string, value: string
) => { ) => {
const updatedTargets = targets.map((target, i) => const updatedTargets = targets.map((target, i) =>
i === index ? { ...target, [field]: value } : target, i === index ? { ...target, [field]: value } : target
) )
onChange(updatedTargets) onChange(updatedTargets)
} }
@@ -100,7 +99,7 @@ const CellTargetEditor: React.FC<{
</label> </label>
<Select <Select
value={target.sheet} value={target.sheet}
onValueChange={(value) => onValueChange={value =>
handleUpdateTarget(index, 'sheet', value) handleUpdateTarget(index, 'sheet', value)
} }
> >
@@ -122,7 +121,7 @@ const CellTargetEditor: React.FC<{
<Input <Input
placeholder="A1, B5..." placeholder="A1, B5..."
value={target.cell} value={target.cell}
onChange={(e) => onChange={e =>
handleUpdateTarget(index, 'cell', e.target.value) handleUpdateTarget(index, 'cell', e.target.value)
} }
className="h-7 text-xs" className="h-7 text-xs"
@@ -353,7 +352,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
: null : null
const handleAddOption = () => { const handleAddOption = () => {
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
options: [...(prev.options || []), { value: '', label: '' }], options: [...(prev.options || []), { value: '', label: '' }],
})) }))
@@ -362,18 +361,18 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
const handleUpdateOption = ( const handleUpdateOption = (
index: number, index: number,
field: 'value' | 'label', field: 'value' | 'label',
value: string, value: string
) => { ) => {
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
options: prev.options?.map((option, i) => options: prev.options?.map((option, i) =>
i === index ? { ...option, [field]: value } : option, i === index ? { ...option, [field]: value } : option
), ),
})) }))
} }
const handleRemoveOption = (index: number) => { const handleRemoveOption = (index: number) => {
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
options: prev.options?.filter((_, i) => i !== index), options: prev.options?.filter((_, i) => i !== index),
})) }))
@@ -394,11 +393,11 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<label className="text-sm font-medium">Тип элемента</label> <label className="text-sm font-medium">Тип элемента</label>
<Select <Select
value={formData.type} value={formData.type}
onValueChange={(value) => { onValueChange={value => {
const newType = value as ElementType const newType = value as ElementType
console.log('Element type changed to:', newType) console.log('Element type changed to:', newType)
setFormData((prev) => { setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType } const updates: Partial<TemplateElement> = { type: newType }
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию // Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
@@ -407,7 +406,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
newType === 'calibration-conditions' newType === 'calibration-conditions'
) { ) {
console.log( console.log(
`${newType} type selected, getting element definition`, `${newType} type selected, getting element definition`
) )
const elementDefinition = getElementDefinition(newType) const elementDefinition = getElementDefinition(newType)
console.log('Element definition:', elementDefinition) console.log('Element definition:', elementDefinition)
@@ -450,8 +449,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<Input <Input
placeholder="Название поля" placeholder="Название поля"
value={formData.label} value={formData.label}
onChange={(e) => onChange={e =>
setFormData((prev) => ({ ...prev, label: e.target.value })) setFormData(prev => ({ ...prev, label: e.target.value }))
} }
className="h-9" className="h-9"
/> />
@@ -462,8 +461,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<Input <Input
placeholder="Введите значение..." placeholder="Введите значение..."
value={formData.placeholder} value={formData.placeholder}
onChange={(e) => onChange={e =>
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
placeholder: e.target.value, placeholder: e.target.value,
})) }))
@@ -477,8 +476,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<Checkbox <Checkbox
id="required" id="required"
checked={formData.required} checked={formData.required}
onCheckedChange={(checked) => onCheckedChange={checked =>
setFormData((prev) => ({ ...prev, required: !!checked })) setFormData(prev => ({ ...prev, required: !!checked }))
} }
/> />
<label htmlFor="required" className="text-sm font-medium"> <label htmlFor="required" className="text-sm font-medium">
@@ -488,16 +487,16 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<CellTargetEditor <CellTargetEditor
targets={formData.targetCells || []} targets={formData.targetCells || []}
onChange={(targets) => onChange={targets =>
setFormData((prev) => ({ ...prev, targetCells: targets })) setFormData(prev => ({ ...prev, targetCells: targets }))
} }
/> />
{/* Специфичный редактор элемента */} {/* Специфичный редактор элемента */}
<elementDefinition.Editor <elementDefinition.Editor
config={formData as any} config={formData as any}
onChange={(newConfig) => onChange={newConfig =>
setFormData((prev) => ({ ...prev, ...newConfig })) setFormData(prev => ({ ...prev, ...newConfig }))
} }
/> />
</div> </div>
@@ -519,11 +518,11 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<label className="text-sm font-medium">Тип элемента</label> <label className="text-sm font-medium">Тип элемента</label>
<Select <Select
value={formData.type} value={formData.type}
onValueChange={(value) => { onValueChange={value => {
const newType = value as ElementType const newType = value as ElementType
console.log('Element type changed to (fallback):', newType) console.log('Element type changed to (fallback):', newType)
setFormData((prev) => { setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType } const updates: Partial<TemplateElement> = { type: newType }
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию // Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
@@ -532,12 +531,12 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
newType === 'calibration-conditions' newType === 'calibration-conditions'
) { ) {
console.log( console.log(
`${newType} type selected (fallback), getting element definition`, `${newType} type selected (fallback), getting element definition`
) )
const elementDefinition = getElementDefinition(newType) const elementDefinition = getElementDefinition(newType)
console.log( console.log(
'Element definition (fallback):', 'Element definition (fallback):',
elementDefinition, elementDefinition
) )
if (elementDefinition && elementDefinition.defaultConfig) { if (elementDefinition && elementDefinition.defaultConfig) {
@@ -547,7 +546,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
elementDefinition.mapToCells?.(defaultConfig) || [] elementDefinition.mapToCells?.(defaultConfig) || []
console.log( console.log(
'Generated targetCells (fallback):', 'Generated targetCells (fallback):',
updates.targetCells, updates.targetCells
) )
} }
} }
@@ -579,8 +578,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<Input <Input
placeholder="Название поля" placeholder="Название поля"
value={formData.label} value={formData.label}
onChange={(e) => onChange={e =>
setFormData((prev) => ({ ...prev, label: e.target.value })) setFormData(prev => ({ ...prev, label: e.target.value }))
} }
className="h-9" className="h-9"
/> />
@@ -588,8 +587,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<CellTargetEditor <CellTargetEditor
targets={formData.targetCells || []} targets={formData.targetCells || []}
onChange={(targets) => onChange={targets =>
setFormData((prev) => ({ ...prev, targetCells: targets })) setFormData(prev => ({ ...prev, targetCells: targets }))
} }
/> />
@@ -613,7 +612,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<Input <Input
placeholder="Значение" placeholder="Значение"
value={option.value} value={option.value}
onChange={(e) => onChange={e =>
handleUpdateOption(index, 'value', e.target.value) handleUpdateOption(index, 'value', e.target.value)
} }
className="h-7 text-xs" className="h-7 text-xs"
@@ -621,7 +620,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
<Input <Input
placeholder="Подпись" placeholder="Подпись"
value={option.label} value={option.label}
onChange={(e) => onChange={e =>
handleUpdateOption(index, 'label', e.target.value) handleUpdateOption(index, 'label', e.target.value)
} }
className="h-7 text-xs" className="h-7 text-xs"
@@ -664,7 +663,7 @@ const CanvasSettings: React.FC<CanvasSettingsProps> = ({
<label className="text-sm font-medium">Размер сетки (px)</label> <label className="text-sm font-medium">Размер сетки (px)</label>
<Select <Select
value={settings.gridSize.toString()} value={settings.gridSize.toString()}
onValueChange={(value) => onValueChange={value =>
onChange({ ...settings, gridSize: parseInt(value) }) onChange({ ...settings, gridSize: parseInt(value) })
} }
> >
@@ -684,7 +683,7 @@ const CanvasSettings: React.FC<CanvasSettingsProps> = ({
<Checkbox <Checkbox
id="showGrid" id="showGrid"
checked={settings.showGrid} checked={settings.showGrid}
onCheckedChange={(checked) => onCheckedChange={checked =>
onChange({ ...settings, showGrid: !!checked }) onChange({ ...settings, showGrid: !!checked })
} }
/> />
@@ -697,31 +696,29 @@ const CanvasSettings: React.FC<CanvasSettingsProps> = ({
} }
interface ElementConstructorProps { interface ElementConstructorProps {
elements: TemplateElement[] template: Template
layoutSettings: FormLayoutSettings
onElementAdd: (element: TemplateElement) => void onElementAdd: (element: TemplateElement) => void
onElementUpdate: ( onElementUpdate: (elementId: string, element: TemplateElement) => void
elementId: string,
updates: Partial<TemplateElement>,
) => void
onElementDelete: (elementId: string) => void onElementDelete: (elementId: string) => void
onElementsReorder: (elements: TemplateElement[]) => void disabled?: boolean
onLayoutSettingsChange: (settings: FormLayoutSettings) => void
} }
export const ElementConstructor: React.FC<ElementConstructorProps> = ({ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
elements, template,
layoutSettings,
onElementAdd, onElementAdd,
onElementUpdate, onElementUpdate,
onElementDelete, onElementDelete,
// onElementsReorder, disabled = false,
onLayoutSettingsChange,
}) => { }) => {
const elements = template.elements
const layoutSettings = template.layoutSettings || {
gridSize: 20,
showGrid: true,
}
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [editingElement, setEditingElement] = useState<TemplateElement | null>( const [editingElement, setEditingElement] = useState<TemplateElement | null>(
null, null
) )
const [formData, setFormData] = useState<Partial<TemplateElement>>({ const [formData, setFormData] = useState<Partial<TemplateElement>>({
type: 'text', type: 'text',
@@ -760,19 +757,19 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
!formData.targetCells?.length !formData.targetCells?.length
) { ) {
console.log( console.log(
'Validation failed: missing targetCells for element that requires manual targetCells', 'Validation failed: missing targetCells for element that requires manual targetCells'
) )
return return
} }
// Генерируем layout для нового элемента // Генерируем layout для нового элемента
const existingLayouts = elements.map((e) => e.layout).filter(Boolean) const existingLayouts = elements.map(e => e.layout).filter(Boolean)
const defaultLayout = generateDefaultLayout(elements.length) const defaultLayout = generateDefaultLayout(elements.length)
const layout = findFreePosition( const layout = findFreePosition(
defaultLayout, defaultLayout,
existingLayouts as any[], existingLayouts as any[],
layoutSettings.gridSize, layoutSettings.gridSize
) )
// Специальная обработка для элементов с автоматической генерацией targetCells // Специальная обработка для элементов с автоматической генерацией targetCells
@@ -852,11 +849,15 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
const handleAutoArrange = () => { const handleAutoArrange = () => {
const arrangedElements = autoArrangeElements( const arrangedElements = autoArrangeElements(
elements, elements,
layoutSettings.gridSize, layoutSettings.gridSize
) )
arrangedElements.forEach((element, index) => { arrangedElements.forEach((element, index) => {
onElementUpdate(element.id, { layout: element.layout, order: index }) onElementUpdate(element.id, {
...element,
layout: element.layout,
order: index,
})
}) })
} }
@@ -885,7 +886,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm" disabled={disabled}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Добавить элемент Добавить элемент
</Button> </Button>
@@ -908,6 +909,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
<Button <Button
onClick={handleAddElement} onClick={handleAddElement}
disabled={ disabled={
disabled ||
!formData.label || !formData.label ||
(formData.type !== 'standards' && (formData.type !== 'standards' &&
formData.type !== 'calibration-conditions' && formData.type !== 'calibration-conditions' &&
@@ -939,38 +941,13 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
<Button onClick={handleCancelEdit} variant="outline"> <Button onClick={handleCancelEdit} variant="outline">
Отмена Отмена
</Button> </Button>
<Button onClick={handleUpdateElement}> <Button onClick={handleUpdateElement} disabled={disabled}>
Сохранить изменения Сохранить изменения
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
<Button onClick={handleAutoArrange} variant="outline" size="sm">
<LayoutGrid className="mr-2 h-4 w-4" />
Расставить автоматически
</Button>
<Dialog open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Настройки холста
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Настройки холста</DialogTitle>
<DialogDescription>
Настройте отображение сетки и другие параметры.
</DialogDescription>
</DialogHeader>
<CanvasSettings
settings={layoutSettings}
onChange={onLayoutSettingsChange}
/>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
@@ -979,7 +956,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
layoutSettings={layoutSettings} layoutSettings={layoutSettings}
onElementUpdate={onElementUpdate} onElementUpdate={onElementUpdate}
onElementDelete={handleDeleteAndSelect} onElementDelete={handleDeleteAndSelect}
onLayoutSettingsChange={onLayoutSettingsChange} onLayoutSettingsChange={() => {}} // Настройки макета только для чтения
onElementEdit={handleEditElement} onElementEdit={handleEditElement}
/> />
</div> </div>

View File

@@ -1,12 +1,12 @@
import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet' import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet'
import { ExcelParseResult } from '@/services/excelSevice' import { ExcelParseResult } from '@/services/fileApiSevice'
import React from 'react' import React from 'react'
interface SpreadsheetViewerProps { interface SpreadsheetViewerProps {
data: Record<string, any> data: Record<string, any>
mergedCells: ExcelParseResult['mergedCells'] mergedCells: ExcelParseResult['mergedCells']
dataVersion: number dataVersion: number
templateId: number | string templateId: string | undefined
} }
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
@@ -20,7 +20,7 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
key={dataVersion} key={dataVersion}
templateData={{ L: data }} templateData={{ L: data }}
mergedCells={mergedCells} mergedCells={mergedCells}
templateId={`template-${templateId}`} templateId={templateId}
/> />
</div> </div>
) )

View File

@@ -1,4 +1,4 @@
import { parseExcelFile } from '@/services/excelSevice' import { parseExcelFile } from '@/services/fileApiSevice'
import { Template } from '@/types/template' import { Template } from '@/types/template'
import React, { useState } from 'react' import React, { useState } from 'react'
import { ExcelUploadPanel } from './ExcelUploadPanel' import { ExcelUploadPanel } from './ExcelUploadPanel'
@@ -27,19 +27,27 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
if (!file) return if (!file) return
setIsLoading(true) setIsLoading(true)
try { try {
const { data, mergedCells } = await parseExcelFile(file) const { fileId, data, mergedCells } = await parseExcelFile(
file,
template.id
)
setExcelData(data) setExcelData(data)
setEditedTemplate(prev => ({
...prev, const updatedTemplate = {
...template,
excelFile: file, excelFile: file,
excelData: data, excelData: data,
mergedCells, mergedCells,
updatedAt: new Date(), updatedAt: new Date(),
})) }
setEditedTemplate(updatedTemplate)
setDataVersion(v => v + 1) setDataVersion(v => v + 1)
console.log('✅ Файл загружен с ID:', fileId)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
alert('Ошибка при чтении Excel файла') alert('Ошибка при загрузке Excel файла на сервер')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

View File

@@ -25,8 +25,15 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
templateId, templateId,
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { templates, addTemplate, updateTemplate, deleteTemplate } = const {
useTemplateContext() templates,
isLoading,
error,
addTemplate,
updateTemplate,
deleteTemplate,
loadTemplates,
} = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>( const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null null
) )
@@ -39,6 +46,7 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
new Set() new Set()
) )
const [templatesToDelete, setTemplatesToDelete] = useState<Template[]>([]) const [templatesToDelete, setTemplatesToDelete] = useState<Template[]>([])
const [isCreating, setIsCreating] = useState(false)
// Автоматически открываем редактирование шаблона, если передан templateId // Автоматически открываем редактирование шаблона, если передан templateId
useEffect(() => { useEffect(() => {
@@ -51,29 +59,50 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
} }
}, [templateId, templates]) }, [templateId, templates])
const handleCreateTemplate = () => { const handleCreateTemplate = async () => {
if (!newTemplateName.trim()) return if (!newTemplateName.trim()) return
const newTemplate: Template = { setIsCreating(true)
id: Date.now().toString(), try {
await addTemplate({
name: newTemplateName.trim(), name: newTemplateName.trim(),
description: newTemplateDescription.trim(), description: newTemplateDescription.trim(),
elements: [], elements: [],
createdAt: new Date(), layoutSettings: undefined,
updatedAt: new Date(), })
}
addTemplate(newTemplate) // Находим созданный шаблон и открываем его для редактирования
// Поскольку addTemplate может занять время, используем небольшую задержку
setTimeout(() => {
const newTemplate = templates.find(
t => t.name === newTemplateName.trim()
)
if (newTemplate) {
setSelectedTemplate(newTemplate) setSelectedTemplate(newTemplate)
setIsEditMode(true) setIsEditMode(true)
}
}, 100)
setIsCreateDialogOpen(false) setIsCreateDialogOpen(false)
setNewTemplateName('') setNewTemplateName('')
setNewTemplateDescription('') setNewTemplateDescription('')
} catch (error) {
console.error('Ошибка при создании шаблона:', error)
// Здесь можно добавить уведомление пользователю об ошибке
alert('Ошибка при создании шаблона. Попробуйте еще раз.')
} finally {
setIsCreating(false)
}
} }
const handleTemplateUpdate = (updatedTemplate: Template) => { const handleTemplateUpdate = async (updatedTemplate: Template) => {
updateTemplate(updatedTemplate) try {
await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate) setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при обновлении шаблона:', error)
alert('Ошибка при обновлении шаблона. Попробуйте еще раз.')
}
} }
const handleDeleteTemplates = () => { const handleDeleteTemplates = () => {
@@ -112,55 +141,75 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
setTemplatesToDelete(selectedTemplatesList) setTemplatesToDelete(selectedTemplatesList)
} }
if (selectedTemplate && isEditMode) { const handleRetry = () => {
loadTemplates()
}
if (isEditMode && selectedTemplate) {
return ( return (
<TemplateEditor <TemplateEditor
template={selectedTemplate} template={selectedTemplate}
onBack={() => { onBack={() => {
setSelectedTemplate(null)
setIsEditMode(false) setIsEditMode(false)
setSelectedTemplate(null)
if (templateId) {
navigate('/templates') navigate('/templates')
}
}} }}
/> />
) )
} }
// Показываем ошибку, если есть
if (error && !isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<FileText className="mx-auto mb-4 h-16 w-16 text-red-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Ошибка загрузки шаблонов
</h3>
<p className="mb-4 text-gray-600">{error}</p>
<Button onClick={handleRetry}>Попробовать снова</Button>
</div>
</div>
)
}
return ( return (
<div className="mx-auto max-w-7xl p-6"> <div className="mx-auto max-w-7xl p-6">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <h1 className="text-2xl font-bold text-gray-900">Шаблоны протоколов</h1>
<h1 className="text-3xl font-bold text-gray-900">Protoc</h1> <div className="flex gap-3">
<p className="mt-2 text-gray-600"> {isSelectionMode ? (
Создание протоколов с помощью шаблонов <>
</p> <Button
</div> variant="outline"
onClick={handleSelectionModeToggle}
<div className="flex gap-2"> className="flex items-center gap-2"
{isSelectionMode && selectedTemplates.size > 0 && ( >
Отменить выбор
</Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={handleDeleteSelected} onClick={handleDeleteSelected}
disabled={selectedTemplates.size === 0}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Удалить ({selectedTemplates.size}) Удалить выбранные ({selectedTemplates.size})
</Button> </Button>
)} </>
<Button
variant="ghost"
onClick={handleSelectionModeToggle}
className={`flex items-center gap-2 ${
isSelectionMode
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}
>
{isSelectionMode ? (
<CircleCheck className="h-4 w-4" />
) : ( ) : (
<>
<Button
variant="outline"
onClick={handleSelectionModeToggle}
disabled={templates.length === 0}
className="flex items-center gap-2"
>
<Circle className="h-4 w-4" /> <Circle className="h-4 w-4" />
)} Выбрать
{isSelectionMode ? 'Отменить' : 'Выбрать'}
</Button> </Button>
<Dialog <Dialog
open={isCreateDialogOpen} open={isCreateDialogOpen}
@@ -169,130 +218,173 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
<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>
<DialogContent>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Создать новый шаблон</DialogTitle> <DialogTitle>Создать новый шаблон</DialogTitle>
<DialogDescription> <DialogDescription>
Введите название и описание для нового шаблона. Введите основную информацию для нового шаблона протокола
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4">
<div className="space-y-2"> <div>
<label className="text-sm font-medium"> <label className="mb-1 block text-sm font-medium">
Название шаблона Название шаблона
</label> </label>
<Input <Input
placeholder="Введите название шаблона" placeholder="Например: Протокол испытаний"
value={newTemplateName} value={newTemplateName}
onChange={e => setNewTemplateName(e.target.value)} onChange={e => setNewTemplateName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCreateTemplate()
}
}}
/> />
</div> </div>
<div className="space-y-2"> <div>
<label className="text-sm font-medium">Описание</label> <label className="mb-1 block text-sm font-medium">
Описание (необязательно)
</label>
<Input <Input
placeholder="Введите описание шаблона" placeholder="Краткое описание назначения шаблона"
value={newTemplateDescription} value={newTemplateDescription}
onChange={e => setNewTemplateDescription(e.target.value)} onChange={e =>
setNewTemplateDescription(e.target.value)
}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCreateTemplate()
}
}}
/> />
</div> </div>
</div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsCreateDialogOpen(false)} onClick={() => setIsCreateDialogOpen(false)}
disabled={isCreating}
> >
Отмена Отменить
</Button> </Button>
<Button <Button
onClick={handleCreateTemplate} onClick={handleCreateTemplate}
disabled={!newTemplateName.trim()} disabled={!newTemplateName.trim() || isCreating}
> >
Создать {isCreating ? 'Создание...' : 'Создать'}
</Button> </Button>
</div> </div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
)}
</div> </div>
</div> </div>
{/* Показываем индикатор загрузки */}
{isLoading && (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
<p className="text-gray-600">Загрузка шаблонов...</p>
</div>
</div>
)}
{/* Список шаблонов */}
{!isLoading && (
<>
{templates.length === 0 ? ( {templates.length === 0 ? (
<div className="py-12 text-center"> <div className="flex h-64 items-center justify-center">
<div className="text-center">
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" /> <FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> <h3 className="mb-2 text-lg font-medium text-gray-900">
Нет созданных шаблонов Нет шаблонов
</h3> </h3>
<p className="mb-6 text-gray-600"> <p className="mb-4 text-gray-600">
Создайте первый шаблон для начала работы Создайте первый шаблон для начала работы
</p> </p>
<Button onClick={() => setIsCreateDialogOpen(true)}> <Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Создать шаблон Создать шаблон
</Button> </Button>
</DialogTrigger>
</Dialog>
</div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{templates.map(template => ( {templates.map(template => (
<div key={template.id} className="relative"> <div key={template.id} className="relative">
<div {isSelectionMode && (
className="cursor-pointer transition-all duration-200" <div className="absolute right-2 top-2 z-10">
onClick={() => { <button
if (isSelectionMode) { onClick={() => toggleTemplateSelection(template.id)}
toggleTemplateSelection(template.id) className="rounded-full bg-white p-1 shadow-md"
}
}}
> >
{selectedTemplates.has(template.id) ? (
<CircleCheck className="h-5 w-5 text-blue-600" />
) : (
<Circle className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
)}
<TemplateCard <TemplateCard
template={template} template={template}
onDelete={() => {}} // Убрали onDelete из карточки onDelete={() => deleteTemplate(template.id)}
isSelected={ isSelected={selectedTemplates.has(template.id)}
isSelectionMode && selectedTemplates.has(template.id)
}
/> />
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
</>
)}
{/* Диалог подтверждения удаления */} {/* Диалог подтверждения удаления */}
{templatesToDelete.length > 0 && (
<Dialog <Dialog
open={templatesToDelete.length > 0} open={templatesToDelete.length > 0}
onOpenChange={() => setTemplatesToDelete([])} onOpenChange={() => setTemplatesToDelete([])}
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Удалить шаблоны</DialogTitle> <DialogTitle>Подтверждение удаления</DialogTitle>
<DialogDescription> <DialogDescription>
Вы уверены, что хотите удалить {templatesToDelete.length}{' '} Вы действительно хотите удалить выбранные шаблоны? Это действие
шаблонов? Это действие нельзя будет отменить. нельзя отменить.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
{templatesToDelete.map(template => ( {templatesToDelete.map(template => (
<div <div key={template.id} className="text-sm text-gray-600">
key={template.id} {template.name}
className="flex items-center gap-2 rounded border p-2"
>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{template.name}</span>
</div> </div>
))} ))}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setTemplatesToDelete([])}> <Button
Отмена variant="outline"
onClick={() => setTemplatesToDelete([])}
>
Отменить
</Button> </Button>
<Button variant="destructive" onClick={handleDeleteTemplates}> <Button variant="destructive" onClick={handleDeleteTemplates}>
<Trash2 className="mr-2 h-4 w-4" />
Удалить Удалить
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)}
</div> </div>
) )
} }

View File

@@ -1,23 +1,39 @@
import React, { import React, {
createContext, createContext,
ReactNode, ReactNode,
useCallback,
useContext, useContext,
useEffect, useEffect,
useState, useState,
} from 'react' } from 'react'
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils' import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils'
import {
apiTemplateToTemplate,
createTemplate as createTemplateApi,
getTemplate as getTemplateApi,
getTemplates,
patchTemplate,
templateToCreateInput,
templateToPatchInput,
} from '../services/templateApiService'
import { Template, TemplateElement } from '../types/template' import { Template, TemplateElement } from '../types/template'
interface TemplateContextType { interface TemplateContextType {
templates: Template[] templates: Template[]
addTemplate: (template: Template) => void isLoading: boolean
updateTemplate: (template: Template) => void error: string | null
addTemplate: (
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
) => Promise<void>
updateTemplate: (template: Template) => Promise<void>
deleteTemplate: (templateId: string) => void deleteTemplate: (templateId: string) => void
getTemplate: (templateId: string) => Template | undefined getTemplate: (templateId: string) => Template | undefined
loadTemplates: () => Promise<void>
refreshTemplate: (templateId: string) => Promise<void>
} }
const TemplateContext = createContext<TemplateContextType | undefined>( const TemplateContext = createContext<TemplateContextType | undefined>(
undefined, undefined
) )
export const useTemplateContext = () => { export const useTemplateContext = () => {
@@ -32,9 +48,6 @@ interface TemplateProviderProps {
children: ReactNode children: ReactNode
} }
// Ключ для localStorage
const TEMPLATES_STORAGE_KEY = 'protoc_templates'
// Функция создания элемента названия протокола // Функция создания элемента названия протокола
function createProtocolNameElement(): TemplateElement { function createProtocolNameElement(): TemplateElement {
return { return {
@@ -70,57 +83,68 @@ function migrateTemplate(template: any): Template {
} }
} }
// Функция загрузки шаблонов из localStorage
function loadTemplatesFromStorage(): Template[] {
try {
const stored = localStorage.getItem(TEMPLATES_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
return Array.isArray(parsed) ? parsed.map(migrateTemplate) : []
}
} catch (error) {
console.error('Ошибка при загрузке шаблонов из localStorage:', error)
}
return []
}
// Функция сохранения шаблонов в localStorage
function saveTemplatesToStorage(templates: Template[]): void {
try {
localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(templates))
} catch (error) {
console.error('Ошибка при сохранении шаблонов в localStorage:', error)
}
}
export const TemplateProvider: React.FC<TemplateProviderProps> = ({ export const TemplateProvider: React.FC<TemplateProviderProps> = ({
children, children,
}) => { }) => {
const [templates, setTemplates] = useState<Template[]>(() => { const [templates, setTemplates] = useState<Template[]>([])
// Загружаем шаблоны из localStorage при инициализации const [isLoading, setIsLoading] = useState(false)
return loadTemplatesFromStorage() const [error, setError] = useState<string | null>(null)
})
// Сохраняем шаблоны в localStorage при каждом изменении // Загрузка всех шаблонов с API
const loadTemplates = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const response = await getTemplates()
const convertedTemplates = response.templates.map(apiTemplateToTemplate)
setTemplates(convertedTemplates)
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Неизвестная ошибка'
setError(errorMessage)
console.error('Ошибка при загрузке шаблонов:', err)
} finally {
setIsLoading(false)
}
}, [])
// Загрузка шаблонов при монтировании компонента
useEffect(() => { useEffect(() => {
saveTemplatesToStorage(templates) loadTemplates()
}, [templates]) }, [loadTemplates])
const addTemplate = (template: Template) => { // Обновление конкретного шаблона с API
const refreshTemplate = useCallback(async (templateId: string) => {
try {
const response = await getTemplateApi(templateId)
if (response.template) {
const convertedTemplate = apiTemplateToTemplate(response.template)
setTemplates(prev =>
prev.map(t => (t.id === templateId ? convertedTemplate : t))
)
}
} catch (err) {
console.error('Ошибка при обновлении шаблона:', err)
}
}, [])
const addTemplate = async (
templateData: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
) => {
// Проверяем есть ли уже элемент названия протокола // Проверяем есть ли уже элемент названия протокола
const hasProtocolName = template.elements.some( const hasProtocolName = templateData.elements.some(
(el) => el.name === 'protocolName' || el.label === 'Название протокола', el => el.name === 'protocolName' || el.label === 'Название протокола'
) )
let templateWithProtocolName = template let templateWithProtocolName = templateData
if (!hasProtocolName) { if (!hasProtocolName) {
// Добавляем элемент названия протокола как первый элемент // Добавляем элемент названия протокола как первый элемент
const protocolNameElement = createProtocolNameElement() const protocolNameElement = createProtocolNameElement()
templateWithProtocolName = { templateWithProtocolName = {
...template, ...templateData,
elements: [ elements: [
protocolNameElement, protocolNameElement,
...template.elements.map((el) => ({ ...templateData.elements.map(el => ({
...el, ...el,
order: (el.order || 0) + 1, order: (el.order || 0) + 1,
})), })),
@@ -128,29 +152,66 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({
} }
} }
const migratedTemplate = migrateTemplate(templateWithProtocolName) setIsLoading(true)
setTemplates((prev) => [...prev, migratedTemplate]) setError(null)
try {
const createInput = templateToCreateInput(templateWithProtocolName)
const response = await createTemplateApi(createInput)
// Получаем созданный шаблон с сервера
const templateResponse = await getTemplateApi(response.id)
if (templateResponse.template) {
const newTemplate = apiTemplateToTemplate(templateResponse.template)
setTemplates(prev => [...prev, newTemplate])
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Неизвестная ошибка'
setError(errorMessage)
console.error('Ошибка при создании шаблона:', err)
throw err
} finally {
setIsLoading(false)
}
} }
const updateTemplate = (updatedTemplate: Template) => { const updateTemplate = async (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)
setIsLoading(true)
setError(null)
try {
const patchInput = templateToPatchInput(updatedTemplate)
await patchTemplate(updatedTemplate.id, patchInput)
// Обновляем локальное состояние
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) => setTemplates(prev =>
prev.map((t) => (t.id === migratedTemplate.id ? migratedTemplate : t)), prev.map(t => (t.id === migratedTemplate.id ? migratedTemplate : t))
) )
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Неизвестная ошибка'
setError(errorMessage)
console.error('Ошибка при обновлении шаблона:', err)
throw err
} finally {
setIsLoading(false)
}
} }
const deleteTemplate = (templateId: string) => { const deleteTemplate = (templateId: string) => {
setTemplates((prev) => prev.filter((t) => t.id !== templateId)) // Пока удаление только локально, так как в API нет endpoint для удаления
// В будущем здесь можно добавить вызов API для удаления
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
} }
@@ -158,10 +219,14 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({
<TemplateContext.Provider <TemplateContext.Provider
value={{ value={{
templates: templates.map(migrateTemplate), // Применяем миграцию при получении templates: templates.map(migrateTemplate), // Применяем миграцию при получении
isLoading,
error,
addTemplate, addTemplate,
updateTemplate, updateTemplate,
deleteTemplate, deleteTemplate,
getTemplate, getTemplate,
loadTemplates,
refreshTemplate,
}} }}
> >
{children} {children}

View File

@@ -32,7 +32,7 @@ export const useFormulaAutoSave = (
getAllModifiedCells: () => Record<string, Record<string, any>>, getAllModifiedCells: () => Record<string, Record<string, any>>,
isCalculating: boolean, isCalculating: boolean,
revision: number, revision: number,
options: UseFormulaAutoSaveOptions = {}, options: UseFormulaAutoSaveOptions = {}
) => { ) => {
const { templateId, autoSaveDelay = 2000, enableAutoSave = true } = options const { templateId, autoSaveDelay = 2000, enableAutoSave = true } = options
@@ -61,7 +61,7 @@ export const useFormulaAutoSave = (
console.log( console.log(
'📂 Загружены сохраненные формулы (версия:', '📂 Загружены сохраненные формулы (версия:',
parsedData.version, parsedData.version,
')', ')'
) )
} }
} catch (error) { } catch (error) {
@@ -87,8 +87,8 @@ export const useFormulaAutoSave = (
try { try {
// Имитируем задержку API для реалистичности // Имитируем задержку API для реалистичности
await new Promise((resolve) => await new Promise(resolve =>
setTimeout(resolve, 200 + Math.random() * 300), setTimeout(resolve, 200 + Math.random() * 300)
) )
// Иногда имитируем ошибку (2% вероятность) // Иногда имитируем ошибку (2% вероятность)
@@ -114,8 +114,8 @@ export const useFormulaAutoSave = (
'💾 Сохранено формул:', '💾 Сохранено формул:',
Object.values(data).reduce( Object.values(data).reduce(
(sum, sheet) => sum + Object.keys(sheet).length, (sum, sheet) => sum + Object.keys(sheet).length,
0, 0
), )
) )
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
@@ -123,7 +123,7 @@ export const useFormulaAutoSave = (
setLastSaveError(errorMessage) setLastSaveError(errorMessage)
console.error( console.error(
'❌ Ошибка при сохранении формул в localStorage:', '❌ Ошибка при сохранении формул в localStorage:',
errorMessage, errorMessage
) )
// Не обновляем lastSavedRevision при ошибке, чтобы попытаться снова // Не обновляем lastSavedRevision при ошибке, чтобы попытаться снова
@@ -131,7 +131,7 @@ export const useFormulaAutoSave = (
setIsSaving(false) setIsSaving(false)
} }
}, },
[templateId, revision], [templateId, revision]
) )
// Автосохранение с дебаунсом // Автосохранение с дебаунсом
@@ -256,7 +256,7 @@ export const useFormulaAutoSave = (
} catch (error) { } catch (error) {
console.error( console.error(
'❌ Ошибка при загрузке сохраненных формул из localStorage:', '❌ Ошибка при загрузке сохраненных формул из localStorage:',
error, error
) )
return null return null
} }

View File

@@ -3,12 +3,6 @@ import {
CellValue, CellValue,
Workbook, Workbook,
} from '../lib/spreadsheet-engine/spreadsheet-engine' } from '../lib/spreadsheet-engine/spreadsheet-engine'
// interface CellData {
// value: string
// isSelected: boolean
// }
interface SheetConfig { interface SheetConfig {
name: string name: string
rows: number rows: number
@@ -30,6 +24,9 @@ export function useMultiSheetEngine({
const debouncedRecalcTimeoutRef = useRef<number | null>(null) const debouncedRecalcTimeoutRef = useRef<number | null>(null)
const isRecalculatingRef = useRef(false) const isRecalculatingRef = useRef(false)
// Храним изначальные данные шаблона для сравнения
const templateDataRef = useRef<Record<string, Record<string, any>>>({})
// Безопасная функция для вычислений // Безопасная функция для вычислений
const safeRecalc = useCallback(() => { const safeRecalc = useCallback(() => {
if (!workbookRef.current || isRecalculatingRef.current) return if (!workbookRef.current || isRecalculatingRef.current) return
@@ -47,7 +44,7 @@ export function useMultiSheetEngine({
setIsCalculating(false) setIsCalculating(false)
isRecalculatingRef.current = false isRecalculatingRef.current = false
// Увеличиваем ревизию, чтобы сообщить компонентам о необходимости обновления // Увеличиваем ревизию, чтобы сообщить компонентам о необходимости обновления
setRevision((r) => r + 1) setRevision(r => r + 1)
}, 50) // Уменьшено с 150ms до 50ms }, 50) // Уменьшено с 150ms до 50ms
} }
}, []) }, [])
@@ -58,12 +55,12 @@ export function useMultiSheetEngine({
workbookRef.current = new Workbook() workbookRef.current = new Workbook()
// Создаем листы // Создаем листы
sheets.forEach((sheetConfig) => { sheets.forEach(sheetConfig => {
workbookRef.current!.addSheet(sheetConfig.name) workbookRef.current!.addSheet(sheetConfig.name)
}) })
// Увеличиваем ревизию для начальной отрисовки // Увеличиваем ревизию для начальной отрисовки
setRevision((r) => r + 1) setRevision(r => r + 1)
} }
}, [sheets]) }, [sheets])
@@ -84,7 +81,7 @@ export function useMultiSheetEngine({
safeRecalc() safeRecalc()
} else { } else {
// Если данных нет, просто обновляем ревизию // Если данных нет, просто обновляем ревизию
setRevision((r) => r + 1) setRevision(r => r + 1)
} }
}, [initialData, safeRecalc]) }, [initialData, safeRecalc])
@@ -141,7 +138,7 @@ export function useMultiSheetEngine({
// Функция для немедленного обновления интерфейса без пересчета // Функция для немедленного обновления интерфейса без пересчета
const updateRevision = useCallback(() => { const updateRevision = useCallback(() => {
setRevision((r) => r + 1) setRevision(r => r + 1)
}, []) }, [])
// Установка значения ячейки без немедленного пересчета // Установка значения ячейки без немедленного пересчета
@@ -155,7 +152,7 @@ export function useMultiSheetEngine({
// Устанавливаем значение в движке без пересчета // Устанавливаем значение в движке без пересчета
workbookRef.current.setCell(qualifiedName, value) workbookRef.current.setCell(qualifiedName, value)
}, },
[], []
) )
// Установка значения ячейки с немедленным пересчетом // Установка значения ячейки с немедленным пересчетом
@@ -172,7 +169,7 @@ export function useMultiSheetEngine({
// Запускаем безопасный пересчет // Запускаем безопасный пересчет
safeRecalc() safeRecalc()
}, },
[safeRecalc], [safeRecalc]
) )
// Получение значения ячейки (вычисленное) // Получение значения ячейки (вычисленное)
@@ -185,7 +182,7 @@ export function useMultiSheetEngine({
return workbookRef.current.getValue(qualifiedName) return workbookRef.current.getValue(qualifiedName)
}, },
[], []
) )
// Получение сырого значения ячейки (формула) // Получение сырого значения ячейки (формула)
@@ -201,7 +198,7 @@ export function useMultiSheetEngine({
return String(cell.raw || '') return String(cell.raw || '')
}, },
[], []
) )
// Получение всех измененных ячеек для листа // Получение всех измененных ячеек для листа
@@ -214,17 +211,68 @@ export function useMultiSheetEngine({
return sheet.toJSON() return sheet.toJSON()
}, },
[], []
) )
// Получение всех измененных ячеек для всех листов // Установка изначальных данных шаблона
const setTemplateData = useCallback(
(data: Record<string, Record<string, any>>) => {
templateDataRef.current = JSON.parse(JSON.stringify(data)) // Глубокая копия
console.log('📋 Установлены данные шаблона:', templateDataRef.current)
},
[]
)
// Получение всех измененных ячеек для всех листов (только пользовательские изменения)
const getAllModifiedCells = useCallback((): Record< const getAllModifiedCells = useCallback((): Record<
string, string,
Record<string, CellValue> Record<string, CellValue>
> => { > => {
if (!workbookRef.current) return {} if (!workbookRef.current) return {}
return workbookRef.current.toJSON() const allData = workbookRef.current.toJSON()
const modifiedData: Record<string, Record<string, CellValue>> = {}
// Сравниваем с изначальными данными шаблона
Object.entries(allData).forEach(([sheetName, sheetData]) => {
const templateSheet = templateDataRef.current[sheetName] || {}
const modifiedSheet: Record<string, CellValue> = {}
Object.entries(sheetData).forEach(([cellAddress, value]) => {
const templateValue = templateSheet[cellAddress]
// Нормализуем значения для сравнения
const normalizeValue = (val: any): string => {
if (val === null || val === undefined) return ''
const str = String(val).trim()
// Считаем "0" и пустую строку эквивалентными
if (str === '0' || str === '') return ''
return str
}
const normalizedCurrent = normalizeValue(value)
const normalizedTemplate = normalizeValue(templateValue)
// Если значение отличается от шаблона, считаем его модификацией
if (normalizedCurrent !== normalizedTemplate) {
modifiedSheet[cellAddress] = value
console.log(
`🔧 Пользовательское изменение ${sheetName}:${cellAddress}:`,
{
template: normalizedTemplate,
current: normalizedCurrent,
value,
}
)
}
})
if (Object.keys(modifiedSheet).length > 0) {
modifiedData[sheetName] = modifiedSheet
}
})
return modifiedData
}, []) }, [])
// Очистка всех данных // Очистка всех данных
@@ -232,7 +280,7 @@ export function useMultiSheetEngine({
if (!workbookRef.current) return if (!workbookRef.current) return
workbookRef.current.clear() workbookRef.current.clear()
setRevision((r) => r + 1) setRevision(r => r + 1)
}, []) }, [])
// Загрузка данных из JSON // Загрузка данных из JSON
@@ -243,7 +291,7 @@ export function useMultiSheetEngine({
workbookRef.current.fromJSON(data) workbookRef.current.fromJSON(data)
safeRecalc() safeRecalc()
}, },
[safeRecalc], [safeRecalc]
) )
// Пересчет всех формул // Пересчет всех формул
@@ -271,6 +319,7 @@ export function useMultiSheetEngine({
getCellRawValue, getCellRawValue,
getModifiedCells, getModifiedCells,
getAllModifiedCells, getAllModifiedCells,
setTemplateData, // Экспортируем функцию установки данных шаблона
clearAllData, clearAllData,
loadData, loadData,
recalculate, recalculate,

View File

@@ -0,0 +1,370 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
ApiSheet,
createSheet,
getSheetsByTemplate,
patchSheet,
} from '../services/sheetApiService'
export interface UseSheetAutoSaveOptions {
templateId?: string
autoSaveDelay?: number // задержка автосохранения в мс
enableAutoSave?: boolean
}
/**
* Хук для автосохранения листов через API
*
* Функции:
* - Автоматическое сохранение с дебаунсом при изменениях
* - Ручное сохранение по требованию
* - Создание и обновление листов через API
* - Загрузка существующих листов
*/
// Утилита для извлечения UUID из templateId
const extractUUID = (id: string): string => {
// Если это уже чистый UUID, возвращаем как есть
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (uuidRegex.test(id)) {
return id
}
// Если есть префикс (например "template-"), извлекаем UUID
const match = id.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
)
if (match) {
return match[0]
}
return id // Возвращаем оригинал если не найден UUID
}
export const useSheetAutoSave = (
getAllModifiedCells: () => Record<string, Record<string, any>>,
isCalculating: boolean,
revision: number,
options: UseSheetAutoSaveOptions = {}
) => {
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 [sheets, setSheets] = useState<Record<string, ApiSheet>>({})
const [isLoading, setIsLoading] = useState(false)
// Refs
const saveTimeoutRef = useRef<number | null>(null)
// Загрузка листов для шаблона при инициализации
const loadSheets = useCallback(async () => {
if (!templateId) return {}
const cleanTemplateId = extractUUID(templateId)
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(cleanTemplateId)) {
console.error('❌ Не удалось извлечь валидный UUID для загрузки:', {
original: templateId,
extracted: cleanTemplateId,
})
setLastSaveError('Неверный ID шаблона')
return {}
}
setIsLoading(true)
try {
const response = await getSheetsByTemplate(cleanTemplateId)
const sheetsMap = response.sheets.reduce(
(acc, sheet) => {
acc[sheet.name] = sheet
return acc
},
{} as Record<string, ApiSheet>
)
setSheets(sheetsMap)
console.log('📂 Загружены листы для шаблона:', Object.keys(sheetsMap))
return sheetsMap
} catch (error) {
console.error('❌ Ошибка при загрузке листов:', error)
setLastSaveError('Ошибка загрузки листов')
return {}
} finally {
setIsLoading(false)
}
}, [templateId])
// Загрузка листов при изменении templateId
useEffect(() => {
if (templateId) {
loadSheets()
}
}, [templateId, loadSheets])
// Основная функция сохранения через API
const performSave = useCallback(
async (data: Record<string, Record<string, any>>) => {
const sheetNames = Object.keys(data)
if (sheetNames.length === 0) {
console.log('📭 Нет данных для сохранения')
return
}
if (!templateId) {
console.warn('⚠️ Не указан templateId для сохранения листов')
return
}
// Извлекаем и валидируем UUID из templateId
const cleanTemplateId = extractUUID(templateId)
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(cleanTemplateId)) {
console.error('❌ Не удалось извлечь валидный UUID из templateId:', {
original: templateId,
extracted: cleanTemplateId,
})
setLastSaveError('Неверный ID шаблона')
return
}
console.log('🧹 Очищенный templateId:', {
original: templateId,
clean: cleanTemplateId,
})
setIsSaving(true)
setLastSaveError(null)
try {
const savePromises = sheetNames.map(async sheetName => {
const sheetCells = data[sheetName]
const existingSheet = sheets[sheetName]
// Проверяем что есть данные для сохранения
if (!sheetCells || Object.keys(sheetCells).length === 0) {
console.log(`⏭️ Пропускаем лист "${sheetName}" - нет данных`)
return
}
if (existingSheet) {
// Обновляем существующий лист
console.log(`🔄 Обновляем лист "${sheetName}"`)
await patchSheet(existingSheet.id, {
cells: sheetCells,
})
// Обновляем локальное состояние
setSheets(prev => ({
...prev,
[sheetName]: {
...existingSheet,
cells: sheetCells,
updated_at: new Date().toISOString(),
},
}))
} else {
// Создаем новый лист
console.log(`✨ Создаем новый лист "${sheetName}"`)
console.log('📋 Данные для создания:', {
name: sheetName,
cells: sheetCells,
template_id: cleanTemplateId,
cellsCount: Object.keys(sheetCells).length,
})
const newSheet = await createSheet({
name: sheetName,
cells: sheetCells,
template_id: cleanTemplateId,
})
// Получаем полную информацию о созданном листе
const fullSheetData: ApiSheet = {
id: newSheet.id,
name: sheetName,
cells: sheetCells,
template_id: cleanTemplateId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
}
// Обновляем локальное состояние
setSheets(prev => ({
...prev,
[sheetName]: fullSheetData,
}))
}
})
await Promise.all(savePromises)
setLastSavedRevision(revision)
const totalCells = Object.values(data).reduce(
(sum, sheet) => sum + Object.keys(sheet).length,
0
)
console.log('💾 Сохранено ячеек:', totalCells, 'в листах:', sheetNames)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Неизвестная ошибка'
setLastSaveError(errorMessage)
console.error('❌ Ошибка при сохранении листов:', errorMessage)
} finally {
setIsSaving(false)
}
},
[templateId, sheets, 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])
// Функция загрузки сохраненных листов
const loadSavedSheets = useCallback((): Record<
string,
Record<string, any>
> => {
const result: Record<string, Record<string, any>> = {}
Object.entries(sheets).forEach(([sheetName, sheet]) => {
if (sheet.cells && Object.keys(sheet.cells).length > 0) {
result[sheetName] = sheet.cells
}
})
console.log('📂 Загружены сохраненные листы:', Object.keys(result))
return result
}, [sheets])
// Очистка таймаута при размонтировании
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
// Вычисляемые значения
const isDataChanged = revision > lastSavedRevision
const hasUnsavedChanges = isDataChanged && !isSaving
return {
// Состояние
isSaving,
isLoading,
isDataChanged,
hasUnsavedChanges,
lastSaveError,
lastSavedRevision,
sheets,
// Методы
manualSave,
loadSavedSheets,
loadSheets,
// Утилиты
getCurrentData: getAllModifiedCells,
clearError: () => setLastSaveError(null),
}
}

View File

@@ -4,7 +4,6 @@ import {
CellValue, CellValue,
Workbook, Workbook,
} from '../lib/spreadsheet-engine/spreadsheet-engine' } from '../lib/spreadsheet-engine/spreadsheet-engine'
// import { addCustomFunctions } from '../lib/spreadsheet-example'
interface CellData { interface CellData {
value: string value: string
@@ -31,7 +30,7 @@ export function useSpreadsheetEngine({
.map(() => .map(() =>
Array(cols) Array(cols)
.fill(null) .fill(null)
.map(() => ({ value: '', isSelected: false })), .map(() => ({ value: '', isSelected: false }))
) )
}) })
const [isCalculating, setIsCalculating] = useState(false) const [isCalculating, setIsCalculating] = useState(false)
@@ -62,7 +61,7 @@ export function useSpreadsheetEngine({
.map(() => .map(() =>
Array(cols) Array(cols)
.fill(null) .fill(null)
.map(() => ({ value: '', isSelected: false })), .map(() => ({ value: '', isSelected: false }))
) )
// Получаем все ячейки из движка // Получаем все ячейки из движка
@@ -119,7 +118,7 @@ export function useSpreadsheetEngine({
setIsCalculating(false) setIsCalculating(false)
} }
}, },
[sheetName, syncFromEngine], [sheetName, syncFromEngine]
) )
// Получение вычисленного значения ячейки // Получение вычисленного значения ячейки
@@ -137,7 +136,7 @@ export function useSpreadsheetEngine({
return '#ERROR!' return '#ERROR!'
} }
}, },
[sheetName], [sheetName]
) )
// Получение сырого значения ячейки (для отображения в строке формул) // Получение сырого значения ячейки (для отображения в строке формул)
@@ -153,7 +152,7 @@ export function useSpreadsheetEngine({
const cell = sheet.getCell(cellName) const cell = sheet.getCell(cellName)
return cell.raw !== null && cell.raw !== undefined ? String(cell.raw) : '' return cell.raw !== null && cell.raw !== undefined ? String(cell.raw) : ''
}, },
[sheetName], [sheetName]
) )
// Загрузка данных из массива // Загрузка данных из массива
@@ -192,7 +191,7 @@ export function useSpreadsheetEngine({
setIsCalculating(false) setIsCalculating(false)
} }
}, },
[rows, cols, sheetName, syncFromEngine], [rows, cols, sheetName, syncFromEngine]
) )
// Экспорт данных в массив (для сохранения) // Экспорт данных в массив (для сохранения)
@@ -245,7 +244,7 @@ export function useSpreadsheetEngine({
setIsCalculating(false) setIsCalculating(false)
} }
}, },
[syncFromEngine], [syncFromEngine]
) )
// Добавление пользовательской функции // Добавление пользовательской функции
@@ -254,7 +253,7 @@ export function useSpreadsheetEngine({
if (!workbookRef.current) return if (!workbookRef.current) return
workbookRef.current.addFunction(name, func) workbookRef.current.addFunction(name, func)
}, },
[], []
) )
// Пересчёт всех формул // Пересчёт всех формул
@@ -290,7 +289,7 @@ export function useSpreadsheetEngine({
dependents: Array.from(cell.dependents), dependents: Array.from(cell.dependents),
} }
}, },
[sheetName], [sheetName]
) )
// Получение статистики workbook // Получение статистики workbook

View File

@@ -1,7 +1,7 @@
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 { useTemplateContext } from '@/contexts/TemplateContext' import { useTemplateContext } from '@/contexts/TemplateContext'
import { FormLayoutSettings, Template, TemplateElement } from '@/types/template' import { Template, TemplateElement } from '@/types/template'
import { ArrowLeft, Wrench } from 'lucide-react' import { ArrowLeft, Wrench } from 'lucide-react'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
@@ -9,10 +9,11 @@ import { useNavigate, useParams } from 'react-router-dom'
export const ElementsCreation: FC = () => { export const ElementsCreation: FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { templateId } = useParams<{ templateId: string }>() const { templateId } = useParams<{ templateId: string }>()
const { templates, updateTemplate } = useTemplateContext() const { templates, updateTemplate, isLoading, error } = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>( const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null null
) )
const [isSaving, setIsSaving] = useState(false)
// Автоматически выбираем шаблон, если передан templateId // Автоматически выбираем шаблон, если передан templateId
useEffect(() => { useEffect(() => {
@@ -20,14 +21,14 @@ export const ElementsCreation: FC = () => {
const template = templates.find(t => t.id === templateId) const template = templates.find(t => t.id === templateId)
if (template) { if (template) {
setSelectedTemplate(template) setSelectedTemplate(template)
} else { } else if (!isLoading && templates.length > 0) {
// Если шаблон не найден, перенаправляем на страницу со списком шаблонов // Если шаблон не найден после загрузки, перенаправляем на страницу со списком шаблонов
navigate('/templates', { replace: true }) navigate('/templates', { replace: true })
} }
} }
}, [templateId, templates, navigate]) }, [templateId, templates, navigate, isLoading])
const handleElementAdd = (element: TemplateElement) => { const handleElementAdd = async (element: TemplateElement) => {
console.log('handleElementAdd called with element:', element) console.log('handleElementAdd called with element:', element)
if (!selectedTemplate) { if (!selectedTemplate) {
@@ -35,6 +36,8 @@ export const ElementsCreation: FC = () => {
return return
} }
setIsSaving(true)
try {
const updatedTemplate = { const updatedTemplate = {
...selectedTemplate, ...selectedTemplate,
elements: [...selectedTemplate.elements, element], elements: [...selectedTemplate.elements, element],
@@ -44,79 +47,94 @@ export const ElementsCreation: FC = () => {
console.log('Updated template:', updatedTemplate) console.log('Updated template:', updatedTemplate)
console.log('Elements count:', updatedTemplate.elements.length) console.log('Elements count:', updatedTemplate.elements.length)
updateTemplate(updatedTemplate) await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate) setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при добавлении элемента:', error)
alert('Ошибка при добавлении элемента. Попробуйте еще раз.')
} finally {
setIsSaving(false)
}
} }
const handleElementUpdate = ( const handleElementUpdate = async (
elementId: string, elementId: string,
updates: Partial<TemplateElement> updatedElement: TemplateElement
) => { ) => {
if (!selectedTemplate) return if (!selectedTemplate) return
setIsSaving(true)
try {
const updatedTemplate = { const updatedTemplate = {
...selectedTemplate, ...selectedTemplate,
elements: selectedTemplate.elements.map(el => elements: selectedTemplate.elements.map(el =>
el.id === elementId ? { ...el, ...updates } : el el.id === elementId ? updatedElement : el
), ),
updatedAt: new Date(), updatedAt: new Date(),
} }
updateTemplate(updatedTemplate) await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate) setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при обновлении элемента:', error)
alert('Ошибка при обновлении элемента. Попробуйте еще раз.')
} finally {
setIsSaving(false)
}
} }
const handleElementDelete = (elementId: string) => { const handleElementDelete = async (elementId: string) => {
if (!selectedTemplate) return if (!selectedTemplate) return
setIsSaving(true)
try {
const updatedTemplate = { const updatedTemplate = {
...selectedTemplate, ...selectedTemplate,
elements: selectedTemplate.elements.filter(el => el.id !== elementId), elements: selectedTemplate.elements.filter(el => el.id !== elementId),
updatedAt: new Date(), updatedAt: new Date(),
} }
updateTemplate(updatedTemplate) await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate) setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при удалении элемента:', error)
alert('Ошибка при удалении элемента. Попробуйте еще раз.')
} finally {
setIsSaving(false)
}
} }
const handleElementsReorder = (reorderedElements: TemplateElement[]) => { // Показываем загрузку
if (!selectedTemplate) return if (isLoading) {
return (
// Обновляем порядок элементов <div className="flex h-screen items-center justify-center">
const elementsWithOrder = reorderedElements.map((element, index) => ({ <div className="text-center">
...element, <div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
order: index, <p className="text-gray-600">Загрузка шаблона...</p>
})) </div>
</div>
const updatedTemplate = { )
...selectedTemplate,
elements: elementsWithOrder,
updatedAt: new Date(),
} }
updateTemplate(updatedTemplate) // Показываем ошибку
setSelectedTemplate(updatedTemplate) if (error) {
} return (
<div className="flex h-screen items-center justify-center">
const handleLayoutSettingsChange = (settings: FormLayoutSettings) => { <div className="text-center">
if (!selectedTemplate) return <Wrench className="mx-auto mb-4 h-16 w-16 text-red-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
const updatedTemplate = { Ошибка загрузки
...selectedTemplate, </h3>
layoutSettings: settings, <p className="mb-4 text-gray-600">{error}</p>
updatedAt: new Date(), <Button onClick={() => navigate('/templates')}>
} Назад к шаблонам
</Button>
updateTemplate(updatedTemplate) </div>
setSelectedTemplate(updatedTemplate) </div>
} )
// Настройки отображения по умолчанию
const defaultLayoutSettings: FormLayoutSettings = {
gridSize: 20,
showGrid: true,
} }
// Если шаблон не найден
if (!selectedTemplate) { if (!selectedTemplate) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-screen items-center justify-center">
@@ -126,10 +144,10 @@ export const ElementsCreation: FC = () => {
Шаблон не найден Шаблон не найден
</h3> </h3>
<p className="mb-4 text-gray-600"> <p className="mb-4 text-gray-600">
Перейдите к списку шаблонов для выбора Выбранный шаблон не существует или был удален
</p> </p>
<Button onClick={() => navigate('/templates')}> <Button onClick={() => navigate('/templates')}>
К списку шаблонов Назад к шаблонам
</Button> </Button>
</div> </div>
</div> </div>
@@ -137,39 +155,43 @@ export const ElementsCreation: FC = () => {
} }
return ( return (
<div className="h-full"> <div className="h-screen bg-background">
<div className="sticky top-0 z-50 border-b border-gray-200 bg-white p-4"> <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center justify-between"> <div className="flex h-14 items-center px-4">
<div className="flex items-center gap-4"> <Button
<Button variant="ghost" onClick={() => navigate(`/templates`)}> variant="ghost"
<ArrowLeft className="mr-2 h-4 w-4" /> size="icon"
onClick={() => navigate('/templates')}
className="mr-4"
>
<ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div className="flex items-center space-x-2">
<h1 className="text-xl font-semibold text-gray-800"> <Wrench className="h-5 w-5" />
Конструктор элементов: {selectedTemplate.name} <h1 className="text-lg font-semibold">Редактор элементов</h1>
</h1> <span className="text-sm text-muted-foreground"></span>
<p className="mt-1 text-sm text-gray-600"> <span className="text-sm text-muted-foreground">
Создайте элементы формы и разместите их на холсте {selectedTemplate.name}
перетаскиванием </span>
</p>
</div> </div>
{/* Индикатор сохранения */}
{isSaving && (
<div className="ml-auto flex items-center space-x-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"></div>
<span>Сохранение...</span>
</div> </div>
)}
</div> </div>
</div> </div>
<div className="h-[calc(100vh-104px)]">
<ElementConstructor <ElementConstructor
elements={selectedTemplate.elements} template={selectedTemplate}
layoutSettings={
selectedTemplate.layoutSettings || defaultLayoutSettings
}
onElementAdd={handleElementAdd} onElementAdd={handleElementAdd}
onElementUpdate={handleElementUpdate} onElementUpdate={handleElementUpdate}
onElementDelete={handleElementDelete} onElementDelete={handleElementDelete}
onElementsReorder={handleElementsReorder} disabled={isSaving}
onLayoutSettingsChange={handleLayoutSettingsChange}
/> />
</div> </div>
</div>
) )
} }

View File

@@ -1,3 +1,4 @@
import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet'
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'
@@ -366,6 +367,7 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => { const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [formData, setFormData] = useState<Record<string, any>>({}) const [formData, setFormData] = useState<Record<string, any>>({})
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
const handleFieldChange = (elementId: string, value: any) => { const handleFieldChange = (elementId: string, value: any) => {
setFormData(prev => ({ setFormData(prev => ({
@@ -444,6 +446,13 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
variant={showSpreadsheet ? 'default' : 'outline'}
onClick={() => setShowSpreadsheet(!showSpreadsheet)}
>
<Grid className="mr-2 h-4 w-4" />
{showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'}
</Button>
<Button variant="outline" onClick={handleExport}> <Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Экспорт в Excel Экспорт в Excel
@@ -456,8 +465,22 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
</div> </div>
</div> </div>
{/* Полноэкранная форма */} {/* Основное содержимое */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-hidden">
{showSpreadsheet ? (
/* Режим таблиц */
<div className="h-full">
<DualSpreadsheet
templateData={
template.excelData ? { report: template.excelData } : {}
}
mergedCells={template.mergedCells || []}
templateId={template.id}
/>
</div>
) : (
/* Режим формы */
<div className="h-full overflow-auto">
<div <div
className="relative mx-auto my-8" className="relative mx-auto my-8"
style={{ width: canvasSize.width, height: canvasSize.height }} style={{ width: canvasSize.width, height: canvasSize.height }}
@@ -500,7 +523,10 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
<p className="mb-4 text-gray-600"> <p className="mb-4 text-gray-600">
В этом шаблоне пока не созданы элементы формы В этом шаблоне пока не созданы элементы формы
</p> </p>
<Button variant="outline" onClick={() => window.history.back()}> <Button
variant="outline"
onClick={() => window.history.back()}
>
Настроить шаблон Настроить шаблон
</Button> </Button>
</div> </div>
@@ -508,6 +534,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
)} )}
</div> </div>
</div> </div>
)}
</div>
</div> </div>
) )
} }

View File

@@ -1,58 +0,0 @@
import * as XLSX from 'xlsx'
export interface ExcelParseResult {
data: Record<string, any>
mergedCells: Array<{ from: string; to: string; value: any }>
}
// fetch(/api/upload)
export async function parseExcelFile(file: File): Promise<ExcelParseResult> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = e => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer)
const workbook = XLSX.read(data, { type: 'array' })
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1')
const formattedData: Record<string, any> = {}
const mergedCells: ExcelParseResult['mergedCells'] = []
// Основные ячейки
for (let R = range.s.r; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const addr = XLSX.utils.encode_cell({ r: R, c: C })
const cell = worksheet[addr]
if (cell && cell.v != null && cell.v !== '') {
formattedData[addr] = cell.v
}
}
}
// Обработать объединения
;(worksheet['!merges'] || []).forEach(m => {
const from = XLSX.utils.encode_cell({ r: m.s.r, c: m.s.c })
const to = XLSX.utils.encode_cell({ r: m.e.r, c: m.e.c })
const val = worksheet[from]?.v
if (val != null) {
mergedCells.push({ from, to, value: val })
// остальные ячейки затираем пустым, чтобы UI знал об объединении
for (let R = m.s.r; R <= m.e.r; ++R) {
for (let C = m.s.c; C <= m.e.c; ++C) {
const addr = XLSX.utils.encode_cell({ r: R, c: C })
if (addr !== from) formattedData[addr] = ''
}
}
}
})
resolve({ data: formattedData, mergedCells })
} catch (err) {
reject(err)
}
}
reader.onerror = () => reject(new Error('Ошибка чтения файла'))
reader.readAsArrayBuffer(file)
})
}

View File

@@ -0,0 +1,177 @@
export interface ExcelParseResult {
fileId: string // ID загруженного файла
data: Record<string, any>
mergedCells: Array<{ from: string; to: string; value: any }>
}
export interface ApiUploadResponse {
file_id: string
cells: {
cells: Record<string, any>
merged: Array<{ from: string; to: string }>
}
}
export interface ApiFileResponse {
file: {
id: string
name: string
path: string
size: number
template_id: string | null
created_at: string
updated_at: string
deleted_at: string | null
cells?: {
cells: Record<string, any>
merged: Array<{ from: string; to: string }>
}
} | null
}
export interface ApiFilesResponse {
files: Array<{
id: string
name: string
path: string
size: number
template_id: string | null
created_at: string
updated_at: string
deleted_at: string | null
}>
}
// API загрузка файла на сервер
export async function parseExcelFile(
file: File,
templateId: string
): Promise<ExcelParseResult> {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch(
`/api/files/upload?template_id=${templateId}`,
{
method: 'POST',
body: formData,
}
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: ApiUploadResponse = await response.json()
// Преобразуем ответ API в формат, ожидаемый приложением
const data = result.cells.cells
const mergedCells = result.cells.merged.map(merge => ({
from: merge.from,
to: merge.to,
value: data[merge.from] || '',
}))
return {
fileId: result.file_id, // Добавляем fileId в возвращаемые данные
data,
mergedCells,
}
} catch (error) {
console.error('Ошибка при загрузке файла:', error)
throw new Error('Ошибка при загрузке файла на сервер')
}
}
// Получение файлов по template_id
export async function getFilesByTemplateId(
templateId: string
): Promise<ApiFilesResponse['files']> {
try {
const response = await fetch('/api/files/', {
method: 'GET',
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: ApiFilesResponse = await response.json()
// Фильтруем файлы по template_id
const templateFiles = result.files.filter(
file => file.template_id === templateId && !file.deleted_at
)
return templateFiles
} catch (error) {
console.error('Ошибка при получении файлов:', error)
throw new Error('Ошибка при получении файлов')
}
}
// Получение последнего файла для шаблона
export async function getLatestFileForTemplate(
templateId: string
): Promise<ApiFilesResponse['files'][0] | null> {
try {
const files = await getFilesByTemplateId(templateId)
if (files.length === 0) {
return null
}
// Сортируем по дате обновления и берем последний
const sortedFiles = files.sort(
(a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
)
return sortedFiles[0]
} catch (error) {
console.error('Ошибка при получении последнего файла:', error)
return null
}
}
// Получение данных файла по ID
export async function getFileData(fileId: string): Promise<ExcelParseResult> {
try {
const response = await fetch(`/api/files/${fileId}`, {
method: 'GET',
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: ApiFileResponse = await response.json()
if (!result.file) {
throw new Error('Файл не найден')
}
// Проверяем, есть ли данные ячеек в ответе
if (!result.file.cells) {
throw new Error('Данные файла не содержат информации о ячейках')
}
// Преобразуем данные в формат, ожидаемый приложением
const data = result.file.cells.cells
const mergedCells = result.file.cells.merged.map(merge => ({
from: merge.from,
to: merge.to,
value: data[merge.from] || '',
}))
return {
fileId: result.file.id,
data,
mergedCells,
}
} catch (error) {
console.error('Ошибка при получении данных файла:', error)
throw new Error('Ошибка при получении данных файла')
}
}

View File

@@ -1,381 +0,0 @@
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 }

View File

@@ -0,0 +1,204 @@
// API типы для работы с листами
export interface CreateSheetInput {
name: string
cells: Record<string, any>
template_id: string
}
export interface CreateSheetOutput {
id: string
}
export interface GetSheetsOutput {
sheets: ApiSheet[]
}
export interface GetSheetOutput {
sheet: ApiSheet | null
}
export interface PatchSheetInput {
name?: string | null
cells?: Record<string, any> | null
}
export interface PatchSheetOutput {
id: string
}
export interface ApiSheet {
id: string
name: string
cells: Record<string, any>
template_id: string
created_at: string
updated_at: string
deleted_at?: string | null
}
// API создание листа
export async function createSheet(
sheet: CreateSheetInput
): Promise<CreateSheetOutput> {
try {
console.log('📤 Отправляем данные для создания листа:', sheet)
const response = await fetch('/api/sheets/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sheet),
})
if (!response.ok) {
// Пытаемся получить подробности ошибки от сервера
let errorDetails = `HTTP error! status: ${response.status}`
try {
const errorData = await response.json()
console.error('❌ Детали ошибки сервера:', errorData)
errorDetails += ` - ${JSON.stringify(errorData)}`
} catch (e) {
console.error('❌ Не удалось получить детали ошибки')
}
throw new Error(errorDetails)
}
const result: CreateSheetOutput = await response.json()
console.log('✅ Лист создан успешно:', result)
return result
} catch (error) {
console.error('Ошибка при создании листа:', error)
throw new Error('Ошибка при создании листа на сервере')
}
}
// API получение всех листов
export async function getSheets(): Promise<GetSheetsOutput> {
try {
const response = await fetch('/api/sheets/', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: GetSheetsOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при получении листов:', error)
throw new Error('Ошибка при получении листов с сервера')
}
}
// API получение листов для конкретного шаблона
export async function getSheetsByTemplate(
templateId: string
): Promise<GetSheetsOutput> {
try {
console.log('📋 Запрашиваем листы для templateId:', templateId)
// Получаем все листы и фильтруем на клиенте
const response = await fetch(`/api/sheets/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
console.log('📋 Ответ сервера на запрос листов:', response.status)
if (!response.ok) {
// Пытаемся получить подробности ошибки от сервера
let errorDetails = `HTTP error! status: ${response.status}`
try {
const errorData = await response.json()
console.error('❌ Детали ошибки при получении листов:', errorData)
errorDetails += ` - ${JSON.stringify(errorData)}`
} catch (e) {
console.error(
'❌ Не удалось получить детали ошибки при получении листов'
)
}
throw new Error(errorDetails)
}
const allSheets: GetSheetsOutput = await response.json()
// Фильтруем листы по template_id на клиенте
const filteredSheets = allSheets.sheets.filter(
sheet => sheet.template_id === templateId
)
console.log('✅ Получены и отфильтрованы листы для шаблона:', {
templateId,
totalSheets: allSheets.sheets.length,
filteredSheetsCount: filteredSheets.length,
sheetNames: filteredSheets.map(s => s.name),
})
return { sheets: filteredSheets }
} catch (error) {
console.error('❌ Ошибка при получении листов шаблона:', error)
// Если ошибка 404 или подобная, возвращаем пустой список вместо выброса ошибки
if (error instanceof Error && error.message.includes('404')) {
console.log('📭 Листы для шаблона не найдены, возвращаем пустой список')
return { sheets: [] }
}
throw new Error('Ошибка при получении листов шаблона с сервера')
}
}
// API получение конкретного листа
export async function getSheet(sheetId: string): Promise<GetSheetOutput> {
try {
const response = await fetch(`/api/sheets/${sheetId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: GetSheetOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при получении листа:', error)
throw new Error('Ошибка при получении листа с сервера')
}
}
// API обновление листа
export async function patchSheet(
sheetId: string,
sheet: PatchSheetInput
): Promise<PatchSheetOutput> {
try {
const response = await fetch(`/api/sheets/${sheetId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sheet),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: PatchSheetOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при обновлении листа:', error)
throw new Error('Ошибка при обновлении листа на сервере')
}
}

View File

@@ -0,0 +1,198 @@
export interface CreateTemplateInput {
name: string
description?: string
elements: Record<string, any>
}
export interface CreateTemplateOutput {
id: string
}
export interface GetTemplatesOutput {
templates: ApiTemplate[]
}
export interface GetTemplateOutput {
template: ApiTemplate | null
}
export interface PatchTemplateInput {
name?: string | null
description?: string | null
elements?: Record<string, any> | null
}
export interface PatchTemplateOutput {
id: string
}
export interface ApiTemplate {
id: string
name: string
description?: string | null
elements: Record<string, any>
created_at: string
updated_at: string
deleted_at?: string | null
}
// Импортируем типы для преобразования
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils'
import { Template, TemplateElement } from '../types/template'
// Утилитарные функции для преобразования данных
// Преобразование API шаблона в внутренний формат
export function apiTemplateToTemplate(apiTemplate: ApiTemplate): Template {
// Преобразуем elements из Record<string, any> в TemplateElement[]
const elements: TemplateElement[] = Object.values(apiTemplate.elements || {})
.map(el => migrateTemplateElement(el))
.sort((a, b) => (a.order || 0) - (b.order || 0))
return {
id: apiTemplate.id,
name: apiTemplate.name,
description: apiTemplate.description || undefined,
elements,
layoutSettings: getDefaultLayoutSettings(),
createdAt: new Date(apiTemplate.created_at),
updatedAt: new Date(apiTemplate.updated_at),
}
}
// Преобразование внутреннего шаблона в API формат для создания
export function templateToCreateInput(
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
): CreateTemplateInput {
// Преобразуем элементы в Record<string, any>
const elements: Record<string, any> = {}
template.elements.forEach((element, index) => {
elements[element.id] = element
})
return {
name: template.name,
description: template.description,
elements,
}
}
// Преобразование внутреннего шаблона в API формат для обновления
export function templateToPatchInput(template: Template): PatchTemplateInput {
// Преобразуем элементы в Record<string, any>
const elements: Record<string, any> = {}
template.elements.forEach((element, index) => {
elements[element.id] = element
})
return {
name: template.name,
description: template.description,
elements,
}
}
// API создание шаблона
export async function createTemplate(
template: CreateTemplateInput
): Promise<CreateTemplateOutput> {
try {
const response = await fetch('/api/templates/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(template),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: CreateTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при создании шаблона:', error)
throw new Error('Ошибка при создании шаблона на сервере')
}
}
// API получение всех шаблонов
export async function getTemplates(): Promise<GetTemplatesOutput> {
try {
const response = await fetch('/api/templates/', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: GetTemplatesOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при получении шаблонов:', error)
throw new Error('Ошибка при получении шаблонов с сервера')
}
}
// API получение конкретного шаблона
export async function getTemplate(
templateId: string
): Promise<GetTemplateOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: GetTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при получении шаблона:', error)
throw new Error('Ошибка при получении шаблона с сервера')
}
}
// API обновление шаблона
export async function patchTemplate(
templateId: string,
template: PatchTemplateInput
): Promise<PatchTemplateOutput> {
try {
const response = await fetch(`/api/templates/${templateId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(template),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result: PatchTemplateOutput = await response.json()
return result
} catch (error) {
console.error('Ошибка при обновлении шаблона:', error)
throw new Error('Ошибка при обновлении шаблона на сервере')
}
}
// Алиас для удобства использования
export async function updateTemplate(
template: Template
): Promise<PatchTemplateOutput> {
const patchInput = templateToPatchInput(template)
return patchTemplate(template.id, patchInput)
}

View File

@@ -1,16 +1,25 @@
import react from "@vitejs/plugin-react-swc"; import react from '@vitejs/plugin-react-swc'
import path from "path"; import path from 'path'
import { defineConfig } from "vite"; import { defineConfig } from 'vite'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve("src"), '@': path.resolve('src'),
"@widgets": path.resolve("src/widgets"), '@widgets': path.resolve('src/widgets'),
"@features": path.resolve("src/features"), '@features': path.resolve('src/features'),
"@entities": path.resolve("src/entities"), '@entities': path.resolve('src/entities'),
}, },
}, },
}); server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
})