AI рефакторинг !
This commit is contained in:
@@ -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<DualSpreadsheetProps> = ({
|
||||
templateId,
|
||||
onEngineReady,
|
||||
}) => {
|
||||
const { revision, updateRevision, setTemplateData, ...multiSheetEngine } =
|
||||
useMultiSheetEngine({
|
||||
sheets: [
|
||||
// Мемоизируем конфигурацию листов
|
||||
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: 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<SheetType>('report')
|
||||
const [selectedCell, setSelectedCell] = useState<{
|
||||
@@ -56,31 +67,61 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
Record<string, Record<string, any>>
|
||||
>({}) // Базовые данные файла
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
|
||||
// Мемоизируем начальное состояние ширин колонок
|
||||
const initialColumnWidths = useMemo(
|
||||
() => ({
|
||||
report: 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] =
|
||||
useState<Record<SheetType, number[]>>(initialColumnWidths)
|
||||
|
||||
// Мемоизируем начальные refs объекты
|
||||
const initialGridRefs = useMemo(
|
||||
() => ({
|
||||
report: null,
|
||||
calculations: null,
|
||||
})
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const initialContainerRefs = useMemo(
|
||||
() => ({
|
||||
report: null,
|
||||
calculations: null,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const initialHeaderRefs = useMemo(
|
||||
() => ({
|
||||
report: null,
|
||||
calculations: null,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const initialRowHeaderRefs = useMemo(
|
||||
() => ({
|
||||
report: null,
|
||||
calculations: null,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const gridRefs = useRef<Record<SheetType, any>>(initialGridRefs)
|
||||
const containerRefs =
|
||||
useRef<Record<SheetType, HTMLDivElement | null>>(initialContainerRefs)
|
||||
const headerRefs =
|
||||
useRef<Record<SheetType, HTMLDivElement | null>>(initialHeaderRefs)
|
||||
const rowHeaderRefs =
|
||||
useRef<Record<SheetType, HTMLDivElement | null>>(initialRowHeaderRefs)
|
||||
|
||||
const activeCellInputRef = useRef<HTMLInputElement>(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 cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
|
||||
@@ -88,34 +129,28 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
)
|
||||
const lastRevision = useRef<number>(-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 {
|
||||
// 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)
|
||||
} 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
|
||||
@@ -123,10 +158,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
} else {
|
||||
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<DualSpreadsheetProps> = ({
|
||||
}
|
||||
}, [selectedCell, activeSheet])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToSelectedCell()
|
||||
}, [scrollToSelectedCell])
|
||||
|
||||
const getCachedCellData = useCallback(
|
||||
(sheetType: SheetType): Record<string, CachedCellData> => {
|
||||
const cacheKey = `${sheetType}-${revision}-${JSON.stringify(columnWidths[sheetType])}`
|
||||
@@ -522,10 +565,28 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
[]
|
||||
)
|
||||
|
||||
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
|
||||
// Мемоизируем состояние ширин таблиц
|
||||
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<DualSpreadsheetProps> = ({
|
||||
multiSheetEngine.getAllModifiedCells,
|
||||
multiSheetEngine.isCalculating,
|
||||
revision,
|
||||
{
|
||||
templateId,
|
||||
autoSaveDelay: 2000,
|
||||
enableAutoSave: true,
|
||||
}
|
||||
autoSaveOptions
|
||||
)
|
||||
|
||||
// Отладка templateId
|
||||
@@ -574,26 +631,20 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
|
||||
const separatorRef = useDraggableDivider(handleDividerDrag)
|
||||
|
||||
// Загружаем сохраненные формулы при инициализации
|
||||
useEffect(() => {
|
||||
// Мемоизируем функцию инициализации данных
|
||||
const initializeData = useCallback(async () => {
|
||||
if (!templateId || isInitialized) return
|
||||
|
||||
// Асинхронная функция для загрузки и слияния данных
|
||||
const initializeData = async () => {
|
||||
let finalBaseData: Record<string, Record<string, any>> = {}
|
||||
|
||||
// 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА
|
||||
if (templateId) {
|
||||
try {
|
||||
console.log('🔍 Ищем файлы для templateId:', templateId)
|
||||
const latestFile = await getLatestFileForTemplate(templateId)
|
||||
|
||||
if (latestFile) {
|
||||
console.log(
|
||||
'📄 Найден файл:',
|
||||
latestFile.name,
|
||||
'ID:',
|
||||
latestFile.id
|
||||
'📁 Найден последний файл для шаблона:',
|
||||
latestFile.filename
|
||||
)
|
||||
const fileData = await getFileData(latestFile.id)
|
||||
|
||||
@@ -693,9 +744,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
'📊 Загружаем объединенные данные (базовые + пользовательские)'
|
||||
)
|
||||
multiSheetEngine.loadData(mergedData)
|
||||
|
||||
// Обновляем baseline, включая уже сохранённые изменения пользователя,
|
||||
// чтобы их не сохранять повторно
|
||||
} else if (Object.keys(finalBaseData).length > 0) {
|
||||
console.log(
|
||||
'📋 Используем только базовые данные (нет пользовательских изменений)'
|
||||
@@ -705,10 +753,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
console.log('⚠️ Нет данных для загрузки')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'❌ Ошибка при загрузке пользовательских изменений:',
|
||||
error
|
||||
)
|
||||
console.error('❌ Ошибка при загрузке пользовательских изменений:', error)
|
||||
|
||||
// В случае ошибки используем только базовые данные
|
||||
if (Object.keys(finalBaseData).length > 0) {
|
||||
@@ -718,9 +763,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
}
|
||||
|
||||
setIsInitialized(true)
|
||||
}
|
||||
|
||||
initializeData()
|
||||
}, [
|
||||
templateId,
|
||||
templateData,
|
||||
@@ -730,6 +772,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
isInitialized,
|
||||
])
|
||||
|
||||
// Загружаем сохраненные формулы при инициализации
|
||||
useEffect(() => {
|
||||
initializeData()
|
||||
}, [initializeData])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<FormulaBar
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { cellAddressToCoordinates, getColumnLabel } from '@/lib/cell-utils'
|
||||
import { MergedCell } from '@/type/template'
|
||||
import { MergedCellInfo } from './types'
|
||||
|
||||
// --- Helper Functions ---
|
||||
export const getColumnLabel = (index: number) => 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 (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 { ExcelParseResult } from '@/service/fileApiSevice'
|
||||
import { ExcelParseResult } from '@/service/fileApiService'
|
||||
import React from 'react'
|
||||
|
||||
interface SpreadsheetViewerProps {
|
||||
|
||||
@@ -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<TemplateEditorProps> = ({
|
||||
const [dataVersion, setDataVersion] = useState(0)
|
||||
const [serverFileName, setServerFileName] = useState<string | undefined>()
|
||||
|
||||
// Получаем функцию обновления шаблона из контекста
|
||||
const { updateTemplate } = useTemplateContext()
|
||||
|
||||
// При монтировании пытаемся загрузить последний файл для шаблона
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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 для элемента
|
||||
|
||||
@@ -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<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
||||
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
||||
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) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -401,7 +382,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ 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<ProtocolFormProps> = ({ 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<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
||||
const cellsToUpdate: Record<string, any> = {}
|
||||
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,
|
||||
|
||||
@@ -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<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 = () => {
|
||||
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 = () => {
|
||||
<div className="flex gap-3">
|
||||
{!!selected.size && (
|
||||
<Button variant="destructive" onClick={removeSelected}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить ({selected.size})
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {selected.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => navigate('/templates/new')}>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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 => (
|
||||
<TemplateCard
|
||||
key={t.id}
|
||||
template={t}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selected.has(t.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplatesOverviewPage
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ElementsCreation } from './ElementsCreation'
|
||||
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