добился какой то работы с API
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useFormulaAutoSave } from '@/hooks/useFormulaAutoSave'
|
||||
import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine'
|
||||
import { useSheetAutoSave } from '@/hooks/useSheetAutoSave'
|
||||
import { getFileData, getLatestFileForTemplate } from '@/services/fileApiSevice'
|
||||
import { produce } from 'immer'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
@@ -22,15 +23,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
mergedCells = [],
|
||||
templateId,
|
||||
}) => {
|
||||
const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine(
|
||||
{
|
||||
const { revision, updateRevision, setTemplateData, ...multiSheetEngine } =
|
||||
useMultiSheetEngine({
|
||||
sheets: [
|
||||
{ name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT },
|
||||
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
|
||||
],
|
||||
initialData: templateData || {},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
|
||||
const [selectedCell, setSelectedCell] = useState<{
|
||||
@@ -40,6 +40,9 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [baseFileData, setBaseFileData] = useState<
|
||||
Record<string, Record<string, any>>
|
||||
>({}) // Базовые данные файла
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
|
||||
() => ({
|
||||
@@ -74,29 +77,42 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
const lastRevision = useRef<number>(-1)
|
||||
|
||||
useEffect(() => {
|
||||
const reportTemplate = templateData?.[SHEET_NAMES.report]
|
||||
// Используем baseFileData как источник базовых значений для сравнения
|
||||
const reportBaseData = baseFileData?.[SHEET_NAMES.report]
|
||||
|
||||
// Минимальная отладка templateData
|
||||
if (!templateData || Object.keys(templateData).length === 0) {
|
||||
console.log('⚠️ templateData пустой')
|
||||
}
|
||||
|
||||
if (reportTemplate) {
|
||||
if (reportBaseData && Object.keys(reportBaseData).length > 0) {
|
||||
// Очищаем старые значения
|
||||
initialTemplateValues.current = {}
|
||||
|
||||
Object.entries(reportTemplate).forEach(([cellName, value]) => {
|
||||
Object.entries(reportBaseData).forEach(([cellName, value]) => {
|
||||
initialTemplateValues.current[cellName] = String(value)
|
||||
})
|
||||
|
||||
console.log(
|
||||
'✅ Загружено значений шаблона:',
|
||||
'✅ Загружено базовых значений файла для сравнения:',
|
||||
Object.keys(initialTemplateValues.current).length
|
||||
)
|
||||
} 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(() => {
|
||||
if (selectedCell && activeSheet) {
|
||||
@@ -150,16 +166,16 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
const displayValue = allCellValues[cellKey]
|
||||
|
||||
const cellName = getColumnLabel(col) + (row + 1)
|
||||
const templateValue = initialTemplateValues.current[cellName] ?? ''
|
||||
const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
|
||||
|
||||
// Отладка только для измененных ячеек
|
||||
if (
|
||||
sheetType === 'report' &&
|
||||
templateValue &&
|
||||
rawValue !== templateValue
|
||||
baseFileValue &&
|
||||
rawValue !== baseFileValue
|
||||
) {
|
||||
console.log(`🔧 Измененная ячейка ${cellName}:`, {
|
||||
templateValue,
|
||||
baseFileValue,
|
||||
rawValue,
|
||||
isModified: true,
|
||||
})
|
||||
@@ -174,18 +190,18 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
return str
|
||||
}
|
||||
|
||||
const normalizedTemplate = normalizeValue(templateValue)
|
||||
const normalizedBaseFile = normalizeValue(baseFileValue)
|
||||
const normalizedRaw = normalizeValue(rawValue)
|
||||
|
||||
const isModified =
|
||||
sheetType === 'report' &&
|
||||
normalizedTemplate !== '' &&
|
||||
normalizedRaw !== normalizedTemplate
|
||||
normalizedBaseFile !== '' &&
|
||||
normalizedRaw !== normalizedBaseFile
|
||||
|
||||
// Отладка только для измененных ячеек
|
||||
if (sheetType === 'report' && isModified) {
|
||||
console.log(`🔍 Измененная ячейка ${cellName}:`, {
|
||||
templateValue,
|
||||
baseFileValue,
|
||||
rawValue,
|
||||
isModified,
|
||||
})
|
||||
@@ -276,15 +292,15 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
selectedCell.row,
|
||||
selectedCell.col
|
||||
)
|
||||
const templateValue = initialTemplateValues.current[cellName] ?? ''
|
||||
const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
|
||||
|
||||
console.log('💾 Сохранение ячейки:', {
|
||||
cellName,
|
||||
oldValue,
|
||||
newValue: editingValue,
|
||||
templateValue,
|
||||
baseFileValue,
|
||||
isChanged: oldValue !== editingValue,
|
||||
isModifiedFromTemplate: templateValue !== editingValue,
|
||||
isModifiedFromBaseFile: baseFileValue !== editingValue,
|
||||
})
|
||||
|
||||
multiSheetEngine.setCellValueWithoutRecalc(
|
||||
@@ -494,12 +510,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
// Используем новый хук автосохранения
|
||||
const {
|
||||
isSaving,
|
||||
isLoading,
|
||||
hasUnsavedChanges,
|
||||
lastSaveError,
|
||||
manualSave,
|
||||
clearError,
|
||||
loadSavedFormulas,
|
||||
} = useFormulaAutoSave(
|
||||
loadSavedSheets,
|
||||
loadSheets, // Добавляем асинхронную функцию загрузки
|
||||
} = useSheetAutoSave(
|
||||
multiSheetEngine.getAllModifiedCells,
|
||||
multiSheetEngine.isCalculating,
|
||||
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) => {
|
||||
// Используем requestAnimationFrame для более плавного обновления
|
||||
@@ -527,59 +558,136 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
useEffect(() => {
|
||||
if (!templateId || isInitialized) return
|
||||
|
||||
// Проверяем, есть ли начальные данные
|
||||
const hasInitialData =
|
||||
Object.keys(templateData).length > 0 &&
|
||||
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
|
||||
// Асинхронная функция для загрузки и слияния данных
|
||||
const initializeData = async () => {
|
||||
let finalBaseData: Record<string, Record<string, any>> = {}
|
||||
|
||||
// Всегда загружаем сохраненные данные, если они есть
|
||||
const savedFormulas = loadSavedFormulas()
|
||||
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
|
||||
if (templateId) {
|
||||
try {
|
||||
console.log('🔍 Ищем файлы для templateId:', templateId)
|
||||
const latestFile = await getLatestFileForTemplate(templateId)
|
||||
|
||||
if (savedFormulas && Object.keys(savedFormulas).length > 0) {
|
||||
console.log('🔄 Загружаем сохраненные формулы в движок:', savedFormulas)
|
||||
if (latestFile) {
|
||||
console.log(
|
||||
'📄 Найден файл:',
|
||||
latestFile.name,
|
||||
'ID:',
|
||||
latestFile.id
|
||||
)
|
||||
const fileData = await getFileData(latestFile.id)
|
||||
|
||||
if (hasInitialData) {
|
||||
// Если есть начальные данные, СЛИВАЕМ их с сохраненными изменениями
|
||||
console.log('📋 Сливаем данные шаблона с сохраненными изменениями')
|
||||
|
||||
// Создаем объединенные данные: шаблон + сохраненные изменения
|
||||
const mergedData: Record<string, Record<string, any>> = {}
|
||||
|
||||
// Копируем данные шаблона
|
||||
Object.entries(templateData).forEach(([sheetName, sheetData]) => {
|
||||
mergedData[sheetName] = { ...sheetData }
|
||||
})
|
||||
|
||||
// Применяем сохраненные изменения поверх шаблона
|
||||
Object.entries(savedFormulas).forEach(([sheetName, sheetData]) => {
|
||||
if (!mergedData[sheetName]) {
|
||||
mergedData[sheetName] = {}
|
||||
// TODO: Пока getFileData возвращает пустые данные, используем templateData
|
||||
// Когда API будет готов, здесь будут реальные данные файла
|
||||
if (Object.keys(fileData.data).length > 0) {
|
||||
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
|
||||
setBaseFileData(finalBaseData)
|
||||
console.log('📄 Загружены базовые данные файла:', finalBaseData)
|
||||
} else {
|
||||
console.log(
|
||||
'⚠️ getFileData вернул пустые данные, используем templateData'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ Файл не найден для templateId:', templateId)
|
||||
}
|
||||
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||||
mergedData[sheetName][cellAddress] = value
|
||||
})
|
||||
})
|
||||
|
||||
console.log('📊 Загружаем объединенные данные (шаблон + изменения)')
|
||||
multiSheetEngine.loadData(mergedData)
|
||||
} else {
|
||||
// Если нет начальных данных, используем только сохраненные
|
||||
multiSheetEngine.loadData(savedFormulas)
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка при загрузке данных файла:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Сохраненные формулы загружены в движок')
|
||||
} else if (hasInitialData) {
|
||||
console.log('📋 Используем только начальные данные шаблона')
|
||||
} else {
|
||||
console.log('⚠️ Нет данных для загрузки')
|
||||
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
|
||||
const hasTemplateData =
|
||||
Object.keys(templateData).length > 0 &&
|
||||
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
|
||||
|
||||
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,
|
||||
templateData,
|
||||
loadSavedFormulas,
|
||||
loadSheets,
|
||||
multiSheetEngine,
|
||||
setTemplateData,
|
||||
isInitialized,
|
||||
])
|
||||
|
||||
@@ -598,6 +706,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
formulaBarRef.current = el
|
||||
}}
|
||||
isSaving={isSaving}
|
||||
isLoading={isLoading}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
lastSaveError={lastSaveError}
|
||||
onManualSave={manualSave}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const FormulaBar = memo(
|
||||
onKeyDown,
|
||||
formulaBarRef,
|
||||
isSaving = false,
|
||||
isLoading = false,
|
||||
hasUnsavedChanges = false,
|
||||
lastSaveError = null,
|
||||
onManualSave,
|
||||
@@ -68,7 +69,7 @@ export const FormulaBar = memo(
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{(isCalculating || isSaving) && (
|
||||
{(isCalculating || isSaving || isLoading) && (
|
||||
<div className="flex items-center text-muted-foreground">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 animate-spin"
|
||||
@@ -90,7 +91,11 @@ export const FormulaBar = memo(
|
||||
></path>
|
||||
</svg>
|
||||
<span className="ml-1 text-xs">
|
||||
{isSaving ? 'Сохранение...' : 'Расчет...'}
|
||||
{isSaving
|
||||
? 'Сохранение...'
|
||||
: isLoading
|
||||
? 'Загрузка...'
|
||||
: 'Расчет...'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface FormulaBarProps {
|
||||
formulaBarRef: (el: HTMLInputElement | null) => void
|
||||
// Обновляем props для сохранения
|
||||
isSaving?: boolean
|
||||
isLoading?: boolean
|
||||
hasUnsavedChanges?: boolean
|
||||
lastSaveError?: string | null
|
||||
onManualSave?: () => void
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CellTarget,
|
||||
ElementType,
|
||||
FormLayoutSettings,
|
||||
Template,
|
||||
TemplateElement,
|
||||
} from '@/types/template'
|
||||
import {
|
||||
@@ -18,10 +19,8 @@ import {
|
||||
Droplets,
|
||||
Edit3,
|
||||
Gauge,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Radio,
|
||||
Settings,
|
||||
Thermometer,
|
||||
Trash2,
|
||||
Zap,
|
||||
@@ -64,10 +63,10 @@ const CellTargetEditor: React.FC<{
|
||||
const handleUpdateTarget = (
|
||||
index: number,
|
||||
field: keyof CellTarget,
|
||||
value: string,
|
||||
value: string
|
||||
) => {
|
||||
const updatedTargets = targets.map((target, i) =>
|
||||
i === index ? { ...target, [field]: value } : target,
|
||||
i === index ? { ...target, [field]: value } : target
|
||||
)
|
||||
onChange(updatedTargets)
|
||||
}
|
||||
@@ -100,7 +99,7 @@ const CellTargetEditor: React.FC<{
|
||||
</label>
|
||||
<Select
|
||||
value={target.sheet}
|
||||
onValueChange={(value) =>
|
||||
onValueChange={value =>
|
||||
handleUpdateTarget(index, 'sheet', value)
|
||||
}
|
||||
>
|
||||
@@ -122,7 +121,7 @@ const CellTargetEditor: React.FC<{
|
||||
<Input
|
||||
placeholder="A1, B5..."
|
||||
value={target.cell}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateTarget(index, 'cell', e.target.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
@@ -353,7 +352,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
: null
|
||||
|
||||
const handleAddOption = () => {
|
||||
setFormData((prev) => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: [...(prev.options || []), { value: '', label: '' }],
|
||||
}))
|
||||
@@ -362,18 +361,18 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
const handleUpdateOption = (
|
||||
index: number,
|
||||
field: 'value' | 'label',
|
||||
value: string,
|
||||
value: string
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: prev.options?.map((option, i) =>
|
||||
i === index ? { ...option, [field]: value } : option,
|
||||
i === index ? { ...option, [field]: value } : option
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
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>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => {
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType
|
||||
console.log('Element type changed to:', newType)
|
||||
|
||||
setFormData((prev) => {
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType }
|
||||
|
||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
||||
@@ -407,7 +406,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
newType === 'calibration-conditions'
|
||||
) {
|
||||
console.log(
|
||||
`${newType} type selected, getting element definition`,
|
||||
`${newType} type selected, getting element definition`
|
||||
)
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
console.log('Element definition:', elementDefinition)
|
||||
@@ -450,8 +449,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<Input
|
||||
placeholder="Название поля"
|
||||
value={formData.label}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, label: e.target.value }))
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, label: e.target.value }))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
@@ -462,8 +461,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<Input
|
||||
placeholder="Введите значение..."
|
||||
value={formData.placeholder}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
onChange={e =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
placeholder: e.target.value,
|
||||
}))
|
||||
@@ -477,8 +476,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={formData.required}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData((prev) => ({ ...prev, required: !!checked }))
|
||||
onCheckedChange={checked =>
|
||||
setFormData(prev => ({ ...prev, required: !!checked }))
|
||||
}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
@@ -488,16 +487,16 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
|
||||
<CellTargetEditor
|
||||
targets={formData.targetCells || []}
|
||||
onChange={(targets) =>
|
||||
setFormData((prev) => ({ ...prev, targetCells: targets }))
|
||||
onChange={targets =>
|
||||
setFormData(prev => ({ ...prev, targetCells: targets }))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Специфичный редактор элемента */}
|
||||
<elementDefinition.Editor
|
||||
config={formData as any}
|
||||
onChange={(newConfig) =>
|
||||
setFormData((prev) => ({ ...prev, ...newConfig }))
|
||||
onChange={newConfig =>
|
||||
setFormData(prev => ({ ...prev, ...newConfig }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -519,11 +518,11 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<label className="text-sm font-medium">Тип элемента</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => {
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType
|
||||
console.log('Element type changed to (fallback):', newType)
|
||||
|
||||
setFormData((prev) => {
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType }
|
||||
|
||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
||||
@@ -532,12 +531,12 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
newType === 'calibration-conditions'
|
||||
) {
|
||||
console.log(
|
||||
`${newType} type selected (fallback), getting element definition`,
|
||||
`${newType} type selected (fallback), getting element definition`
|
||||
)
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
console.log(
|
||||
'Element definition (fallback):',
|
||||
elementDefinition,
|
||||
elementDefinition
|
||||
)
|
||||
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
@@ -547,7 +546,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
elementDefinition.mapToCells?.(defaultConfig) || []
|
||||
console.log(
|
||||
'Generated targetCells (fallback):',
|
||||
updates.targetCells,
|
||||
updates.targetCells
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -579,8 +578,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<Input
|
||||
placeholder="Название поля"
|
||||
value={formData.label}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, label: e.target.value }))
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, label: e.target.value }))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
@@ -588,8 +587,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
|
||||
<CellTargetEditor
|
||||
targets={formData.targetCells || []}
|
||||
onChange={(targets) =>
|
||||
setFormData((prev) => ({ ...prev, targetCells: targets }))
|
||||
onChange={targets =>
|
||||
setFormData(prev => ({ ...prev, targetCells: targets }))
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -613,7 +612,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<Input
|
||||
placeholder="Значение"
|
||||
value={option.value}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'value', e.target.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
@@ -621,7 +620,7 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<Input
|
||||
placeholder="Подпись"
|
||||
value={option.label}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'label', e.target.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
@@ -664,7 +663,7 @@ const CanvasSettings: React.FC<CanvasSettingsProps> = ({
|
||||
<label className="text-sm font-medium">Размер сетки (px)</label>
|
||||
<Select
|
||||
value={settings.gridSize.toString()}
|
||||
onValueChange={(value) =>
|
||||
onValueChange={value =>
|
||||
onChange({ ...settings, gridSize: parseInt(value) })
|
||||
}
|
||||
>
|
||||
@@ -684,7 +683,7 @@ const CanvasSettings: React.FC<CanvasSettingsProps> = ({
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={settings.showGrid}
|
||||
onCheckedChange={(checked) =>
|
||||
onCheckedChange={checked =>
|
||||
onChange({ ...settings, showGrid: !!checked })
|
||||
}
|
||||
/>
|
||||
@@ -697,31 +696,29 @@ const CanvasSettings: React.FC<CanvasSettingsProps> = ({
|
||||
}
|
||||
|
||||
interface ElementConstructorProps {
|
||||
elements: TemplateElement[]
|
||||
layoutSettings: FormLayoutSettings
|
||||
template: Template
|
||||
onElementAdd: (element: TemplateElement) => void
|
||||
onElementUpdate: (
|
||||
elementId: string,
|
||||
updates: Partial<TemplateElement>,
|
||||
) => void
|
||||
onElementUpdate: (elementId: string, element: TemplateElement) => void
|
||||
onElementDelete: (elementId: string) => void
|
||||
onElementsReorder: (elements: TemplateElement[]) => void
|
||||
onLayoutSettingsChange: (settings: FormLayoutSettings) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
elements,
|
||||
layoutSettings,
|
||||
template,
|
||||
onElementAdd,
|
||||
onElementUpdate,
|
||||
onElementDelete,
|
||||
// onElementsReorder,
|
||||
onLayoutSettingsChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const elements = template.elements
|
||||
const layoutSettings = template.layoutSettings || {
|
||||
gridSize: 20,
|
||||
showGrid: true,
|
||||
}
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
const [editingElement, setEditingElement] = useState<TemplateElement | null>(
|
||||
null,
|
||||
null
|
||||
)
|
||||
const [formData, setFormData] = useState<Partial<TemplateElement>>({
|
||||
type: 'text',
|
||||
@@ -760,19 +757,19 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
!formData.targetCells?.length
|
||||
) {
|
||||
console.log(
|
||||
'Validation failed: missing targetCells for element that requires manual targetCells',
|
||||
'Validation failed: missing targetCells for element that requires manual targetCells'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Генерируем 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 layout = findFreePosition(
|
||||
defaultLayout,
|
||||
existingLayouts as any[],
|
||||
layoutSettings.gridSize,
|
||||
layoutSettings.gridSize
|
||||
)
|
||||
|
||||
// Специальная обработка для элементов с автоматической генерацией targetCells
|
||||
@@ -852,11 +849,15 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
const handleAutoArrange = () => {
|
||||
const arrangedElements = autoArrangeElements(
|
||||
elements,
|
||||
layoutSettings.gridSize,
|
||||
layoutSettings.gridSize
|
||||
)
|
||||
|
||||
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">
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" disabled={disabled}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Добавить элемент
|
||||
</Button>
|
||||
@@ -908,6 +909,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
<Button
|
||||
onClick={handleAddElement}
|
||||
disabled={
|
||||
disabled ||
|
||||
!formData.label ||
|
||||
(formData.type !== 'standards' &&
|
||||
formData.type !== 'calibration-conditions' &&
|
||||
@@ -939,38 +941,13 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
<Button onClick={handleCancelEdit} variant="outline">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleUpdateElement}>
|
||||
<Button onClick={handleUpdateElement} disabled={disabled}>
|
||||
Сохранить изменения
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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 className="flex-1 overflow-hidden">
|
||||
@@ -979,7 +956,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
layoutSettings={layoutSettings}
|
||||
onElementUpdate={onElementUpdate}
|
||||
onElementDelete={handleDeleteAndSelect}
|
||||
onLayoutSettingsChange={onLayoutSettingsChange}
|
||||
onLayoutSettingsChange={() => {}} // Настройки макета только для чтения
|
||||
onElementEdit={handleEditElement}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet'
|
||||
import { ExcelParseResult } from '@/services/excelSevice'
|
||||
import { ExcelParseResult } from '@/services/fileApiSevice'
|
||||
import React from 'react'
|
||||
|
||||
interface SpreadsheetViewerProps {
|
||||
data: Record<string, any>
|
||||
mergedCells: ExcelParseResult['mergedCells']
|
||||
dataVersion: number
|
||||
templateId: number | string
|
||||
templateId: string | undefined
|
||||
}
|
||||
|
||||
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
|
||||
@@ -20,7 +20,7 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
|
||||
key={dataVersion}
|
||||
templateData={{ L: data }}
|
||||
mergedCells={mergedCells}
|
||||
templateId={`template-${templateId}`}
|
||||
templateId={templateId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseExcelFile } from '@/services/excelSevice'
|
||||
import { parseExcelFile } from '@/services/fileApiSevice'
|
||||
import { Template } from '@/types/template'
|
||||
import React, { useState } from 'react'
|
||||
import { ExcelUploadPanel } from './ExcelUploadPanel'
|
||||
@@ -27,19 +27,27 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
if (!file) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { data, mergedCells } = await parseExcelFile(file)
|
||||
const { fileId, data, mergedCells } = await parseExcelFile(
|
||||
file,
|
||||
template.id
|
||||
)
|
||||
setExcelData(data)
|
||||
setEditedTemplate(prev => ({
|
||||
...prev,
|
||||
|
||||
const updatedTemplate = {
|
||||
...template,
|
||||
excelFile: file,
|
||||
excelData: data,
|
||||
mergedCells,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
}
|
||||
|
||||
setEditedTemplate(updatedTemplate)
|
||||
setDataVersion(v => v + 1)
|
||||
|
||||
console.log('✅ Файл загружен с ID:', fileId)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Ошибка при чтении Excel файла')
|
||||
alert('Ошибка при загрузке Excel файла на сервер')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,15 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
templateId,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { templates, addTemplate, updateTemplate, deleteTemplate } =
|
||||
useTemplateContext()
|
||||
const {
|
||||
templates,
|
||||
isLoading,
|
||||
error,
|
||||
addTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
loadTemplates,
|
||||
} = useTemplateContext()
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null
|
||||
)
|
||||
@@ -39,6 +46,7 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
new Set()
|
||||
)
|
||||
const [templatesToDelete, setTemplatesToDelete] = useState<Template[]>([])
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
// Автоматически открываем редактирование шаблона, если передан templateId
|
||||
useEffect(() => {
|
||||
@@ -51,29 +59,50 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
}
|
||||
}, [templateId, templates])
|
||||
|
||||
const handleCreateTemplate = () => {
|
||||
const handleCreateTemplate = async () => {
|
||||
if (!newTemplateName.trim()) return
|
||||
|
||||
const newTemplate: Template = {
|
||||
id: Date.now().toString(),
|
||||
name: newTemplateName.trim(),
|
||||
description: newTemplateDescription.trim(),
|
||||
elements: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
setIsCreating(true)
|
||||
try {
|
||||
await addTemplate({
|
||||
name: newTemplateName.trim(),
|
||||
description: newTemplateDescription.trim(),
|
||||
elements: [],
|
||||
layoutSettings: undefined,
|
||||
})
|
||||
|
||||
addTemplate(newTemplate)
|
||||
setSelectedTemplate(newTemplate)
|
||||
setIsEditMode(true)
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewTemplateName('')
|
||||
setNewTemplateDescription('')
|
||||
// Находим созданный шаблон и открываем его для редактирования
|
||||
// Поскольку addTemplate может занять время, используем небольшую задержку
|
||||
setTimeout(() => {
|
||||
const newTemplate = templates.find(
|
||||
t => t.name === newTemplateName.trim()
|
||||
)
|
||||
if (newTemplate) {
|
||||
setSelectedTemplate(newTemplate)
|
||||
setIsEditMode(true)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewTemplateName('')
|
||||
setNewTemplateDescription('')
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании шаблона:', error)
|
||||
// Здесь можно добавить уведомление пользователю об ошибке
|
||||
alert('Ошибка при создании шаблона. Попробуйте еще раз.')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTemplateUpdate = (updatedTemplate: Template) => {
|
||||
updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
const handleTemplateUpdate = async (updatedTemplate: Template) => {
|
||||
try {
|
||||
await updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении шаблона:', error)
|
||||
alert('Ошибка при обновлении шаблона. Попробуйте еще раз.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTemplates = () => {
|
||||
@@ -112,187 +141,250 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
setTemplatesToDelete(selectedTemplatesList)
|
||||
}
|
||||
|
||||
if (selectedTemplate && isEditMode) {
|
||||
const handleRetry = () => {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if (isEditMode && selectedTemplate) {
|
||||
return (
|
||||
<TemplateEditor
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setSelectedTemplate(null)
|
||||
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 (
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Protoc</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Создание протоколов с помощью шаблонов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex 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" />
|
||||
Создать
|
||||
<h1 className="text-2xl font-bold text-gray-900">Шаблоны протоколов</h1>
|
||||
<div className="flex gap-3">
|
||||
{isSelectionMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSelectionModeToggle}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Отменить выбор
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Создать новый шаблон</DialogTitle>
|
||||
<DialogDescription>
|
||||
Введите название и описание для нового шаблона.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Название шаблона
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Введите название шаблона"
|
||||
value={newTemplateName}
|
||||
onChange={e => setNewTemplateName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Описание</label>
|
||||
<Input
|
||||
placeholder="Введите описание шаблона"
|
||||
value={newTemplateDescription}
|
||||
onChange={e => setNewTemplateDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateTemplate}
|
||||
disabled={!newTemplateName.trim()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedTemplates.size === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Удалить выбранные ({selectedTemplates.size})
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSelectionModeToggle}
|
||||
disabled={templates.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Circle className="h-4 w-4" />
|
||||
Выбрать
|
||||
</Button>
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Создать шаблон
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Создать новый шаблон</DialogTitle>
|
||||
<DialogDescription>
|
||||
Введите основную информацию для нового шаблона протокола
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Название шаблона
|
||||
</label>
|
||||
<Input
|
||||
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>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Нет созданных шаблонов
|
||||
</h3>
|
||||
<p className="mb-6 text-gray-600">
|
||||
Создайте первый шаблон для начала работы
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="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>
|
||||
))}
|
||||
{/* Показываем индикатор загрузки */}
|
||||
{isLoading && (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
|
||||
<p className="text-gray-600">Загрузка шаблонов...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Диалог подтверждения удаления */}
|
||||
<Dialog
|
||||
open={templatesToDelete.length > 0}
|
||||
onOpenChange={() => setTemplatesToDelete([])}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Удалить шаблоны</DialogTitle>
|
||||
<DialogDescription>
|
||||
Вы уверены, что хотите удалить {templatesToDelete.length}{' '}
|
||||
шаблонов? Это действие нельзя будет отменить.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{templatesToDelete.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="flex items-center gap-2 rounded border p-2"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{template.name}</span>
|
||||
{/* Список шаблонов */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{templates.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Нет шаблонов
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-600">
|
||||
Создайте первый шаблон для начала работы
|
||||
</p>
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать шаблон
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Dialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setTemplatesToDelete([])}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteTemplates}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map(template => (
|
||||
<div key={template.id} className="relative">
|
||||
{isSelectionMode && (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<button
|
||||
onClick={() => toggleTemplateSelection(template.id)}
|
||||
className="rounded-full bg-white p-1 shadow-md"
|
||||
>
|
||||
{selectedTemplates.has(template.id) ? (
|
||||
<CircleCheck className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<TemplateCard
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
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'
|
||||
|
||||
interface TemplateContextType {
|
||||
templates: Template[]
|
||||
addTemplate: (template: Template) => void
|
||||
updateTemplate: (template: Template) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
addTemplate: (
|
||||
template: Omit<Template, 'id' | 'createdAt' | 'updatedAt'>
|
||||
) => Promise<void>
|
||||
updateTemplate: (template: Template) => Promise<void>
|
||||
deleteTemplate: (templateId: string) => void
|
||||
getTemplate: (templateId: string) => Template | undefined
|
||||
loadTemplates: () => Promise<void>
|
||||
refreshTemplate: (templateId: string) => Promise<void>
|
||||
}
|
||||
|
||||
const TemplateContext = createContext<TemplateContextType | undefined>(
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
export const useTemplateContext = () => {
|
||||
@@ -32,9 +48,6 @@ interface TemplateProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Ключ для localStorage
|
||||
const TEMPLATES_STORAGE_KEY = 'protoc_templates'
|
||||
|
||||
// Функция создания элемента названия протокола
|
||||
function createProtocolNameElement(): TemplateElement {
|
||||
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> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [templates, setTemplates] = useState<Template[]>(() => {
|
||||
// Загружаем шаблоны из localStorage при инициализации
|
||||
return loadTemplatesFromStorage()
|
||||
})
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
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(() => {
|
||||
saveTemplatesToStorage(templates)
|
||||
}, [templates])
|
||||
loadTemplates()
|
||||
}, [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(
|
||||
(el) => el.name === 'protocolName' || el.label === 'Название протокола',
|
||||
const hasProtocolName = templateData.elements.some(
|
||||
el => el.name === 'protocolName' || el.label === 'Название протокола'
|
||||
)
|
||||
|
||||
let templateWithProtocolName = template
|
||||
let templateWithProtocolName = templateData
|
||||
if (!hasProtocolName) {
|
||||
// Добавляем элемент названия протокола как первый элемент
|
||||
const protocolNameElement = createProtocolNameElement()
|
||||
templateWithProtocolName = {
|
||||
...template,
|
||||
...templateData,
|
||||
elements: [
|
||||
protocolNameElement,
|
||||
...template.elements.map((el) => ({
|
||||
...templateData.elements.map(el => ({
|
||||
...el,
|
||||
order: (el.order || 0) + 1,
|
||||
})),
|
||||
@@ -128,29 +152,66 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const migratedTemplate = migrateTemplate(templateWithProtocolName)
|
||||
setTemplates((prev) => [...prev, migratedTemplate])
|
||||
setIsLoading(true)
|
||||
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('Elements count:', updatedTemplate.elements.length)
|
||||
|
||||
const migratedTemplate = migrateTemplate(updatedTemplate)
|
||||
console.log('Migrated template:', migratedTemplate)
|
||||
console.log('Migrated elements count:', migratedTemplate.elements.length)
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
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) => {
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== templateId))
|
||||
// Пока удаление только локально, так как в API нет endpoint для удаления
|
||||
// В будущем здесь можно добавить вызов API для удаления
|
||||
setTemplates(prev => prev.filter(t => t.id !== templateId))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -158,10 +219,14 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({
|
||||
<TemplateContext.Provider
|
||||
value={{
|
||||
templates: templates.map(migrateTemplate), // Применяем миграцию при получении
|
||||
isLoading,
|
||||
error,
|
||||
addTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
getTemplate,
|
||||
loadTemplates,
|
||||
refreshTemplate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useFormulaAutoSave = (
|
||||
getAllModifiedCells: () => Record<string, Record<string, any>>,
|
||||
isCalculating: boolean,
|
||||
revision: number,
|
||||
options: UseFormulaAutoSaveOptions = {},
|
||||
options: UseFormulaAutoSaveOptions = {}
|
||||
) => {
|
||||
const { templateId, autoSaveDelay = 2000, enableAutoSave = true } = options
|
||||
|
||||
@@ -61,7 +61,7 @@ export const useFormulaAutoSave = (
|
||||
console.log(
|
||||
'📂 Загружены сохраненные формулы (версия:',
|
||||
parsedData.version,
|
||||
')',
|
||||
')'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -87,8 +87,8 @@ export const useFormulaAutoSave = (
|
||||
|
||||
try {
|
||||
// Имитируем задержку API для реалистичности
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 200 + Math.random() * 300),
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, 200 + Math.random() * 300)
|
||||
)
|
||||
|
||||
// Иногда имитируем ошибку (2% вероятность)
|
||||
@@ -114,8 +114,8 @@ export const useFormulaAutoSave = (
|
||||
'💾 Сохранено формул:',
|
||||
Object.values(data).reduce(
|
||||
(sum, sheet) => sum + Object.keys(sheet).length,
|
||||
0,
|
||||
),
|
||||
0
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
@@ -123,7 +123,7 @@ export const useFormulaAutoSave = (
|
||||
setLastSaveError(errorMessage)
|
||||
console.error(
|
||||
'❌ Ошибка при сохранении формул в localStorage:',
|
||||
errorMessage,
|
||||
errorMessage
|
||||
)
|
||||
|
||||
// Не обновляем lastSavedRevision при ошибке, чтобы попытаться снова
|
||||
@@ -131,7 +131,7 @@ export const useFormulaAutoSave = (
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[templateId, revision],
|
||||
[templateId, revision]
|
||||
)
|
||||
|
||||
// Автосохранение с дебаунсом
|
||||
@@ -256,7 +256,7 @@ export const useFormulaAutoSave = (
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'❌ Ошибка при загрузке сохраненных формул из localStorage:',
|
||||
error,
|
||||
error
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,12 +3,6 @@ import {
|
||||
CellValue,
|
||||
Workbook,
|
||||
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
||||
|
||||
// interface CellData {
|
||||
// value: string
|
||||
// isSelected: boolean
|
||||
// }
|
||||
|
||||
interface SheetConfig {
|
||||
name: string
|
||||
rows: number
|
||||
@@ -30,6 +24,9 @@ export function useMultiSheetEngine({
|
||||
const debouncedRecalcTimeoutRef = useRef<number | null>(null)
|
||||
const isRecalculatingRef = useRef(false)
|
||||
|
||||
// Храним изначальные данные шаблона для сравнения
|
||||
const templateDataRef = useRef<Record<string, Record<string, any>>>({})
|
||||
|
||||
// Безопасная функция для вычислений
|
||||
const safeRecalc = useCallback(() => {
|
||||
if (!workbookRef.current || isRecalculatingRef.current) return
|
||||
@@ -47,7 +44,7 @@ export function useMultiSheetEngine({
|
||||
setIsCalculating(false)
|
||||
isRecalculatingRef.current = false
|
||||
// Увеличиваем ревизию, чтобы сообщить компонентам о необходимости обновления
|
||||
setRevision((r) => r + 1)
|
||||
setRevision(r => r + 1)
|
||||
}, 50) // Уменьшено с 150ms до 50ms
|
||||
}
|
||||
}, [])
|
||||
@@ -58,12 +55,12 @@ export function useMultiSheetEngine({
|
||||
workbookRef.current = new Workbook()
|
||||
|
||||
// Создаем листы
|
||||
sheets.forEach((sheetConfig) => {
|
||||
sheets.forEach(sheetConfig => {
|
||||
workbookRef.current!.addSheet(sheetConfig.name)
|
||||
})
|
||||
|
||||
// Увеличиваем ревизию для начальной отрисовки
|
||||
setRevision((r) => r + 1)
|
||||
setRevision(r => r + 1)
|
||||
}
|
||||
}, [sheets])
|
||||
|
||||
@@ -84,7 +81,7 @@ export function useMultiSheetEngine({
|
||||
safeRecalc()
|
||||
} else {
|
||||
// Если данных нет, просто обновляем ревизию
|
||||
setRevision((r) => r + 1)
|
||||
setRevision(r => r + 1)
|
||||
}
|
||||
}, [initialData, safeRecalc])
|
||||
|
||||
@@ -141,7 +138,7 @@ export function useMultiSheetEngine({
|
||||
|
||||
// Функция для немедленного обновления интерфейса без пересчета
|
||||
const updateRevision = useCallback(() => {
|
||||
setRevision((r) => r + 1)
|
||||
setRevision(r => r + 1)
|
||||
}, [])
|
||||
|
||||
// Установка значения ячейки без немедленного пересчета
|
||||
@@ -155,7 +152,7 @@ export function useMultiSheetEngine({
|
||||
// Устанавливаем значение в движке без пересчета
|
||||
workbookRef.current.setCell(qualifiedName, value)
|
||||
},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
// Установка значения ячейки с немедленным пересчетом
|
||||
@@ -172,7 +169,7 @@ export function useMultiSheetEngine({
|
||||
// Запускаем безопасный пересчет
|
||||
safeRecalc()
|
||||
},
|
||||
[safeRecalc],
|
||||
[safeRecalc]
|
||||
)
|
||||
|
||||
// Получение значения ячейки (вычисленное)
|
||||
@@ -185,7 +182,7 @@ export function useMultiSheetEngine({
|
||||
|
||||
return workbookRef.current.getValue(qualifiedName)
|
||||
},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
// Получение сырого значения ячейки (формула)
|
||||
@@ -201,7 +198,7 @@ export function useMultiSheetEngine({
|
||||
|
||||
return String(cell.raw || '')
|
||||
},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
// Получение всех измененных ячеек для листа
|
||||
@@ -214,17 +211,68 @@ export function useMultiSheetEngine({
|
||||
|
||||
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<
|
||||
string,
|
||||
Record<string, CellValue>
|
||||
> => {
|
||||
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
|
||||
|
||||
workbookRef.current.clear()
|
||||
setRevision((r) => r + 1)
|
||||
setRevision(r => r + 1)
|
||||
}, [])
|
||||
|
||||
// Загрузка данных из JSON
|
||||
@@ -243,7 +291,7 @@ export function useMultiSheetEngine({
|
||||
workbookRef.current.fromJSON(data)
|
||||
safeRecalc()
|
||||
},
|
||||
[safeRecalc],
|
||||
[safeRecalc]
|
||||
)
|
||||
|
||||
// Пересчет всех формул
|
||||
@@ -271,6 +319,7 @@ export function useMultiSheetEngine({
|
||||
getCellRawValue,
|
||||
getModifiedCells,
|
||||
getAllModifiedCells,
|
||||
setTemplateData, // Экспортируем функцию установки данных шаблона
|
||||
clearAllData,
|
||||
loadData,
|
||||
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 {
|
||||
CellUtils,
|
||||
CellValue,
|
||||
Workbook,
|
||||
CellUtils,
|
||||
CellValue,
|
||||
Workbook,
|
||||
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
||||
// import { addCustomFunctions } from '../lib/spreadsheet-example'
|
||||
|
||||
interface CellData {
|
||||
value: string
|
||||
@@ -31,7 +30,7 @@ export function useSpreadsheetEngine({
|
||||
.map(() =>
|
||||
Array(cols)
|
||||
.fill(null)
|
||||
.map(() => ({ value: '', isSelected: false })),
|
||||
.map(() => ({ value: '', isSelected: false }))
|
||||
)
|
||||
})
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
@@ -62,7 +61,7 @@ export function useSpreadsheetEngine({
|
||||
.map(() =>
|
||||
Array(cols)
|
||||
.fill(null)
|
||||
.map(() => ({ value: '', isSelected: false })),
|
||||
.map(() => ({ value: '', isSelected: false }))
|
||||
)
|
||||
|
||||
// Получаем все ячейки из движка
|
||||
@@ -119,7 +118,7 @@ export function useSpreadsheetEngine({
|
||||
setIsCalculating(false)
|
||||
}
|
||||
},
|
||||
[sheetName, syncFromEngine],
|
||||
[sheetName, syncFromEngine]
|
||||
)
|
||||
|
||||
// Получение вычисленного значения ячейки
|
||||
@@ -137,7 +136,7 @@ export function useSpreadsheetEngine({
|
||||
return '#ERROR!'
|
||||
}
|
||||
},
|
||||
[sheetName],
|
||||
[sheetName]
|
||||
)
|
||||
|
||||
// Получение сырого значения ячейки (для отображения в строке формул)
|
||||
@@ -153,7 +152,7 @@ export function useSpreadsheetEngine({
|
||||
const cell = sheet.getCell(cellName)
|
||||
return cell.raw !== null && cell.raw !== undefined ? String(cell.raw) : ''
|
||||
},
|
||||
[sheetName],
|
||||
[sheetName]
|
||||
)
|
||||
|
||||
// Загрузка данных из массива
|
||||
@@ -192,7 +191,7 @@ export function useSpreadsheetEngine({
|
||||
setIsCalculating(false)
|
||||
}
|
||||
},
|
||||
[rows, cols, sheetName, syncFromEngine],
|
||||
[rows, cols, sheetName, syncFromEngine]
|
||||
)
|
||||
|
||||
// Экспорт данных в массив (для сохранения)
|
||||
@@ -245,7 +244,7 @@ export function useSpreadsheetEngine({
|
||||
setIsCalculating(false)
|
||||
}
|
||||
},
|
||||
[syncFromEngine],
|
||||
[syncFromEngine]
|
||||
)
|
||||
|
||||
// Добавление пользовательской функции
|
||||
@@ -254,7 +253,7 @@ export function useSpreadsheetEngine({
|
||||
if (!workbookRef.current) return
|
||||
workbookRef.current.addFunction(name, func)
|
||||
},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
// Пересчёт всех формул
|
||||
@@ -290,7 +289,7 @@ export function useSpreadsheetEngine({
|
||||
dependents: Array.from(cell.dependents),
|
||||
}
|
||||
},
|
||||
[sheetName],
|
||||
[sheetName]
|
||||
)
|
||||
|
||||
// Получение статистики workbook
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ElementConstructor } from '@/components/TemplateManager/ElementConstructor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { FC, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
@@ -9,10 +9,11 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
export const ElementsCreation: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { templateId } = useParams<{ templateId: string }>()
|
||||
const { templates, updateTemplate } = useTemplateContext()
|
||||
const { templates, updateTemplate, isLoading, error } = useTemplateContext()
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null
|
||||
)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Автоматически выбираем шаблон, если передан templateId
|
||||
useEffect(() => {
|
||||
@@ -20,14 +21,14 @@ export const ElementsCreation: FC = () => {
|
||||
const template = templates.find(t => t.id === templateId)
|
||||
if (template) {
|
||||
setSelectedTemplate(template)
|
||||
} else {
|
||||
// Если шаблон не найден, перенаправляем на страницу со списком шаблонов
|
||||
} else if (!isLoading && templates.length > 0) {
|
||||
// Если шаблон не найден после загрузки, перенаправляем на страницу со списком шаблонов
|
||||
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)
|
||||
|
||||
if (!selectedTemplate) {
|
||||
@@ -35,88 +36,105 @@ export const ElementsCreation: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate,
|
||||
elements: [...selectedTemplate.elements, element],
|
||||
updatedAt: new Date(),
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const updatedTemplate = {
|
||||
...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,
|
||||
updates: Partial<TemplateElement>
|
||||
updatedElement: TemplateElement
|
||||
) => {
|
||||
if (!selectedTemplate) return
|
||||
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate,
|
||||
elements: selectedTemplate.elements.map(el =>
|
||||
el.id === elementId ? { ...el, ...updates } : el
|
||||
),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate,
|
||||
elements: selectedTemplate.elements.map(el =>
|
||||
el.id === elementId ? updatedElement : el
|
||||
),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
await updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении элемента:', error)
|
||||
alert('Ошибка при обновлении элемента. Попробуйте еще раз.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleElementDelete = (elementId: string) => {
|
||||
const handleElementDelete = async (elementId: string) => {
|
||||
if (!selectedTemplate) return
|
||||
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate,
|
||||
elements: selectedTemplate.elements.filter(el => el.id !== elementId),
|
||||
updatedAt: new Date(),
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const updatedTemplate = {
|
||||
...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
|
||||
|
||||
// Обновляем порядок элементов
|
||||
const elementsWithOrder = reorderedElements.map((element, index) => ({
|
||||
...element,
|
||||
order: index,
|
||||
}))
|
||||
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate,
|
||||
elements: elementsWithOrder,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
// Показываем загрузку
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
|
||||
<p className="text-gray-600">Загрузка шаблона...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleLayoutSettingsChange = (settings: FormLayoutSettings) => {
|
||||
if (!selectedTemplate) return
|
||||
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate,
|
||||
layoutSettings: settings,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
}
|
||||
|
||||
// Настройки отображения по умолчанию
|
||||
const defaultLayoutSettings: FormLayoutSettings = {
|
||||
gridSize: 20,
|
||||
showGrid: true,
|
||||
// Показываем ошибку
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Wrench 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={() => navigate('/templates')}>
|
||||
Назад к шаблонам
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Если шаблон не найден
|
||||
if (!selectedTemplate) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
@@ -126,10 +144,10 @@ export const ElementsCreation: FC = () => {
|
||||
Шаблон не найден
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-600">
|
||||
Перейдите к списку шаблонов для выбора
|
||||
Выбранный шаблон не существует или был удален
|
||||
</p>
|
||||
<Button onClick={() => navigate('/templates')}>
|
||||
К списку шаблонов
|
||||
Назад к шаблонам
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,39 +155,43 @@ export const ElementsCreation: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="sticky top-0 z-50 border-b border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate(`/templates`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-800">
|
||||
Конструктор элементов: {selectedTemplate.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Создайте элементы формы и разместите их на холсте
|
||||
перетаскиванием
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-screen bg-background">
|
||||
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/templates')}
|
||||
className="mr-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wrench className="h-5 w-5" />
|
||||
<h1 className="text-lg font-semibold">Редактор элементов</h1>
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedTemplate.name}
|
||||
</span>
|
||||
</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 className="h-[calc(100vh-104px)]">
|
||||
<ElementConstructor
|
||||
elements={selectedTemplate.elements}
|
||||
layoutSettings={
|
||||
selectedTemplate.layoutSettings || defaultLayoutSettings
|
||||
}
|
||||
onElementAdd={handleElementAdd}
|
||||
onElementUpdate={handleElementUpdate}
|
||||
onElementDelete={handleElementDelete}
|
||||
onElementsReorder={handleElementsReorder}
|
||||
onLayoutSettingsChange={handleLayoutSettingsChange}
|
||||
/>
|
||||
</div>
|
||||
<ElementConstructor
|
||||
template={selectedTemplate}
|
||||
onElementAdd={handleElementAdd}
|
||||
onElementUpdate={handleElementUpdate}
|
||||
onElementDelete={handleElementDelete}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet'
|
||||
import { StandardsSelector } from '@/components/StandardsElement/StandardsSelector'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 [formData, setFormData] = useState<Record<string, any>>({})
|
||||
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
||||
|
||||
const handleFieldChange = (elementId: string, value: any) => {
|
||||
setFormData(prev => ({
|
||||
@@ -444,6 +446,13 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Экспорт в Excel
|
||||
@@ -456,57 +465,76 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Полноэкранная форма */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div
|
||||
className="relative mx-auto my-8"
|
||||
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||
>
|
||||
{template.elements.map(element => {
|
||||
const layout = element.layout || {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 300,
|
||||
height: 80,
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
{/* Основное содержимое */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{showSpreadsheet ? (
|
||||
/* Режим таблиц */
|
||||
<div className="h-full">
|
||||
<DualSpreadsheet
|
||||
templateData={
|
||||
template.excelData ? { report: template.excelData } : {}
|
||||
}
|
||||
mergedCells={template.mergedCells || []}
|
||||
templateId={template.id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Режим формы */
|
||||
<div className="h-full overflow-auto">
|
||||
<div
|
||||
className="relative mx-auto my-8"
|
||||
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||
>
|
||||
{template.elements.map(element => {
|
||||
const layout = element.layout || {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 300,
|
||||
height: 80,
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-400">
|
||||
<FileText className="mx-auto mb-4 h-16 w-16" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Нет элементов формы
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-600">
|
||||
В этом шаблоне пока не созданы элементы формы
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Настроить шаблон
|
||||
</Button>
|
||||
</div>
|
||||
{template.elements.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-400">
|
||||
<FileText className="mx-auto mb-4 h-16 w-16" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Нет элементов формы
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-600">
|
||||
В этом шаблоне пока не созданы элементы формы
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Настроить шаблон
|
||||
</Button>
|
||||
</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)
|
||||
}
|
||||
Reference in New Issue
Block a user