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

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

View File

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

View File

@@ -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,14 +77,26 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const lastRevision = useRef<number>(-1)
useEffect(() => {
// Используем baseFileData как источник базовых значений для сравнения
const reportBaseData = baseFileData?.[SHEET_NAMES.report]
if (reportBaseData && Object.keys(reportBaseData).length > 0) {
// Очищаем старые значения
initialTemplateValues.current = {}
Object.entries(reportBaseData).forEach(([cellName, value]) => {
initialTemplateValues.current[cellName] = String(value)
})
console.log(
'✅ Загружено базовых значений файла для сравнения:',
Object.keys(initialTemplateValues.current).length
)
} else {
// Fallback: используем templateData, если baseFileData еще не загружен
const reportTemplate = templateData?.[SHEET_NAMES.report]
// Минимальная отладка templateData
if (!templateData || Object.keys(templateData).length === 0) {
console.log('⚠️ templateData пустой')
}
if (reportTemplate) {
if (reportTemplate && Object.keys(reportTemplate).length > 0) {
// Очищаем старые значения
initialTemplateValues.current = {}
@@ -90,13 +105,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
})
console.log(
'✅ Загружено значений шаблона:',
'✅ Загружено значений templateData для сравнения (fallback):',
Object.keys(initialTemplateValues.current).length
)
} else {
console.log('⚠️ DualSpreadsheet: Данные шаблона отчета не найдены')
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,31 +558,93 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
useEffect(() => {
if (!templateId || isInitialized) return
// Проверяем, есть ли начальные данные
const hasInitialData =
// Асинхронная функция для загрузки и слияния данных
const initializeData = async () => {
let finalBaseData: Record<string, Record<string, any>> = {}
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
if (templateId) {
try {
console.log('🔍 Ищем файлы для templateId:', templateId)
const latestFile = await getLatestFileForTemplate(templateId)
if (latestFile) {
console.log(
'📄 Найден файл:',
latestFile.name,
'ID:',
latestFile.id
)
const fileData = await getFileData(latestFile.id)
// TODO: Пока getFileData возвращает пустые данные, используем templateData
// Когда API будет готов, здесь будут реальные данные файла
if (Object.keys(fileData.data).length > 0) {
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
setBaseFileData(finalBaseData)
console.log('📄 Загружены базовые данные файла:', finalBaseData)
} else {
console.log(
'⚠️ getFileData вернул пустые данные, используем templateData'
)
}
} else {
console.log('⚠️ Файл не найден для templateId:', templateId)
}
} catch (error) {
console.error('❌ Ошибка при загрузке данных файла:', error)
}
}
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
const hasTemplateData =
Object.keys(templateData).length > 0 &&
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
// Всегда загружаем сохраненные данные, если они есть
const savedFormulas = loadSavedFormulas()
if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
finalBaseData = templateData
setBaseFileData(finalBaseData)
console.log(
'📄 Используем templateData как базовые данные:',
finalBaseData
)
setTemplateData(templateData)
}
if (savedFormulas && Object.keys(savedFormulas).length > 0) {
console.log('🔄 Загружаем сохраненные формулы в движок:', savedFormulas)
// 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
try {
console.log('🔍 Загружаем пользовательские изменения из API...')
const apiSheets = await loadSheets()
if (hasInitialData) {
// Если есть начальные данные, СЛИВАЕМ их с сохраненными изменениями
console.log('📋 Сливаем данные шаблона с сохраненными изменениями')
// Преобразуем листы 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(templateData).forEach(([sheetName, sheetData]) => {
// Копируем базовые данные файла как основу
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
mergedData[sheetName] = { ...sheetData }
})
// Применяем сохраненные изменения поверх шаблона
Object.entries(savedFormulas).forEach(([sheetName, sheetData]) => {
// Применяем пользовательские изменения поверх базовых данных
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
if (!mergedData[sheetName]) {
mergedData[sheetName] = {}
}
@@ -560,26 +653,41 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
})
})
console.log('📊 Загружаем объединенные данные (шаблон + изменения)')
console.log(
'📊 Загружаем объединенные данные (базовые + пользовательские)'
)
multiSheetEngine.loadData(mergedData)
} else {
// Если нет начальных данных, используем только сохраненные
multiSheetEngine.loadData(savedFormulas)
}
console.log('✅ Сохраненные формулы загружены в движок')
} else if (hasInitialData) {
console.log('📋 Используем только начальные данные шаблона')
} 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)
}
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}

