починка багов

This commit is contained in:
2025-07-23 16:08:50 +03:00
parent 3fc352f8e9
commit 907b3e315e
24 changed files with 1249 additions and 1430 deletions

View File

@@ -3,6 +3,13 @@ import { Button } from '@/component/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import {
AlertCircle,
@@ -412,6 +419,9 @@ function CalibrationCondInfo({
interface CalibrationConditionsConfig {
targetCells: any[]
defaultConditions?: CalibrationConditions
cellMapping?: {
[key: string]: string // параметр -> ячейка из targetCells (по индексу)
}
}
export const calibrationConditionsDefinition: ElementDefinition<
@@ -424,6 +434,7 @@ export const calibrationConditionsDefinition: ElementDefinition<
version: 1,
defaultConfig: {
targetCells: [],
cellMapping: {},
defaultConditions: {
temperature: '20',
humidity: '65',
@@ -433,164 +444,289 @@ export const calibrationConditionsDefinition: ElementDefinition<
lastUpdated: new Date().toISOString(),
},
},
mapToCells: () => {
// Генерируем ячейки для каждого параметра калибровки
const cells = [
{ sheet: 'L', cell: 'B2', displayName: 'Температура' },
{ sheet: 'L', cell: 'B3', displayName: 'Влажность' },
{ sheet: 'L', cell: 'B4', displayName: 'Давление' },
{ sheet: 'L', cell: 'B5', displayName: 'Напряжение' },
{ sheet: 'L', cell: 'B6', displayName: 'Частота' },
{ sheet: 'L', cell: 'B7', displayName: 'Дата обновления' },
]
return cells
mapToCells: cfg => {
// Используем только ячейки, указанные пользователем в targetCells
return cfg.targetCells || []
},
Editor: ({ config, onChange }) => (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p className="text-sm text-gray-600">
Элемент "Условия калибровки" позволяет вводить и отслеживать параметры
окружающей среды при проведении измерений. Поддерживает валидацию
значений и отслеживание времени обновления.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Параметры по умолчанию</label>
<div className="grid grid-cols-2 gap-4 rounded-lg border border-gray-200 p-4">
mapToCellValues: (config, value) => {
// Используем targetCells из конфигурации
const cells = config.targetCells || []
const cellMapping = config.cellMapping || {}
// Если значение не объект, возвращаем пустые значения
if (!value || typeof value !== 'object') {
return cells.map(target => ({
target,
value: '',
}))
}
// Создаем обратное сопоставление: индекс ячейки -> параметр
const reversedMapping: { [cellIndex: string]: string } = {}
Object.entries(cellMapping).forEach(([param, cellIndex]) => {
reversedMapping[cellIndex] = param
})
return cells.map((target, idx) => {
const paramKey = reversedMapping[idx.toString()]
const paramValue = paramKey ? (value as any)[paramKey] : ''
return {
target,
value: paramValue || '',
}
})
},
Editor: ({ config, onChange }) => {
const availableParams = [
{ key: 'temperature', label: 'Температура' },
{ key: 'humidity', label: 'Влажность' },
{ key: 'pressure', label: 'Давление' },
{ key: 'voltage', label: 'Напряжение' },
{ key: 'frequency', label: 'Частота' },
{ key: 'lastUpdated', label: 'Дата обновления' },
]
const targetCells = config.targetCells || []
const cellMapping = config.cellMapping || {}
// Функция для создания автоматического сопоставления по порядку
const createAutoMapping = () => {
const newMapping: { [key: string]: string } = {}
const paramKeys = availableParams.map(p => p.key)
// Сопоставляем первые N параметров с первыми N ячейками
targetCells.forEach((_, index) => {
if (index < paramKeys.length) {
newMapping[paramKeys[index]] = index.toString()
}
})
onChange({
...config,
cellMapping: newMapping,
})
}
const updateCellMapping = (param: string, cellIndex: string) => {
const newMapping = { ...cellMapping }
if (cellIndex === '' || cellIndex === 'none') {
delete newMapping[param]
} else {
newMapping[param] = cellIndex
}
onChange({
...config,
cellMapping: newMapping,
})
}
return (
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<p className="text-sm text-gray-600">
Элемент "Условия калибровки" позволяет вводить и отслеживать
параметры окружающей среды при проведении измерений. Поддерживает
валидацию значений и отслеживание времени обновления.
</p>
</div>
{/* Сопоставление параметров с ячейками */}
{targetCells.length > 0 && (
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Температура (°C)
</label>
<Input
type="number"
value={config.defaultConditions?.temperature || '20'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: e.target.value,
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="20"
/>
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Сопоставление с ячейками
</Label>
<Button
variant="outline"
size="sm"
onClick={createAutoMapping}
className="h-7 px-2 text-xs"
>
Автозаполнение
</Button>
</div>
<div className="space-y-3 rounded-lg border border-gray-200 p-4">
{availableParams.map(param => (
<div key={param.key} className="flex items-center gap-3">
<div className="min-w-[120px]">
<span className="text-sm text-gray-700">{param.label}</span>
</div>
<div className="flex-1">
<Select
value={cellMapping[param.key] || 'none'}
onValueChange={value =>
updateCellMapping(param.key, value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Не указано" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не указано</SelectItem>
{targetCells.map((cell, index) => (
<SelectItem key={index} value={index.toString()}>
{cell.sheet}!{cell.cell}{' '}
{cell.displayName ? `(${cell.displayName})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
<p className="text-xs text-gray-500">
Настройте какой параметр условий калибровки будет записан в какую
ячейку. Используйте кнопку "Автозаполнение" для автоматического
сопоставления по порядку.
</p>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Влажность (%)
</label>
<Input
type="number"
value={config.defaultConditions?.humidity || '65'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: e.target.value,
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="65"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Давление (кПа)
</label>
<Input
type="number"
value={config.defaultConditions?.pressure || '101.3'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: e.target.value,
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="101.3"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Напряжение (В)
</label>
<Input
type="number"
value={config.defaultConditions?.voltage || '220'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: e.target.value,
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="220"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Частота (Гц)
</label>
<Input
type="number"
value={config.defaultConditions?.frequency || '50'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: e.target.value,
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="50"
/>
)}
<div className="space-y-2">
<label className="text-sm font-medium">Параметры по умолчанию</label>
<div className="grid grid-cols-2 gap-4 rounded-lg border border-gray-200 p-4">
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Температура (°C)
</label>
<Input
type="number"
value={config.defaultConditions?.temperature || '20'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature: e.target.value,
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="20"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Влажность (%)
</label>
<Input
type="number"
value={config.defaultConditions?.humidity || '65'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature:
config.defaultConditions?.temperature || '20',
humidity: e.target.value,
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="65"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Давление (кПа)
</label>
<Input
type="number"
value={config.defaultConditions?.pressure || '101.3'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature:
config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: e.target.value,
voltage: config.defaultConditions?.voltage || '220',
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="101.3"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Напряжение (В)
</label>
<Input
type="number"
value={config.defaultConditions?.voltage || '220'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature:
config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: e.target.value,
frequency: config.defaultConditions?.frequency || '50',
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="220"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">
Частота (Гц)
</label>
<Input
type="number"
value={config.defaultConditions?.frequency || '50'}
onChange={e =>
onChange({
...config,
defaultConditions: {
...config.defaultConditions,
temperature:
config.defaultConditions?.temperature || '20',
humidity: config.defaultConditions?.humidity || '65',
pressure: config.defaultConditions?.pressure || '101.3',
voltage: config.defaultConditions?.voltage || '220',
frequency: e.target.value,
lastUpdated:
config.defaultConditions?.lastUpdated ||
new Date().toISOString(),
},
})
}
placeholder="50"
/>
</div>
</div>
</div>
</div>
</div>
),
)
},
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
@@ -613,6 +749,7 @@ export const calibrationConditionsDefinition: ElementDefinition<
</div>
),
Render: ({ config, value, onChange }) => {
// Если значение не установлено, используем defaultConditions из конфигурации
const conditions = value ||
config.defaultConditions || {
temperature: '20',

View File

@@ -18,6 +18,10 @@ export const checkboxDefinition: ElementDefinition<CheckboxConfig, boolean> = {
targetCells: [],
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || false }))
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют

View File

@@ -17,6 +17,10 @@ export const dateDefinition: ElementDefinition<DateConfig, string> = {
targetCells: [],
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: ({ config, onChange }) => (
<div className="space-y-4">
<div className="flex items-center space-x-2">

View File

@@ -19,6 +19,10 @@ export const numberDefinition: ElementDefinition<NumberConfig, number> = {
targetCells: [],
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || 0 }))
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют

View File

@@ -23,6 +23,10 @@ export const radioDefinition: ElementDefinition<RadioConfig, string> = {
targetCells: [],
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({

View File

@@ -30,6 +30,10 @@ export const selectDefinition: ElementDefinition<SelectConfig, string> = {
targetCells: [],
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({

View File

@@ -19,6 +19,10 @@ export const textareaDefinition: ElementDefinition<TextareaConfig, string> = {
targetCells: [],
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: () => (
<div className="text-sm text-gray-600">
Дополнительные настройки отсутствуют

View File

@@ -1,6 +1,6 @@
import { useFileData, useLatestFileForTemplate } from '@/hook/useFileQueries'
import { useMultiSheetEngine } from '@/hook/useMultiSheetEngine'
import { useSheetAutoSave } from '@/hook/useSheetAutoSave'
import { getFileData, getLatestFileForTemplate } from '@/service/fileApiService'
import { produce } from 'immer'
import React, {
FC,
@@ -78,6 +78,10 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
row: number
col: number
} | null>(null)
// Флаг, что пользователь изменил хотя бы одну ячейку
const [userEdited, setUserEdited] = useState(false)
// Состояние для множественного выделения
const [multiSelection, setMultiSelection] = useState<CellSelection[]>([])
const [isDragging, setIsDragging] = useState(false)
@@ -95,7 +99,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
>({}) // Базовые данные файла
const [highlightedCells, setHighlightedCells] = useState<HighlightedCell[]>(
[]
) // Подсвеченные ячейки для формул
)
// React Query хуки для оптимизации API запросов
const { data: latestFile, isLoading: isLoadingLatestFile } =
useLatestFileForTemplate(templateId)
const { data: fileData, isLoading: isLoadingFileData } = useFileData(
latestFile?.id
)
// Мемоизируем начальное состояние ширин колонок
const initialColumnWidths = useMemo(
@@ -109,46 +120,22 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const [columnWidths, setColumnWidths] =
useState<Record<SheetType, number[]>>(initialColumnWidths)
// Мемоизируем начальные refs объекты
const initialGridRefs = useMemo(
() => ({
report: null,
calculations: null,
}),
[]
)
const initialContainerRefs = useMemo(
() => ({
report: null,
calculations: null,
}),
[]
)
const initialHeaderRefs = useMemo(
() => ({
report: null,
calculations: null,
}),
[]
)
const initialRowHeaderRefs = useMemo(
() => ({
report: null,
calculations: null,
}),
[]
)
const gridRefs = useRef<Record<SheetType, any>>(initialGridRefs)
const containerRefs =
useRef<Record<SheetType, HTMLDivElement | null>>(initialContainerRefs)
const headerRefs =
useRef<Record<SheetType, HTMLDivElement | null>>(initialHeaderRefs)
const rowHeaderRefs =
useRef<Record<SheetType, HTMLDivElement | null>>(initialRowHeaderRefs)
const gridRefs = useRef<Record<SheetType, any>>({
report: null,
calculations: null,
})
const containerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const rowHeaderRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const activeCellInputRef = useRef<HTMLInputElement | null>(null)
const formulaBarRef = useRef<HTMLInputElement | null>(null)
@@ -171,22 +158,12 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
initialTemplateValues.current[cellAddress] = String(value || '')
})
})
// console.log(
// '✅ Загружено значений baseFileData для сравнения (приоритет):',
// Object.keys(initialTemplateValues.current).length
//)
} else if (hasTemplateData) {
Object.entries(templateData).forEach(([sheetName, sheetData]) => {
Object.entries(sheetData).forEach(([cellAddress, value]) => {
initialTemplateValues.current[cellAddress] = String(value || '')
})
})
// console.log(
// '✅ Загружено значений templateData для сравнения (fallback):',
// Object.keys(initialTemplateValues.current).length
// )
} else {
// console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения')
}
}, [baseFileData, templateData])
@@ -212,7 +189,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const getCachedCellData = useCallback(
(sheetType: SheetType): Record<string, CachedCellData> => {
const cacheKey = `${sheetType}-${revision}-${JSON.stringify(columnWidths[sheetType])}`
const cacheKey = `${sheetType}-${revision}-${columnWidths[sheetType].join('-')}`
if (cellDataCache.current[cacheKey]) {
return cellDataCache.current[cacheKey]
@@ -262,19 +239,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const cellName = getColumnLabel(col) + (row + 1)
const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
// Отладка только для измененных ячеек
if (
sheetType === 'report' &&
baseFileValue &&
rawValue !== baseFileValue
) {
// console.log(`🔧 Измененная ячейка ${cellName}:`, {
// baseFileValue,
// rawValue,
// isModified: true,
// })
}
// Нормализуем значения для сравнения
const normalizeValue = (value: any): string => {
if (value === null || value === undefined) return ''
@@ -290,15 +254,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const isModified =
sheetType === 'report' && normalizedRaw !== normalizedBaseFile
// Отладка только для измененных ячеек
if (sheetType === 'report' && isModified) {
// console.log(`🔍 Измененная ячейка ${cellName}:`, {
// baseFileValue,
// rawValue,
// isModified,
// })
}
cachedData[cellKey] = {
displayValue,
rawValue,
@@ -343,11 +298,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
)
setHighlightedCells(highlights)
// console.log('🎨 Подсветка ячеек для формулы:', formula, {
// references,
// highlights,
// })
} catch (error) {
console.error('Ошибка при обновлении подсветки ячеек:', error)
setHighlightedCells([])
@@ -422,23 +372,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
(save: boolean) => {
if (save && selectedCell) {
const sheetName = SHEET_NAMES[activeSheet]
const cellName =
getColumnLabel(selectedCell.col) + (selectedCell.row + 1)
const oldValue = multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col
)
const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
// console.log('💾 Сохранение ячейки:', {
//cellName,
//oldValue,
//newValue: editingValue,
//baseFileValue,
//isChanged: oldValue !== editingValue,
//isModifiedFromBaseFile: baseFileValue !== editingValue,
//})
multiSheetEngine.setCellValueWithoutRecalc(
sheetName,
@@ -461,23 +394,25 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
(newValue: string) => {
if (isEditing) {
setEditingValue(newValue)
if (!userEdited) setUserEdited(true)
}
},
[isEditing]
[isEditing, userEdited]
)
const handleClearCell = useCallback(() => {
if (!selectedCell) return
const sheetName = SHEET_NAMES[activeSheet]
multiSheetEngine.setCellValueWithoutRecalc(
sheetName,
selectedCell.row,
selectedCell.col,
''
)
setUserEdited(true)
updateRevision()
multiSheetEngine.debouncedRecalc()
// Сбрасываем множественное выделение при очистке одиночной ячейки
setMultiSelection([])
}, [activeSheet, selectedCell, multiSheetEngine, updateRevision])
@@ -515,22 +450,19 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const sheetName = SHEET_NAMES[activeSheet]
const cellsToDelete = getAllCellsInSelections(multiSelection)
cellsToDelete.forEach(({ row, col }: { row: number; col: number }) => {
cellsToDelete.forEach(({ row, col }) => {
multiSheetEngine.setCellValueWithoutRecalc(sheetName, row, col, '')
})
setUserEdited(true)
updateRevision()
multiSheetEngine.debouncedRecalc()
// Очищаем выделение после удаления
setMultiSelection([])
}, [multiSelection, activeSheet, multiSheetEngine, updateRevision])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isEditing) {
return
}
if (isEditing) return
if (!selectedCell && e.key.match(/^(Arrow|Enter|F2|Delete|Backspace)$/)) {
moveSelection(0, 0)
@@ -595,6 +527,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
setEditingSource('cell')
setEditingValue(e.key)
setIsEditing(true)
if (!userEdited) setUserEdited(true)
}
},
[
@@ -610,13 +543,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const handleCellClick = useCallback(
(row: number, col: number, event?: React.MouseEvent) => {
// console.log('🖱️ Обычный клик по ячейке:', {
// row,
//col,
//isEditing,
//activeSheet,
//})
if (isEditing) {
stopEditing(true)
}
@@ -710,44 +636,23 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
// Новый обработчик для кликов в режиме редактирования - вставляет ссылку
const handleCellReferenceClick = useCallback(
(targetSheetType: SheetType, row: number, col: number) => {
// console.log('🔗 Клик для вставки ссылки:', {
//targetSheetType,
//row,
//col,
//isEditing,
//selectedCell,
//editingValue,
//})
// Проверяем, что мы в режиме редактирования
if (!isEditing || !selectedCell) {
// console.log(
// '⚠️ Игнорируем клик ссылки: не в режиме редактирования или нет выбранной ячейки'
//)
if (!isEditing || !selectedCell || !editingValue.startsWith('=')) {
stopEditing(true)
setSelectedCell({ row, col })
return
}
// Создаем ссылку на ячейку
let cellReference: string
// Если это другой лист, делаем квалифицированную ссылку
if (targetSheetType !== activeSheet) {
cellReference = createQualifiedCellReference(targetSheetType, row, col)
// console.log('📋 Создана межлистовая ссылка:', cellReference)
} else {
// Если это тот же лист, делаем локальную ссылку
cellReference = createCellAddress(row, col)
// console.log('📋 Создана локальная ссылка:', cellReference)
}
// Добавляем ссылку к текущему значению редактирования
const newValue = editingValue + cellReference
// console.log('✏️ Новое значение формулы:', {
//oldValue: editingValue,
//newValue,
//})
setEditingValue(newValue)
if (!userEdited) setUserEdited(true)
if (editingSource === 'formula') {
if (formulaBarRef.current) {
@@ -795,13 +700,12 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const handleFormulaBarChange = useCallback(
(newValue: string) => {
if (selectedCell) {
if (!isEditing) {
setIsEditing(true)
}
if (!isEditing) setIsEditing(true)
setEditingValue(newValue)
if (!userEdited) setUserEdited(true)
}
},
[selectedCell, isEditing]
[selectedCell, isEditing, userEdited]
)
const handleFormulaBarFocus = useCallback(() => {
@@ -860,32 +764,25 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
)
// Мемоизируем состояние ширин таблиц
const initialSpreadsheetWidths = useMemo(
() => ({
L: 0.5,
R: 0.5,
}),
[]
)
const [spreadsheetWidths, setSpreadsheetWidths] = useState(
initialSpreadsheetWidths
)
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
L: 0.5,
R: 0.5,
})
// Мемоизируем опции автосохранения
const autoSaveOptions = useMemo(
() => ({
templateId,
autoSaveDelay: 2000,
enableAutoSave,
enableAutoSave: enableAutoSave && userEdited,
}),
[templateId, enableAutoSave]
[templateId, enableAutoSave, userEdited]
)
// Мемоизируем getAllModifiedCells для предотвращения частых ререндеров хука автосохранения
const memoizedGetAllModifiedCells = useCallback(
multiSheetEngine.getAllModifiedCells,
[multiSheetEngine.getAllModifiedCells]
[revision] // Используем revision вместо функции для более стабильной мемоизации
)
// Используем новый хук автосохранения
@@ -896,8 +793,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
lastSaveError,
manualSave,
clearError,
loadSavedSheets,
loadSheets, // Добавляем асинхронную функцию загрузки
loadSheets,
} = useSheetAutoSave(
memoizedGetAllModifiedCells,
multiSheetEngine.isCalculating,
@@ -924,33 +820,12 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
let finalBaseData: Record<string, Record<string, any>> = {}
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
if (templateId) {
try {
const latestFile = await getLatestFileForTemplate(templateId)
if (latestFile) {
// console.log('📁 Найден последний файл для шаблона:', latestFile.name)
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)
// Сохраняем baseline, чтобы в L попадали только пользовательские изменения
setTemplateData(finalBaseData)
// console.log('📄 Загружены базовые данные файла:', finalBaseData)
} else {
// console.log(
// '⚠️ getFileData вернул пустые данные, используем templateData'
//)
}
} else {
// console.log('⚠️ Файл не найден для templateId:', templateId)
}
} catch (error) {
console.error('❌ Ошибка при загрузке данных файла:', error)
}
// 1. ИСПОЛЬЗУЕМ ДАННЫЕ ИЗ REACT QUERY КЕША
if (fileData && Object.keys(fileData.data).length > 0) {
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
setBaseFileData(finalBaseData)
// Сохраняем baseline, чтобы в листы попадали только пользовательские изменения
setTemplateData(finalBaseData)
}
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
@@ -961,16 +836,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
finalBaseData = templateData
setBaseFileData(finalBaseData)
// console.log(
// '📄 Используем templateData как базовые данные:',
// finalBaseData
//)
setTemplateData(templateData)
}
// 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
try {
// console.log('🔍 Загружаем пользовательские изменения из API...')
const apiSheets = await loadSheets()
// Преобразуем листы API в формат данных
@@ -995,17 +865,8 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
}
})
// console.log(
// '📂 Загружены пользовательские изменения:',
// Object.keys(userChanges)
//)
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
if (Object.keys(userChanges).length > 0) {
// console.log(
// '🔄 Объединяем базовые данные с пользовательскими изменениями'
//)
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
const mergedData: Record<string, Record<string, any>> = {}
@@ -1024,24 +885,15 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
})
})
// console.log(
// '📊 Загружаем объединенные данные (базовые + пользовательские)'
//)
multiSheetEngine.loadData(mergedData)
} else if (Object.keys(finalBaseData).length > 0) {
// console.log(
// '📋 Используем только базовые данные (нет пользовательских изменений)'
//)
multiSheetEngine.loadData(finalBaseData)
} else {
// console.log('⚠️ Нет данных для загрузки')
}
} catch (error) {
console.error('❌ Ошибка при загрузке пользовательских изменений:', error)
// В случае ошибки используем только базовые данные
if (Object.keys(finalBaseData).length > 0) {
// console.log('📋 Ошибка API, используем только базовые данные')
multiSheetEngine.loadData(finalBaseData)
}
}
@@ -1050,6 +902,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
}, [
templateId,
templateData,
fileData,
loadSheets,
multiSheetEngine,
setTemplateData,
@@ -1057,9 +910,22 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
])
// Загружаем сохраненные формулы при инициализации
// Ждем загрузки данных из React Query перед инициализацией
useEffect(() => {
initializeData()
}, [initializeData])
// Инициализируем только когда все данные загружены или их нет вообще
const canInitialize =
!isLoadingLatestFile && !isLoadingFileData && templateId && !isInitialized
if (canInitialize) {
initializeData()
}
}, [
initializeData,
isLoadingLatestFile,
isLoadingFileData,
templateId,
isInitialized,
])
return (
<div className="flex h-full flex-col bg-background">

View File

@@ -1,5 +1,3 @@
import { Button } from '@/component/ui/button'
import { Input } from '@/component/ui/input'
import {
Select,
SelectContent,
@@ -8,10 +6,9 @@ import {
SelectValue,
} from '@/component/ui/select'
import { CellTarget } from '@/type/template'
import { Calendar, Settings, Trash2 } from 'lucide-react'
import { Info, Settings } from 'lucide-react'
interface StandardsConfig {
registryNumber: string // ГРСИ
sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7
@@ -23,119 +20,13 @@ interface StandardsEditorProps {
onChange: (config: StandardsConfig) => void
}
const CellTargetEditor: React.FC<{
targets: CellTarget[]
onChange: (targets: CellTarget[]) => void
}> = ({ targets, onChange }) => {
const handleAddTarget = () => {
onChange([...targets, { sheet: 'L', cell: '', displayName: '' }])
}
const handleUpdateTarget = (
index: number,
field: keyof CellTarget,
value: string
) => {
const updatedTargets = targets.map((target, i) =>
i === index ? { ...target, [field]: value } : target
)
onChange(updatedTargets)
}
const handleRemoveTarget = (index: number) => {
onChange(targets.filter((_, i) => i !== index))
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Целевые ячейки</label>
<Button variant="outline" size="sm" onClick={handleAddTarget}>
<Calendar className="mr-1 h-4 w-4" />
Добавить ячейку
</Button>
</div>
<div className="max-h-48 space-y-3 overflow-y-auto">
{targets.map((target, index) => (
<div key={index} className="rounded-lg border bg-gray-50 p-3">
<div className="mb-2 grid grid-cols-3 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">
Лист
</label>
<Select
value={target.sheet}
onValueChange={value =>
handleUpdateTarget(index, 'sheet', value)
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="L">L (Левый)</SelectItem>
<SelectItem value="R">R (Правый)</SelectItem>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Calculations">Calculations</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">
Ячейка
</label>
<Input
placeholder="A1, B5..."
value={target.cell}
onChange={e =>
handleUpdateTarget(index, 'cell', e.target.value)
}
className="h-8"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">
Название
</label>
<Input
placeholder="Описание"
value={target.displayName || ''}
onChange={e =>
handleUpdateTarget(index, 'displayName', e.target.value)
}
className="h-8"
/>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleRemoveTarget(index)}
className="h-6 text-xs text-red-600 hover:text-red-700"
>
<Trash2 className="mr-1 h-3 w-3" />
Удалить
</Button>
</div>
))}
{targets.length === 0 && (
<p className="py-4 text-center text-sm text-gray-500">
Добавьте ячейки для связи с элементом
</p>
)}
</div>
</div>
)
}
export const StandardsEditor: React.FC<StandardsEditorProps> = ({
config,
onChange,
}) => {
// Обеспечиваем значения по умолчанию
const safeConfig = {
registryNumber: config.registryNumber || '',
sheet: config.sheet || 'Report',
sheet: config.sheet || 'L',
startCell: config.startCell || 'A1',
maxItems: config.maxItems || 7,
targetCells: config.targetCells || [],
@@ -157,86 +48,36 @@ export const StandardsEditor: React.FC<StandardsEditorProps> = ({
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Номер ГРСИ</label>
<Input
placeholder="Введите номер ГРСИ"
value={safeConfig.registryNumber}
onChange={e => updateConfig({ registryNumber: e.target.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Максимум эталонов</label>
<Select
value={safeConfig.maxItems.toString()}
onValueChange={value => updateConfig({ maxItems: parseInt(value) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3 эталона</SelectItem>
<SelectItem value="5">5 эталонов</SelectItem>
<SelectItem value="7">7 эталонов</SelectItem>
<SelectItem value="10">10 эталонов</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Лист Excel</label>
<Select
value={safeConfig.sheet}
onValueChange={value => updateConfig({ sheet: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="L">L (Левый)</SelectItem>
<SelectItem value="R">R (Правый)</SelectItem>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Calculations">Calculations</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Начальная ячейка</label>
<Input
placeholder="A1, B5..."
value={safeConfig.startCell}
onChange={e => updateConfig({ startCell: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Целевые ячейки</label>
<label className="text-sm font-medium">Максимум эталонов</label>
<Select
value={safeConfig.maxItems.toString()}
onValueChange={value => updateConfig({ maxItems: parseInt(value) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3 эталона</SelectItem>
<SelectItem value="5">5 эталонов</SelectItem>
<SelectItem value="7">7 эталонов</SelectItem>
<SelectItem value="10">10 эталонов</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
Ячейки для записи выбранных эталонов (автоматически генерируются)
Максимальное количество эталонов для выбора
</p>
<CellTargetEditor
targets={safeConfig.targetCells}
onChange={targets => updateConfig({ targetCells: targets })}
/>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-start gap-2">
<Calendar className="mt-0.5 h-4 w-4 text-blue-600" />
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
<div className="text-sm">
<div className="font-medium text-blue-800">
Автоматическое заполнение
</div>
<div className="font-medium text-blue-800">Настройка ячеек</div>
<div className="mt-1 text-xs text-blue-600">
При выборе эталонов данные будут записаны в указанные ячейки. Если
выбрано меньше эталонов, чем максимальное количество, оставшиеся
ячейки останутся пустыми.
Ячейки для размещения эталонов настраиваются в разделе "Целевые
ячейки" выше. Эталоны будут записываться в указанные ячейки по
порядку.
</div>
</div>
</div>

View File

@@ -1,9 +1,9 @@
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import { Calendar, Settings } from 'lucide-react'
import { Label } from '@/component/ui/label'
import { Settings, Wrench } from 'lucide-react'
interface StandardsConfig {
registryNumber: string
sheet: string
startCell: string
maxItems: number
@@ -14,51 +14,122 @@ interface StandardsPreviewProps {
config: StandardsConfig
}
// Моковые данные для эталонов (такие же как в definition.tsx)
const mockStandards = [
{
id: '1',
name: 'Эталон массы 1 кг',
shortName: 'ЭМ-1кг',
type: 'Масса',
registryNumber: 'ГРСИ 12345-01',
range: '0.5-2 кг',
accuracy: '±0.001 г',
certificateNumber: 'СИ-2024-001',
validUntil: '2025-12-31',
},
{
id: '2',
name: 'Эталон длины 1 м',
shortName: 'ЭД-1м',
type: 'Длина',
registryNumber: 'ГРСИ 12345-02',
range: '0.5-2 м',
accuracy: '±0.001 мм',
certificateNumber: 'СИ-2024-002',
validUntil: '2025-06-30',
},
{
id: '3',
name: 'Эталон температуры 20°C',
shortName: 'ЭТ-20°C',
type: 'Температура',
registryNumber: 'ГРСИ 12345-03',
range: '15-25°C',
accuracy: '±0.01°C',
certificateNumber: 'СИ-2024-003',
validUntil: '2025-03-15',
},
{
id: '4',
name: 'Эталон давления 1 Па',
shortName: 'ЭД-1Па',
type: 'Давление',
registryNumber: 'ГРСИ 12345-04',
range: '0.5-2 Па',
accuracy: '±0.001 Па',
certificateNumber: 'СИ-2024-004',
validUntil: '2025-09-20',
},
{
id: '5',
name: 'Эталон времени 1 с',
shortName: 'ЭВ-1с',
type: 'Время',
registryNumber: 'ГРСИ 12345-05',
range: '0.5-2 с',
accuracy: '±0.000001 с',
certificateNumber: 'СИ-2024-005',
validUntil: '2025-12-01',
},
]
// Функция для получения типа эталона в сокращенном виде
const getStandardTypeBadge = (type: string) => {
switch (type) {
case 'Манометр грузопоршневой':
return 'МГП'
case 'Калибратор давления':
return 'КД'
default:
return 'МО'
}
}
export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
config,
}) => {
// Берем первые config.maxItems эталонов для предпросмотра
const previewStandards = mockStandards.slice(0, config.maxItems || 7)
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">Измерительные эталоны</span>
</div>
<div className="space-y-2">
{/* Блок с измерительными эталонами */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm font-medium">Эталоны</Label>
<Button variant="outline" size="sm" disabled className="h-7 px-2">
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</div>
<div className="space-y-2">
<div className="text-xs text-gray-500">
ГРСИ: {config.registryNumber || 'Не указан'}
</div>
<div className="text-xs text-gray-500">
Максимум: {config.maxItems} эталонов
</div>
<div className="space-y-2">
<div className="space-y-1.5">
{previewStandards.map((standard, index) => (
<div
key={standard.id}
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-medium text-primary">
{index + 1}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">
{standard.shortName}
</div>
<div className="text-xs text-muted-foreground">
{standard.accuracy} {standard.range}
</div>
</div>
<Badge variant="secondary" className="shrink-0 text-xs">
<Wrench className="mr-1 h-3 w-3" />
{getStandardTypeBadge(standard.type)}
</Badge>
</div>
))}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled className="text-xs">
<Calendar className="mr-1 h-3 w-3" />
Выбрать эталоны
</Button>
<Badge variant="secondary" className="text-xs">
0/{config.maxItems} выбрано
</Badge>
</div>
{config.targetCells && config.targetCells.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{config.targetCells.map((target, index) => (
<span
key={index}
className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700"
title={target.displayName}
>
{target.sheet}!{target.cell}
</span>
))}
</div>
)}
</div>
</div>
</div>

View File

@@ -1,12 +1,91 @@
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import { Label } from '@/component/ui/label'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { parseCellAddress } from '@/lib/cell-utils'
import { CellTarget } from '@/type/template'
import { Weight } from 'lucide-react'
import { Settings, Weight, Wrench } from 'lucide-react'
import { useEffect, useState } from 'react'
import { StandardsEditor } from './StandardsEditor'
import { StandardsPreview } from './StandardsPreview'
import { StandardsSelector } from './StandardsSelector'
// Моковые данные для эталонов (такие же как в StandardsSelector)
const mockStandards = [
{
id: '1',
name: 'Эталон массы 1 кг',
shortName: 'ЭМ-1кг',
type: 'Масса',
registryNumber: 'ГРСИ 12345-01',
range: '0.5-2 кг',
accuracy: '±0.001 г',
certificateNumber: 'СИ-2024-001',
validUntil: '2025-12-31',
},
{
id: '2',
name: 'Эталон длины 1 м',
shortName: 'ЭД-1м',
type: 'Длина',
registryNumber: 'ГРСИ 12345-02',
range: '0.5-2 м',
accuracy: '±0.001 мм',
certificateNumber: 'СИ-2024-002',
validUntil: '2025-06-30',
},
{
id: '3',
name: 'Эталон температуры 20°C',
shortName: 'ЭТ-20°C',
type: 'Температура',
registryNumber: 'ГРСИ 12345-03',
range: '15-25°C',
accuracy: '±0.01°C',
certificateNumber: 'СИ-2024-003',
validUntil: '2025-03-15',
},
{
id: '4',
name: 'Эталон давления 1 Па',
shortName: 'ЭД-1Па',
type: 'Давление',
registryNumber: 'ГРСИ 12345-04',
range: '0.5-2 Па',
accuracy: '±0.001 Па',
certificateNumber: 'СИ-2024-004',
validUntil: '2025-09-20',
},
{
id: '5',
name: 'Эталон времени 1 с',
shortName: 'ЭВ-1с',
type: 'Время',
registryNumber: 'ГРСИ 12345-05',
range: '0.5-2 с',
accuracy: '±0.000001 с',
certificateNumber: 'СИ-2024-005',
validUntil: '2025-12-01',
},
]
// Функция для получения данных эталона по ID
const getStandardById = (id: string) => {
return mockStandards.find(standard => standard.id === id)
}
// Функция для получения типа эталона в сокращенном виде
const getStandardTypeBadge = (type: string) => {
switch (type) {
case 'Манометр грузопоршневой':
return 'МГП'
case 'Калибратор давления':
return 'КД'
default:
return 'МО'
}
}
interface StandardsConfig {
registryNumber: string // ГРСИ
sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7
@@ -20,39 +99,125 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
icon: <Weight className="h-4 w-4" />,
version: 1,
defaultConfig: {
registryNumber: '',
sheet: 'Report',
sheet: 'L',
startCell: 'A1',
maxItems: 7,
targetCells: [],
},
Editor: StandardsEditor,
Preview: StandardsPreview,
Render: () => {
// Здесь нужно создать обертку для StandardsDialog
// Пока возвращаем null, так как StandardsDialog требует дополнительные пропсы
return null
Render: ({ config, value, onChange }) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
const [selectedStandardIds, setSelectedStandardIds] = useState<string[]>(
[]
)
// Если value содержит названия эталонов, находим соответствующие ID
useEffect(() => {
if (Array.isArray(value) && value.length > 0) {
const ids = value
.map(name => {
const standard = mockStandards.find(s => s.name === name)
return standard?.id || ''
})
.filter(Boolean)
setSelectedStandardIds(ids)
}
}, [value])
const selectedStandardsData = selectedStandardIds
.map(id => getStandardById(id))
.filter(Boolean)
return (
<div className="space-y-2">
{/* Блок с измерительными эталонами */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm font-medium">Эталоны</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsStandardsDialogOpen(true)}
className="h-7 px-2"
>
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</div>
<div className="space-y-2">
{selectedStandardsData.length > 0 ? (
<div className="space-y-1.5">
{selectedStandardsData.map(
(standard, index) =>
standard && (
<div
key={standard.id}
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-medium text-primary">
{index + 1}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">
{standard.shortName}
</div>
<div className="text-xs text-muted-foreground">
{standard.accuracy} {standard.range}
</div>
</div>
<Badge
variant="secondary"
className="shrink-0 text-xs"
>
<Wrench className="mr-1 h-3 w-3" />
{getStandardTypeBadge(standard.type)}
</Badge>
</div>
)
)}
</div>
) : (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center text-sm text-muted-foreground">
<Settings className="mx-auto mb-1 h-4 w-4 opacity-50" />
Эталоны не выбраны
</div>
)}
</div>
</div>
<StandardsSelector
isOpen={isStandardsDialogOpen}
onClose={() => setIsStandardsDialogOpen(false)}
value={selectedStandardIds}
onChange={standardIds => {
setSelectedStandardIds(standardIds)
// Преобразуем ID эталонов в их названия для сохранения в ячейки
const standardNames = standardIds.map(
id => getStandardById(id)?.name || ''
)
onChange?.(standardNames)
}}
/>
</div>
)
},
mapToCells: cfg => {
const res: CellTarget[] = []
const parsed = parseCellAddress(cfg.startCell)
// Используем только ячейки, указанные пользователем в targetCells
return cfg.targetCells || []
},
mapToCellValues: (cfg, value) => {
// Используем targetCells из конфигурации
const cells = cfg.targetCells || []
if (!parsed) {
// Если не удалось распарсить, возвращаем пустой массив
return res
}
const { column, row } = parsed
// Генерируем ячейки вертикально (A1, A2, A3...)
for (let i = 0; i < cfg.maxItems; i++) {
res.push({
sheet: cfg.sheet,
cell: `${column}${row + i}`,
displayName: `Эталон ${i + 1}`,
})
}
return res
// Сопоставляем значения с ячейками
const standardNames = Array.isArray(value) ? value : []
return cells.map((target, idx) => ({
target,
value: standardNames[idx] || '', // Берем значение по индексу или пустую строку
}))
},
}

View File

@@ -2,6 +2,7 @@ import {
getAvailableElementTypes,
getElementDefinition,
} from '@/entitiy/element/model/interface'
import { useToast } from '@/lib/hooks/useToast'
import {
autoArrangeElements,
findFreePosition,
@@ -14,17 +15,7 @@ import {
Template,
TemplateElement,
} from '@/type/template'
import {
Calendar,
Droplets,
Edit3,
Gauge,
Plus,
Radio,
Thermometer,
Trash2,
Zap,
} from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react'
import React, { useState } from 'react'
import { Button } from '../ui/button'
import { Checkbox } from '../ui/checkbox'
@@ -43,7 +34,6 @@ import {
SelectTrigger,
SelectValue,
} from '../ui/select'
import { Textarea } from '../ui/textarea'
import { ElementFormulaBar } from './ElementFormulaBar'
import { VisualLayoutEditor } from './VisualLayoutEditor'
@@ -56,6 +46,8 @@ const CellTargetEditor: React.FC<{
targets: CellTarget[]
onChange: (targets: CellTarget[]) => void
}> = ({ targets, onChange }) => {
const { warning } = useToast()
const handleAddTarget = () => {
onChange([...targets, { sheet: 'L', cell: '' }])
}
@@ -75,6 +67,21 @@ const CellTargetEditor: React.FC<{
onChange(targets.filter((_, i) => i !== index))
}
const handleCellInputChange = (index: number, value: string) => {
// Проверяем на наличие кириллицы
const hasCyrillic = /[а-яё]/i.test(value)
if (hasCyrillic) {
warning('Кириллические символы не разрешены в адресе ячейки', {
title: 'Недопустимый символ',
duration: 3000,
})
return // Не обновляем значение
}
handleUpdateTarget(index, 'cell', value)
}
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
@@ -119,9 +126,7 @@ const CellTargetEditor: React.FC<{
<Input
placeholder="A1, B5..."
value={target.cell}
onChange={e =>
handleUpdateTarget(index, 'cell', e.target.value)
}
onChange={e => handleCellInputChange(index, e.target.value)}
className="h-7 text-xs"
/>
</div>
@@ -159,163 +164,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
return <elementDefinition.Preview config={element as any} />
}
// Fallback для старых элементов
const renderPreviewInput = () => {
switch (element.type) {
case 'text':
return (
<Input
placeholder={element.placeholder || 'Введите текст'}
disabled
className="w-full"
/>
)
case 'textarea':
return (
<Textarea
placeholder={element.placeholder || 'Введите текст'}
disabled
rows={3}
className="w-full resize-none"
/>
)
case 'number':
return (
<Input
type="number"
placeholder={element.placeholder || '0'}
disabled
className="w-full"
/>
)
case 'date':
return <Input type="date" disabled className="w-full" />
case 'select':
return (
<Select disabled>
<SelectTrigger className="w-full">
<span className="text-gray-500">
{element.placeholder || 'Выберите значение'}
</span>
</SelectTrigger>
</Select>
)
case 'radio':
return (
<div className="space-y-2">
{element.options?.length ? (
element.options.map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<div className="h-4 w-4 rounded-full border border-gray-300" />
<span className="text-sm">{option.label}</span>
</div>
))
) : (
<div className="flex items-center space-x-2">
<div className="h-4 w-4 rounded-full border border-gray-300" />
<span className="text-sm text-gray-500">Вариант 1</span>
</div>
)}
</div>
)
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<div className="h-4 w-4 rounded border border-gray-300" />
<span className="text-sm">
{element.placeholder || element.label || 'Отметить'}
</span>
</div>
)
case 'standards':
return (
<div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-input bg-background p-3">
<span className="text-sm text-muted-foreground">
{element.placeholder || 'Выберите эталоны'}
</span>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 opacity-50" />
<span className="text-xs text-muted-foreground">Выбрать</span>
</div>
</div>
<div className="text-xs text-muted-foreground">
Выбрано эталонов: 0
</div>
</div>
)
case 'calibration_conditions':
return (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<Thermometer className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">20°C</span>
</div>
<div className="flex items-center gap-1">
<Droplets className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">65%</span>
</div>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">101.3кПа</span>
</div>
<div className="flex items-center gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">220В</span>
</div>
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground" />
<span className="text-foreground">50Гц</span>
</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
<Edit3 className="h-3 w-3" />
</Button>
</div>
</div>
)
case 'button_group':
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{element.options?.length ? (
element.options.map((option, index) => (
<button
key={index}
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200 hover:border-primary/30 hover:bg-primary/5"
>
{option.label || `Кнопка ${index + 1}`}
</button>
))
) : (
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
{element.placeholder || 'Добавьте кнопки'}
</div>
)}
</div>
</div>
)
default:
return (
<div className="rounded border border-gray-200 bg-gray-50 p-3 text-center text-sm text-gray-500">
Выберите тип элемента
</div>
)
}
}
// Fallback для неизвестных типов элементов
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
@@ -326,7 +175,13 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
{element.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
{renderPreviewInput()}
<div className="rounded border border-gray-200 bg-gray-50 p-3 text-center text-sm text-gray-500">
{element.type
? `Элемент типа "${element.type}" не найден`
: 'Выберите тип элемента'}
</div>
{element.targetCells && element.targetCells.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{element.targetCells.map((target, index) => (
@@ -699,7 +554,6 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
showGrid: true,
}
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [editingElement, setEditingElement] = useState<TemplateElement | null>(
null
)

View File

@@ -165,7 +165,7 @@ export const ToastContainer: FC<ToastContainerProps> = ({
if (toasts.length === 0) return null
return (
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex w-full max-w-sm flex-col-reverse">
<div className="pointer-events-none fixed bottom-4 left-4 z-[100] flex w-full max-w-sm flex-col-reverse">
{toasts.map(toast => (
<div key={toast.id} className="pointer-events-auto">
<ToastComponent toast={toast} onRemove={onRemove} />

View File

@@ -33,6 +33,15 @@ export const buttonGroupDefinition: ElementDefinition<
},
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе
mapToCells: cfg => cfg.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
// Находим option по value чтобы получить label
const selectedOption = config.options?.find(opt => opt.value === value)
const cellValue = selectedOption ? selectedOption.label : value || ''
return cells.map(target => ({ target, value: cellValue }))
},
Editor: ({ config, onChange }) => {
const handleAddOption = () => {
onChange({

View File

@@ -22,6 +22,10 @@ export const textDefinition: ElementDefinition<TextConfig, string> = {
name: '',
},
mapToCells: config => config.targetCells || [],
mapToCellValues: (config, value) => {
const cells = config.targetCells || []
return cells.map(target => ({ target, value: value || '' }))
},
Editor: () => <ExtraSettingsBadge />,
Preview: ({ config }) => (
<div className="space-y-1">

View File

@@ -23,7 +23,11 @@ export interface ElementDefinition<Config = any, Value = any> {
/* бизнес-логика */
defaultConfig: Config
mapToCells(config: Config): CellTarget[] // куда пишем данные
mapToCells(config: Config): CellTarget[] // куда пишем данные (для отображения)
mapToCellValues(
config: Config,
value: Value
): Array<{ target: CellTarget; value: any }> // готовые пары ячейка-значение для записи
/* мета */
version: number

View File

@@ -0,0 +1,85 @@
import {
getFileData,
getFilesByTemplateId,
getLatestFileForTemplate,
parseExcelFile,
} from '@/service/fileApiService'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
// Ключи для React Query
export const fileKeys = {
all: ['files'] as const,
byTemplate: (templateId: string) =>
[...fileKeys.all, 'template', templateId] as const,
latest: (templateId: string) =>
[...fileKeys.byTemplate(templateId), 'latest'] as const,
data: (fileId: string) => [...fileKeys.all, 'data', fileId] as const,
}
// Хук для получения файлов по template_id с кешированием
export function useFilesByTemplate(templateId: string | undefined) {
return useQuery({
queryKey: fileKeys.byTemplate(templateId || ''),
queryFn: () => getFilesByTemplateId(templateId!),
enabled: !!templateId,
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 10 * 60 * 1000, // 10 минут кеш
})
}
// Хук для получения последнего файла для шаблона
export function useLatestFileForTemplate(templateId: string | undefined) {
return useQuery({
queryKey: fileKeys.latest(templateId || ''),
queryFn: () => getLatestFileForTemplate(templateId!),
enabled: !!templateId,
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 10 * 60 * 1000, // 10 минут кеш
})
}
// Хук для получения данных файла по ID
export function useFileData(fileId: string | undefined) {
return useQuery({
queryKey: fileKeys.data(fileId || ''),
queryFn: () => getFileData(fileId!),
enabled: !!fileId,
staleTime: 10 * 60 * 1000, // 10 минут для данных файла
gcTime: 30 * 60 * 1000, // 30 минут кеш
})
}
// Хук для загрузки Excel файла
export function useParseExcelFile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ file, templateId }: { file: File; templateId: string }) =>
parseExcelFile(file, templateId),
onSuccess: (data, variables) => {
// Инвалидируем связанные кеши после успешной загрузки
queryClient.invalidateQueries({
queryKey: fileKeys.byTemplate(variables.templateId),
})
queryClient.invalidateQueries({
queryKey: fileKeys.latest(variables.templateId),
})
// Сохраняем данные файла в кеш
queryClient.setQueryData(fileKeys.data(data.fileId), data)
},
})
}
// Хук для предзагрузки данных файла
export function usePrefetchFileData() {
const queryClient = useQueryClient()
return (fileId: string) => {
queryClient.prefetchQuery({
queryKey: fileKeys.data(fileId),
queryFn: () => getFileData(fileId),
staleTime: 10 * 60 * 1000,
})
}
}

View File

@@ -192,10 +192,10 @@ export function useMultiSheetEngine({
}
}
// Запускаем безопасный пересчет
safeRecalc()
// Запускаем отложенный пересчет с дебаунсом (избегаем двойного пересчёта)
debouncedRecalc()
},
[safeRecalc]
[debouncedRecalc]
)
// Получение значения ячейки (вычисленное)

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useDebounce } from 'use-debounce'
import type { ApiSheet, CreateSheetInput } from '../service/sheetApiService'
import {
ApiSheet,
createSheet,
getSheetsByTemplate,
patchSheet,
} from '../service/sheetApiService'
useCreateSheet,
usePatchSheet,
useSheetsByTemplate,
} from './useSheetQueries'
export interface UseSheetAutoSaveOptions {
templateId?: string
@@ -19,133 +19,88 @@ export const useSheetAutoSave = (
revision: number,
options: UseSheetAutoSaveOptions = {}
) => {
console.log('getAllModifiedCells', getAllModifiedCells())
const memoizedGetAllModifiedCells = useCallback(getAllModifiedCells, [
getAllModifiedCells,
])
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)
// React Query хуки
const {
data: sheetsResponse,
isLoading,
error: loadError,
refetch: refetchSheets,
} = useSheetsByTemplate(templateId)
const createSheetMutation = useCreateSheet()
const patchSheetMutation = usePatchSheet()
// Мемоизируем карту листов
const sheetsMap = useMemo(() => {
if (!sheetsResponse?.sheets) return {}
return sheetsResponse.sheets.reduce<Record<string, ApiSheet>>(
(acc, sheet) => {
acc[sheet.name] = sheet
return acc
},
{}
)
}, [sheetsResponse?.sheets])
// Дебаунсинг ревизии для автосохранения
const [debouncedRevision] = useDebounce(revision, autoSaveDelay)
// Загрузка листов для шаблона при инициализации
const loadSheets = useCallback(async () => {
if (!templateId) return {}
// Состояние сохранения
const isSaving = createSheetMutation.isPending || patchSheetMutation.isPending
setIsLoading(true)
try {
const response = await getSheetsByTemplate(templateId)
const sheetsMap = response.sheets.reduce<Record<string, ApiSheet>>(
(acc, sheet) => {
const existingSheet = acc[sheet.name]
if (existingSheet) {
// Если лист уже есть, объединяем ячейки
acc[sheet.name] = {
...sheet, // берем актуальную версию листа
cells: {
...existingSheet.cells, // старые ячейки
...sheet.cells, // новые заменяют старые с тем же ключом
},
}
} else {
acc[sheet.name] = { ...sheet }
}
return acc
},
{}
)
setSheets(sheetsMap)
return sheetsMap
} catch (error) {
console.error('❌ Ошибка при загрузке листов:', error)
setLastSaveError('Ошибка загрузки листов')
return {}
} finally {
setIsLoading(false)
}
}, [templateId])
// Загрузка листов при изменении templateId
// Обновляем ошибку при ошибках загрузки или сохранения
useEffect(() => {
if (templateId) {
loadSheets()
if (loadError) {
setLastSaveError('Ошибка загрузки листов')
} else if (createSheetMutation.error) {
setLastSaveError(createSheetMutation.error.message)
} else if (patchSheetMutation.error) {
setLastSaveError(patchSheetMutation.error.message)
}
}, [templateId, loadSheets])
}, [loadError, createSheetMutation.error, patchSheetMutation.error])
// Основная функция сохранения через API
// Основная функция сохранения через React Query
const performSave = useCallback(
async (data: Record<string, Record<string, any>>) => {
if (!templateId) return
// Получаем список всех листов, которые нужно обработать:
// 1. Листы с данными (из data)
// 2. Существующие листы, которые могли быть очищены
// Получаем список всех листов, которые нужно обработать
const dataSheetNames = Object.keys(data)
const existingSheetNames = Object.keys(sheets)
const existingSheetNames = Object.keys(sheetsMap)
const allSheetNames = new Set([...dataSheetNames, ...existingSheetNames])
if (allSheetNames.size === 0) return
setIsSaving(true)
setLastSaveError(null)
try {
// Создаем массив промисов для параллельного выполнения
const savePromises = Array.from(allSheetNames).map(async sheetName => {
const sheetCells = data[sheetName] || {} // если данных нет, используем пустой объект
const existingSheet = sheets[sheetName]
const existingSheet = sheetsMap[sheetName]
if (existingSheet) {
// Всегда сохраняем, даже если ячейки пустые (очистка листа)
await patchSheet(existingSheet.id, {
cells: sheetCells,
// Обновляем существующий лист
await patchSheetMutation.mutateAsync({
sheetId: existingSheet.id,
data: { cells: sheetCells },
})
// Обновляем локальное состояние
setSheets(prev => ({
...prev,
[sheetName]: {
...existingSheet,
cells: sheetCells,
updated_at: new Date().toISOString(),
},
}))
} else {
// Создаем новый лист только если есть данные
if (Object.keys(sheetCells).length > 0) {
const newSheet = await createSheet({
const createInput: CreateSheetInput = {
name: sheetName,
cells: sheetCells,
template_id: templateId,
})
// Получаем полную информацию о созданном листе
const fullSheetData: ApiSheet = {
id: newSheet.id,
name: sheetName,
cells: sheetCells,
template_id: templateId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
}
// Обновляем локальное состояние
setSheets(prev => ({
...prev,
[sheetName]: fullSheetData,
}))
await createSheetMutation.mutateAsync(createInput)
}
}
})
@@ -157,11 +112,9 @@ export const useSheetAutoSave = (
error instanceof Error ? error.message : 'Неизвестная ошибка'
setLastSaveError(errorMessage)
console.error('❌ Ошибка при сохранении листов:', errorMessage)
} finally {
setIsSaving(false)
}
},
[templateId, sheets, revision]
[templateId, sheetsMap, revision, createSheetMutation, patchSheetMutation]
)
// Автосохранение при изменении дебаунсированной ревизии
@@ -174,8 +127,16 @@ export const useSheetAutoSave = (
templateId
if (shouldAutoSave) {
console.log('✅ Выполняем автосохранение для ревизии:', debouncedRevision)
performSave(memoizedGetAllModifiedCells())
if (process.env.NODE_ENV !== 'production') {
console.log(
'✅ Выполняем автосохранение для ревизии:',
debouncedRevision
)
}
const diff = getAllModifiedCells()
if (Object.keys(diff).length > 0) {
performSave(diff)
}
}
}, [
enableAutoSave,
@@ -184,31 +145,44 @@ export const useSheetAutoSave = (
isCalculating,
isSaving,
templateId,
memoizedGetAllModifiedCells,
getAllModifiedCells,
performSave,
])
// Ручное сохранение
const manualSave = useCallback(() => {
const currentData = memoizedGetAllModifiedCells()
performSave(currentData)
}, [memoizedGetAllModifiedCells, performSave])
const currentData = getAllModifiedCells()
if (Object.keys(currentData).length > 0) {
performSave(currentData)
}
}, [getAllModifiedCells, performSave])
// Функция загрузки сохраненных листов
// Функция загрузки сохраненных листов (теперь используем React Query кеш)
const loadSavedSheets = useCallback((): Record<
string,
Record<string, any>
> => {
const result: Record<string, Record<string, any>> = {}
Object.entries(sheets).forEach(([sheetName, sheet]) => {
Object.entries(sheetsMap).forEach(([sheetName, sheet]) => {
if (sheet.cells && Object.keys(sheet.cells).length > 0) {
result[sheetName] = sheet.cells
}
})
return result
}, [sheets])
}, [sheetsMap])
// Функция загрузки листов (используем refetch из React Query)
const loadSheets = useCallback(async () => {
const result = await refetchSheets()
return (
result.data?.sheets.reduce<Record<string, ApiSheet>>((acc, sheet) => {
acc[sheet.name] = sheet
return acc
}, {}) || {}
)
}, [refetchSheets])
// Вычисляемые значения
const isDataChanged = revision > lastSavedRevision
@@ -222,7 +196,7 @@ export const useSheetAutoSave = (
hasUnsavedChanges,
lastSaveError,
lastSavedRevision,
sheets,
sheets: sheetsMap,
// Методы
manualSave,
@@ -230,7 +204,7 @@ export const useSheetAutoSave = (
loadSheets,
// Утилиты
getCurrentData: memoizedGetAllModifiedCells,
getCurrentData: getAllModifiedCells,
clearError: () => setLastSaveError(null),
}
}

196
src/hook/useSheetQueries.ts Normal file
View File

@@ -0,0 +1,196 @@
import {
createSheet,
getSheet,
getSheets,
getSheetsByTemplate,
patchSheet,
type ApiSheet,
type GetSheetsOutput,
type PatchSheetInput,
} from '@/service/sheetApiService'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
// Ключи для React Query
export const sheetKeys = {
all: ['sheets'] as const,
byTemplate: (templateId: string) =>
[...sheetKeys.all, 'template', templateId] as const,
detail: (sheetId: string) => [...sheetKeys.all, 'detail', sheetId] as const,
}
// Хук для получения всех листов
export function useSheets() {
return useQuery({
queryKey: sheetKeys.all,
queryFn: getSheets,
staleTime: 2 * 60 * 1000, // 2 минуты
gcTime: 10 * 60 * 1000, // 10 минут кеш
})
}
// Хук для получения листов по template_id с кешированием
export function useSheetsByTemplate(templateId: string | undefined) {
return useQuery({
queryKey: sheetKeys.byTemplate(templateId || ''),
queryFn: () => getSheetsByTemplate(templateId!),
enabled: !!templateId,
staleTime: 1 * 60 * 1000, // 1 минута для активных данных
gcTime: 5 * 60 * 1000, // 5 минут кеш
refetchOnWindowFocus: false, // Не перезапрашивать при фокусе окна
})
}
// Хук для получения конкретного листа
export function useSheet(sheetId: string | undefined) {
return useQuery({
queryKey: sheetKeys.detail(sheetId || ''),
queryFn: () => getSheet(sheetId!),
enabled: !!sheetId,
staleTime: 5 * 60 * 1000, // 5 минут
gcTime: 10 * 60 * 1000, // 10 минут кеш
})
}
// Хук для создания листа
export function useCreateSheet() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createSheet,
onSuccess: (data, variables) => {
// Инвалидируем кеши после создания листа
queryClient.invalidateQueries({
queryKey: sheetKeys.byTemplate(variables.template_id),
})
queryClient.invalidateQueries({
queryKey: sheetKeys.all,
})
// Добавляем новый лист в кеш списка листов для шаблона
queryClient.setQueryData<GetSheetsOutput>(
sheetKeys.byTemplate(variables.template_id),
oldData => {
if (!oldData) return { sheets: [] }
const newSheet: ApiSheet = {
id: data.id,
name: variables.name,
cells: variables.cells,
template_id: variables.template_id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
return {
sheets: [...oldData.sheets, newSheet],
}
}
)
},
})
}
// Хук для обновления листа
export function usePatchSheet() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
sheetId,
data,
}: {
sheetId: string
data: PatchSheetInput
}) => patchSheet(sheetId, data),
onSuccess: (result, variables) => {
// Обновляем конкретный лист в кеше
queryClient.setQueryData(
sheetKeys.detail(variables.sheetId),
(oldData: any) => {
if (!oldData?.sheet) return oldData
return {
sheet: {
...oldData.sheet,
...variables.data,
updated_at: new Date().toISOString(),
},
}
}
)
// Обновляем лист в списке листов для шаблона
const sheetDetail = queryClient.getQueryData(
sheetKeys.detail(variables.sheetId)
) as any
const templateId = sheetDetail?.sheet?.template_id
if (templateId) {
queryClient.setQueryData<GetSheetsOutput>(
sheetKeys.byTemplate(templateId),
oldData => {
if (!oldData) return oldData
return {
sheets: oldData.sheets.map(sheet =>
sheet.id === variables.sheetId
? {
...sheet,
name: variables.data.name || sheet.name,
cells: variables.data.cells || sheet.cells,
updated_at: new Date().toISOString(),
}
: sheet
),
}
}
)
}
},
})
}
// Хук для массового обновления листов (для автосохранения)
export function useBatchUpdateSheets() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (
updates: Array<{
sheetId: string
data: PatchSheetInput
templateId: string
}>
) => {
// Выполняем все обновления параллельно
const results = await Promise.allSettled(
updates.map(({ sheetId, data }) => patchSheet(sheetId, data))
)
return results
},
onSuccess: (results, variables) => {
// Обновляем кеши для всех затронутых шаблонов
const templateIds = new Set(variables.map(v => v.templateId))
templateIds.forEach(templateId => {
queryClient.invalidateQueries({
queryKey: sheetKeys.byTemplate(templateId),
})
})
},
})
}
// Хук для предзагрузки листов шаблона
export function usePrefetchSheetsByTemplate() {
const queryClient = useQueryClient()
return (templateId: string) => {
queryClient.prefetchQuery({
queryKey: sheetKeys.byTemplate(templateId),
queryFn: () => getSheetsByTemplate(templateId),
staleTime: 1 * 60 * 1000,
})
}
}

View File

@@ -8,6 +8,7 @@ export interface ExcelFunction {
export const VOLATILE_FUNCTIONS = new Set([
'СЛУЧ',
'СЛУЧМЕЖДУ',
'СЛУЧДР',
'СЕЙЧАС',
'СЕГОДНЯ',
])
@@ -16,6 +17,44 @@ export const DEFAULT_FUNCTIONS: Record<string, ExcelFunction> = {
СЛУЧ: () => Math.random(),
СЛУЧМЕЖДУ: (низ: number, верх: number) =>
Math.floor(Math.random() * (верх - низ + 1)) + низ,
СЛУЧДР: (низ: number, верх: number, знаков: number = 0) => {
// Проверка валидности входных параметров
if (
typeof низ !== 'number' ||
typeof верх !== 'number' ||
typeof знаков !== 'number'
) {
return '#ЗНАЧ!' // Ошибка значения
}
if (!isFinite(низ) || !isFinite(верх) || !isFinite(знаков)) {
return '#ЧИСЛО!' // Ошибка числа
}
// Если низ больше верха, меняем местами
if (низ > верх) {
;[низ, верх] = [верх, низ]
}
// Если низ равен верху, возвращаем это значение
if (низ === верх) {
const множитель = Math.pow(10, Math.max(0, Math.floor(знаков)))
return Math.round(низ * множитель) / множитель
}
// Ограничиваем количество знаков после запятой (максимум 15 для точности JavaScript)
const ограниченныеЗнаки = Math.max(0, Math.min(15, Math.floor(знаков)))
// Генерируем случайное число
const случайное = Math.random() * (верх - низ) + низ
const множитель = Math.pow(10, ограниченныеЗнаки)
// Округляем и возвращаем результат
const результат = Math.round(случайное * множитель) / множитель
// Проверяем, что результат в допустимых пределах
return Math.max(низ, Math.min(верх, результат))
},
СУММ: (...args: any[]) => {
const flatArgs = args.flat()
return flatArgs.reduce(

View File

@@ -1,105 +1,15 @@
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
import { StandardsSelector } from '@/component/StandardsElement/StandardsSelector'
import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button'
import { Checkbox } from '@/component/ui/checkbox'
import { Input } from '@/component/ui/input'
import { Label } from '@/component/ui/label'
import { RadioGroup, RadioGroupItem } from '@/component/ui/radio-group'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/component/ui/select'
import { Textarea } from '@/component/ui/textarea'
import { getElementDefinition } from '@/entitiy/element/model/interface'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import { cellAddressToCoordinates } from '@/lib/cell-utils'
import { useToast } from '@/lib/hooks/useToast'
import { getLatestFileForTemplate } from '@/service/fileApiService'
import { Template, TemplateElement } from '@/type/template'
import { ArrowLeft, FileText, Grid, Save, Settings, Wrench } from 'lucide-react'
import { ArrowLeft, FileText, Grid, Save } from 'lucide-react'
import { FC, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
// Моковые данные для эталонов (такие же как в StandardsSelector)
const mockStandards = [
{
id: '1',
name: 'Эталон массы 1 кг',
shortName: 'ЭМ-1кг',
type: 'Масса',
registryNumber: 'ГРСИ 12345-01',
range: '0.5-2 кг',
accuracy: '±0.001 г',
certificateNumber: 'СИ-2024-001',
validUntil: '2025-12-31',
},
{
id: '2',
name: 'Эталон длины 1 м',
shortName: 'ЭД-1м',
type: 'Длина',
registryNumber: 'ГРСИ 12345-02',
range: '0.5-2 м',
accuracy: '±0.001 мм',
certificateNumber: 'СИ-2024-002',
validUntil: '2025-06-30',
},
{
id: '3',
name: 'Эталон температуры 20°C',
shortName: 'ЭТ-20°C',
type: 'Температура',
registryNumber: 'ГРСИ 12345-03',
range: '15-25°C',
accuracy: '±0.01°C',
certificateNumber: 'СИ-2024-003',
validUntil: '2025-03-15',
},
{
id: '4',
name: 'Эталон давления 1 Па',
shortName: 'ЭД-1Па',
type: 'Давление',
registryNumber: 'ГРСИ 12345-04',
range: '0.5-2 Па',
accuracy: '±0.001 Па',
certificateNumber: 'СИ-2024-004',
validUntil: '2025-09-20',
},
{
id: '5',
name: 'Эталон времени 1 с',
shortName: 'ЭВ-1с',
type: 'Время',
registryNumber: 'ГРСИ 12345-05',
range: '0.5-2 с',
accuracy: '±0.000001 с',
certificateNumber: 'СИ-2024-005',
validUntil: '2025-12-01',
},
]
// Функция для получения данных эталона по ID
const getStandardById = (id: string) => {
return mockStandards.find(standard => standard.id === id)
}
// Функция для получения типа эталона в сокращенном виде
const getStandardTypeBadge = (type: string) => {
switch (type) {
case 'Манометр грузопоршневой':
return 'МГП'
case 'Калибратор давления':
return 'КД'
default:
return 'МО'
}
}
interface ProtocolFormProps {
template: Template
onSave: (data: Record<string, any>) => void
@@ -113,297 +23,52 @@ interface FormElementProps {
}
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
// Получаем определение элемента из реестра
const elementDefinition = getElementDefinition(element.type)
const renderInput = () => {
switch (element.type) {
case 'text':
// Используем определение элемента из реестра для рендеринга
const textDefinition = getElementDefinition('text')
if (textDefinition && textDefinition.Render) {
return (
<textDefinition.Render
config={element as any}
value={value}
onChange={onChange}
/>
)
}
return (
<Input
placeholder={element.placeholder}
value={value || ''}
onChange={e => onChange(e.target.value)}
required={element.required}
/>
)
case 'textarea':
return (
<Textarea
placeholder={element.placeholder}
value={value || ''}
onChange={e => onChange(e.target.value)}
required={element.required}
rows={3}
/>
)
case 'number':
return (
<Input
type="number"
placeholder={element.placeholder}
value={value || ''}
onChange={e =>
onChange(e.target.value ? parseFloat(e.target.value) : '')
}
required={element.required}
/>
)
case 'date':
return (
<Input
type="date"
value={value || ''}
onChange={e => onChange(e.target.value)}
required={element.required}
/>
)
case 'select':
return (
<Select value={value || ''} onValueChange={onChange}>
<SelectTrigger>
<SelectValue
placeholder={element.placeholder || 'Выберите значение'}
/>
</SelectTrigger>
<SelectContent>
{element.options?.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
case 'radio':
return (
<RadioGroup value={value || ''} onValueChange={onChange}>
{element.options?.map(option => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem
value={option.value}
id={`${element.id}-${option.value}`}
/>
<label
htmlFor={`${element.id}-${option.value}`}
className="text-sm font-medium"
>
{option.label}
</label>
</div>
))}
</RadioGroup>
)
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={!!value}
onCheckedChange={onChange}
id={element.id}
/>
<label htmlFor={element.id} className="text-sm font-medium">
{element.placeholder || 'Отметить'}
</label>
</div>
)
case 'standards':
const selectedStandards = Array.isArray(value) ? value : []
const selectedStandardsData = selectedStandards
.map(id => getStandardById(id))
.filter(Boolean)
return (
<div className="space-y-2">
{/* Блок с измерительными эталонами */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="text-sm font-medium">Эталоны</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsStandardsDialogOpen(true)}
className="h-7 px-2"
>
<Settings className="mr-1 h-3 w-3" />
Настроить
</Button>
</div>
<div className="space-y-2">
{selectedStandardsData.length > 0 ? (
<div className="space-y-1.5">
{selectedStandardsData.map(
(standard, index) =>
standard && (
<div
key={standard.id}
className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm"
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-medium text-primary">
{index + 1}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">
{standard.shortName}
</div>
<div className="text-xs text-muted-foreground">
{standard.accuracy} {standard.range}
</div>
</div>
<Badge
variant="secondary"
className="shrink-0 text-xs"
>
<Wrench className="mr-1 h-3 w-3" />
{getStandardTypeBadge(standard.type)}
</Badge>
</div>
)
)}
</div>
) : (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center text-sm text-muted-foreground">
<Settings className="mx-auto mb-1 h-4 w-4 opacity-50" />
Эталоны не выбраны
</div>
)}
</div>
</div>
<StandardsSelector
isOpen={isStandardsDialogOpen}
onClose={() => setIsStandardsDialogOpen(false)}
value={selectedStandards}
onChange={onChange}
registryNumber={
element.targetCells?.[0]?.displayName || 'ГРСИ 12345'
}
/>
</div>
)
case 'calibration_conditions':
// Используем определение элемента из реестра для рендеринга
const elementDefinition = getElementDefinition('calibration_conditions')
if (elementDefinition && elementDefinition.Render) {
return (
<elementDefinition.Render
config={element as any}
value={value}
onChange={onChange}
/>
)
}
return null
case 'button_group':
// Используем определение элемента из реестра для рендеринга
const buttonGroupDefinition = getElementDefinition('button_group')
if (buttonGroupDefinition && buttonGroupDefinition.Render) {
return (
<buttonGroupDefinition.Render
config={element as any}
value={value}
onChange={onChange}
/>
)
}
return null
default:
return null
}
// Если определение элемента не найдено, показываем ошибку
if (!elementDefinition) {
return (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
Элемент типа "{element.type}" не найден в реестре
</div>
)
}
// Проверяем, использует ли элемент определение из реестра для рендеринга
const elementDefinition = getElementDefinition(element.type)
const usesElementRender =
elementDefinition &&
elementDefinition.Render &&
(element.type === 'text' ||
element.type === 'calibration_conditions' ||
element.type === 'button_group')
const renderElement = () => (
<elementDefinition.Render
config={element as any}
value={value}
onChange={onChange}
/>
)
return (
<div className="space-y-3">
{/* Показываем label и индикатор ячеек только если элемент не использует свой Render компонент */}
{!usesElementRender && (
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-gray-900">
{element.label}
</label>
{element.required && (
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
)}
</div>
{/* Индикатор ячеек */}
{element.targetCells && element.targetCells.length > 0 && (
<div className="group/cells relative">
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/cells:opacity-100">
<div className="mb-1 font-medium">Целевые ячейки:</div>
{element.targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && (
<span className="ml-1 text-gray-300">
({cell.displayName})
</span>
)}
</div>
))}
</div>
{/* Показываем индикатор ячеек для всех элементов */}
{element.targetCells && element.targetCells.length > 0 && (
<div className="flex justify-end">
<div className="group/cells relative">
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/cells:opacity-100">
<div className="mb-1 font-medium">Целевые ячейки:</div>
{element.targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && (
<span className="ml-1 text-gray-300">
({cell.displayName})
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Для элементов с собственными Render компонентами показываем только индикатор ячеек */}
{usesElementRender &&
element.targetCells &&
element.targetCells.length > 0 && (
<div className="flex justify-end">
<div className="group/cells relative">
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/cells:opacity-100">
<div className="mb-1 font-medium">Целевые ячейки:</div>
{element.targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && (
<span className="ml-1 text-gray-300">
({cell.displayName})
</span>
)}
</div>
))}
</div>
</div>
</div>
)}
{renderInput()}
{/* Рендер элемента через единый интерфейс */}
{renderElement()}
</div>
)
}
@@ -423,54 +88,29 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
// Прямо обновляем движок, чтобы формулы пересчитались
if (engineRef.current) {
const element = template.elements.find(el => el.id === elementId)
if (element && element.targetCells) {
const cells = element.targetCells
cells.forEach((tc, idx) => {
let cellValue: any = value
// Специальная обработка для массивов эталонов
if (element.type === 'standards' && Array.isArray(value)) {
cellValue = getStandardById(value[idx] as string)?.name || ''
}
// Специальная обработка для условий калибровки
if (
element.type === 'calibration_conditions' &&
typeof value === 'object' &&
value !== null
) {
const propsOrder = [
'temperature',
'humidity',
'pressure',
'voltage',
'frequency',
'lastUpdated',
] as const
cellValue = (value as any)[propsOrder[idx]] ?? ''
}
// Специальная обработка для группы кнопок
if (element.type === 'button_group' && typeof value === 'string') {
// Найдем соответствующий option для получения label
const buttonOption = element.options?.find(
opt => opt.value === value
)
cellValue = buttonOption ? buttonOption.label : value
}
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
col,
cellValue
if (element) {
// Получаем определение элемента
const elementDefinition = getElementDefinition(element.type)
if (elementDefinition) {
// Используем единый интерфейс для получения пар ячейка-значение
const cellValues = elementDefinition.mapToCellValues(
element as any,
value
)
})
engineRef.current.debouncedRecalc()
// Записываем все значения в соответствующие ячейки
cellValues.forEach(({ target, value: cellValue }) => {
const { row, col } = cellAddressToCoordinates(target.cell)
engineRef.current.setCellValueWithoutRecalc(
target.sheet,
row,
col,
cellValue
)
})
engineRef.current.debouncedRecalc()
}
}
}
}
@@ -503,28 +143,22 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
let value = formData[el.id]
if (value === undefined || value === null) value = ''
let cells =
el.targetCells && el.targetCells.length > 0
? el.targetCells
: undefined
if ((!cells || cells.length === 0) && el.type) {
const def = getElementDefinition(el.type as any)
if (def?.mapToCells) cells = def.mapToCells(el as any)
}
// Получаем определение элемента
const elementDefinition = getElementDefinition(el.type)
if (elementDefinition) {
// Используем единый интерфейс для получения пар ячейка-значение
const cellValues = elementDefinition.mapToCellValues(el as any, value)
// === Новая логика распределения значений по ячейкам ===
if (!cells || cells.length === 0) return
// 1) Массив (например, standards) -> каждой ячейке своё значение
if (el.type === 'standards' && Array.isArray(value)) {
cells.forEach((tc, idx) => {
const cellValue = getStandardById(value[idx] as string)?.name || ''
const { row, col } = cellAddressToCoordinates(tc.cell)
if (tc.sheet === 'Calculations') {
// Записываем все значения в соответствующие ячейки
cellValues.forEach(({ target, value: cellValue }) => {
const { row, col } = cellAddressToCoordinates(target.cell)
if (target.sheet === 'Calculations') {
toast.warning('Обнаружен лист Calculations')
}
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
target.sheet === 'R' || target.sheet === 'Calculations'
? 'R'
: 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
@@ -532,85 +166,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
cellValue
)
})
return
}
// Обработка любых других массивов
if (Array.isArray(value)) {
cells.forEach((tc, idx) => {
const cellValue = value[idx] ?? ''
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
col,
cellValue
)
})
return
}
// 2) Объект условия калибровки -> пишем свойства по порядку
if (
el.type === 'calibration_conditions' &&
typeof value === 'object' &&
value !== null
) {
const propsOrder = [
'temperature',
'humidity',
'pressure',
'voltage',
'frequency',
'lastUpdated',
] as const
cells.forEach((tc, idx) => {
const cellValue = (value as any)[propsOrder[idx]] ?? ''
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
col,
cellValue
)
})
return
}
// 3) Группа кнопок -> записываем label выбранной кнопки
if (el.type === 'button_group' && typeof value === 'string') {
const buttonOption = el.options?.find(opt => opt.value === value)
const cellValue = buttonOption ? buttonOption.label : value
cells.forEach(tc => {
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
col,
cellValue
)
})
return
}
// 4) Примитив или прочие объекты -> одно значение во все ячейки
cells.forEach(tc => {
const { row, col } = cellAddressToCoordinates(tc.cell)
const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
col,
value
)
})
})
// 2. Пересчитываем формулы
@@ -630,8 +186,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
)
})
// console.log('[DBG] Final cells_to_update', cellsToUpdate)
// Получаем последний файл для шаблона
const latestFile = await getLatestFileForTemplate(template.id)
if (!latestFile) {
@@ -645,8 +199,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
template_id: template.id,
}
console.log('[DBG] POST body', body)
const resp = await fetch('/api/protocols/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -746,13 +298,14 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
{/* Основное содержимое */}
<div className="flex-1 overflow-hidden">
{/* Всегда монтируем DualSpreadsheet, скрываем/показываем через hidden */}
<div className={`${showSpreadsheet ? 'block' : 'hidden'} h-full`}>
<DualSpreadsheet
templateData={template.excelData ? { L: template.excelData } : {}}
mergedCells={template.mergedCells || []}
templateId={template.id}
onEngineReady={engine => (engineRef.current = engine)}
onEngineReady={engine => {
engineRef.current = engine
}}
enableAutoSave={false}
/>
</div>

View File

@@ -41,8 +41,6 @@ export async function createSheet(
sheet: CreateSheetInput
): Promise<CreateSheetOutput> {
try {
// console.log('📤 Отправляем данные для создания листа:', sheet)
const response = await fetch('/api/sheets/', {
method: 'POST',
headers: {
@@ -65,7 +63,6 @@ export async function createSheet(
}
const result: CreateSheetOutput = await response.json()
// console.log('✅ Лист создан успешно:', result)
return result
} catch (error) {
console.error('Ошибка при создании листа:', error)
@@ -100,8 +97,6 @@ export async function getSheetsByTemplate(
templateId: string
): Promise<GetSheetsOutput> {
try {
// console.log('📋 Запрашиваем листы для templateId:', templateId)
// Получаем все листы и фильтруем на клиенте
const response = await fetch(`/api/sheets/`, {
method: 'GET',
@@ -110,8 +105,6 @@ export async function getSheetsByTemplate(
},
})
// console.log('📋 Ответ сервера на запрос листов:', response.status)
if (!response.ok) {
// Пытаемся получить подробности ошибки от сервера
let errorDetails = `HTTP error! status: ${response.status}`
@@ -183,7 +176,9 @@ export async function patchSheet(
sheet: PatchSheetInput
): Promise<PatchSheetOutput> {
try {
console.log('POST BODY:' + JSON.stringify(sheet))
if (process.env.NODE_ENV !== 'production') {
console.log('PATCH BODY:', sheet)
}
const response = await fetch(`/api/sheets/${sheetId}`, {
method: 'PATCH',
headers: {

View File

@@ -14,6 +14,8 @@ export default defineConfig({
},
},
server: {
host: '0.0.0.0', // Открыть для всех IP адресов
port: 80, // Явно указать порт (по умолчанию 5173)
proxy: {
'/api': {
target: 'http://192.169.0.163:8000',