From 47e0c7f81c4013aa3309e790f3d599819e648a6f Mon Sep 17 00:00:00 2001 From: tlartem Date: Mon, 21 Jul 2025 18:19:18 +0300 Subject: [PATCH] =?UTF-8?q?AI=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DualSpreadsheet/DualSpreadsheet.tsx | 445 ++++++++++-------- src/component/DualSpreadsheet/utils.ts | 21 +- src/component/StandardsElement/definition.tsx | 2 +- src/component/TemplateCard.tsx | 75 --- .../TemplateManager/SpreadsheetViewer.tsx | 2 +- .../TemplateManager/TemplateEditor.tsx | 4 +- .../TemplateManager/TemplateManager.tsx | 2 +- src/hook/adapters/apiSaveAdapter.ts | 126 +++++ src/hook/adapters/localStorageSaveAdapter.ts | 86 ++++ src/hook/useAutoSave.ts | 197 ++++++++ src/hook/useFormulaAutoSaveNew.ts | 59 +++ src/hook/useSheetAutoSaveNew.ts | 60 +++ src/lib/cell-utils.ts | 152 ++++++ .../spreadsheet-engine/spreadsheet-engine.ts | 12 +- src/lib/utils.ts | 96 ++-- src/page/ProtocolCreation/Page.tsx | 27 +- src/page/TemplatesOverviewPage.tsx | 138 +++++- src/page/index.ts | 10 +- .../{fileApiSevice.ts => fileApiService.ts} | 0 src/widget/template/ui/TemplateCard.tsx | 140 ++++++ 20 files changed, 1262 insertions(+), 392 deletions(-) delete mode 100644 src/component/TemplateCard.tsx create mode 100644 src/hook/adapters/apiSaveAdapter.ts create mode 100644 src/hook/adapters/localStorageSaveAdapter.ts create mode 100644 src/hook/useAutoSave.ts create mode 100644 src/hook/useFormulaAutoSaveNew.ts create mode 100644 src/hook/useSheetAutoSaveNew.ts create mode 100644 src/lib/cell-utils.ts rename src/service/{fileApiSevice.ts => fileApiService.ts} (100%) create mode 100644 src/widget/template/ui/TemplateCard.tsx diff --git a/src/component/DualSpreadsheet/DualSpreadsheet.tsx b/src/component/DualSpreadsheet/DualSpreadsheet.tsx index dbe1ea7..b9f445e 100644 --- a/src/component/DualSpreadsheet/DualSpreadsheet.tsx +++ b/src/component/DualSpreadsheet/DualSpreadsheet.tsx @@ -1,6 +1,6 @@ import { useMultiSheetEngine } from '@/hook/useMultiSheetEngine' import { useSheetAutoSave } from '@/hook/useSheetAutoSave' -import { getFileData, getLatestFileForTemplate } from '@/service/fileApiSevice' +import { getFileData, getLatestFileForTemplate } from '@/service/fileApiService' import { produce } from 'immer' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -24,25 +24,36 @@ const DualSpreadsheet: FC = ({ templateId, 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 } = useMultiSheetEngine({ - sheets: [ - { name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT }, - { name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT }, - ], + sheets: sheetsConfig, initialData: templateData || {}, }) + // Мемоизируем объект для передачи родительскому компоненту + const engineAPI = useMemo( + () => ({ + ...multiSheetEngine, + updateRevision, + }), + [multiSheetEngine, updateRevision] + ) + // Передаем методы движка родительскому компоненту useEffect(() => { if (onEngineReady) { - onEngineReady({ - ...multiSheetEngine, - updateRevision, - }) + onEngineReady(engineAPI) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onEngineReady]) + }, [onEngineReady, engineAPI]) const [activeSheet, setActiveSheet] = useState('report') const [selectedCell, setSelectedCell] = useState<{ @@ -56,31 +67,61 @@ const DualSpreadsheet: FC = ({ Record> >({}) // Базовые данные файла - const [columnWidths, setColumnWidths] = useState>( + // Мемоизируем начальное состояние ширин колонок + const initialColumnWidths = useMemo( () => ({ report: Array(COL_COUNT).fill(96), calculations: Array(COL_COUNT).fill(96), - }) + }), + [] ) - const gridRefs = useRef>({ - report: null, - calculations: null, - }) - const containerRefs = useRef>({ - report: null, - calculations: null, - }) + const [columnWidths, setColumnWidths] = + useState>(initialColumnWidths) + + // Мемоизируем начальные refs объекты + const initialGridRefs = useMemo( + () => ({ + report: null, + calculations: null, + }), + [] + ) + + const initialContainerRefs = useMemo( + () => ({ + report: null, + calculations: null, + }), + [] + ) + + const initialHeaderRefs = useMemo( + () => ({ + report: null, + calculations: null, + }), + [] + ) + + const initialRowHeaderRefs = useMemo( + () => ({ + report: null, + calculations: null, + }), + [] + ) + + const gridRefs = useRef>(initialGridRefs) + const containerRefs = + useRef>(initialContainerRefs) + const headerRefs = + useRef>(initialHeaderRefs) + const rowHeaderRefs = + useRef>(initialRowHeaderRefs) + const activeCellInputRef = useRef(null) const formulaBarRef = useRef(null) - const headerRefs = useRef>({ - report: null, - calculations: null, - }) - const rowHeaderRefs = useRef>({ - report: null, - calculations: null, - }) const initialTemplateValues = useRef>({}) const cellDataCache = useRef>>( @@ -88,45 +129,43 @@ const DualSpreadsheet: FC = ({ ) const lastRevision = useRef(-1) - useEffect(() => { - // Используем baseFileData как источник базовых значений для сравнения - const reportBaseData = baseFileData?.[SHEET_NAMES.report] + // Мемоизируем обработчик обновления начальных значений + const updateInitialValues = useCallback(() => { + // Обновляем начальные значения только если есть данные + const hasBaseFileData = Object.keys(baseFileData).length > 0 + const hasTemplateData = Object.keys(templateData).length > 0 - if (reportBaseData && Object.keys(reportBaseData).length > 0) { - // Очищаем старые значения - initialTemplateValues.current = {} - - Object.entries(reportBaseData).forEach(([cellName, value]) => { - initialTemplateValues.current[cellName] = String(value) + if (hasBaseFileData) { + Object.entries(baseFileData).forEach(([sheetName, sheetData]) => { + Object.entries(sheetData).forEach(([cellAddress, value]) => { + initialTemplateValues.current[cellAddress] = String(value || '') + }) }) - console.log( - '✅ Загружено базовых значений файла для сравнения:', + '✅ Загружено значений baseFileData для сравнения (приоритет):', + Object.keys(initialTemplateValues.current).length + ) + } else if (hasTemplateData) { + Object.entries(templateData).forEach(([sheetName, sheetData]) => { + Object.entries(sheetData).forEach(([cellAddress, value]) => { + initialTemplateValues.current[cellAddress] = String(value || '') + }) + }) + console.log( + '✅ Загружено значений templateData для сравнения (fallback):', Object.keys(initialTemplateValues.current).length ) } else { - // Fallback: используем templateData, если baseFileData еще не загружен - const reportTemplate = templateData?.[SHEET_NAMES.report] - - if (reportTemplate && Object.keys(reportTemplate).length > 0) { - // Очищаем старые значения - initialTemplateValues.current = {} - - Object.entries(reportTemplate).forEach(([cellName, value]) => { - initialTemplateValues.current[cellName] = String(value) - }) - - console.log( - '✅ Загружено значений templateData для сравнения (fallback):', - Object.keys(initialTemplateValues.current).length - ) - } else { - console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения') - } + console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения') } }, [baseFileData, templateData]) useEffect(() => { + updateInitialValues() + }, [updateInitialValues]) + + // Мемоизируем обработчик скролла к выбранной ячейке + const scrollToSelectedCell = useCallback(() => { if (selectedCell && activeSheet) { const grid = gridRefs.current[activeSheet] grid?.scrollToItem({ @@ -137,6 +176,10 @@ const DualSpreadsheet: FC = ({ } }, [selectedCell, activeSheet]) + useEffect(() => { + scrollToSelectedCell() + }, [scrollToSelectedCell]) + const getCachedCellData = useCallback( (sheetType: SheetType): Record => { const cacheKey = `${sheetType}-${revision}-${JSON.stringify(columnWidths[sheetType])}` @@ -522,10 +565,28 @@ const DualSpreadsheet: FC = ({ [] ) - const [spreadsheetWidths, setSpreadsheetWidths] = useState({ - L: 0.5, - R: 0.5, - }) + // Мемоизируем состояние ширин таблиц + const initialSpreadsheetWidths = useMemo( + () => ({ + L: 0.5, + R: 0.5, + }), + [] + ) + + const [spreadsheetWidths, setSpreadsheetWidths] = useState( + initialSpreadsheetWidths + ) + + // Мемоизируем опции автосохранения + const autoSaveOptions = useMemo( + () => ({ + templateId, + autoSaveDelay: 2000, + enableAutoSave: true, + }), + [templateId] + ) // Используем новый хук автосохранения const { @@ -541,11 +602,7 @@ const DualSpreadsheet: FC = ({ multiSheetEngine.getAllModifiedCells, multiSheetEngine.isCalculating, revision, - { - templateId, - autoSaveDelay: 2000, - enableAutoSave: true, - } + autoSaveOptions ) // Отладка templateId @@ -574,153 +631,138 @@ const DualSpreadsheet: FC = ({ const separatorRef = useDraggableDivider(handleDividerDrag) - // Загружаем сохраненные формулы при инициализации - useEffect(() => { + // Мемоизируем функцию инициализации данных + const initializeData = useCallback(async () => { if (!templateId || isInitialized) return - // Асинхронная функция для загрузки и слияния данных - const initializeData = async () => { - let finalBaseData: Record> = {} + let finalBaseData: Record> = {} - // 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА - if (templateId) { - try { - console.log('🔍 Ищем файлы для templateId:', templateId) - const latestFile = await getLatestFileForTemplate(templateId) - - if (latestFile) { - console.log( - '📄 Найден файл:', - latestFile.name, - 'ID:', - latestFile.id - ) - const fileData = await getFileData(latestFile.id) - - // TODO: Пока getFileData возвращает пустые данные, используем templateData - // Когда API будет готов, здесь будут реальные данные файла - if (Object.keys(fileData.data).length > 0) { - finalBaseData = { [SHEET_NAMES.report]: fileData.data } - setBaseFileData(finalBaseData) - // Сохраняем 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 + // 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА + if (templateId) { try { - console.log('🔍 Загружаем пользовательские изменения из API...') - const apiSheets = await loadSheets() + const latestFile = await getLatestFileForTemplate(templateId) + if (latestFile) { + console.log( + '📁 Найден последний файл для шаблона:', + latestFile.filename + ) + const fileData = await getFileData(latestFile.id) - // Преобразуем листы API в формат данных - const userChanges: Record> = {} - 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 + // 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 { + console.log('🔍 Загружаем пользовательские изменения из API...') + const apiSheets = await loadSheets() + + // Преобразуем листы API в формат данных + const userChanges: Record> = {} + 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> = {} + + // Копируем базовые данные файла как основу + 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( - '📂 Загружены пользовательские изменения:', - Object.keys(userChanges) + '📊 Загружаем объединенные данные (базовые + пользовательские)' ) - - // 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ - if (Object.keys(userChanges).length > 0) { - console.log( - '🔄 Объединяем базовые данные с пользовательскими изменениями' - ) - - // Создаем объединенные данные: базовые данные файла + пользовательские изменения - const mergedData: Record> = {} - - // Копируем базовые данные файла как основу - 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(mergedData) + } else if (Object.keys(finalBaseData).length > 0) { + console.log( + '📋 Используем только базовые данные (нет пользовательских изменений)' ) - - // В случае ошибки используем только базовые данные - if (Object.keys(finalBaseData).length > 0) { - console.log('📋 Ошибка API, используем только базовые данные') - multiSheetEngine.loadData(finalBaseData) - } + multiSheetEngine.loadData(finalBaseData) + } else { + console.log('⚠️ Нет данных для загрузки') } + } catch (error) { + console.error('❌ Ошибка при загрузке пользовательских изменений:', error) - setIsInitialized(true) + // В случае ошибки используем только базовые данные + if (Object.keys(finalBaseData).length > 0) { + console.log('📋 Ошибка API, используем только базовые данные') + multiSheetEngine.loadData(finalBaseData) + } } - initializeData() + setIsInitialized(true) }, [ templateId, templateData, @@ -730,6 +772,11 @@ const DualSpreadsheet: FC = ({ isInitialized, ]) + // Загружаем сохраненные формулы при инициализации + useEffect(() => { + initializeData() + }, [initializeData]) + return (
String.fromCharCode(65 + index) +export { getColumnLabel } -// Функция для преобразования адреса ячейки в координаты -export const cellAddressToCoords = ( - 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 cellAddressToCoords = cellAddressToCoordinates // Функция для проверки, является ли ячейка частью объединенной группы export const findMergedCellInfo = ( @@ -24,8 +15,8 @@ export const findMergedCellInfo = ( mergedCells: MergedCell[] ): MergedCellInfo => { for (const merged of mergedCells) { - const fromCoords = cellAddressToCoords(merged.from) - const toCoords = cellAddressToCoords(merged.to) + const fromCoords = cellAddressToCoordinates(merged.from) + const toCoords = cellAddressToCoordinates(merged.to) // Проверяем, попадает ли текущая ячейка в диапазон объединения if ( diff --git a/src/component/StandardsElement/definition.tsx b/src/component/StandardsElement/definition.tsx index 13559a2..8501a8b 100644 --- a/src/component/StandardsElement/definition.tsx +++ b/src/component/StandardsElement/definition.tsx @@ -1,5 +1,5 @@ +import { parseCellAddress } from '@/lib/cell-utils' import { ElementDefinition } from '@/lib/element-registry' -import { parseCellAddress } from '@/lib/utils' import { CellTarget } from '@/type/template' import { Weight } from 'lucide-react' import { StandardsEditor } from './StandardsEditor' diff --git a/src/component/TemplateCard.tsx b/src/component/TemplateCard.tsx deleted file mode 100644 index f5f5b08..0000000 --- a/src/component/TemplateCard.tsx +++ /dev/null @@ -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 = ({ - template, - isSelected = false, -}) => { - const navigate = useNavigate() - - // Шаблон считается готовым, когда есть хотя бы один элемент и загружена - // Excel-таблица (что подтверждается наличием mergedCells). - const isConfigured = - template.elements.length > 0 && (template.mergedCells?.length ?? 0) > 0 - - return ( - - - {template.name} - - - {template.description && ( -

- {template.description} -

- )} -
- - Создан: {new Date(template.createdAt).toLocaleDateString()} - - - Обновлён: {new Date(template.updatedAt).toLocaleDateString()} - -
-
- - - -
-
-
- ) -} - -export default TemplateCard diff --git a/src/component/TemplateManager/SpreadsheetViewer.tsx b/src/component/TemplateManager/SpreadsheetViewer.tsx index 8b453a9..3002977 100644 --- a/src/component/TemplateManager/SpreadsheetViewer.tsx +++ b/src/component/TemplateManager/SpreadsheetViewer.tsx @@ -1,5 +1,5 @@ import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet' -import { ExcelParseResult } from '@/service/fileApiSevice' +import { ExcelParseResult } from '@/service/fileApiService' import React from 'react' interface SpreadsheetViewerProps { diff --git a/src/component/TemplateManager/TemplateEditor.tsx b/src/component/TemplateManager/TemplateEditor.tsx index 9a76a7c..4ddffba 100644 --- a/src/component/TemplateManager/TemplateEditor.tsx +++ b/src/component/TemplateManager/TemplateEditor.tsx @@ -3,7 +3,7 @@ import { getFileData, getLatestFileForTemplate, parseExcelFile, -} from '@/service/fileApiSevice' +} from '@/service/fileApiService' import { Template } from '@/type/template' import React, { useEffect, useState } from 'react' import { ExcelUploadPanel } from './ExcelUploadPanel' @@ -28,10 +28,8 @@ export const TemplateEditor: React.FC = ({ const [dataVersion, setDataVersion] = useState(0) const [serverFileName, setServerFileName] = useState() - // Получаем функцию обновления шаблона из контекста const { updateTemplate } = useTemplateContext() - // При монтировании пытаемся загрузить последний файл для шаблона useEffect(() => { let isMounted = true diff --git a/src/component/TemplateManager/TemplateManager.tsx b/src/component/TemplateManager/TemplateManager.tsx index 82c13e2..cd65bd3 100644 --- a/src/component/TemplateManager/TemplateManager.tsx +++ b/src/component/TemplateManager/TemplateManager.tsx @@ -3,7 +3,7 @@ import { Template } from '@/type/template' import { Circle, CircleCheck, FileText, Plus, Trash2 } from 'lucide-react' import React, { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import TemplateCard from '../TemplateCard' +import TemplateCard from '../../widget/template/ui/TemplateCard' import { Button } from '../ui/button' import { Dialog, diff --git a/src/hook/adapters/apiSaveAdapter.ts b/src/hook/adapters/apiSaveAdapter.ts new file mode 100644 index 0000000..22e5b82 --- /dev/null +++ b/src/hook/adapters/apiSaveAdapter.ts @@ -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 = {} + + 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>) { + 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 = {} + const result: Record> = {} + + 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 = {} + }, + } +} diff --git a/src/hook/adapters/localStorageSaveAdapter.ts b/src/hook/adapters/localStorageSaveAdapter.ts new file mode 100644 index 0000000..4e6dff6 --- /dev/null +++ b/src/hook/adapters/localStorageSaveAdapter.ts @@ -0,0 +1,86 @@ +import { SaveAdapter } from '../useAutoSave' + +interface SavedLocalStorageData { + templateId: string + formulaData: Record> + 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>) { + // Имитируем задержку 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) + } + }, + } +} diff --git a/src/hook/useAutoSave.ts b/src/hook/useAutoSave.ts new file mode 100644 index 0000000..2bfd331 --- /dev/null +++ b/src/hook/useAutoSave.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * Адаптер для различных способов сохранения + */ +export interface SaveAdapter { + save: (data: Record>) => Promise + load?: () => Promise>> + clear?: () => Promise +} + +export interface UseAutoSaveOptions { + templateId?: string + autoSaveDelay?: number + enableAutoSave?: boolean + adapter: SaveAdapter +} + +/** + * Универсальный хук автосохранения + * Поддерживает различные адаптеры сохранения (API, localStorage, etc.) + */ +export const useAutoSave = ( + getAllModifiedCells: () => Record>, + 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(null) + const [isLoading, setIsLoading] = useState(false) + + // Refs + const saveTimeoutRef = useRef(null) + + // Основная функция сохранения + const performSave = useCallback( + async (data: Record>) => { + 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), + } +} diff --git a/src/hook/useFormulaAutoSaveNew.ts b/src/hook/useFormulaAutoSaveNew.ts new file mode 100644 index 0000000..74527f8 --- /dev/null +++ b/src/hook/useFormulaAutoSaveNew.ts @@ -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>, + 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(), + } +} diff --git a/src/hook/useSheetAutoSaveNew.ts b/src/hook/useSheetAutoSaveNew.ts new file mode 100644 index 0000000..ca5e7f7 --- /dev/null +++ b/src/hook/useSheetAutoSaveNew.ts @@ -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>, + 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(), + } +} diff --git a/src/lib/cell-utils.ts b/src/lib/cell-utils.ts new file mode 100644 index 0000000..ba28d2d --- /dev/null +++ b/src/lib/cell-utils.ts @@ -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 +} diff --git a/src/lib/spreadsheet-engine/spreadsheet-engine.ts b/src/lib/spreadsheet-engine/spreadsheet-engine.ts index bd429cd..9baa4f0 100644 --- a/src/lib/spreadsheet-engine/spreadsheet-engine.ts +++ b/src/lib/spreadsheet-engine/spreadsheet-engine.ts @@ -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 values = this.expandRange(range, sheetName) return `[${values.join(',')}]` }) - code = code.replace(RANGE_RE, (match) => { + code = code.replace(RANGE_RE, match => { const values = this.expandRange(match) return `[${values.join(',')}]` }) // Заменяем квалифицированные ссылки на ячейки - code = code.replace(QUALIFIED_REF_RE, (match) => { + code = code.replace(QUALIFIED_REF_RE, match => { try { const [sheetName] = match.split('!') 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)) { return match @@ -154,7 +154,7 @@ export class Cell { // Для кириллических символов \b может не работать, используем более универсальное решение const funcRegex = new RegExp( `(?:^|[^\\w])(${escapedFuncName})\\s*\\(`, - 'gi', + 'gi' ) const before = code code = code.replace(funcRegex, (match, p1) => { @@ -200,7 +200,7 @@ export class Cell { // Для кириллических символов используем более универсальное решение const funcRegex = new RegExp( `(?:^|[^\\w])${escapedFuncName}\\s*\\(`, - 'gi', + 'gi' ) if (funcRegex.test(expr)) { hasVolatileFunction = true diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c9f2741..0172727 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,6 +6,7 @@ import { FormLayoutSettings, TemplateElement, } from '../type/template' +import { parseCellAddress } from './cell-utils' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -49,70 +50,68 @@ export function getDefaultLayoutSettings(): FormLayoutSettings { } } -// Генерация позиции по умолчанию для нового элемента -export function generateDefaultLayout(order: number = 0): ElementLayout { - const cols = 4 // Количество колонок для автоматического размещения - const elementWidth = 300 - const elementHeight = 80 - const padding = 50 - - const col = order % cols - const row = Math.floor(order / cols) +// Генерация layout по умолчанию для элемента +export function generateDefaultLayout(order: number): ElementLayout { + const baseX = 50 + const baseY = 50 + const spacing = 100 return { - x: col * (elementWidth + padding) + padding, - y: row * (elementHeight + padding) + padding, - width: elementWidth, - height: elementHeight, + x: baseX, + y: baseY + order * spacing, + width: 300, + height: 80, zIndex: 1, } } -// Автоматическое размещение элементов на холсте +// Автоматическое размещение элементов без пересечений export function autoArrangeElements( elements: TemplateElement[], gridSize: number = 20 ): TemplateElement[] { - const elementWidth = 300 - const elementHeight = 80 - const padding = 50 - const cols = 4 // Фиксированное количество колонок + const arranged = [...elements] + const cols = 3 // Количество колонок + const itemWidth = 300 + 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 row = Math.floor(index / cols) - const x = col * (elementWidth + padding) + padding - const y = row * (elementHeight + padding) + padding + const x = startX + col * (itemWidth + marginX) + const y = startY + row * (itemHeight + marginY) - // Привязка к сетке - const snappedX = Math.round(x / gridSize) * gridSize - const snappedY = Math.round(y / gridSize) * gridSize + // Выравниваем по сетке + const alignedX = Math.round(x / gridSize) * gridSize + const alignedY = Math.round(y / gridSize) * gridSize - return { - ...element, - layout: { - x: snappedX, - y: snappedY, - width: elementWidth, - height: elementHeight, - zIndex: 1, - }, + element.layout = { + ...element.layout, + x: alignedX, + y: alignedY, + width: itemWidth, + height: itemHeight, } }) + + return arranged } -// Проверка пересечения элементов +// Проверка пересечения двух элементов export function checkElementOverlap( element1: ElementLayout, - element2: ElementLayout, - threshold: number = 5 + element2: ElementLayout ): boolean { return !( - element1.x + element1.width + threshold < element2.x || - element2.x + element2.width + threshold < element1.x || - element1.y + element1.height + threshold < element2.y || - element2.y + element2.height + threshold < element1.y + element1.x + element1.width <= element2.x || + element2.x + element2.width <= element1.x || + element1.y + element1.height <= element2.y || + element2.y + element2.height <= element1.y ) } @@ -153,22 +152,9 @@ export function findFreePosition( } } -// Валидация адреса ячейки +// Валидация адреса ячейки - теперь использует централизованную утилиту export function validateCellAddress(cell: string): boolean { - return /^[A-Z]+\d+$/.test(cell) -} - -// Парсинг адреса ячейки в компоненты -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), - } + return parseCellAddress(cell) !== null } // Генерация уникального ID для элемента diff --git a/src/page/ProtocolCreation/Page.tsx b/src/page/ProtocolCreation/Page.tsx index 86e8969..6702bb8 100644 --- a/src/page/ProtocolCreation/Page.tsx +++ b/src/page/ProtocolCreation/Page.tsx @@ -16,7 +16,7 @@ import { import { Textarea } from '@/component/ui/textarea' import { useTemplateContext } from '@/entitiy/template/model/TemplateContext' import { getElementDefinition } from '@/lib/element-registry' -import { getLatestFileForTemplate } from '@/service/fileApiSevice' +import { getLatestFileForTemplate } from '@/service/fileApiService' import { Template, TemplateElement } from '@/type/template' import { ArrowLeft, @@ -371,25 +371,6 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { const [showSpreadsheet, setShowSpreadsheet] = useState(false) const engineRef = useRef(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) => { setFormData(prev => ({ ...prev, @@ -401,7 +382,7 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { const element = template.elements.find(el => el.id === elementId) if (element && element.targetCells) { element.targetCells.forEach(tc => { - const { row, col } = parseCellAddress(tc.cell) + const { row, col } = cellAddressToCoordinates(tc.cell) const sheetName = tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L' engineRef.current.setCellValueWithoutRecalc( @@ -464,7 +445,7 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { } cells?.forEach(tc => { - const { row, col } = parseCellAddress(tc.cell) + const { row, col } = cellAddressToCoordinates(tc.cell) const sheetName = tc.sheet === 'R' || tc.sheet === 'Calculations' ? 'R' : 'L' console.log( @@ -493,7 +474,7 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { const cellsToUpdate: Record = {} const modifiedLeft = allModified['L'] || {} Object.keys(modifiedLeft).forEach(cellAddress => { - const { row, col } = parseCellAddress(cellAddress) + const { row, col } = cellAddressToCoordinates(cellAddress) cellsToUpdate[cellAddress] = engineRef.current.getCellValue( 'L', row, diff --git a/src/page/TemplatesOverviewPage.tsx b/src/page/TemplatesOverviewPage.tsx index e0beb6f..6e37f2d 100644 --- a/src/page/TemplatesOverviewPage.tsx +++ b/src/page/TemplatesOverviewPage.tsx @@ -1,18 +1,66 @@ -import TemplateCard from '@/component/TemplateCard' 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 { Plus, Trash2 } from 'lucide-react' -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +import TemplateCard from '@/widget/template/ui/TemplateCard' +import clsx from 'clsx' +import { CheckSquare, Plus, Square, Trash2 } from 'lucide-react' +import { useCallback, useState } from 'react' export const TemplatesOverviewPage = () => { - const navigate = useNavigate() - const { templates, deleteTemplate } = useTemplateContext() + const { templates, deleteTemplate, addTemplate } = useTemplateContext() + const [selected, setSelected] = useState>(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 = () => { selected.forEach(deleteTemplate) 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 ( @@ -22,25 +70,93 @@ export const TemplatesOverviewPage = () => {
{!!selected.size && ( )} - + + + + + + + + Новый шаблон + + Укажите имя вашего нового шаблона протокола. + + +
+
+ + setNewName(e.target.value)} + placeholder="Введите название" + /> +
+
+ +