View File

@@ -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>
)}

View File

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

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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)
}

View File

@@ -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(),
setIsCreating(true)
try {
await addTemplate({
name: newTemplateName.trim(),
description: newTemplateDescription.trim(),
elements: [],
createdAt: new Date(),
updatedAt: new Date(),
}
layoutSettings: undefined,
})
addTemplate(newTemplate)
// Находим созданный шаблон и открываем его для редактирования
// Поскольку 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)
const handleTemplateUpdate = async (updatedTemplate: Template) => {
try {
await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при обновлении шаблона:', error)
alert('Ошибка при обновлении шаблона. Попробуйте еще раз.')
}
}
const handleDeleteTemplates = () => {
@@ -112,55 +141,75 @@ 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)
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 && (
<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>
<Button
variant="destructive"
onClick={handleDeleteSelected}
disabled={selectedTemplates.size === 0}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Удалить ({selectedTemplates.size})
Удалить выбранные ({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" />
</>
) : (
<>
<Button
variant="outline"
onClick={handleSelectionModeToggle}
disabled={templates.length === 0}
className="flex items-center gap-2"
>
<Circle className="h-4 w-4" />
)}
{isSelectionMode ? 'Отменить' : 'Выбрать'}
Выбрать
</Button>
<Dialog
open={isCreateDialogOpen}
@@ -169,130 +218,173 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать
Создать шаблон
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent>
<DialogHeader>
<DialogTitle>Создать новый шаблон</DialogTitle>
<DialogDescription>
Введите название и описание для нового шаблона.
Введите основную информацию для нового шаблона протокола
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">
Название шаблона
</label>
<Input
placeholder="Введите название шаблона"
placeholder="Например: Протокол испытаний"
value={newTemplateName}
onChange={e => setNewTemplateName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCreateTemplate()
}
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Описание</label>
<div>
<label className="mb-1 block text-sm font-medium">
Описание (необязательно)
</label>
<Input
placeholder="Введите описание шаблона"
placeholder="Краткое описание назначения шаблона"
value={newTemplateDescription}
onChange={e => setNewTemplateDescription(e.target.value)}
onChange={e =>
setNewTemplateDescription(e.target.value)
}
onKeyDown={e => {
if (e.key === 'Enter') {
handleCreateTemplate()
}
}}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
disabled={isCreating}
>
Отмена
Отменить
</Button>
<Button
onClick={handleCreateTemplate}
disabled={!newTemplateName.trim()}
disabled={!newTemplateName.trim() || isCreating}
>
Создать
{isCreating ? 'Создание...' : 'Создать'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
</div>
{/* Показываем индикатор загрузки */}
{isLoading && (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
<p className="text-gray-600">Загрузка шаблонов...</p>
</div>
</div>
)}
{/* Список шаблонов */}
{!isLoading && (
<>
{templates.length === 0 ? (
<div className="py-12 text-center">
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Нет созданных шаблонов
Нет шаблонов
</h3>
<p className="mb-6 text-gray-600">
<p className="mb-4 text-gray-600">
Создайте первый шаблон для начала работы
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Создать шаблон
</Button>
</DialogTrigger>
</Dialog>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{templates.map(template => (
<div key={template.id} className="relative">
<div
className="cursor-pointer transition-all duration-200"
onClick={() => {
if (isSelectionMode) {
toggleTemplateSelection(template.id)
}
}}
{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={() => {}} // Убрали onDelete из карточки
isSelected={
isSelectionMode && selectedTemplates.has(template.id)
}
onDelete={() => deleteTemplate(template.id)}
isSelected={selectedTemplates.has(template.id)}
/>
</div>
</div>
))}
</div>
)}
</>
)}
{/* Диалог подтверждения удаления */}
{templatesToDelete.length > 0 && (
<Dialog
open={templatesToDelete.length > 0}
onOpenChange={() => setTemplatesToDelete([])}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить шаблоны</DialogTitle>
<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>
<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
variant="outline"
onClick={() => setTemplatesToDelete([])}
>
Отменить
</Button>
<Button variant="destructive" onClick={handleDeleteTemplates}>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}

View File

@@ -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)
setIsLoading(true)
setError(null)
try {
const patchInput = templateToPatchInput(updatedTemplate)
await patchTemplate(updatedTemplate.id, patchInput)
// Обновляем локальное состояние
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)),
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}

View File

@@ -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
}

