AI рефакторинг !

This commit is contained in:
2025-07-21 18:19:18 +03:00
parent 5737e37386
commit 47e0c7f81c
20 changed files with 1262 additions and 392 deletions

View File

@@ -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 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<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>>({
report: null,
calculations: 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,45 +129,43 @@ 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 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<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({
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<DualSpreadsheetProps> = ({
multiSheetEngine.getAllModifiedCells,
multiSheetEngine.isCalculating,
revision,
{
templateId,
autoSaveDelay: 2000,
enableAutoSave: true,
}
autoSaveOptions
)
// Отладка templateId
@@ -574,153 +631,138 @@ 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>> = {}
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
)
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<string, Record<string, any>> = {}
Object.entries(apiSheets).forEach(([sheetName, sheet]) => {
// Нормализуем имя листа: "Report" -> "L"
const normalizedName =
sheetName === 'Report'
? SHEET_NAMES.report
: sheetName === 'Calculations'
? SHEET_NAMES.calculations
: sheetName
// Интересуют только листы L и R
if (
(normalizedName === SHEET_NAMES.report ||
normalizedName === SHEET_NAMES.calculations) &&
sheet.cells &&
Object.keys(sheet.cells).length > 0
) {
userChanges[normalizedName] = sheet.cells
// 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<string, Record<string, any>> = {}
Object.entries(apiSheets).forEach(([sheetName, sheet]) => {
// Нормализуем имя листа: "Report" -> "L"
const normalizedName =
sheetName === 'Report'
? SHEET_NAMES.report
: sheetName === 'Calculations'
? SHEET_NAMES.calculations
: sheetName
// Интересуют только листы L и R
if (
(normalizedName === SHEET_NAMES.report ||
normalizedName === SHEET_NAMES.calculations) &&
sheet.cells &&
Object.keys(sheet.cells).length > 0
) {
userChanges[normalizedName] = sheet.cells
}
})
console.log(
'📂 Загружены пользовательские изменения:',
Object.keys(userChanges)
)
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
if (Object.keys(userChanges).length > 0) {
console.log(
'🔄 Объединяем базовые данные с пользовательскими изменениями'
)
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
const mergedData: Record<string, Record<string, any>> = {}
// Копируем базовые данные файла как основу
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
mergedData[sheetName] = { ...sheetData }
})
// Применяем пользовательские изменения поверх базовых данных
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
if (!mergedData[sheetName]) {
mergedData[sheetName] = {}
}
Object.entries(sheetData).forEach(([cellAddress, value]) => {
mergedData[sheetName][cellAddress] = value
})
})
console.log(
'📂 Загружены пользовательские изменения:',
Object.keys(userChanges)
'📊 Загружаем объединенные данные (базовые + пользовательские)'
)
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
if (Object.keys(userChanges).length > 0) {
console.log(
'🔄 Объединяем базовые данные с пользовательскими изменениями'
)
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
const mergedData: Record<string, Record<string, any>> = {}
// Копируем базовые данные файла как основу
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
mergedData[sheetName] = { ...sheetData }
})
// Применяем пользовательские изменения поверх базовых данных
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
if (!mergedData[sheetName]) {
mergedData[sheetName] = {}
}
Object.entries(sheetData).forEach(([cellAddress, value]) => {
mergedData[sheetName][cellAddress] = value
})
})
console.log(
'📊 Загружаем объединенные данные (базовые + пользовательские)'
)
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<DualSpreadsheetProps> = ({
isInitialized,
])
// Загружаем сохраненные формулы при инициализации
useEffect(() => {
initializeData()
}, [initializeData])
return (
<div className="flex h-full flex-col bg-background">
<FormulaBar

View File

@@ -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 (

View File

@@ -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'

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,

View 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 = {}
},
}
}

View 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
View 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),
}
}

View 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(),
}
}

View 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
View 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
}

View File

@@ -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

View File

@@ -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 для элемента

View File

@@ -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,

View File

@@ -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')}>
<Plus className="mr-2 h-4 w-4" /> Создать
<Button
variant={selectionMode ? 'secondary' : 'outline'}
onClick={toggleSelectionMode}
>
{selectionMode ? (
<>
<Square className="mr-2 h-4 w-4" /> Отменить
</>
) : (
<>
<CheckSquare className="mr-2 h-4 w-4" /> Выбрать
</>
)}
</Button>
<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

View File

@@ -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,
}

View 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