Files
protoc-frontend/src/page/ProtocolCreation/Page.tsx
2025-07-25 09:19:36 +03:00

496 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
import { Button } from '@/component/ui/button'
import { getElementDefinition } from '@/entitiy/element/model/interface'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import { cellAddressToCoordinates } from '@/lib/cell-utils'
import { useToast } from '@/lib/hooks/useToast'
import { getLatestFileForTemplate } from '@/service/fileApiService'
import { Template, TemplateElement } from '@/type/template'
import { ArrowLeft, FileText, Grid, Save, Settings, Wrench } from 'lucide-react'
import { FC, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
interface ProtocolFormProps {
template: Template
onSave: (data: Record<string, any>) => void
onBack: () => void
}
interface FormElementProps {
element: TemplateElement
value: any
onChange: (value: any) => void
}
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
// Получаем определение элемента из реестра
const elementDefinition = getElementDefinition(element.type)
// Если определение элемента не найдено, показываем ошибку
if (!elementDefinition) {
return (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
Элемент типа "{element.type}" не найден в реестре
</div>
)
}
const renderElement = () => (
<elementDefinition.Render
config={element as any}
value={value}
onChange={onChange}
/>
)
return (
<div className="relative space-y-3">
{/* Показываем индикатор ячеек для всех элементов */}
{element.targetCells && element.targetCells.length > 0 && (
<div className="group/cells absolute right-0 top-0">
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/cells:opacity-100">
<div className="mb-1 font-medium">Целевые ячейки:</div>
{element.targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && (
<span className="ml-1 text-gray-300">
({cell.displayName})
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Рендер элемента через единый интерфейс */}
{renderElement()}
</div>
)
}
const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [formData, setFormData] = useState<Record<string, any>>({})
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const engineRef = useRef<any>(null)
const toast = useToast()
const navigate = useNavigate()
// Инициализация значений для специальных элементов
useEffect(() => {
if (!isInitialized && template.elements.length > 0) {
const initialData: Record<string, any> = {}
template.elements.forEach(element => {
// Получаем определение элемента
const elementDefinition = getElementDefinition(element.type)
if (elementDefinition?.getInitialValue) {
// Используем метод getInitialValue для получения начального значения
const initialValue = elementDefinition.getInitialValue(element as any)
if (initialValue !== undefined) {
initialData[element.id] = initialValue
}
}
})
if (Object.keys(initialData).length > 0) {
setFormData(initialData)
}
setIsInitialized(true)
}
}, [template.elements, isInitialized])
const handleFieldChange = (elementId: string, value: any) => {
setFormData(prev => ({
...prev,
[elementId]: value,
}))
// Прямо обновляем движок, чтобы формулы пересчитались
if (engineRef.current) {
const element = template.elements.find(el => el.id === elementId)
if (element) {
// Получаем определение элемента
const elementDefinition = getElementDefinition(element.type)
if (elementDefinition) {
// Используем единый интерфейс для получения пар ячейка-значение
const cellValues = elementDefinition.mapToCellValues(
element as any,
value
)
// Записываем все значения в соответствующие ячейки
cellValues.forEach(({ target, value: cellValue }) => {
const { row, col } = cellAddressToCoordinates(target.cell)
engineRef.current.setCellValueWithoutRecalc(
target.sheet,
row,
col,
cellValue
)
})
engineRef.current.debouncedRecalc()
}
}
}
}
const handleSave = async () => {
// Ищем элемент с названием протокола (должен иметь специальный тип или название)
const protocolNameElement = template.elements.find(
el => el.name === 'protocolName' || el.label === 'Название протокола'
)
const protocolName = protocolNameElement
? formData[protocolNameElement.id]
: 'Новый протокол'
const data = {
protocolName,
templateId: template.id,
formData,
createdAt: new Date(),
}
onSave(data)
try {
if (!engineRef.current) {
toast.error('Движок ещё не инициализирован')
return
}
// 1. Записываем ВСЕ значения формы в движок (поверх текущих)
template.elements.forEach(el => {
let value = formData[el.id]
if (value === undefined || value === null) value = ''
// Получаем определение элемента
const elementDefinition = getElementDefinition(el.type)
if (elementDefinition) {
// Используем единый интерфейс для получения пар ячейка-значение
const cellValues = elementDefinition.mapToCellValues(el as any, value)
// Записываем все значения в соответствующие ячейки
cellValues.forEach(({ target, value: cellValue }) => {
const { row, col } = cellAddressToCoordinates(target.cell)
if (target.sheet === 'Calculations') {
toast.warning('Обнаружен лист Calculations')
}
const sheetName =
target.sheet === 'R' || target.sheet === 'Calculations'
? 'R'
: 'L'
engineRef.current.setCellValueWithoutRecalc(
sheetName,
row,
col,
cellValue
)
})
}
})
// 2. Пересчитываем формулы
engineRef.current.recalculate()
// 3. Получаем изменения только левого листа относительно базового файла
const allModified = engineRef.current?.getAllModifiedCells() || {}
// Преобразуем в конечные (вычисленные) значения
const cellsToUpdate: Record<string, any> = {}
const modifiedLeft = allModified['L'] || {}
Object.keys(modifiedLeft).forEach(cellAddress => {
const { row, col } = cellAddressToCoordinates(cellAddress)
cellsToUpdate[cellAddress] = engineRef.current.getCellValue(
'L',
row,
col
)
})
// Получаем последний файл для шаблона
const latestFile = await getLatestFileForTemplate(template.id)
if (!latestFile) {
toast.error('Не найден исходный файл Excel для шаблона')
return
}
const body = {
cells_to_update: cellsToUpdate,
file_id: latestFile.id,
template_id: template.id,
}
const resp = await fetch('/api/protocols/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
// Получаем файл как blob и скачиваем его
const blob = await resp.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
// Получаем значение из ячейки R!A1 для имени файла
const filenameFromCell = engineRef.current.getCellValue('R', 0, 0) // R!A1
console.log(filenameFromCell)
let filename = 'Измените R!A1.xlsx'
if (
filenameFromCell &&
typeof filenameFromCell === 'string' &&
filenameFromCell.trim()
) {
// Очищаем имя файла от недопустимых символов
const cleanName = filenameFromCell.trim().replace(/[<>:"/\\|?*]/g, '_')
filename = `${cleanName}.xlsx`
}
// Если сервер передал имя файла в заголовке Content-Disposition, игнорируем его
// и используем строго значение из R!A1
const contentDisposition = resp.headers.get('content-disposition')
if (contentDisposition) {
// Оставляем возможность переопределения только если R!A1 пуста
if (!filenameFromCell || !filenameFromCell.toString().trim()) {
const matches = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
)
if (matches && matches[1]) {
filename = matches[1].replace(/['"]/g, '')
}
}
}
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.success('Протокол создан и загружен!')
} catch (err) {
console.error('Ошибка при создании протокола', err)
toast.error('Ошибка при создании протокола')
}
}
const canvasSize = useMemo(() => {
const PADDING_Y = 200
const baseWidth = 1200
const baseHeight = 800
if (template.elements.length === 0) {
return { width: baseWidth, height: baseHeight }
}
const contentHeight = Math.max(
0,
...template.elements.map(
el => (el.layout?.y || 0) + (el.layout?.height || 0)
)
)
return {
width: baseWidth,
height: Math.max(baseHeight, contentHeight + PADDING_Y),
}
}, [template.elements])
const canSave = true
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex-shrink-0 bg-card px-4 py-2">
<div className="relative flex items-center">
{/* Левая часть: кнопка назад и заголовок */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-2">
<FileText className="h-5 w-5" />
<h1 className="text-lg font-semibold">Создание протокола</h1>
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">
{template.name}
</span>
</div>
</div>
{/* Центрированные кнопки */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 gap-2">
<Button
variant={showSpreadsheet ? 'default' : 'outline'}
size="icon"
onClick={() => setShowSpreadsheet(!showSpreadsheet)}
aria-label={
showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'
}
>
<Grid className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={handleSave} disabled={!canSave}>
<Save className="h-4 w-4" />
Сохранить протокол
</Button>
</div>
{/* Кнопки справа */}
<div className="absolute right-0 flex gap-2">
<Button
variant="link"
size="sm"
onClick={() => navigate(`/templates/${template.id}/edit`)}
className="flex items-center gap-1 hover:bg-muted"
>
<Settings className="h-4 w-4" />
<span className="text-sm">Редактор шаблона</span>
</Button>
<Button
variant="link"
size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)}
className="flex items-center gap-1 hover:bg-muted"
>
<Wrench className="h-4 w-4" />
<span className="text-sm">Редактор интерфейса</span>
</Button>
</div>
</div>
</div>
{/* Основное содержимое */}
<div className="flex-1 overflow-hidden">
<div className={`${showSpreadsheet ? 'block' : 'hidden'} h-full`}>
<DualSpreadsheet
templateData={template.excelData ? { L: template.excelData } : {}}
mergedCells={template.mergedCells || []}
templateId={template.id}
onEngineReady={engine => {
engineRef.current = engine
}}
enableAutoSave={false}
/>
</div>
{/* Форма */}
<div
className={`${showSpreadsheet ? 'hidden' : 'block'} h-full overflow-auto`}
>
<div
className="relative mx-auto my-8"
style={{ width: canvasSize.width, height: canvasSize.height }}
>
{template.elements.map(element => {
const layout = element.layout || {
x: 50,
y: 50,
width: 300,
height: 80,
}
return (
<div
key={element.id}
className="absolute"
style={{
left: layout.x,
top: layout.y,
width: layout.width,
minHeight: layout.height,
zIndex: layout.zIndex || 1,
}}
>
<FormElement
element={element}
value={formData[element.id]}
onChange={value => handleFieldChange(element.id, value)}
/>
</div>
)
})}
{template.elements.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<FileText className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Нет элементов формы
</h3>
<p className="mb-4 text-gray-600">
В этом шаблоне пока не созданы элементы формы
</p>
<Button
variant="outline"
onClick={() => window.history.back()}
>
Настроить шаблон
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
export const ProtocolCreation: FC = () => {
const navigate = useNavigate()
const { templateId } = useParams<{ templateId?: string }>()
const { templates } = useTemplateContext()
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null
)
const toast = useToast()
// Автоматически выбираем шаблон если передан ID в URL
useEffect(() => {
if (templateId && templates.length > 0) {
const template = templates.find(t => t.id === templateId)
if (template) {
setSelectedTemplate(template)
}
}
}, [templateId, templates])
const handleProtocolSave = (data: Record<string, any>) => {
toast.success(`Протокол "${data.protocolName}" сохранен!`)
}
const handleBack = () => {
navigate('/templates')
}
if (!selectedTemplate) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900">
Шаблон не найден
</h3>
<p className="mb-4 text-gray-600">
Перейдите к списку шаблонов для выбора
</p>
<Button onClick={() => navigate('/templates')}>
К списку шаблонов
</Button>
</div>
</div>
)
}
return (
<ProtocolForm
template={selectedTemplate}
onSave={handleProtocolSave}
onBack={handleBack}
/>
)
}