View File

@@ -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,

View File

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

View File

@@ -4,7 +4,6 @@ import {
CellValue,
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

View File

@@ -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,6 +36,8 @@ export const ElementsCreation: FC = () => {
return
}
setIsSaving(true)
try {
const updatedTemplate = {
...selectedTemplate,
elements: [...selectedTemplate.elements, element],
@@ -44,79 +47,94 @@ export const ElementsCreation: FC = () => {
console.log('Updated template:', updatedTemplate)
console.log('Elements count:', updatedTemplate.elements.length)
updateTemplate(updatedTemplate)
await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при добавлении элемента:', error)
alert('Ошибка при добавлении элемента. Попробуйте еще раз.')
} finally {
setIsSaving(false)
}
}
const handleElementUpdate = (
const handleElementUpdate = async (
elementId: string,
updates: Partial<TemplateElement>
updatedElement: TemplateElement
) => {
if (!selectedTemplate) return
setIsSaving(true)
try {
const updatedTemplate = {
...selectedTemplate,
elements: selectedTemplate.elements.map(el =>
el.id === elementId ? { ...el, ...updates } : el
el.id === elementId ? updatedElement : el
),
updatedAt: new Date(),
}
updateTemplate(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
setIsSaving(true)
try {
const updatedTemplate = {
...selectedTemplate,
elements: selectedTemplate.elements.filter(el => el.id !== elementId),
updatedAt: new Date(),
}
updateTemplate(updatedTemplate)
await updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate)
} catch (error) {
console.error('Ошибка при удалении элемента:', error)
alert('Ошибка при удалении элемента. Попробуйте еще раз.')
} finally {
setIsSaving(false)
}
}
const handleElementsReorder = (reorderedElements: TemplateElement[]) => {
if (!selectedTemplate) return
// Обновляем порядок элементов
const elementsWithOrder = reorderedElements.map((element, index) => ({
...element,
order: index,
}))
const updatedTemplate = {
...selectedTemplate,
elements: elementsWithOrder,
updatedAt: new Date(),
// Показываем загрузку
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>
)
}
updateTemplate(updatedTemplate)
setSelectedTemplate(updatedTemplate)
}
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" />
<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>
<h1 className="text-xl font-semibold text-gray-800">
Конструктор элементов: {selectedTemplate.name}
</h1>
<p className="mt-1 text-sm text-gray-600">
Создайте элементы формы и разместите их на холсте
перетаскиванием
</p>
<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
}
template={selectedTemplate}
onElementAdd={handleElementAdd}
onElementUpdate={handleElementUpdate}
onElementDelete={handleElementDelete}
onElementsReorder={handleElementsReorder}
onLayoutSettingsChange={handleLayoutSettingsChange}
disabled={isSaving}
/>
</div>
</div>
)
}

View File

