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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { Badge } from '@/component/ui/badge' import { Badge } from '@/component/ui/badge'
import { Button } from '@/component/ui/button' 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 { interface StandardsConfig {
registryNumber: string
sheet: string sheet: string
startCell: string startCell: string
maxItems: number maxItems: number
@@ -14,51 +14,122 @@ interface StandardsPreviewProps {
config: StandardsConfig 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> = ({ export const StandardsPreview: React.FC<StandardsPreviewProps> = ({
config, config,
}) => { }) => {
// Берем первые config.maxItems эталонов для предпросмотра
const previewStandards = mockStandards.slice(0, config.maxItems || 7)
return ( return (
<div className="space-y-3"> <div className="space-y-2">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div> {/* Блок с измерительными эталонами */}
<div className="rounded-lg border border-gray-200 bg-white p-4"> <div>
<div className="space-y-3"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2"> <Label className="text-sm font-medium">Эталоны</Label>
<Settings className="h-4 w-4 text-gray-500" /> <Button variant="outline" size="sm" disabled className="h-7 px-2">
<span className="text-sm font-medium">Измерительные эталоны</span> <Settings className="mr-1 h-3 w-3" />
</div> Настроить
</Button>
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs text-gray-500"> <div className="space-y-1.5">
ГРСИ: {config.registryNumber || 'Не указан'} {previewStandards.map((standard, index) => (
</div> <div
<div className="text-xs text-gray-500"> key={standard.id}
Максимум: {config.maxItems} эталонов className="flex items-center gap-2 rounded-md bg-muted/30 p-2 text-sm"
</div> >
<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>
<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> </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 { ElementDefinition } from '@/entitiy/element/model/interface'
import { parseCellAddress } from '@/lib/cell-utils'
import { CellTarget } from '@/type/template' 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 { StandardsEditor } from './StandardsEditor'
import { StandardsPreview } from './StandardsPreview' 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 { interface StandardsConfig {
registryNumber: string // ГРСИ
sheet: string // Лист Excel sheet: string // Лист Excel
startCell: string // Первая ячейка (например "A10") startCell: string // Первая ячейка (например "A10")
maxItems: number // Обычно 7 maxItems: number // Обычно 7
@@ -20,39 +99,125 @@ export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> =
icon: <Weight className="h-4 w-4" />, icon: <Weight className="h-4 w-4" />,
version: 1, version: 1,
defaultConfig: { defaultConfig: {
registryNumber: '', sheet: 'L',
sheet: 'Report',
startCell: 'A1', startCell: 'A1',
maxItems: 7, maxItems: 7,
targetCells: [], targetCells: [],
}, },
Editor: StandardsEditor, Editor: StandardsEditor,
Preview: StandardsPreview, Preview: StandardsPreview,
Render: () => { Render: ({ config, value, onChange }) => {
// Здесь нужно создать обертку для StandardsDialog const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false)
// Пока возвращаем null, так как StandardsDialog требует дополнительные пропсы const [selectedStandardIds, setSelectedStandardIds] = useState<string[]>(
return null []
)
// Если 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 => { mapToCells: cfg => {
const res: CellTarget[] = [] // Используем только ячейки, указанные пользователем в targetCells
const parsed = parseCellAddress(cfg.startCell) return cfg.targetCells || []
},
mapToCellValues: (cfg, value) => {
// Используем targetCells из конфигурации
const cells = cfg.targetCells || []
if (!parsed) { // Сопоставляем значения с ячейками
// Если не удалось распарсить, возвращаем пустой массив const standardNames = Array.isArray(value) ? value : []
return res return cells.map((target, idx) => ({
} target,
value: standardNames[idx] || '', // Берем значение по индексу или пустую строку
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
}, },
} }

View File

@@ -2,6 +2,7 @@ import {
getAvailableElementTypes, getAvailableElementTypes,
getElementDefinition, getElementDefinition,
} from '@/entitiy/element/model/interface' } from '@/entitiy/element/model/interface'
import { useToast } from '@/lib/hooks/useToast'
import { import {
autoArrangeElements, autoArrangeElements,
findFreePosition, findFreePosition,
@@ -14,17 +15,7 @@ import {
Template, Template,
TemplateElement, TemplateElement,
} from '@/type/template' } from '@/type/template'
import { import { Plus, Trash2 } from 'lucide-react'
Calendar,
Droplets,
Edit3,
Gauge,
Plus,
Radio,
Thermometer,
Trash2,
Zap,
} from 'lucide-react'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Checkbox } from '../ui/checkbox' import { Checkbox } from '../ui/checkbox'
@@ -43,7 +34,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '../ui/select' } from '../ui/select'
import { Textarea } from '../ui/textarea'
import { ElementFormulaBar } from './ElementFormulaBar' import { ElementFormulaBar } from './ElementFormulaBar'
import { VisualLayoutEditor } from './VisualLayoutEditor' import { VisualLayoutEditor } from './VisualLayoutEditor'
@@ -56,6 +46,8 @@ const CellTargetEditor: React.FC<{
targets: CellTarget[] targets: CellTarget[]
onChange: (targets: CellTarget[]) => void onChange: (targets: CellTarget[]) => void
}> = ({ targets, onChange }) => { }> = ({ targets, onChange }) => {
const { warning } = useToast()
const handleAddTarget = () => { const handleAddTarget = () => {
onChange([...targets, { sheet: 'L', cell: '' }]) onChange([...targets, { sheet: 'L', cell: '' }])
} }
@@ -75,6 +67,21 @@ const CellTargetEditor: React.FC<{
onChange(targets.filter((_, i) => i !== index)) 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 ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -119,9 +126,7 @@ const CellTargetEditor: React.FC<{
<Input <Input
placeholder="A1, B5..." placeholder="A1, B5..."
value={target.cell} value={target.cell}
onChange={e => onChange={e => handleCellInputChange(index, e.target.value)}
handleUpdateTarget(index, 'cell', e.target.value)
}
className="h-7 text-xs" className="h-7 text-xs"
/> />
</div> </div>
@@ -159,163 +164,7 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
return <elementDefinition.Preview config={element as any} /> return <elementDefinition.Preview config={element as any} />
} }
// Fallback для старых элементов // 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>
)
}
}
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div> <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>} {element.required && <span className="ml-1 text-red-500">*</span>}
</label> </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 && ( {element.targetCells && element.targetCells.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{element.targetCells.map((target, index) => ( {element.targetCells.map((target, index) => (
@@ -699,7 +554,6 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
showGrid: true, showGrid: true,
} }
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [editingElement, setEditingElement] = useState<TemplateElement | null>( const [editingElement, setEditingElement] = useState<TemplateElement | null>(
null null
) )

View File

@@ -165,7 +165,7 @@ export const ToastContainer: FC<ToastContainerProps> = ({
if (toasts.length === 0) return null if (toasts.length === 0) return null
return ( 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 => ( {toasts.map(toast => (
<div key={toast.id} className="pointer-events-auto"> <div key={toast.id} className="pointer-events-auto">
<ToastComponent toast={toast} onRemove={onRemove} /> <ToastComponent toast={toast} onRemove={onRemove} />

View File

@@ -33,6 +33,15 @@ export const buttonGroupDefinition: ElementDefinition<
}, },
// Берём целевые ячейки прямо из конфига, если они заданы в редакторе // Берём целевые ячейки прямо из конфига, если они заданы в редакторе
mapToCells: cfg => cfg.targetCells || [], 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 }) => { Editor: ({ config, onChange }) => {
const handleAddOption = () => { const handleAddOption = () => {
onChange({ onChange({

View File

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

View File

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

View File

@@ -1,105 +1,15 @@
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet' 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 { 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 { getElementDefinition } from '@/entitiy/element/model/interface'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext' import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import { cellAddressToCoordinates } from '@/lib/cell-utils' import { cellAddressToCoordinates } from '@/lib/cell-utils'
import { useToast } from '@/lib/hooks/useToast' import { useToast } from '@/lib/hooks/useToast'
import { getLatestFileForTemplate } from '@/service/fileApiService' import { getLatestFileForTemplate } from '@/service/fileApiService'
import { Template, TemplateElement } from '@/type/template' 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 { FC, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' 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 { interface ProtocolFormProps {
template: Template template: Template
onSave: (data: Record<string, any>) => void onSave: (data: Record<string, any>) => void
@@ -113,297 +23,52 @@ interface FormElementProps {
} }
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => { const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false) // Получаем определение элемента из реестра
const elementDefinition = getElementDefinition(element.type)
const renderInput = () => { // Если определение элемента не найдено, показываем ошибку
switch (element.type) { if (!elementDefinition) {
case 'text': return (
// Используем определение элемента из реестра для рендеринга <div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
const textDefinition = getElementDefinition('text') Элемент типа "{element.type}" не найден в реестре
if (textDefinition && textDefinition.Render) { </div>
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
}
} }
// Проверяем, использует ли элемент определение из реестра для рендеринга const renderElement = () => (
const elementDefinition = getElementDefinition(element.type) <elementDefinition.Render
const usesElementRender = config={element as any}
elementDefinition && value={value}
elementDefinition.Render && onChange={onChange}
(element.type === 'text' || />
element.type === 'calibration_conditions' || )
element.type === 'button_group')
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Показываем label и индикатор ячеек только если элемент не использует свой Render компонент */} {/* Показываем индикатор ячеек для всех элементов */}
{!usesElementRender && ( {element.targetCells && element.targetCells.length > 0 && (
<div className="flex items-start justify-between gap-2"> <div className="flex justify-end">
<div className="flex items-center gap-1"> <div className="group/cells relative">
<label className="text-sm font-medium text-gray-900"> <Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
{element.label} <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">
</label> <div className="mb-1 font-medium">Целевые ячейки:</div>
{element.required && ( {element.targetCells.map((cell, i) => (
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" /> <div key={i} className="font-mono">
)} {cell.sheet}!{cell.cell}
</div> {cell.displayName && (
{/* Индикатор ячеек */} <span className="ml-1 text-gray-300">
{element.targetCells && element.targetCells.length > 0 && ( ({cell.displayName})
<div className="group/cells relative"> </span>
<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>
<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>
)} </div>
</div> </div>
)} )}
{/* Для элементов с собственными Render компонентами показываем только индикатор ячеек */} {/* Рендер элемента через единый интерфейс */}
{usesElementRender && {renderElement()}
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()}
</div> </div>
) )
} }
@@ -423,54 +88,29 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
// Прямо обновляем движок, чтобы формулы пересчитались // Прямо обновляем движок, чтобы формулы пересчитались
if (engineRef.current) { if (engineRef.current) {
const element = template.elements.find(el => el.id === elementId) const element = template.elements.find(el => el.id === elementId)
if (element && element.targetCells) { if (element) {
const cells = element.targetCells // Получаем определение элемента
const elementDefinition = getElementDefinition(element.type)
cells.forEach((tc, idx) => { if (elementDefinition) {
let cellValue: any = value // Используем единый интерфейс для получения пар ячейка-значение
const cellValues = elementDefinition.mapToCellValues(
// Специальная обработка для массивов эталонов element as any,
if (element.type === 'standards' && Array.isArray(value)) { 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
) )
})
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] let value = formData[el.id]
if (value === undefined || value === null) value = '' if (value === undefined || value === null) value = ''
let cells = // Получаем определение элемента
el.targetCells && el.targetCells.length > 0 const elementDefinition = getElementDefinition(el.type)
? el.targetCells if (elementDefinition) {
: undefined // Используем единый интерфейс для получения пар ячейка-значение
if ((!cells || cells.length === 0) && el.type) { const cellValues = elementDefinition.mapToCellValues(el as any, value)
const def = getElementDefinition(el.type as any)
if (def?.mapToCells) cells = def.mapToCells(el as any)
}
// === Новая логика распределения значений по ячейкам === // Записываем все значения в соответствующие ячейки
if (!cells || cells.length === 0) return cellValues.forEach(({ target, value: cellValue }) => {
const { row, col } = cellAddressToCoordinates(target.cell)
// 1) Массив (например, standards) -> каждой ячейке своё значение if (target.sheet === 'Calculations') {
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') {
toast.warning('Обнаружен лист Calculations') toast.warning('Обнаружен лист Calculations')
} }
const sheetName = const sheetName =
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L' target.sheet === 'R' || target.sheet === 'Calculations'
? 'R'
: 'L'
engineRef.current.setCellValueWithoutRecalc( engineRef.current.setCellValueWithoutRecalc(
sheetName, sheetName,
row, row,
@@ -532,85 +166,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
cellValue 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. Пересчитываем формулы // 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) const latestFile = await getLatestFileForTemplate(template.id)
if (!latestFile) { if (!latestFile) {
@@ -645,8 +199,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
template_id: template.id, template_id: template.id,
} }
console.log('[DBG] POST body', body)
const resp = await fetch('/api/protocols/', { const resp = await fetch('/api/protocols/', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -746,13 +298,14 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
{/* Основное содержимое */} {/* Основное содержимое */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{/* Всегда монтируем DualSpreadsheet, скрываем/показываем через hidden */}
<div className={`${showSpreadsheet ? 'block' : 'hidden'} h-full`}> <div className={`${showSpreadsheet ? 'block' : 'hidden'} h-full`}>
<DualSpreadsheet <DualSpreadsheet
templateData={template.excelData ? { L: template.excelData } : {}} templateData={template.excelData ? { L: template.excelData } : {}}
mergedCells={template.mergedCells || []} mergedCells={template.mergedCells || []}
templateId={template.id} templateId={template.id}
onEngineReady={engine => (engineRef.current = engine)} onEngineReady={engine => {
engineRef.current = engine
}}
enableAutoSave={false} enableAutoSave={false}
/> />
</div> </div>

View File

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

View File

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