добился какой то работы с API
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,29 +77,42 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
const lastRevision = useRef<number>(-1)
|
const lastRevision = useRef<number>(-1)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reportTemplate = templateData?.[SHEET_NAMES.report]
|
// Используем baseFileData как источник базовых значений для сравнения
|
||||||
|
const reportBaseData = baseFileData?.[SHEET_NAMES.report]
|
||||||
|
|
||||||
// Минимальная отладка templateData
|
if (reportBaseData && Object.keys(reportBaseData).length > 0) {
|
||||||
if (!templateData || Object.keys(templateData).length === 0) {
|
|
||||||
console.log('⚠️ templateData пустой')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reportTemplate) {
|
|
||||||
// Очищаем старые значения
|
// Очищаем старые значения
|
||||||
initialTemplateValues.current = {}
|
initialTemplateValues.current = {}
|
||||||
|
|
||||||
Object.entries(reportTemplate).forEach(([cellName, value]) => {
|
Object.entries(reportBaseData).forEach(([cellName, value]) => {
|
||||||
initialTemplateValues.current[cellName] = String(value)
|
initialTemplateValues.current[cellName] = String(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
'✅ Загружено значений шаблона:',
|
'✅ Загружено базовых значений файла для сравнения:',
|
||||||
Object.keys(initialTemplateValues.current).length
|
Object.keys(initialTemplateValues.current).length
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
console.log('⚠️ DualSpreadsheet: Данные шаблона отчета не найдены')
|
// Fallback: используем templateData, если baseFileData еще не загружен
|
||||||
|
const reportTemplate = templateData?.[SHEET_NAMES.report]
|
||||||
|
|
||||||
|
if (reportTemplate && Object.keys(reportTemplate).length > 0) {
|
||||||
|
// Очищаем старые значения
|
||||||
|
initialTemplateValues.current = {}
|
||||||
|
|
||||||
|
Object.entries(reportTemplate).forEach(([cellName, value]) => {
|
||||||
|
initialTemplateValues.current[cellName] = String(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'✅ Загружено значений templateData для сравнения (fallback):',
|
||||||
|
Object.keys(initialTemplateValues.current).length
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
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,59 +558,136 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!templateId || isInitialized) return
|
if (!templateId || isInitialized) return
|
||||||
|
|
||||||
// Проверяем, есть ли начальные данные
|
// Асинхронная функция для загрузки и слияния данных
|
||||||
const hasInitialData =
|
const initializeData = async () => {
|
||||||
Object.keys(templateData).length > 0 &&
|
let finalBaseData: Record<string, Record<string, any>> = {}
|
||||||
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
|
|
||||||
|
|
||||||
// Всегда загружаем сохраненные данные, если они есть
|
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
|
||||||
const savedFormulas = loadSavedFormulas()
|
if (templateId) {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Ищем файлы для templateId:', templateId)
|
||||||
|
const latestFile = await getLatestFileForTemplate(templateId)
|
||||||
|
|
||||||
if (savedFormulas && Object.keys(savedFormulas).length > 0) {
|
if (latestFile) {
|
||||||
console.log('🔄 Загружаем сохраненные формулы в движок:', savedFormulas)
|
console.log(
|
||||||
|
'📄 Найден файл:',
|
||||||
|
latestFile.name,
|
||||||
|
'ID:',
|
||||||
|
latestFile.id
|
||||||
|
)
|
||||||
|
const fileData = await getFileData(latestFile.id)
|
||||||
|
|
||||||
if (hasInitialData) {
|
// TODO: Пока getFileData возвращает пустые данные, используем templateData
|
||||||
// Если есть начальные данные, СЛИВАЕМ их с сохраненными изменениями
|
// Когда API будет готов, здесь будут реальные данные файла
|
||||||
console.log('📋 Сливаем данные шаблона с сохраненными изменениями')
|
if (Object.keys(fileData.data).length > 0) {
|
||||||
|
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
|
||||||
// Создаем объединенные данные: шаблон + сохраненные изменения
|
setBaseFileData(finalBaseData)
|
||||||
const mergedData: Record<string, Record<string, any>> = {}
|
console.log('📄 Загружены базовые данные файла:', finalBaseData)
|
||||||
|
} else {
|
||||||
// Копируем данные шаблона
|
console.log(
|
||||||
Object.entries(templateData).forEach(([sheetName, sheetData]) => {
|
'⚠️ getFileData вернул пустые данные, используем templateData'
|
||||||
mergedData[sheetName] = { ...sheetData }
|
)
|
||||||
})
|
}
|
||||||
|
} else {
|
||||||
// Применяем сохраненные изменения поверх шаблона
|
console.log('⚠️ Файл не найден для templateId:', templateId)
|
||||||
Object.entries(savedFormulas).forEach(([sheetName, sheetData]) => {
|
|
||||||
if (!mergedData[sheetName]) {
|
|
||||||
mergedData[sheetName] = {}
|
|
||||||
}
|
}
|
||||||
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
} catch (error) {
|
||||||
mergedData[sheetName][cellAddress] = value
|
console.error('❌ Ошибка при загрузке данных файла:', error)
|
||||||
})
|
}
|
||||||
})
|
|
||||||
|
|
||||||
console.log('📊 Загружаем объединенные данные (шаблон + изменения)')
|
|
||||||
multiSheetEngine.loadData(mergedData)
|
|
||||||
} else {
|
|
||||||
// Если нет начальных данных, используем только сохраненные
|
|
||||||
multiSheetEngine.loadData(savedFormulas)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Сохраненные формулы загружены в движок')
|
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
|
||||||
} else if (hasInitialData) {
|
const hasTemplateData =
|
||||||
console.log('📋 Используем только начальные данные шаблона')
|
Object.keys(templateData).length > 0 &&
|
||||||
} else {
|
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
|
||||||
console.log('⚠️ Нет данных для загрузки')
|
|
||||||
|
if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
|
||||||
|
finalBaseData = templateData
|
||||||
|
setBaseFileData(finalBaseData)
|
||||||
|
console.log(
|
||||||
|
'📄 Используем templateData как базовые данные:',
|
||||||
|
finalBaseData
|
||||||
|
)
|
||||||
|
setTemplateData(templateData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
|
||||||
|
try {
|
||||||
|
console.log('🔍 Загружаем пользовательские изменения из API...')
|
||||||
|
const apiSheets = await loadSheets()
|
||||||
|
|
||||||
|
// Преобразуем листы API в формат данных
|
||||||
|
const userChanges: Record<string, Record<string, any>> = {}
|
||||||
|
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>> = {}
|
||||||
|
|
||||||
|
// Копируем базовые данные файла как основу
|
||||||
|
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
|
||||||
|
mergedData[sheetName] = { ...sheetData }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Применяем пользовательские изменения поверх базовых данных
|
||||||
|
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
|
||||||
|
if (!mergedData[sheetName]) {
|
||||||
|
mergedData[sheetName] = {}
|
||||||
|
}
|
||||||
|
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||||||
|
mergedData[sheetName][cellAddress] = value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'📊 Загружаем объединенные данные (базовые + пользовательские)'
|
||||||
|
)
|
||||||
|
multiSheetEngine.loadData(mergedData)
|
||||||
|
} else if (Object.keys(finalBaseData).length > 0) {
|
||||||
|
console.log(
|
||||||
|
'📋 Используем только базовые данные (нет пользовательских изменений)'
|
||||||
|
)
|
||||||
|
multiSheetEngine.loadData(finalBaseData)
|
||||||
|
} else {
|
||||||
|
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
name: newTemplateName.trim(),
|
await addTemplate({
|
||||||
description: newTemplateDescription.trim(),
|
name: newTemplateName.trim(),
|
||||||
elements: [],
|
description: newTemplateDescription.trim(),
|
||||||
createdAt: new Date(),
|
elements: [],
|
||||||
updatedAt: new Date(),
|
layoutSettings: undefined,
|
||||||
}
|
})
|
||||||
|
|
||||||
addTemplate(newTemplate)
|
// Находим созданный шаблон и открываем его для редактирования
|
||||||
setSelectedTemplate(newTemplate)
|
// Поскольку addTemplate может занять время, используем небольшую задержку
|
||||||
setIsEditMode(true)
|
setTimeout(() => {
|
||||||
setIsCreateDialogOpen(false)
|
const newTemplate = templates.find(
|
||||||
setNewTemplateName('')
|
t => t.name === newTemplateName.trim()
|
||||||
setNewTemplateDescription('')
|
)
|
||||||
|
if (newTemplate) {
|
||||||
|
setSelectedTemplate(newTemplate)
|
||||||
|
setIsEditMode(true)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
setIsCreateDialogOpen(false)
|
||||||
|
setNewTemplateName('')
|
||||||
|
setNewTemplateDescription('')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при создании шаблона:', error)
|
||||||
|
// Здесь можно добавить уведомление пользователю об ошибке
|
||||||
|
alert('Ошибка при создании шаблона. Попробуйте еще раз.')
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTemplateUpdate = (updatedTemplate: Template) => {
|
const handleTemplateUpdate = async (updatedTemplate: Template) => {
|
||||||
updateTemplate(updatedTemplate)
|
try {
|
||||||
setSelectedTemplate(updatedTemplate)
|
await updateTemplate(updatedTemplate)
|
||||||
|
setSelectedTemplate(updatedTemplate)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при обновлении шаблона:', error)
|
||||||
|
alert('Ошибка при обновлении шаблона. Попробуйте еще раз.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteTemplates = () => {
|
const handleDeleteTemplates = () => {
|
||||||
@@ -112,187 +141,250 @@ 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)
|
||||||
navigate('/templates')
|
setSelectedTemplate(null)
|
||||||
|
if (templateId) {
|
||||||
|
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
|
Отменить выбор
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteSelected}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Удалить ({selectedTemplates.size})
|
|
||||||
</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" />
|
|
||||||
) : (
|
|
||||||
<Circle className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{isSelectionMode ? 'Отменить' : 'Выбрать'}
|
|
||||||
</Button>
|
|
||||||
<Dialog
|
|
||||||
open={isCreateDialogOpen}
|
|
||||||
onOpenChange={setIsCreateDialogOpen}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="flex items-center gap-2">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Создать
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
onClick={handleDeleteSelected}
|
||||||
<DialogHeader>
|
disabled={selectedTemplates.size === 0}
|
||||||
<DialogTitle>Создать новый шаблон</DialogTitle>
|
className="flex items-center gap-2"
|
||||||
<DialogDescription>
|
>
|
||||||
Введите название и описание для нового шаблона.
|
<Trash2 className="h-4 w-4" />
|
||||||
</DialogDescription>
|
Удалить выбранные ({selectedTemplates.size})
|
||||||
</DialogHeader>
|
</Button>
|
||||||
<div className="space-y-4 py-4">
|
</>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
<label className="text-sm font-medium">
|
<>
|
||||||
Название шаблона
|
<Button
|
||||||
</label>
|
variant="outline"
|
||||||
<Input
|
onClick={handleSelectionModeToggle}
|
||||||
placeholder="Введите название шаблона"
|
disabled={templates.length === 0}
|
||||||
value={newTemplateName}
|
className="flex items-center gap-2"
|
||||||
onChange={e => setNewTemplateName(e.target.value)}
|
>
|
||||||
/>
|
<Circle className="h-4 w-4" />
|
||||||
</div>
|
Выбрать
|
||||||
<div className="space-y-2">
|
</Button>
|
||||||
<label className="text-sm font-medium">Описание</label>
|
<Dialog
|
||||||
<Input
|
open={isCreateDialogOpen}
|
||||||
placeholder="Введите описание шаблона"
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
value={newTemplateDescription}
|
>
|
||||||
onChange={e => setNewTemplateDescription(e.target.value)}
|
<DialogTrigger asChild>
|
||||||
/>
|
<Button className="flex items-center gap-2">
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
</div>
|
Создать шаблон
|
||||||
<div className="flex justify-end gap-2">
|
</Button>
|
||||||
<Button
|
</DialogTrigger>
|
||||||
variant="outline"
|
<DialogContent>
|
||||||
onClick={() => setIsCreateDialogOpen(false)}
|
<DialogHeader>
|
||||||
>
|
<DialogTitle>Создать новый шаблон</DialogTitle>
|
||||||
Отмена
|
<DialogDescription>
|
||||||
</Button>
|
Введите основную информацию для нового шаблона протокола
|
||||||
<Button
|
</DialogDescription>
|
||||||
onClick={handleCreateTemplate}
|
</DialogHeader>
|
||||||
disabled={!newTemplateName.trim()}
|
<div className="space-y-4">
|
||||||
>
|
<div>
|
||||||
Создать
|
<label className="mb-1 block text-sm font-medium">
|
||||||
</Button>
|
Название шаблона
|
||||||
</div>
|
</label>
|
||||||
</DialogContent>
|
<Input
|
||||||
</Dialog>
|
placeholder="Например: Протокол испытаний"
|
||||||
|
value={newTemplateName}
|
||||||
|
onChange={e => setNewTemplateName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateTemplate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">
|
||||||
|
Описание (необязательно)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Краткое описание назначения шаблона"
|
||||||
|
value={newTemplateDescription}
|
||||||
|
onChange={e =>
|
||||||
|
setNewTemplateDescription(e.target.value)
|
||||||
|
}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateTemplate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsCreateDialogOpen(false)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Отменить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateTemplate}
|
||||||
|
disabled={!newTemplateName.trim() || isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? 'Создание...' : 'Создать'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{templates.length === 0 ? (
|
{/* Показываем индикатор загрузки */}
|
||||||
<div className="py-12 text-center">
|
{isLoading && (
|
||||||
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
|
<div className="flex h-64 items-center justify-center">
|
||||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
<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>
|
||||||
</h3>
|
<p className="text-gray-600">Загрузка шаблонов...</p>
|
||||||
<p className="mb-6 text-gray-600">
|
</div>
|
||||||
Создайте первый шаблон для начала работы
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Создать шаблон
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{templates.map(template => (
|
|
||||||
<div key={template.id} className="relative">
|
|
||||||
<div
|
|
||||||
className="cursor-pointer transition-all duration-200"
|
|
||||||
onClick={() => {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
toggleTemplateSelection(template.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateCard
|
|
||||||
template={template}
|
|
||||||
onDelete={() => {}} // Убрали onDelete из карточки
|
|
||||||
isSelected={
|
|
||||||
isSelectionMode && selectedTemplates.has(template.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Диалог подтверждения удаления */}
|
{/* Список шаблонов */}
|
||||||
<Dialog
|
{!isLoading && (
|
||||||
open={templatesToDelete.length > 0}
|
<>
|
||||||
onOpenChange={() => setTemplatesToDelete([])}
|
{templates.length === 0 ? (
|
||||||
>
|
<div className="flex h-64 items-center justify-center">
|
||||||
<DialogContent>
|
<div className="text-center">
|
||||||
<DialogHeader>
|
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
|
||||||
<DialogTitle>Удалить шаблоны</DialogTitle>
|
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||||
<DialogDescription>
|
Нет шаблонов
|
||||||
Вы уверены, что хотите удалить {templatesToDelete.length}{' '}
|
</h3>
|
||||||
шаблонов? Это действие нельзя будет отменить.
|
<p className="mb-4 text-gray-600">
|
||||||
</DialogDescription>
|
Создайте первый шаблон для начала работы
|
||||||
</DialogHeader>
|
</p>
|
||||||
<div className="space-y-2">
|
<Dialog
|
||||||
{templatesToDelete.map(template => (
|
open={isCreateDialogOpen}
|
||||||
<div
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
key={template.id}
|
>
|
||||||
className="flex items-center gap-2 rounded border p-2"
|
<DialogTrigger asChild>
|
||||||
>
|
<Button>
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<span className="text-sm">{template.name}</span>
|
Создать шаблон
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Button variant="outline" onClick={() => setTemplatesToDelete([])}>
|
{templates.map(template => (
|
||||||
Отмена
|
<div key={template.id} className="relative">
|
||||||
</Button>
|
{isSelectionMode && (
|
||||||
<Button variant="destructive" onClick={handleDeleteTemplates}>
|
<div className="absolute right-2 top-2 z-10">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<button
|
||||||
Удалить
|
onClick={() => toggleTemplateSelection(template.id)}
|
||||||
</Button>
|
className="rounded-full bg-white p-1 shadow-md"
|
||||||
</div>
|
>
|
||||||
</DialogContent>
|
{selectedTemplates.has(template.id) ? (
|
||||||
</Dialog>
|
<CircleCheck className="h-5 w-5 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TemplateCard
|
||||||
|
template={template}
|
||||||
|
onDelete={() => deleteTemplate(template.id)}
|
||||||
|
isSelected={selectedTemplates.has(template.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Диалог подтверждения удаления */}
|
||||||
|
{templatesToDelete.length > 0 && (
|
||||||
|
<Dialog
|
||||||
|
open={templatesToDelete.length > 0}
|
||||||
|
onOpenChange={() => setTemplatesToDelete([])}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Подтверждение удаления</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Вы действительно хотите удалить выбранные шаблоны? Это действие
|
||||||
|
нельзя отменить.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{templatesToDelete.map(template => (
|
||||||
|
<div key={template.id} className="text-sm text-gray-600">
|
||||||
|
• {template.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setTemplatesToDelete([])}
|
||||||
|
>
|
||||||
|
Отменить
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteTemplates}>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
const migratedTemplate = migrateTemplate(updatedTemplate)
|
setIsLoading(true)
|
||||||
console.log('Migrated template:', migratedTemplate)
|
setError(null)
|
||||||
console.log('Migrated elements count:', migratedTemplate.elements.length)
|
try {
|
||||||
|
const patchInput = templateToPatchInput(updatedTemplate)
|
||||||
|
await patchTemplate(updatedTemplate.id, patchInput)
|
||||||
|
|
||||||
setTemplates((prev) =>
|
// Обновляем локальное состояние
|
||||||
prev.map((t) => (t.id === migratedTemplate.id ? migratedTemplate : t)),
|
const migratedTemplate = migrateTemplate(updatedTemplate)
|
||||||
)
|
console.log('Migrated template:', migratedTemplate)
|
||||||
|
console.log('Migrated elements count:', migratedTemplate.elements.length)
|
||||||
|
|
||||||
|
setTemplates(prev =>
|
||||||
|
prev.map(t => (t.id === migratedTemplate.id ? migratedTemplate : t))
|
||||||
|
)
|
||||||
|
} 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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
370
src/hooks/useSheetAutoSave.ts
Normal file
370
src/hooks/useSheetAutoSave.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
CellUtils,
|
CellUtils,
|
||||||
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
|
||||||
|
|||||||
@@ -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,88 +36,105 @@ export const ElementsCreation: FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTemplate = {
|
setIsSaving(true)
|
||||||
...selectedTemplate,
|
try {
|
||||||
elements: [...selectedTemplate.elements, element],
|
const updatedTemplate = {
|
||||||
updatedAt: new Date(),
|
...selectedTemplate,
|
||||||
|
elements: [...selectedTemplate.elements, element],
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Updated template:', updatedTemplate)
|
||||||
|
console.log('Elements count:', updatedTemplate.elements.length)
|
||||||
|
|
||||||
|
await updateTemplate(updatedTemplate)
|
||||||
|
setSelectedTemplate(updatedTemplate)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при добавлении элемента:', error)
|
||||||
|
alert('Ошибка при добавлении элемента. Попробуйте еще раз.')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Updated template:', updatedTemplate)
|
|
||||||
console.log('Elements count:', updatedTemplate.elements.length)
|
|
||||||
|
|
||||||
updateTemplate(updatedTemplate)
|
|
||||||
setSelectedTemplate(updatedTemplate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleElementUpdate = (
|
const handleElementUpdate = async (
|
||||||
elementId: string,
|
elementId: string,
|
||||||
updates: Partial<TemplateElement>
|
updatedElement: TemplateElement
|
||||||
) => {
|
) => {
|
||||||
if (!selectedTemplate) return
|
if (!selectedTemplate) return
|
||||||
|
|
||||||
const updatedTemplate = {
|
setIsSaving(true)
|
||||||
...selectedTemplate,
|
try {
|
||||||
elements: selectedTemplate.elements.map(el =>
|
const updatedTemplate = {
|
||||||
el.id === elementId ? { ...el, ...updates } : el
|
...selectedTemplate,
|
||||||
),
|
elements: selectedTemplate.elements.map(el =>
|
||||||
updatedAt: new Date(),
|
el.id === elementId ? updatedElement : el
|
||||||
}
|
),
|
||||||
|
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
|
||||||
|
|
||||||
const updatedTemplate = {
|
setIsSaving(true)
|
||||||
...selectedTemplate,
|
try {
|
||||||
elements: selectedTemplate.elements.filter(el => el.id !== elementId),
|
const updatedTemplate = {
|
||||||
updatedAt: new Date(),
|
...selectedTemplate,
|
||||||
|
elements: selectedTemplate.elements.filter(el => el.id !== elementId),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTemplate(updatedTemplate)
|
||||||
|
setSelectedTemplate(updatedTemplate)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при удалении элемента:', error)
|
||||||
|
alert('Ошибка при удалении элемента. Попробуйте еще раз.')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTemplate(updatedTemplate)
|
|
||||||
setSelectedTemplate(updatedTemplate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLayoutSettingsChange = (settings: FormLayoutSettings) => {
|
// Показываем ошибку
|
||||||
if (!selectedTemplate) return
|
if (error) {
|
||||||
|
return (
|
||||||
const updatedTemplate = {
|
<div className="flex h-screen items-center justify-center">
|
||||||
...selectedTemplate,
|
<div className="text-center">
|
||||||
layoutSettings: settings,
|
<Wrench className="mx-auto mb-4 h-16 w-16 text-red-400" />
|
||||||
updatedAt: new Date(),
|
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||||
}
|
Ошибка загрузки
|
||||||
|
</h3>
|
||||||
updateTemplate(updatedTemplate)
|
<p className="mb-4 text-gray-600">{error}</p>
|
||||||
setSelectedTemplate(updatedTemplate)
|
<Button onClick={() => navigate('/templates')}>
|
||||||
}
|
Назад к шаблонам
|
||||||
|
</Button>
|
||||||
// Настройки отображения по умолчанию
|
</div>
|
||||||
const defaultLayoutSettings: FormLayoutSettings = {
|
</div>
|
||||||
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"
|
||||||
</Button>
|
onClick={() => navigate('/templates')}
|
||||||
<div>
|
className="mr-4"
|
||||||
<h1 className="text-xl font-semibold text-gray-800">
|
>
|
||||||
Конструктор элементов: {selectedTemplate.name}
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</h1>
|
</Button>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<div className="flex items-center space-x-2">
|
||||||
Создайте элементы формы и разместите их на холсте
|
<Wrench className="h-5 w-5" />
|
||||||
перетаскиванием
|
<h1 className="text-lg font-semibold">Редактор элементов</h1>
|
||||||
</p>
|
<span className="text-sm text-muted-foreground">—</span>
|
||||||
</div>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedTemplate.name}
|
||||||
|
</span>
|
||||||
</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 className="h-[calc(100vh-104px)]">
|
<ElementConstructor
|
||||||
<ElementConstructor
|
template={selectedTemplate}
|
||||||
elements={selectedTemplate.elements}
|
onElementAdd={handleElementAdd}
|
||||||
layoutSettings={
|
onElementUpdate={handleElementUpdate}
|
||||||
selectedTemplate.layoutSettings || defaultLayoutSettings
|
onElementDelete={handleElementDelete}
|
||||||
}
|
disabled={isSaving}
|
||||||
onElementAdd={handleElementAdd}
|
/>
|
||||||
onElementUpdate={handleElementUpdate}
|
|
||||||
onElementDelete={handleElementDelete}
|
|
||||||
onElementsReorder={handleElementsReorder}
|
|
||||||
onLayoutSettingsChange={handleLayoutSettingsChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,57 +465,76 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Полноэкранная форма */}
|
{/* Основное содержимое */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div
|
{showSpreadsheet ? (
|
||||||
className="relative mx-auto my-8"
|
/* Режим таблиц */
|
||||||
style={{ width: canvasSize.width, height: canvasSize.height }}
|
<div className="h-full">
|
||||||
>
|
<DualSpreadsheet
|
||||||
{template.elements.map(element => {
|
templateData={
|
||||||
const layout = element.layout || {
|
template.excelData ? { report: template.excelData } : {}
|
||||||
x: 50,
|
}
|
||||||
y: 50,
|
mergedCells={template.mergedCells || []}
|
||||||
width: 300,
|
templateId={template.id}
|
||||||
height: 80,
|
/>
|
||||||
}
|
</div>
|
||||||
return (
|
) : (
|
||||||
<div
|
/* Режим формы */
|
||||||
key={element.id}
|
<div className="h-full overflow-auto">
|
||||||
className="absolute"
|
<div
|
||||||
style={{
|
className="relative mx-auto my-8"
|
||||||
left: layout.x,
|
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||||
top: layout.y,
|
>
|
||||||
width: layout.width,
|
{template.elements.map(element => {
|
||||||
minHeight: layout.height,
|
const layout = element.layout || {
|
||||||
zIndex: layout.zIndex || 1,
|
x: 50,
|
||||||
}}
|
y: 50,
|
||||||
>
|
width: 300,
|
||||||
<FormElement
|
height: 80,
|
||||||
element={element}
|
}
|
||||||
value={formData[element.id]}
|
return (
|
||||||
onChange={value => handleFieldChange(element.id, value)}
|
<div
|
||||||
/>
|
key={element.id}
|
||||||
</div>
|
className="absolute"
|
||||||
)
|
style={{
|
||||||
})}
|
left: layout.x,
|
||||||
|
top: layout.y,
|
||||||
|
width: layout.width,
|
||||||
|
minHeight: layout.height,
|
||||||
|
zIndex: layout.zIndex || 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormElement
|
||||||
|
element={element}
|
||||||
|
value={formData[element.id]}
|
||||||
|
onChange={value => handleFieldChange(element.id, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{template.elements.length === 0 && (
|
{template.elements.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center text-gray-400">
|
<div className="text-center text-gray-400">
|
||||||
<FileText className="mx-auto mb-4 h-16 w-16" />
|
<FileText className="mx-auto mb-4 h-16 w-16" />
|
||||||
<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-4 text-gray-600">
|
<p className="mb-4 text-gray-600">
|
||||||
В этом шаблоне пока не созданы элементы формы
|
В этом шаблоне пока не созданы элементы формы
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" onClick={() => window.history.back()}>
|
<Button
|
||||||
Настроить шаблон
|
variant="outline"
|
||||||
</Button>
|
onClick={() => window.history.back()}
|
||||||
</div>
|
>
|
||||||
|
Настроить шаблон
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
177
src/services/fileApiSevice.ts
Normal file
177
src/services/fileApiSevice.ts
Normal 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('Ошибка при получении данных файла')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
204
src/services/sheetApiService.ts
Normal file
204
src/services/sheetApiService.ts
Normal 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('Ошибка при обновлении листа на сервере')
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/services/templateApiService.ts
Normal file
198
src/services/templateApiService.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user