AI рефакторинг !
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
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/fileApiSevice'
|
import { getFileData, getLatestFileForTemplate } from '@/service/fileApiService'
|
||||||
import { produce } from 'immer'
|
import { produce } from 'immer'
|
||||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
@@ -24,25 +24,36 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
templateId,
|
templateId,
|
||||||
onEngineReady,
|
onEngineReady,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Мемоизируем конфигурацию листов
|
||||||
|
const sheetsConfig = useMemo(
|
||||||
|
() => [
|
||||||
|
{ name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT },
|
||||||
|
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const { revision, updateRevision, setTemplateData, ...multiSheetEngine } =
|
const { revision, updateRevision, setTemplateData, ...multiSheetEngine } =
|
||||||
useMultiSheetEngine({
|
useMultiSheetEngine({
|
||||||
sheets: [
|
sheets: sheetsConfig,
|
||||||
{ name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT },
|
|
||||||
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
|
|
||||||
],
|
|
||||||
initialData: templateData || {},
|
initialData: templateData || {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Мемоизируем объект для передачи родительскому компоненту
|
||||||
|
const engineAPI = useMemo(
|
||||||
|
() => ({
|
||||||
|
...multiSheetEngine,
|
||||||
|
updateRevision,
|
||||||
|
}),
|
||||||
|
[multiSheetEngine, updateRevision]
|
||||||
|
)
|
||||||
|
|
||||||
// Передаем методы движка родительскому компоненту
|
// Передаем методы движка родительскому компоненту
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onEngineReady) {
|
if (onEngineReady) {
|
||||||
onEngineReady({
|
onEngineReady(engineAPI)
|
||||||
...multiSheetEngine,
|
|
||||||
updateRevision,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [onEngineReady, engineAPI])
|
||||||
}, [onEngineReady])
|
|
||||||
|
|
||||||
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
|
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
|
||||||
const [selectedCell, setSelectedCell] = useState<{
|
const [selectedCell, setSelectedCell] = useState<{
|
||||||
@@ -56,31 +67,61 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
Record<string, Record<string, any>>
|
Record<string, Record<string, any>>
|
||||||
>({}) // Базовые данные файла
|
>({}) // Базовые данные файла
|
||||||
|
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
|
// Мемоизируем начальное состояние ширин колонок
|
||||||
|
const initialColumnWidths = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
report: Array(COL_COUNT).fill(96),
|
report: Array(COL_COUNT).fill(96),
|
||||||
calculations: Array(COL_COUNT).fill(96),
|
calculations: Array(COL_COUNT).fill(96),
|
||||||
})
|
}),
|
||||||
|
[]
|
||||||
)
|
)
|
||||||
const gridRefs = useRef<Record<SheetType, any>>({
|
|
||||||
report: null,
|
|
||||||
calculations: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const containerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
const [columnWidths, setColumnWidths] =
|
||||||
report: null,
|
useState<Record<SheetType, number[]>>(initialColumnWidths)
|
||||||
calculations: null,
|
|
||||||
})
|
// Мемоизируем начальные refs объекты
|
||||||
|
const initialGridRefs = useMemo(
|
||||||
|
() => ({
|
||||||
|
report: null,
|
||||||
|
calculations: null,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialContainerRefs = useMemo(
|
||||||
|
() => ({
|
||||||
|
report: null,
|
||||||
|
calculations: null,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialHeaderRefs = useMemo(
|
||||||
|
() => ({
|
||||||
|
report: null,
|
||||||
|
calculations: null,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialRowHeaderRefs = useMemo(
|
||||||
|
() => ({
|
||||||
|
report: null,
|
||||||
|
calculations: null,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const gridRefs = useRef<Record<SheetType, any>>(initialGridRefs)
|
||||||
|
const containerRefs =
|
||||||
|
useRef<Record<SheetType, HTMLDivElement | null>>(initialContainerRefs)
|
||||||
|
const headerRefs =
|
||||||
|
useRef<Record<SheetType, HTMLDivElement | null>>(initialHeaderRefs)
|
||||||
|
const rowHeaderRefs =
|
||||||
|
useRef<Record<SheetType, HTMLDivElement | null>>(initialRowHeaderRefs)
|
||||||
|
|
||||||
const activeCellInputRef = useRef<HTMLInputElement>(null)
|
const activeCellInputRef = useRef<HTMLInputElement>(null)
|
||||||
const formulaBarRef = useRef<HTMLInputElement | null>(null)
|
const formulaBarRef = useRef<HTMLInputElement | null>(null)
|
||||||
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
|
||||||
report: null,
|
|
||||||
calculations: null,
|
|
||||||
})
|
|
||||||
const rowHeaderRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
|
||||||
report: null,
|
|
||||||
calculations: null,
|
|
||||||
})
|
|
||||||
const initialTemplateValues = useRef<Record<string, string>>({})
|
const initialTemplateValues = useRef<Record<string, string>>({})
|
||||||
|
|
||||||
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
|
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
|
||||||
@@ -88,45 +129,43 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
)
|
)
|
||||||
const lastRevision = useRef<number>(-1)
|
const lastRevision = useRef<number>(-1)
|
||||||
|
|
||||||
useEffect(() => {
|
// Мемоизируем обработчик обновления начальных значений
|
||||||
// Используем baseFileData как источник базовых значений для сравнения
|
const updateInitialValues = useCallback(() => {
|
||||||
const reportBaseData = baseFileData?.[SHEET_NAMES.report]
|
// Обновляем начальные значения только если есть данные
|
||||||
|
const hasBaseFileData = Object.keys(baseFileData).length > 0
|
||||||
|
const hasTemplateData = Object.keys(templateData).length > 0
|
||||||
|
|
||||||
if (reportBaseData && Object.keys(reportBaseData).length > 0) {
|
if (hasBaseFileData) {
|
||||||
// Очищаем старые значения
|
Object.entries(baseFileData).forEach(([sheetName, sheetData]) => {
|
||||||
initialTemplateValues.current = {}
|
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||||||
|
initialTemplateValues.current[cellAddress] = String(value || '')
|
||||||
Object.entries(reportBaseData).forEach(([cellName, value]) => {
|
})
|
||||||
initialTemplateValues.current[cellName] = String(value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
'✅ Загружено базовых значений файла для сравнения:',
|
'✅ Загружено значений baseFileData для сравнения (приоритет):',
|
||||||
|
Object.keys(initialTemplateValues.current).length
|
||||||
|
)
|
||||||
|
} else if (hasTemplateData) {
|
||||||
|
Object.entries(templateData).forEach(([sheetName, sheetData]) => {
|
||||||
|
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||||||
|
initialTemplateValues.current[cellAddress] = String(value || '')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
console.log(
|
||||||
|
'✅ Загружено значений templateData для сравнения (fallback):',
|
||||||
Object.keys(initialTemplateValues.current).length
|
Object.keys(initialTemplateValues.current).length
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: используем templateData, если baseFileData еще не загружен
|
console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения')
|
||||||
const reportTemplate = templateData?.[SHEET_NAMES.report]
|
|
||||||
|
|
||||||
if (reportTemplate && Object.keys(reportTemplate).length > 0) {
|
|
||||||
// Очищаем старые значения
|
|
||||||
initialTemplateValues.current = {}
|
|
||||||
|
|
||||||
Object.entries(reportTemplate).forEach(([cellName, value]) => {
|
|
||||||
initialTemplateValues.current[cellName] = String(value)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'✅ Загружено значений templateData для сравнения (fallback):',
|
|
||||||
Object.keys(initialTemplateValues.current).length
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [baseFileData, templateData])
|
}, [baseFileData, templateData])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
updateInitialValues()
|
||||||
|
}, [updateInitialValues])
|
||||||
|
|
||||||
|
// Мемоизируем обработчик скролла к выбранной ячейке
|
||||||
|
const scrollToSelectedCell = useCallback(() => {
|
||||||
if (selectedCell && activeSheet) {
|
if (selectedCell && activeSheet) {
|
||||||
const grid = gridRefs.current[activeSheet]
|
const grid = gridRefs.current[activeSheet]
|
||||||
grid?.scrollToItem({
|
grid?.scrollToItem({
|
||||||
@@ -137,6 +176,10 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedCell, activeSheet])
|
}, [selectedCell, activeSheet])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToSelectedCell()
|
||||||
|
}, [scrollToSelectedCell])
|
||||||
|
|
||||||
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}-${JSON.stringify(columnWidths[sheetType])}`
|
||||||
@@ -522,10 +565,28 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
|
// Мемоизируем состояние ширин таблиц
|
||||||
L: 0.5,
|
const initialSpreadsheetWidths = useMemo(
|
||||||
R: 0.5,
|
() => ({
|
||||||
})
|
L: 0.5,
|
||||||
|
R: 0.5,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [spreadsheetWidths, setSpreadsheetWidths] = useState(
|
||||||
|
initialSpreadsheetWidths
|
||||||
|
)
|
||||||
|
|
||||||
|
// Мемоизируем опции автосохранения
|
||||||
|
const autoSaveOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
templateId,
|
||||||
|
autoSaveDelay: 2000,
|
||||||
|
enableAutoSave: true,
|
||||||
|
}),
|
||||||
|
[templateId]
|
||||||
|
)
|
||||||
|
|
||||||
// Используем новый хук автосохранения
|
// Используем новый хук автосохранения
|
||||||
const {
|
const {
|
||||||
@@ -541,11 +602,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
multiSheetEngine.getAllModifiedCells,
|
multiSheetEngine.getAllModifiedCells,
|
||||||
multiSheetEngine.isCalculating,
|
multiSheetEngine.isCalculating,
|
||||||
revision,
|
revision,
|
||||||
{
|
autoSaveOptions
|
||||||
templateId,
|
|
||||||
autoSaveDelay: 2000,
|
|
||||||
enableAutoSave: true,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Отладка templateId
|
// Отладка templateId
|
||||||
@@ -574,153 +631,138 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
|
|
||||||
const separatorRef = useDraggableDivider(handleDividerDrag)
|
const separatorRef = useDraggableDivider(handleDividerDrag)
|
||||||
|
|
||||||
// Загружаем сохраненные формулы при инициализации
|
// Мемоизируем функцию инициализации данных
|
||||||
useEffect(() => {
|
const initializeData = useCallback(async () => {
|
||||||
if (!templateId || isInitialized) return
|
if (!templateId || isInitialized) return
|
||||||
|
|
||||||
// Асинхронная функция для загрузки и слияния данных
|
let finalBaseData: Record<string, Record<string, any>> = {}
|
||||||
const initializeData = async () => {
|
|
||||||
let finalBaseData: Record<string, Record<string, any>> = {}
|
|
||||||
|
|
||||||
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
|
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
|
||||||
if (templateId) {
|
if (templateId) {
|
||||||
try {
|
|
||||||
console.log('🔍 Ищем файлы для templateId:', templateId)
|
|
||||||
const latestFile = await getLatestFileForTemplate(templateId)
|
|
||||||
|
|
||||||
if (latestFile) {
|
|
||||||
console.log(
|
|
||||||
'📄 Найден файл:',
|
|
||||||
latestFile.name,
|
|
||||||
'ID:',
|
|
||||||
latestFile.id
|
|
||||||
)
|
|
||||||
const fileData = await getFileData(latestFile.id)
|
|
||||||
|
|
||||||
// TODO: Пока getFileData возвращает пустые данные, используем templateData
|
|
||||||
// Когда API будет готов, здесь будут реальные данные файла
|
|
||||||
if (Object.keys(fileData.data).length > 0) {
|
|
||||||
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
|
|
||||||
setBaseFileData(finalBaseData)
|
|
||||||
// Сохраняем 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 или основные данные
|
|
||||||
const hasTemplateData =
|
|
||||||
Object.keys(templateData).length > 0 &&
|
|
||||||
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
|
|
||||||
|
|
||||||
if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
|
|
||||||
finalBaseData = templateData
|
|
||||||
setBaseFileData(finalBaseData)
|
|
||||||
console.log(
|
|
||||||
'📄 Используем templateData как базовые данные:',
|
|
||||||
finalBaseData
|
|
||||||
)
|
|
||||||
setTemplateData(templateData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
|
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Загружаем пользовательские изменения из API...')
|
const latestFile = await getLatestFileForTemplate(templateId)
|
||||||
const apiSheets = await loadSheets()
|
if (latestFile) {
|
||||||
|
console.log(
|
||||||
|
'📁 Найден последний файл для шаблона:',
|
||||||
|
latestFile.filename
|
||||||
|
)
|
||||||
|
const fileData = await getFileData(latestFile.id)
|
||||||
|
|
||||||
// Преобразуем листы API в формат данных
|
// TODO: Пока getFileData возвращает пустые данные, используем templateData
|
||||||
const userChanges: Record<string, Record<string, any>> = {}
|
// Когда API будет готов, здесь будут реальные данные файла
|
||||||
Object.entries(apiSheets).forEach(([sheetName, sheet]) => {
|
if (Object.keys(fileData.data).length > 0) {
|
||||||
// Нормализуем имя листа: "Report" -> "L"
|
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
|
||||||
const normalizedName =
|
setBaseFileData(finalBaseData)
|
||||||
sheetName === 'Report'
|
// Сохраняем baseline, чтобы в L попадали только пользовательские изменения
|
||||||
? SHEET_NAMES.report
|
setTemplateData(finalBaseData)
|
||||||
: sheetName === 'Calculations'
|
console.log('📄 Загружены базовые данные файла:', finalBaseData)
|
||||||
? SHEET_NAMES.calculations
|
} else {
|
||||||
: sheetName
|
console.log(
|
||||||
|
'⚠️ getFileData вернул пустые данные, используем templateData'
|
||||||
// Интересуют только листы L и R
|
)
|
||||||
if (
|
|
||||||
(normalizedName === SHEET_NAMES.report ||
|
|
||||||
normalizedName === SHEET_NAMES.calculations) &&
|
|
||||||
sheet.cells &&
|
|
||||||
Object.keys(sheet.cells).length > 0
|
|
||||||
) {
|
|
||||||
userChanges[normalizedName] = sheet.cells
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Файл не найден для templateId:', templateId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка при загрузке данных файла:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
|
||||||
|
const hasTemplateData =
|
||||||
|
Object.keys(templateData).length > 0 &&
|
||||||
|
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
|
||||||
|
|
||||||
|
if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
|
||||||
|
finalBaseData = templateData
|
||||||
|
setBaseFileData(finalBaseData)
|
||||||
|
console.log(
|
||||||
|
'📄 Используем templateData как базовые данные:',
|
||||||
|
finalBaseData
|
||||||
|
)
|
||||||
|
setTemplateData(templateData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
|
||||||
|
try {
|
||||||
|
console.log('🔍 Загружаем пользовательские изменения из API...')
|
||||||
|
const apiSheets = await loadSheets()
|
||||||
|
|
||||||
|
// Преобразуем листы API в формат данных
|
||||||
|
const userChanges: Record<string, Record<string, any>> = {}
|
||||||
|
Object.entries(apiSheets).forEach(([sheetName, sheet]) => {
|
||||||
|
// Нормализуем имя листа: "Report" -> "L"
|
||||||
|
const normalizedName =
|
||||||
|
sheetName === 'Report'
|
||||||
|
? SHEET_NAMES.report
|
||||||
|
: sheetName === 'Calculations'
|
||||||
|
? SHEET_NAMES.calculations
|
||||||
|
: sheetName
|
||||||
|
|
||||||
|
// Интересуют только листы L и R
|
||||||
|
if (
|
||||||
|
(normalizedName === SHEET_NAMES.report ||
|
||||||
|
normalizedName === SHEET_NAMES.calculations) &&
|
||||||
|
sheet.cells &&
|
||||||
|
Object.keys(sheet.cells).length > 0
|
||||||
|
) {
|
||||||
|
userChanges[normalizedName] = sheet.cells
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'📂 Загружены пользовательские изменения:',
|
||||||
|
Object.keys(userChanges)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
|
||||||
|
if (Object.keys(userChanges).length > 0) {
|
||||||
|
console.log(
|
||||||
|
'🔄 Объединяем базовые данные с пользовательскими изменениями'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
|
||||||
|
const mergedData: Record<string, Record<string, any>> = {}
|
||||||
|
|
||||||
|
// Копируем базовые данные файла как основу
|
||||||
|
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
|
||||||
|
mergedData[sheetName] = { ...sheetData }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Применяем пользовательские изменения поверх базовых данных
|
||||||
|
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
|
||||||
|
if (!mergedData[sheetName]) {
|
||||||
|
mergedData[sheetName] = {}
|
||||||
|
}
|
||||||
|
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||||||
|
mergedData[sheetName][cellAddress] = value
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
'📂 Загружены пользовательские изменения:',
|
'📊 Загружаем объединенные данные (базовые + пользовательские)'
|
||||||
Object.keys(userChanges)
|
|
||||||
)
|
)
|
||||||
|
multiSheetEngine.loadData(mergedData)
|
||||||
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
|
} else if (Object.keys(finalBaseData).length > 0) {
|
||||||
if (Object.keys(userChanges).length > 0) {
|
console.log(
|
||||||
console.log(
|
'📋 Используем только базовые данные (нет пользовательских изменений)'
|
||||||
'🔄 Объединяем базовые данные с пользовательскими изменениями'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
|
|
||||||
const mergedData: Record<string, Record<string, any>> = {}
|
|
||||||
|
|
||||||
// Копируем базовые данные файла как основу
|
|
||||||
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
|
|
||||||
mergedData[sheetName] = { ...sheetData }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Применяем пользовательские изменения поверх базовых данных
|
|
||||||
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
|
|
||||||
if (!mergedData[sheetName]) {
|
|
||||||
mergedData[sheetName] = {}
|
|
||||||
}
|
|
||||||
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
|
||||||
mergedData[sheetName][cellAddress] = value
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'📊 Загружаем объединенные данные (базовые + пользовательские)'
|
|
||||||
)
|
|
||||||
multiSheetEngine.loadData(mergedData)
|
|
||||||
|
|
||||||
// Обновляем baseline, включая уже сохранённые изменения пользователя,
|
|
||||||
// чтобы их не сохранять повторно
|
|
||||||
} else if (Object.keys(finalBaseData).length > 0) {
|
|
||||||
console.log(
|
|
||||||
'📋 Используем только базовые данные (нет пользовательских изменений)'
|
|
||||||
)
|
|
||||||
multiSheetEngine.loadData(finalBaseData)
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ Нет данных для загрузки')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
'❌ Ошибка при загрузке пользовательских изменений:',
|
|
||||||
error
|
|
||||||
)
|
)
|
||||||
|
multiSheetEngine.loadData(finalBaseData)
|
||||||
// В случае ошибки используем только базовые данные
|
} else {
|
||||||
if (Object.keys(finalBaseData).length > 0) {
|
console.log('⚠️ Нет данных для загрузки')
|
||||||
console.log('📋 Ошибка API, используем только базовые данные')
|
|
||||||
multiSheetEngine.loadData(finalBaseData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка при загрузке пользовательских изменений:', error)
|
||||||
|
|
||||||
setIsInitialized(true)
|
// В случае ошибки используем только базовые данные
|
||||||
|
if (Object.keys(finalBaseData).length > 0) {
|
||||||
|
console.log('📋 Ошибка API, используем только базовые данные')
|
||||||
|
multiSheetEngine.loadData(finalBaseData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeData()
|
setIsInitialized(true)
|
||||||
}, [
|
}, [
|
||||||
templateId,
|
templateId,
|
||||||
templateData,
|
templateData,
|
||||||
@@ -730,6 +772,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
isInitialized,
|
isInitialized,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Загружаем сохраненные формулы при инициализации
|
||||||
|
useEffect(() => {
|
||||||
|
initializeData()
|
||||||
|
}, [initializeData])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<FormulaBar
|
<FormulaBar
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
|
import { cellAddressToCoordinates, getColumnLabel } from '@/lib/cell-utils'
|
||||||
import { MergedCell } from '@/type/template'
|
import { MergedCell } from '@/type/template'
|
||||||
import { MergedCellInfo } from './types'
|
import { MergedCellInfo } from './types'
|
||||||
|
|
||||||
// --- Helper Functions ---
|
// --- Helper Functions ---
|
||||||
export const getColumnLabel = (index: number) => String.fromCharCode(65 + index)
|
export { getColumnLabel }
|
||||||
|
|
||||||
// Функция для преобразования адреса ячейки в координаты
|
// Вспомогательная функция для совместимости с существующим кодом
|
||||||
export const cellAddressToCoords = (
|
export const cellAddressToCoords = cellAddressToCoordinates
|
||||||
address: string
|
|
||||||
): { row: number; col: number } => {
|
|
||||||
const match = address.match(/^([A-Z]+)(\d+)$/)
|
|
||||||
if (!match) return { row: 0, col: 0 }
|
|
||||||
|
|
||||||
const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы
|
|
||||||
const row = parseInt(match[2]) - 1
|
|
||||||
|
|
||||||
return { row, col }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Функция для проверки, является ли ячейка частью объединенной группы
|
// Функция для проверки, является ли ячейка частью объединенной группы
|
||||||
export const findMergedCellInfo = (
|
export const findMergedCellInfo = (
|
||||||
@@ -24,8 +15,8 @@ export const findMergedCellInfo = (
|
|||||||
mergedCells: MergedCell[]
|
mergedCells: MergedCell[]
|
||||||
): MergedCellInfo => {
|
): MergedCellInfo => {
|
||||||
for (const merged of mergedCells) {
|
for (const merged of mergedCells) {
|
||||||
const fromCoords = cellAddressToCoords(merged.from)
|
const fromCoords = cellAddressToCoordinates(merged.from)
|
||||||
const toCoords = cellAddressToCoords(merged.to)
|
const toCoords = cellAddressToCoordinates(merged.to)
|
||||||
|
|
||||||
// Проверяем, попадает ли текущая ячейка в диапазон объединения
|
// Проверяем, попадает ли текущая ячейка в диапазон объединения
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { parseCellAddress } from '@/lib/cell-utils'
|
||||||
import { ElementDefinition } from '@/lib/element-registry'
|
import { ElementDefinition } from '@/lib/element-registry'
|
||||||
import { parseCellAddress } from '@/lib/utils'
|
|
||||||
import { CellTarget } from '@/type/template'
|
import { CellTarget } from '@/type/template'
|
||||||
import { Weight } from 'lucide-react'
|
import { Weight } from 'lucide-react'
|
||||||
import { StandardsEditor } from './StandardsEditor'
|
import { StandardsEditor } from './StandardsEditor'
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { FileText, Settings, Wrench } from 'lucide-react'
|
|
||||||
import React from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { Template } from '../type/template'
|
|
||||||
import { Button } from './ui/button'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'
|
|
||||||
|
|
||||||
interface TemplateCardProps {
|
|
||||||
template: Template
|
|
||||||
isSelected?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
|
||||||
template,
|
|
||||||
isSelected = false,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
// Шаблон считается готовым, когда есть хотя бы один элемент и загружена
|
|
||||||
// Excel-таблица (что подтверждается наличием mergedCells).
|
|
||||||
const isConfigured =
|
|
||||||
template.elements.length > 0 && (template.mergedCells?.length ?? 0) > 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={isSelected ? 'border-2 border-blue-500' : ''}>
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<CardTitle className="text-base font-medium">{template.name}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
{template.description && (
|
|
||||||
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">
|
|
||||||
{template.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mb-3 flex justify-between text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
Создан: {new Date(template.createdAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Обновлён: {new Date(template.updatedAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!isConfigured}
|
|
||||||
onClick={() => navigate(`/templates/${template.id}/protocols`)}
|
|
||||||
>
|
|
||||||
<FileText className="mr-2 h-3 w-3" />
|
|
||||||
{isConfigured ? 'Создать протокол' : 'Требует настройки'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => navigate(`/templates/${template.id}/edit`)}
|
|
||||||
>
|
|
||||||
<Settings className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => navigate(`/templates/${template.id}/elements`)}
|
|
||||||
>
|
|
||||||
<Wrench className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TemplateCard
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
|
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
|
||||||
import { ExcelParseResult } from '@/service/fileApiSevice'
|
import { ExcelParseResult } from '@/service/fileApiService'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
interface SpreadsheetViewerProps {
|
interface SpreadsheetViewerProps {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getFileData,
|
getFileData,
|
||||||
getLatestFileForTemplate,
|
getLatestFileForTemplate,
|
||||||
parseExcelFile,
|
parseExcelFile,
|
||||||
} from '@/service/fileApiSevice'
|
} from '@/service/fileApiService'
|
||||||
import { Template } from '@/type/template'
|
import { Template } from '@/type/template'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { ExcelUploadPanel } from './ExcelUploadPanel'
|
import { ExcelUploadPanel } from './ExcelUploadPanel'
|
||||||
@@ -28,10 +28,8 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
|||||||
const [dataVersion, setDataVersion] = useState(0)
|
const [dataVersion, setDataVersion] = useState(0)
|
||||||
const [serverFileName, setServerFileName] = useState<string | undefined>()
|
const [serverFileName, setServerFileName] = useState<string | undefined>()
|
||||||
|
|
||||||
// Получаем функцию обновления шаблона из контекста
|
|
||||||
const { updateTemplate } = useTemplateContext()
|
const { updateTemplate } = useTemplateContext()
|
||||||
|
|
||||||
// При монтировании пытаемся загрузить последний файл для шаблона
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
let isMounted = true
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Template } from '@/type/template'
|
|||||||
import { Circle, CircleCheck, FileText, Plus, Trash2 } from 'lucide-react'
|
import { Circle, CircleCheck, FileText, Plus, Trash2 } from 'lucide-react'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import TemplateCard from '../TemplateCard'
|
import TemplateCard from '../../widget/template/ui/TemplateCard'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
126
src/hook/adapters/apiSaveAdapter.ts
Normal file
126
src/hook/adapters/apiSaveAdapter.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
ApiSheet,
|
||||||
|
createSheet,
|
||||||
|
getSheetsByTemplate,
|
||||||
|
patchSheet,
|
||||||
|
} from '../../service/sheetApiService'
|
||||||
|
import { SaveAdapter } from '../useAutoSave'
|
||||||
|
|
||||||
|
// Утилита для извлечения UUID из templateId
|
||||||
|
const extractUUID = (id: string): string => {
|
||||||
|
const uuidRegex =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
if (uuidRegex.test(id)) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = id.match(
|
||||||
|
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
|
||||||
|
)
|
||||||
|
if (match) {
|
||||||
|
return match[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адаптер для сохранения через API
|
||||||
|
*/
|
||||||
|
export function createApiSaveAdapter(templateId: string): SaveAdapter {
|
||||||
|
let sheetsCache: Record<string, ApiSheet> = {}
|
||||||
|
|
||||||
|
const cleanTemplateId = extractUUID(templateId)
|
||||||
|
const uuidRegex =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
if (!uuidRegex.test(cleanTemplateId)) {
|
||||||
|
throw new Error('Invalid template ID format')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async save(data: Record<string, Record<string, any>>) {
|
||||||
|
const sheetNames = Object.keys(data)
|
||||||
|
if (sheetNames.length === 0) return
|
||||||
|
|
||||||
|
const savePromises = sheetNames.map(async sheetName => {
|
||||||
|
const sheetCells = data[sheetName]
|
||||||
|
const existingSheet = sheetsCache[sheetName]
|
||||||
|
|
||||||
|
if (!sheetCells || Object.keys(sheetCells).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSheet) {
|
||||||
|
// Обновляем существующий лист
|
||||||
|
const mergedCells = {
|
||||||
|
...existingSheet.cells,
|
||||||
|
...sheetCells,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChanged = Object.keys(sheetCells).some(
|
||||||
|
key => existingSheet.cells[key] !== sheetCells[key]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isChanged) return
|
||||||
|
|
||||||
|
await patchSheet(existingSheet.id, {
|
||||||
|
cells: mergedCells,
|
||||||
|
})
|
||||||
|
|
||||||
|
sheetsCache[sheetName] = {
|
||||||
|
...existingSheet,
|
||||||
|
cells: mergedCells,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Создаем новый лист
|
||||||
|
const newSheet = await createSheet({
|
||||||
|
name: sheetName,
|
||||||
|
cells: sheetCells,
|
||||||
|
template_id: cleanTemplateId,
|
||||||
|
})
|
||||||
|
|
||||||
|
sheetsCache[sheetName] = {
|
||||||
|
id: newSheet.id,
|
||||||
|
name: sheetName,
|
||||||
|
cells: sheetCells,
|
||||||
|
template_id: cleanTemplateId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
deleted_at: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(savePromises)
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const sheets = await getSheetsByTemplate(cleanTemplateId)
|
||||||
|
const sheetsMap: Record<string, ApiSheet> = {}
|
||||||
|
const result: Record<string, Record<string, any>> = {}
|
||||||
|
|
||||||
|
sheets.forEach(sheet => {
|
||||||
|
sheetsMap[sheet.name] = sheet
|
||||||
|
if (sheet.cells && Object.keys(sheet.cells).length > 0) {
|
||||||
|
result[sheet.name] = sheet.cells
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sheetsCache = sheetsMap
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при загрузке листов:', error)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
// Для API очистка может означать удаление всех листов
|
||||||
|
// Пока просто очищаем кеш
|
||||||
|
sheetsCache = {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/hook/adapters/localStorageSaveAdapter.ts
Normal file
86
src/hook/adapters/localStorageSaveAdapter.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { SaveAdapter } from '../useAutoSave'
|
||||||
|
|
||||||
|
interface SavedLocalStorageData {
|
||||||
|
templateId: string
|
||||||
|
formulaData: Record<string, Record<string, any>>
|
||||||
|
timestamp: string
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ключ для localStorage
|
||||||
|
const getStorageKey = (templateId: string) => {
|
||||||
|
return `formula_data_${templateId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адаптер для сохранения в localStorage
|
||||||
|
*/
|
||||||
|
export function createLocalStorageSaveAdapter(templateId: string): SaveAdapter {
|
||||||
|
let versionCounter = 1
|
||||||
|
|
||||||
|
// Инициализируем версию из существующих данных
|
||||||
|
try {
|
||||||
|
const storageKey = getStorageKey(templateId)
|
||||||
|
const savedDataStr = localStorage.getItem(storageKey)
|
||||||
|
if (savedDataStr) {
|
||||||
|
const parsedData: SavedLocalStorageData = JSON.parse(savedDataStr)
|
||||||
|
versionCounter = parsedData.version + 1
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Ошибка при загрузке данных из localStorage:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async save(data: Record<string, Record<string, any>>) {
|
||||||
|
// Имитируем задержку API для реалистичности
|
||||||
|
await new Promise(resolve =>
|
||||||
|
setTimeout(resolve, 200 + Math.random() * 300)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Иногда имитируем ошибку (2% вероятность)
|
||||||
|
if (Math.random() < 0.02) {
|
||||||
|
throw new Error('Ошибка записи в хранилище')
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedData: SavedLocalStorageData = {
|
||||||
|
templateId,
|
||||||
|
formulaData: data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: versionCounter++,
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = getStorageKey(templateId)
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(savedData))
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const storageKey = getStorageKey(templateId)
|
||||||
|
const savedDataStr = localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (savedDataStr) {
|
||||||
|
const parsedData: SavedLocalStorageData = JSON.parse(savedDataStr)
|
||||||
|
return parsedData.formulaData
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'❌ Ошибка при загрузке сохраненных данных из localStorage:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async clear() {
|
||||||
|
try {
|
||||||
|
const storageKey = getStorageKey(templateId)
|
||||||
|
localStorage.removeItem(storageKey)
|
||||||
|
versionCounter = 1
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка при очистке сохраненных данных:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/hook/useAutoSave.ts
Normal file
197
src/hook/useAutoSave.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адаптер для различных способов сохранения
|
||||||
|
*/
|
||||||
|
export interface SaveAdapter {
|
||||||
|
save: (data: Record<string, Record<string, any>>) => Promise<void>
|
||||||
|
load?: () => Promise<Record<string, Record<string, any>>>
|
||||||
|
clear?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAutoSaveOptions {
|
||||||
|
templateId?: string
|
||||||
|
autoSaveDelay?: number
|
||||||
|
enableAutoSave?: boolean
|
||||||
|
adapter: SaveAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный хук автосохранения
|
||||||
|
* Поддерживает различные адаптеры сохранения (API, localStorage, etc.)
|
||||||
|
*/
|
||||||
|
export const useAutoSave = (
|
||||||
|
getAllModifiedCells: () => Record<string, Record<string, any>>,
|
||||||
|
isCalculating: boolean,
|
||||||
|
revision: number,
|
||||||
|
options: UseAutoSaveOptions
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
templateId,
|
||||||
|
autoSaveDelay = 2000,
|
||||||
|
enableAutoSave = true,
|
||||||
|
adapter,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// Состояние
|
||||||
|
const [lastSavedRevision, setLastSavedRevision] = useState(0)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [lastSaveError, setLastSaveError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const saveTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// Основная функция сохранения
|
||||||
|
const performSave = useCallback(
|
||||||
|
async (data: Record<string, Record<string, any>>) => {
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!templateId) {
|
||||||
|
console.warn('⚠️ Не указан templateId для сохранения')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
setLastSaveError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adapter.save(data)
|
||||||
|
setLastSavedRevision(revision)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||||
|
setLastSaveError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[templateId, adapter, revision]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Автосохранение с дебаунсом
|
||||||
|
const scheduleAutoSave = useCallback(() => {
|
||||||
|
if (!enableAutoSave || isCalculating || isSaving || !templateId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем предыдущий таймер
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
const currentData = getAllModifiedCells()
|
||||||
|
performSave(currentData)
|
||||||
|
}, autoSaveDelay)
|
||||||
|
}, [
|
||||||
|
enableAutoSave,
|
||||||
|
isCalculating,
|
||||||
|
isSaving,
|
||||||
|
templateId,
|
||||||
|
autoSaveDelay,
|
||||||
|
getAllModifiedCells,
|
||||||
|
performSave,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Эффект для автосохранения при изменениях
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
enableAutoSave &&
|
||||||
|
revision > lastSavedRevision &&
|
||||||
|
!isCalculating &&
|
||||||
|
!isSaving
|
||||||
|
) {
|
||||||
|
scheduleAutoSave()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
enableAutoSave,
|
||||||
|
revision,
|
||||||
|
lastSavedRevision,
|
||||||
|
isCalculating,
|
||||||
|
isSaving,
|
||||||
|
scheduleAutoSave,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Ручное сохранение
|
||||||
|
const manualSave = useCallback(() => {
|
||||||
|
// Отменяем автосохранение если оно запланировано
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current)
|
||||||
|
saveTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentData = getAllModifiedCells()
|
||||||
|
|
||||||
|
if (Object.keys(currentData).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
performSave(currentData)
|
||||||
|
}, [getAllModifiedCells, performSave])
|
||||||
|
|
||||||
|
// Загрузка данных
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!adapter.load) return {}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
return await adapter.load()
|
||||||
|
} catch (error) {
|
||||||
|
setLastSaveError(
|
||||||
|
error instanceof Error ? error.message : 'Ошибка загрузки'
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [adapter])
|
||||||
|
|
||||||
|
// Очистка данных
|
||||||
|
const clearData = useCallback(async () => {
|
||||||
|
if (!adapter.clear) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adapter.clear()
|
||||||
|
setLastSavedRevision(0)
|
||||||
|
} catch (error) {
|
||||||
|
setLastSaveError(
|
||||||
|
error instanceof Error ? error.message : 'Ошибка очистки'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [adapter])
|
||||||
|
|
||||||
|
// Очистка таймаута при размонтировании
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Вычисляемые значения
|
||||||
|
const isDataChanged = revision > lastSavedRevision
|
||||||
|
const hasUnsavedChanges = isDataChanged && !isSaving
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Состояние
|
||||||
|
isSaving,
|
||||||
|
isLoading,
|
||||||
|
isDataChanged,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
lastSaveError,
|
||||||
|
lastSavedRevision,
|
||||||
|
|
||||||
|
// Методы
|
||||||
|
manualSave,
|
||||||
|
loadData,
|
||||||
|
clearData,
|
||||||
|
|
||||||
|
// Утилиты
|
||||||
|
getCurrentData: getAllModifiedCells,
|
||||||
|
clearError: () => setLastSaveError(null),
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/hook/useFormulaAutoSaveNew.ts
Normal file
59
src/hook/useFormulaAutoSaveNew.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { createLocalStorageSaveAdapter } from './adapters/localStorageSaveAdapter'
|
||||||
|
import { useAutoSave } from './useAutoSave'
|
||||||
|
|
||||||
|
export interface UseFormulaAutoSaveOptions {
|
||||||
|
templateId?: string
|
||||||
|
autoSaveDelay?: number
|
||||||
|
enableAutoSave?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для автосохранения формул в localStorage
|
||||||
|
* Теперь использует универсальный хук с localStorage адаптером
|
||||||
|
*/
|
||||||
|
export const useFormulaAutoSave = (
|
||||||
|
getAllModifiedCells: () => Record<string, Record<string, any>>,
|
||||||
|
isCalculating: boolean,
|
||||||
|
revision: number,
|
||||||
|
options: UseFormulaAutoSaveOptions = {}
|
||||||
|
) => {
|
||||||
|
const { templateId, autoSaveDelay = 2000, enableAutoSave = true } = options
|
||||||
|
|
||||||
|
if (!templateId) {
|
||||||
|
// Возвращаем пустую реализацию если нет templateId
|
||||||
|
return {
|
||||||
|
isSaving: false,
|
||||||
|
isDataChanged: false,
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
lastSaveError: null,
|
||||||
|
lastSavedRevision: 0,
|
||||||
|
savedData: null,
|
||||||
|
manualSave: () => {},
|
||||||
|
loadSavedFormulas: () => null,
|
||||||
|
clearSavedFormulas: () => {},
|
||||||
|
getCurrentData: getAllModifiedCells,
|
||||||
|
clearError: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = createLocalStorageSaveAdapter(templateId)
|
||||||
|
|
||||||
|
const autoSaveResult = useAutoSave(
|
||||||
|
getAllModifiedCells,
|
||||||
|
isCalculating,
|
||||||
|
revision,
|
||||||
|
{
|
||||||
|
templateId,
|
||||||
|
autoSaveDelay,
|
||||||
|
enableAutoSave,
|
||||||
|
adapter,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...autoSaveResult,
|
||||||
|
savedData: null, // Для совместимости
|
||||||
|
loadSavedFormulas: async () => autoSaveResult.loadData(),
|
||||||
|
clearSavedFormulas: async () => autoSaveResult.clearData(),
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/hook/useSheetAutoSaveNew.ts
Normal file
60
src/hook/useSheetAutoSaveNew.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { createApiSaveAdapter } from './adapters/apiSaveAdapter'
|
||||||
|
import { useAutoSave } from './useAutoSave'
|
||||||
|
|
||||||
|
export interface UseSheetAutoSaveOptions {
|
||||||
|
templateId?: string
|
||||||
|
autoSaveDelay?: number
|
||||||
|
enableAutoSave?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для автосохранения листов через API
|
||||||
|
* Теперь использует универсальный хук с API адаптером
|
||||||
|
*/
|
||||||
|
export const useSheetAutoSave = (
|
||||||
|
getAllModifiedCells: () => Record<string, Record<string, any>>,
|
||||||
|
isCalculating: boolean,
|
||||||
|
revision: number,
|
||||||
|
options: UseSheetAutoSaveOptions = {}
|
||||||
|
) => {
|
||||||
|
const { templateId, autoSaveDelay = 2000, enableAutoSave = true } = options
|
||||||
|
|
||||||
|
if (!templateId) {
|
||||||
|
// Возвращаем пустую реализацию если нет templateId
|
||||||
|
return {
|
||||||
|
isSaving: false,
|
||||||
|
isLoading: false,
|
||||||
|
isDataChanged: false,
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
lastSaveError: null,
|
||||||
|
lastSavedRevision: 0,
|
||||||
|
sheets: {},
|
||||||
|
manualSave: () => {},
|
||||||
|
loadSavedSheets: () => ({}),
|
||||||
|
loadSheets: async () => ({}),
|
||||||
|
getCurrentData: getAllModifiedCells,
|
||||||
|
clearError: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = createApiSaveAdapter(templateId)
|
||||||
|
|
||||||
|
const autoSaveResult = useAutoSave(
|
||||||
|
getAllModifiedCells,
|
||||||
|
isCalculating,
|
||||||
|
revision,
|
||||||
|
{
|
||||||
|
templateId,
|
||||||
|
autoSaveDelay,
|
||||||
|
enableAutoSave,
|
||||||
|
adapter,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...autoSaveResult,
|
||||||
|
sheets: {}, // Для совместимости
|
||||||
|
loadSavedSheets: async () => autoSaveResult.loadData(),
|
||||||
|
loadSheets: async () => autoSaveResult.loadData(),
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/lib/cell-utils.ts
Normal file
152
src/lib/cell-utils.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Унифицированные утилиты для работы с адресами ячеек Excel
|
||||||
|
* Заменяет дублированный код в разных частях приложения
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CellAddress {
|
||||||
|
column: string
|
||||||
|
row: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellCoordinates {
|
||||||
|
row: number
|
||||||
|
col: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует буквенный столбец в числовой индекс (0-based)
|
||||||
|
* A -> 0, B -> 1, ..., Z -> 25, AA -> 26, AB -> 27, etc.
|
||||||
|
*/
|
||||||
|
export function columnToIndex(column: string): number {
|
||||||
|
let result = 0
|
||||||
|
for (let i = 0; i < column.length; i++) {
|
||||||
|
result = result * 26 + (column.charCodeAt(i) - 64)
|
||||||
|
}
|
||||||
|
return result - 1 // zero-based
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует числовой индекс в буквенный столбец
|
||||||
|
* 0 -> A, 1 -> B, ..., 25 -> Z, 26 -> AA, 27 -> AB, etc.
|
||||||
|
*/
|
||||||
|
export function indexToColumn(index: number): string {
|
||||||
|
let result = ''
|
||||||
|
let num = index + 1 // Convert to 1-based
|
||||||
|
|
||||||
|
while (num > 0) {
|
||||||
|
num-- // Adjust for 0-based alphabet
|
||||||
|
result = String.fromCharCode(65 + (num % 26)) + result
|
||||||
|
num = Math.floor(num / 26)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит адрес ячейки в компоненты (столбец и строка)
|
||||||
|
* "A1" -> { column: "A", row: 1 }
|
||||||
|
* "AB123" -> { column: "AB", row: 123 }
|
||||||
|
*/
|
||||||
|
export function parseCellAddress(cell: string): CellAddress | null {
|
||||||
|
const match = cell.match(/^([A-Z]+)(\d+)$/i)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
column: match[1].toUpperCase(),
|
||||||
|
row: parseInt(match[2], 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует адрес ячейки в координаты (0-based)
|
||||||
|
* "A1" -> { row: 0, col: 0 }
|
||||||
|
* "B3" -> { row: 2, col: 1 }
|
||||||
|
*/
|
||||||
|
export function cellAddressToCoordinates(address: string): CellCoordinates {
|
||||||
|
const parsed = parseCellAddress(address)
|
||||||
|
if (!parsed) return { row: 0, col: 0 }
|
||||||
|
|
||||||
|
return {
|
||||||
|
row: parsed.row - 1, // Convert to 0-based
|
||||||
|
col: columnToIndex(parsed.column),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует координаты в адрес ячейки
|
||||||
|
* { row: 0, col: 0 } -> "A1"
|
||||||
|
* { row: 2, col: 1 } -> "B3"
|
||||||
|
*/
|
||||||
|
export function coordinatesToCellAddress(coords: CellCoordinates): string {
|
||||||
|
const column = indexToColumn(coords.col)
|
||||||
|
const row = coords.row + 1 // Convert to 1-based
|
||||||
|
return `${column}${row}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает адрес ячейки из столбца и строки
|
||||||
|
* createCellAddress("A", 1) -> "A1"
|
||||||
|
* createCellAddress(0, 1) -> "A1" (если передан индекс столбца)
|
||||||
|
*/
|
||||||
|
export function createCellAddress(
|
||||||
|
column: string | number,
|
||||||
|
row: number
|
||||||
|
): string {
|
||||||
|
const columnStr = typeof column === 'number' ? indexToColumn(column) : column
|
||||||
|
return `${columnStr}${row}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидирует адрес ячейки
|
||||||
|
* isValidCellAddress("A1") -> true
|
||||||
|
* isValidCellAddress("ZZZ999999") -> true
|
||||||
|
* isValidCellAddress("A") -> false
|
||||||
|
*/
|
||||||
|
export function isValidCellAddress(cell: string): boolean {
|
||||||
|
return /^[A-Z]+\d+$/i.test(cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает метку столбца из индекса
|
||||||
|
* getColumnLabel(0) -> "A"
|
||||||
|
* getColumnLabel(25) -> "Z"
|
||||||
|
* getColumnLabel(26) -> "AA"
|
||||||
|
*/
|
||||||
|
export function getColumnLabel(index: number): string {
|
||||||
|
return indexToColumn(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вспомогательная функция для совместимости с существующим кодом
|
||||||
|
* Аналог cellAddressToCoordinates, но с другим названием
|
||||||
|
*/
|
||||||
|
export const cellAddressToCoords = cellAddressToCoordinates
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит диапазон ячеек в массив адресов
|
||||||
|
* "A1:C3" -> ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"]
|
||||||
|
*/
|
||||||
|
export function parseRange(range: string): string[] {
|
||||||
|
const match = range.match(/^([A-Z]+\d+):([A-Z]+\d+)$/i)
|
||||||
|
if (!match) return []
|
||||||
|
|
||||||
|
const startCell = parseCellAddress(match[1])
|
||||||
|
const endCell = parseCellAddress(match[2])
|
||||||
|
|
||||||
|
if (!startCell || !endCell) return []
|
||||||
|
|
||||||
|
const startCol = columnToIndex(startCell.column)
|
||||||
|
const endCol = columnToIndex(endCell.column)
|
||||||
|
const startRow = startCell.row
|
||||||
|
const endRow = endCell.row
|
||||||
|
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
for (let row = startRow; row <= endRow; row++) {
|
||||||
|
for (let col = startCol; col <= endCol; col++) {
|
||||||
|
result.push(createCellAddress(col, row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -93,19 +93,19 @@ export class Cell {
|
|||||||
|
|
||||||
// ВАЖНО: Сначала заменяем диапазоны ячеек на массивы значений,
|
// ВАЖНО: Сначала заменяем диапазоны ячеек на массивы значений,
|
||||||
// затем отдельные ячейки, чтобы избежать конфликтов
|
// затем отдельные ячейки, чтобы избежать конфликтов
|
||||||
code = code.replace(QUALIFIED_RANGE_RE, (match) => {
|
code = code.replace(QUALIFIED_RANGE_RE, match => {
|
||||||
const [sheetName, range] = match.split('!')
|
const [sheetName, range] = match.split('!')
|
||||||
const values = this.expandRange(range, sheetName)
|
const values = this.expandRange(range, sheetName)
|
||||||
return `[${values.join(',')}]`
|
return `[${values.join(',')}]`
|
||||||
})
|
})
|
||||||
|
|
||||||
code = code.replace(RANGE_RE, (match) => {
|
code = code.replace(RANGE_RE, match => {
|
||||||
const values = this.expandRange(match)
|
const values = this.expandRange(match)
|
||||||
return `[${values.join(',')}]`
|
return `[${values.join(',')}]`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Заменяем квалифицированные ссылки на ячейки
|
// Заменяем квалифицированные ссылки на ячейки
|
||||||
code = code.replace(QUALIFIED_REF_RE, (match) => {
|
code = code.replace(QUALIFIED_REF_RE, match => {
|
||||||
try {
|
try {
|
||||||
const [sheetName] = match.split('!')
|
const [sheetName] = match.split('!')
|
||||||
const sheet = this.sheet.workbook.getSheet(sheetName)
|
const sheet = this.sheet.workbook.getSheet(sheetName)
|
||||||
@@ -122,7 +122,7 @@ export class Cell {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Заменяем локальные ссылки на ячейки
|
// Заменяем локальные ссылки на ячейки
|
||||||
code = code.replace(LOCAL_REF_RE, (match) => {
|
code = code.replace(LOCAL_REF_RE, match => {
|
||||||
// Избегаем замены функций
|
// Избегаем замены функций
|
||||||
if (this.isFunction(match)) {
|
if (this.isFunction(match)) {
|
||||||
return match
|
return match
|
||||||
@@ -154,7 +154,7 @@ export class Cell {
|
|||||||
// Для кириллических символов \b может не работать, используем более универсальное решение
|
// Для кириллических символов \b может не работать, используем более универсальное решение
|
||||||
const funcRegex = new RegExp(
|
const funcRegex = new RegExp(
|
||||||
`(?:^|[^\\w])(${escapedFuncName})\\s*\\(`,
|
`(?:^|[^\\w])(${escapedFuncName})\\s*\\(`,
|
||||||
'gi',
|
'gi'
|
||||||
)
|
)
|
||||||
const before = code
|
const before = code
|
||||||
code = code.replace(funcRegex, (match, p1) => {
|
code = code.replace(funcRegex, (match, p1) => {
|
||||||
@@ -200,7 +200,7 @@ export class Cell {
|
|||||||
// Для кириллических символов используем более универсальное решение
|
// Для кириллических символов используем более универсальное решение
|
||||||
const funcRegex = new RegExp(
|
const funcRegex = new RegExp(
|
||||||
`(?:^|[^\\w])${escapedFuncName}\\s*\\(`,
|
`(?:^|[^\\w])${escapedFuncName}\\s*\\(`,
|
||||||
'gi',
|
'gi'
|
||||||
)
|
)
|
||||||
if (funcRegex.test(expr)) {
|
if (funcRegex.test(expr)) {
|
||||||
hasVolatileFunction = true
|
hasVolatileFunction = true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FormLayoutSettings,
|
FormLayoutSettings,
|
||||||
TemplateElement,
|
TemplateElement,
|
||||||
} from '../type/template'
|
} from '../type/template'
|
||||||
|
import { parseCellAddress } from './cell-utils'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -49,70 +50,68 @@ export function getDefaultLayoutSettings(): FormLayoutSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерация позиции по умолчанию для нового элемента
|
// Генерация layout по умолчанию для элемента
|
||||||
export function generateDefaultLayout(order: number = 0): ElementLayout {
|
export function generateDefaultLayout(order: number): ElementLayout {
|
||||||
const cols = 4 // Количество колонок для автоматического размещения
|
const baseX = 50
|
||||||
const elementWidth = 300
|
const baseY = 50
|
||||||
const elementHeight = 80
|
const spacing = 100
|
||||||
const padding = 50
|
|
||||||
|
|
||||||
const col = order % cols
|
|
||||||
const row = Math.floor(order / cols)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: col * (elementWidth + padding) + padding,
|
x: baseX,
|
||||||
y: row * (elementHeight + padding) + padding,
|
y: baseY + order * spacing,
|
||||||
width: elementWidth,
|
width: 300,
|
||||||
height: elementHeight,
|
height: 80,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автоматическое размещение элементов на холсте
|
// Автоматическое размещение элементов без пересечений
|
||||||
export function autoArrangeElements(
|
export function autoArrangeElements(
|
||||||
elements: TemplateElement[],
|
elements: TemplateElement[],
|
||||||
gridSize: number = 20
|
gridSize: number = 20
|
||||||
): TemplateElement[] {
|
): TemplateElement[] {
|
||||||
const elementWidth = 300
|
const arranged = [...elements]
|
||||||
const elementHeight = 80
|
const cols = 3 // Количество колонок
|
||||||
const padding = 50
|
const itemWidth = 300
|
||||||
const cols = 4 // Фиксированное количество колонок
|
const itemHeight = 80
|
||||||
|
const marginX = 20
|
||||||
|
const marginY = 20
|
||||||
|
const startX = 50
|
||||||
|
const startY = 50
|
||||||
|
|
||||||
return elements.map((element, index) => {
|
arranged.forEach((element, index) => {
|
||||||
const col = index % cols
|
const col = index % cols
|
||||||
const row = Math.floor(index / cols)
|
const row = Math.floor(index / cols)
|
||||||
|
|
||||||
const x = col * (elementWidth + padding) + padding
|
const x = startX + col * (itemWidth + marginX)
|
||||||
const y = row * (elementHeight + padding) + padding
|
const y = startY + row * (itemHeight + marginY)
|
||||||
|
|
||||||
// Привязка к сетке
|
// Выравниваем по сетке
|
||||||
const snappedX = Math.round(x / gridSize) * gridSize
|
const alignedX = Math.round(x / gridSize) * gridSize
|
||||||
const snappedY = Math.round(y / gridSize) * gridSize
|
const alignedY = Math.round(y / gridSize) * gridSize
|
||||||
|
|
||||||
return {
|
element.layout = {
|
||||||
...element,
|
...element.layout,
|
||||||
layout: {
|
x: alignedX,
|
||||||
x: snappedX,
|
y: alignedY,
|
||||||
y: snappedY,
|
width: itemWidth,
|
||||||
width: elementWidth,
|
height: itemHeight,
|
||||||
height: elementHeight,
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return arranged
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка пересечения элементов
|
// Проверка пересечения двух элементов
|
||||||
export function checkElementOverlap(
|
export function checkElementOverlap(
|
||||||
element1: ElementLayout,
|
element1: ElementLayout,
|
||||||
element2: ElementLayout,
|
element2: ElementLayout
|
||||||
threshold: number = 5
|
|
||||||
): boolean {
|
): boolean {
|
||||||
return !(
|
return !(
|
||||||
element1.x + element1.width + threshold < element2.x ||
|
element1.x + element1.width <= element2.x ||
|
||||||
element2.x + element2.width + threshold < element1.x ||
|
element2.x + element2.width <= element1.x ||
|
||||||
element1.y + element1.height + threshold < element2.y ||
|
element1.y + element1.height <= element2.y ||
|
||||||
element2.y + element2.height + threshold < element1.y
|
element2.y + element2.height <= element1.y
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,22 +152,9 @@ export function findFreePosition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Валидация адреса ячейки
|
// Валидация адреса ячейки - теперь использует централизованную утилиту
|
||||||
export function validateCellAddress(cell: string): boolean {
|
export function validateCellAddress(cell: string): boolean {
|
||||||
return /^[A-Z]+\d+$/.test(cell)
|
return parseCellAddress(cell) !== null
|
||||||
}
|
|
||||||
|
|
||||||
// Парсинг адреса ячейки в компоненты
|
|
||||||
export function parseCellAddress(
|
|
||||||
cell: string
|
|
||||||
): { column: string; row: number } | null {
|
|
||||||
const match = cell.match(/^([A-Z]+)(\d+)$/)
|
|
||||||
if (!match) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
column: match[1],
|
|
||||||
row: parseInt(match[2], 10),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерация уникального ID для элемента
|
// Генерация уникального ID для элемента
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { Textarea } from '@/component/ui/textarea'
|
import { Textarea } from '@/component/ui/textarea'
|
||||||
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
||||||
import { getElementDefinition } from '@/lib/element-registry'
|
import { getElementDefinition } from '@/lib/element-registry'
|
||||||
import { getLatestFileForTemplate } from '@/service/fileApiSevice'
|
import { getLatestFileForTemplate } from '@/service/fileApiService'
|
||||||
import { Template, TemplateElement } from '@/type/template'
|
import { Template, TemplateElement } from '@/type/template'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -371,25 +371,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
||||||
const engineRef = useRef<any>(null)
|
const engineRef = useRef<any>(null)
|
||||||
|
|
||||||
// 👉 Хелперы для конвертации адреса ячейки
|
|
||||||
const labelToColumn = (label: string) => {
|
|
||||||
let col = 0
|
|
||||||
for (let i = 0; i < label.length; i++) {
|
|
||||||
col = col * 26 + (label.charCodeAt(i) - 64)
|
|
||||||
}
|
|
||||||
return col - 1 // zero-based
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseCellAddress = (addr: string) => {
|
|
||||||
const match = addr.match(/([A-Z]+)(\d+)/i)
|
|
||||||
if (!match) return { row: 0, col: 0 }
|
|
||||||
const [, colLabel, rowNum] = match
|
|
||||||
return {
|
|
||||||
row: parseInt(rowNum, 10) - 1,
|
|
||||||
col: labelToColumn(colLabel),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFieldChange = (elementId: string, value: any) => {
|
const handleFieldChange = (elementId: string, value: any) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -401,7 +382,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
const element = template.elements.find(el => el.id === elementId)
|
const element = template.elements.find(el => el.id === elementId)
|
||||||
if (element && element.targetCells) {
|
if (element && element.targetCells) {
|
||||||
element.targetCells.forEach(tc => {
|
element.targetCells.forEach(tc => {
|
||||||
const { row, col } = parseCellAddress(tc.cell)
|
const { row, col } = cellAddressToCoordinates(tc.cell)
|
||||||
const sheetName =
|
const sheetName =
|
||||||
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
||||||
engineRef.current.setCellValueWithoutRecalc(
|
engineRef.current.setCellValueWithoutRecalc(
|
||||||
@@ -464,7 +445,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cells?.forEach(tc => {
|
cells?.forEach(tc => {
|
||||||
const { row, col } = parseCellAddress(tc.cell)
|
const { row, col } = cellAddressToCoordinates(tc.cell)
|
||||||
const sheetName =
|
const sheetName =
|
||||||
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L'
|
||||||
console.log(
|
console.log(
|
||||||
@@ -493,7 +474,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
const cellsToUpdate: Record<string, any> = {}
|
const cellsToUpdate: Record<string, any> = {}
|
||||||
const modifiedLeft = allModified['L'] || {}
|
const modifiedLeft = allModified['L'] || {}
|
||||||
Object.keys(modifiedLeft).forEach(cellAddress => {
|
Object.keys(modifiedLeft).forEach(cellAddress => {
|
||||||
const { row, col } = parseCellAddress(cellAddress)
|
const { row, col } = cellAddressToCoordinates(cellAddress)
|
||||||
cellsToUpdate[cellAddress] = engineRef.current.getCellValue(
|
cellsToUpdate[cellAddress] = engineRef.current.getCellValue(
|
||||||
'L',
|
'L',
|
||||||
row,
|
row,
|
||||||
|
|||||||
@@ -1,18 +1,66 @@
|
|||||||
import TemplateCard from '@/component/TemplateCard'
|
|
||||||
import { Button } from '@/component/ui/button'
|
import { Button } from '@/component/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/component/ui/dialog'
|
||||||
|
import { Input } from '@/component/ui/input'
|
||||||
|
import { Label } from '@/component/ui/label'
|
||||||
|
import { Textarea } from '@/component/ui/textarea'
|
||||||
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import TemplateCard from '@/widget/template/ui/TemplateCard'
|
||||||
import { useState } from 'react'
|
import clsx from 'clsx'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { CheckSquare, Plus, Square, Trash2 } from 'lucide-react'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
export const TemplatesOverviewPage = () => {
|
export const TemplatesOverviewPage = () => {
|
||||||
const navigate = useNavigate()
|
const { templates, deleteTemplate, addTemplate } = useTemplateContext()
|
||||||
const { templates, deleteTemplate } = useTemplateContext()
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false)
|
||||||
|
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newDescription, setNewDescription] = useState('')
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((id: string) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const removeSelected = () => {
|
const removeSelected = () => {
|
||||||
selected.forEach(deleteTemplate)
|
selected.forEach(deleteTemplate)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
|
setSelectionMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (newName.trim()) {
|
||||||
|
addTemplate({
|
||||||
|
name: newName,
|
||||||
|
elements: [],
|
||||||
|
description: newDescription,
|
||||||
|
mergedCells: [],
|
||||||
|
})
|
||||||
|
setNewName('')
|
||||||
|
setIsDialogOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelectionMode = () => {
|
||||||
|
setSelectionMode(v => {
|
||||||
|
if (v) setSelected(new Set())
|
||||||
|
return !v
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,25 +70,93 @@ export const TemplatesOverviewPage = () => {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{!!selected.size && (
|
{!!selected.size && (
|
||||||
<Button variant="destructive" onClick={removeSelected}>
|
<Button variant="destructive" onClick={removeSelected}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" /> {selected.size}
|
||||||
Удалить ({selected.size})
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => navigate('/templates/new')}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" /> Создать
|
<Button
|
||||||
|
variant={selectionMode ? 'secondary' : 'outline'}
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
>
|
||||||
|
{selectionMode ? (
|
||||||
|
<>
|
||||||
|
<Square className="mr-2 h-4 w-4" /> Отменить
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckSquare className="mr-2 h-4 w-4" /> Выбрать
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Создать
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Новый шаблон</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Укажите имя вашего нового шаблона протокола.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="template-name" className="text-right">
|
||||||
|
Название
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="template-name"
|
||||||
|
className="col-span-3"
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
placeholder="Введите название"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="template-description" className="text-right">
|
||||||
|
Описание
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="template-description"
|
||||||
|
className="col-span-3"
|
||||||
|
value={newDescription}
|
||||||
|
onChange={e => setNewDescription(e.target.value)}
|
||||||
|
placeholder="Введите описание"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Отмена</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button onClick={handleCreate}>Создать</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<section
|
||||||
|
className={clsx(
|
||||||
|
'grid gap-6',
|
||||||
|
'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{templates.map(t => (
|
{templates.map(t => (
|
||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={t.id}
|
key={t.id}
|
||||||
template={t}
|
template={t}
|
||||||
|
selectionMode={selectionMode}
|
||||||
isSelected={selected.has(t.id)}
|
isSelected={selected.has(t.id)}
|
||||||
|
onToggleSelect={toggleSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default TemplatesOverviewPage
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ElementsCreation } from './ElementsCreation'
|
import { ElementsCreation } from './ElementsCreation'
|
||||||
import { ProtocolCreation } from './ProtocolCreation'
|
import { ProtocolCreation } from './ProtocolCreation'
|
||||||
import { TemplateSetup } from './editTemplate'
|
import { TemplateEditPage } from './TemplateEditPage'
|
||||||
|
import { TemplatesOverviewPage } from './TemplatesOverviewPage'
|
||||||
|
|
||||||
export { ElementsCreation, ProtocolCreation, TemplateSetup }
|
export {
|
||||||
|
ElementsCreation,
|
||||||
|
ProtocolCreation,
|
||||||
|
TemplateEditPage,
|
||||||
|
TemplatesOverviewPage,
|
||||||
|
}
|
||||||
|
|||||||
140
src/widget/template/ui/TemplateCard.tsx
Normal file
140
src/widget/template/ui/TemplateCard.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { Button } from '@/component/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/component/ui/card'
|
||||||
|
import { Template } from '@/type/template'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { CheckCircle, Circle, FileText, Settings, Wrench } from 'lucide-react'
|
||||||
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface TemplateCardProps {
|
||||||
|
template: Template
|
||||||
|
selectionMode: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
onToggleSelect: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||||
|
template,
|
||||||
|
selectionMode,
|
||||||
|
isSelected,
|
||||||
|
onToggleSelect,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// const isConfigured =
|
||||||
|
// template.elements.length > 0 && template.excelFile !== undefined
|
||||||
|
const isConfigured = true // TODO: remove
|
||||||
|
|
||||||
|
return (
|
||||||
|
console.log(template.excelFile),
|
||||||
|
(
|
||||||
|
<Card
|
||||||
|
aria-label={`Шаблон ${template.name}`}
|
||||||
|
onClick={selectionMode ? () => onToggleSelect(template.id) : undefined}
|
||||||
|
className={clsx(
|
||||||
|
'group relative shadow-sm transition-all duration-200',
|
||||||
|
selectionMode && 'hover:cursor-pointer',
|
||||||
|
selectionMode &&
|
||||||
|
isSelected &&
|
||||||
|
'border border-primary ring-2 ring-primary ring-offset-1',
|
||||||
|
'hover:border-primary hover:shadow-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectionMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
aria-label={isSelected ? 'Снять выделение' : 'Выбрать'}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleSelect(template.id)
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-full border border-border bg-white transition-all',
|
||||||
|
isSelected ? 'text-accent-foreground' : 'text-muted-foreground',
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected ? <CheckCircle size={18} /> : <Circle size={18} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-base font-medium">
|
||||||
|
{template.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col justify-between pt-0">
|
||||||
|
<div>
|
||||||
|
{template.description && (
|
||||||
|
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mb-3 flex justify-between text-xs text-muted-foreground/80">
|
||||||
|
<span>
|
||||||
|
Создан:{' '}
|
||||||
|
{new Date(template.createdAt).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Обновлён:{' '}
|
||||||
|
{new Date(template.updatedAt).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={selectionMode || !isConfigured}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/templates/${template.id}/protocols`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-3 w-3" />
|
||||||
|
{isConfigured ? 'Создать протокол' : 'Требует настройки'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
disabled={selectionMode}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/templates/${template.id}/edit`)
|
||||||
|
}}
|
||||||
|
aria-label="Редактировать шаблон"
|
||||||
|
>
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
disabled={selectionMode}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/templates/${template.id}/elements`)
|
||||||
|
}}
|
||||||
|
aria-label="Настроить элементы"
|
||||||
|
>
|
||||||
|
<Wrench className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemplateCard
|
||||||
Reference in New Issue
Block a user