@@ -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,8 +465,22 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
</div>
</div>
{/* Полноэкранная форма */}
<div className="flex-1 overflow-auto">
{/* Основное содержимое */}
<div className="flex-1 overflow-hidden">
{showSpreadsheet ? (
/* Режим таблиц */
<div className="h-full">
<DualSpreadsheet
templateData={
template.excelData ? { report: template.excelData } : {}
}
mergedCells={template.mergedCells || []}
templateId={template.id}
/>
</div>
) : (
/* Режим формы */
<div className="h-full overflow-auto">
<div
className="relative mx-auto my-8"
style={{ width: canvasSize.width, height: canvasSize.height }}
@@ -500,7 +523,10 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
<p className="mb-4 text-gray-600">
В этом шаблоне пока не созданы элементы формы
</p>
<Button variant="outline" onClick={() => window.history.back()}>
<Button
variant="outline"
onClick={() => window.history.back()}
>
Настроить шаблон
</Button>
</div>
@@ -508,6 +534,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
)}
</div>
</div>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -1,381 +0,0 @@
import {
FormulaApiConfig,
FormulaApiError,
FormulaApiEvent,
FormulaHistoryRequest,
FormulaHistoryResponse,
LoadFormulasRequest,
LoadFormulasResponse,
SaveFormulasRequest,
SaveFormulasResponse,
SyncStatus,
TemplateFormulaData,
} from '@/types/formula-api'
/**
* Сервис для работы с API формул электронных таблиц
* Содержит заглушки для будущего API интерфейса
*/
class FormulaApiService {
private config: FormulaApiConfig
private eventListeners: Map<string, Array<(event: FormulaApiEvent) => void>> =
new Map()
private storage: Map<string, TemplateFormulaData> = new Map() // Заглушка локального хранилища
private versionCounter = 1
constructor(config: FormulaApiConfig) {
this.config = config
}
/**
* Сохранение формул на сервер
*/
async saveFormulas(
request: SaveFormulasRequest
): Promise<SaveFormulasResponse> {
this.emitEvent({ type: 'SAVE_START', templateId: request.templateId })
try {
// Имитация задержки API
await this.delay(300, 700)
// Иногда имитируем ошибку (5% вероятность)
if (Math.random() < 0.05) {
throw new Error('Ошибка соединения с сервером')
}
// Преобразуем данные из движка в наш формат
const templateData: TemplateFormulaData = {
templateId: request.templateId,
version: this.versionCounter++,
sheets: {},
timestamp: request.timestamp,
}
// Конвертируем данные каждого листа
Object.entries(request.formulaData).forEach(([sheetName, cellData]) => {
templateData.sheets[sheetName] = {
sheetName,
cells: cellData,
metadata: {
rowCount: 90,
columnCount: 20,
lastCalculated: new Date().toISOString(),
},
}
})
// Сохраняем в заглушку локального хранилища
this.storage.set(request.templateId, templateData)
const response: SaveFormulasResponse = {
success: true,
templateId: request.templateId,
version: templateData.version,
timestamp: templateData.timestamp,
message: 'Формулы успешно сохранены',
}
// Здесь будет настоящий API вызов:
/*
const response = await fetch(`${this.config.baseUrl}/api/formulas/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config.timeout || 10000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: SaveFormulasResponse = await response.json()
*/
this.emitEvent({
type: 'SAVE_SUCCESS',
templateId: request.templateId,
version: response.version,
})
return response
} catch (error) {
const apiError: FormulaApiError = {
code: 'SAVE_FAILED',
message: error instanceof Error ? error.message : 'Неизвестная ошибка',
timestamp: new Date().toISOString(),
}
this.emitEvent({
type: 'SAVE_ERROR',
templateId: request.templateId,
error: apiError,
})
throw apiError
}
}
/**
* Загрузка сохраненных формул
*/
async loadFormulas(
request: LoadFormulasRequest
): Promise<LoadFormulasResponse> {
this.emitEvent({ type: 'LOAD_START', templateId: request.templateId })
try {
// Имитация задержки API
await this.delay(200, 500)
// Получаем данные из заглушки
const templateData = this.storage.get(request.templateId)
if (!templateData) {
return {
success: true,
notFound: true,
message: 'Сохраненные формулы не найдены',
}
}
// Здесь будет настоящий API вызов:
/*
const url = new URL(`${this.config.baseUrl}/api/formulas/${request.templateId}`)
if (request.version) {
url.searchParams.set('version', request.version.toString())
}
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
signal: AbortSignal.timeout(this.config.timeout || 10000),
})
if (response.status === 404) {
return {
success: true,
notFound: true,
message: 'Сохраненные формулы не найдены',
}
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: LoadFormulasResponse = await response.json()
*/
const response: LoadFormulasResponse = {
success: true,
data: templateData,
message: 'Формулы успешно загружены',
}
this.emitEvent({
type: 'LOAD_SUCCESS',
templateId: request.templateId,
version: templateData.version,
})
return response
} catch (error) {
const apiError: FormulaApiError = {
code: 'LOAD_FAILED',
message: error instanceof Error ? error.message : 'Ошибка загрузки',
timestamp: new Date().toISOString(),
}
this.emitEvent({
type: 'LOAD_ERROR',
templateId: request.templateId,
error: apiError,
})
throw apiError
}
}
/**
* Получение истории изменений формул
*/
async getFormulaHistory(
request: FormulaHistoryRequest
): Promise<FormulaHistoryResponse> {
try {
// Имитация задержки API
await this.delay(300, 600)
// Заглушка истории
const response: FormulaHistoryResponse = {
success: true,
templateId: request.templateId,
history: [],
totalCount: 0,
hasMore: false,
}
// Здесь будет настоящий API вызов:
/*
const url = new URL(`${this.config.baseUrl}/api/formulas/${request.templateId}/history`)
if (request.limit) url.searchParams.set('limit', request.limit.toString())
if (request.offset) url.searchParams.set('offset', request.offset.toString())
if (request.dateFrom) url.searchParams.set('dateFrom', request.dateFrom)
if (request.dateTo) url.searchParams.set('dateTo', request.dateTo)
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
signal: AbortSignal.timeout(this.config.timeout || 10000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result: FormulaHistoryResponse = await response.json()
*/
return response
} catch (error) {
throw {
code: 'HISTORY_FAILED',
message:
error instanceof Error ? error.message : 'Ошибка получения истории',
timestamp: new Date().toISOString(),
} as FormulaApiError
}
}
/**
* Получение статуса синхронизации
*/
async getSyncStatus(templateId: string): Promise<SyncStatus> {
try {
// Имитация задержки API
await this.delay(100, 300)
const localData = this.storage.get(templateId)
const status: SyncStatus = {
templateId,
isUpToDate: true,
localVersion: localData?.version || 0,
remoteVersion: localData?.version || 0,
conflictsCount: 0,
lastSyncTime: localData?.timestamp,
}
// Здесь будет настоящий API вызов:
/*
const response = await fetch(`${this.config.baseUrl}/api/formulas/${templateId}/sync-status`, {
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
},
signal: AbortSignal.timeout(this.config.timeout || 5000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const status: SyncStatus = await response.json()
*/
this.emitEvent({ type: 'SYNC_STATUS_CHANGED', status })
return status
} catch (error) {
throw {
code: 'SYNC_STATUS_FAILED',
message:
error instanceof Error ? error.message : 'Ошибка проверки статуса',
timestamp: new Date().toISOString(),
} as FormulaApiError
}
}
/**
* Подписка на события API
*/
addEventListener(
eventType: string,
listener: (event: FormulaApiEvent) => void
): void {
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, [])
}
this.eventListeners.get(eventType)!.push(listener)
}
/**
* Отписка от событий API
*/
removeEventListener(
eventType: string,
listener: (event: FormulaApiEvent) => void
): void {
const listeners = this.eventListeners.get(eventType)
if (listeners) {
const index = listeners.indexOf(listener)
if (index > -1) {
listeners.splice(index, 1)
}
}
}
/**
* Испускание события
*/
private emitEvent(event: FormulaApiEvent): void {
const listeners = this.eventListeners.get(event.type) || []
listeners.forEach(listener => {
try {
listener(event)
} catch (error) {
console.error('Ошибка в обработчике события API:', error)
}
})
}
/**
* Имитация задержки API
*/
private async delay(min: number, max: number): Promise<void> {
const delay = min + Math.random() * (max - min)
await new Promise(resolve => setTimeout(resolve, delay))
}
/**
* Очистка ресурсов
*/
dispose(): void {
this.eventListeners.clear()
this.storage.clear()
}
/**
* Получение конфигурации (используем config)
*/
getConfig(): FormulaApiConfig {
return this.config
}
}
// Создаем синглтон экземпляр сервиса
const formulaApiService = new FormulaApiService({
baseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001',
apiKey: import.meta.env.VITE_API_KEY,
timeout: 10000,
retryAttempts: 3,
retryDelay: 1000,
})
export default formulaApiService
export { FormulaApiService }

View File

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

View File

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

View File

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