From 6933fab3bb914bfaad3758fccbf261b4afe8c5bf Mon Sep 17 00:00:00 2001 From: tlartem Date: Wed, 16 Jul 2025 17:25:33 +0300 Subject: [PATCH] =?UTF-8?q?=D1=85=D0=BE=D0=BB=D1=81=D1=82=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/app/Layout/Layout.tsx | 2 +- .../DualSpreadsheet/DualSpreadsheet.tsx | 548 ++++++----- .../TemplateManager/ElementConstructor.tsx | 882 +++++++----------- .../TemplateManager/TemplateEditor.tsx | 2 +- .../TemplateManager/VisualLayoutEditor.tsx | 256 +++++ src/components/ui/badge.tsx | 38 + src/contexts/TemplateContext.tsx | 41 +- src/lib/utils.ts | 118 ++- src/pages/ElementsCreation/ui/Page/Page.tsx | 26 +- src/pages/ProtocolCreation/ui/Page/Page.tsx | 270 ++---- src/types/template.ts | 19 +- yarn.lock | 36 +- 13 files changed, 1243 insertions(+), 996 deletions(-) create mode 100644 src/components/TemplateManager/VisualLayoutEditor.tsx create mode 100644 src/components/ui/badge.tsx diff --git a/package.json b/package.json index 8a2025e..e443741 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-grid-layout": "^1.5.2", + "react-rnd": "^10.5.2", "react-window": "^1.8.11", "redux": "^4.2.1", "tailwind-merge": "^3.3.1", diff --git a/src/app/Layout/Layout.tsx b/src/app/Layout/Layout.tsx index 5699df8..9774247 100644 --- a/src/app/Layout/Layout.tsx +++ b/src/app/Layout/Layout.tsx @@ -55,7 +55,7 @@ const Layout: FC = () => { {/* Основной контент */} -
+
diff --git a/src/components/DualSpreadsheet/DualSpreadsheet.tsx b/src/components/DualSpreadsheet/DualSpreadsheet.tsx index 43869a3..d660035 100644 --- a/src/components/DualSpreadsheet/DualSpreadsheet.tsx +++ b/src/components/DualSpreadsheet/DualSpreadsheet.tsx @@ -1,13 +1,13 @@ import { produce } from 'immer' import { - CSSProperties, - FC, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, + CSSProperties, + FC, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, } from 'react' import { VariableSizeGrid } from 'react-window' import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine' @@ -182,14 +182,8 @@ const FormulaBar = memo( onKeyDown, formulaBarRef, }: FormulaBarProps) => { - if (activeSheet !== sheetType) { - return ( -
-
-
- ) - } - + // Теперь FormulaBar всегда отображается, так как он один + return (
@@ -324,7 +318,214 @@ const CellRenderer = ({ ) } -// --- Main Component --- +interface SpreadsheetProps { + sheetType: SheetType + columnWidths: number[] + spreadsheetWidths: { L: number; R: number } + getCachedCellData: (sheetType: SheetType) => Record + selectedCell: { row: number; col: number } | null + activeSheet: SheetType + isEditing: boolean + editingValue: string + handleCellClick: (row: number, col: number) => void + handleCellDoubleClick: (row: number, col: number) => void + handleCellChange: (newValue: string) => void + stopEditing: (save: boolean) => void + setActiveSheet: (type: SheetType) => void + activeCellInputRef: React.RefObject + containerRefs: React.MutableRefObject> + handleKeyDown: (e: React.KeyboardEvent) => void + headerRefs: React.MutableRefObject> + rowHeaderRefs: React.MutableRefObject> + gridRefs: React.MutableRefObject> + onGridScroll: ( + sheetType: SheetType, + params: { scrollLeft: number; scrollTop: number }, + ) => void + handleColumnResize: ( + sheetType: SheetType, + colIndex: number, + newWidth: number, + ) => void +} + +// --- Spreadsheet Component --- +const Spreadsheet = ({ + sheetType, + columnWidths, + spreadsheetWidths, + getCachedCellData, + selectedCell, + activeSheet, + isEditing, + editingValue, + handleCellClick, + handleCellDoubleClick, + handleCellChange, + stopEditing, + setActiveSheet, + activeCellInputRef, + containerRefs, + handleKeyDown, + headerRefs, + rowHeaderRefs, + gridRefs, + onGridScroll, + handleColumnResize, +}: SpreadsheetProps) => { + const widths = columnWidths + const cachedData = getCachedCellData(sheetType) + + const itemData = { + sheetType, + widths, + selectedCell, + activeSheet, + isEditing, + editingValue, + cachedData, + handleCellClick, + handleCellDoubleClick, + handleCellChange, + stopEditing, + setActiveSheet, + activeCellInputRef, + } + + const parentRef = useRef(null) + const [gridSize, setGridSize] = useState({ width: 0, height: 0 }) + + useEffect(() => { + const parent = parentRef.current + if (!parent) return + + const resizeObserver = new ResizeObserver(() => { + const { width, height } = parent.getBoundingClientRect() + const headerHeight = + parent.querySelector('.column-headers')?.clientHeight || 0 + setGridSize({ width, height: height - headerHeight }) + }) + + resizeObserver.observe(parent) + return () => resizeObserver.disconnect() + }, []) + + return ( +
setActiveSheet(sheetType)} + > +
(containerRefs.current[sheetType] = el)} + className="flex h-full flex-col overflow-hidden rounded-lg border bg-card shadow-sm" + onKeyDown={handleKeyDown} + tabIndex={0} + > + {/* Header */} +
+
+
+

+ {sheetType === 'report' ? 'Отчет' : 'Расчеты'} +

+
+
+ + {/* Spreadsheet Body */} +
+ {/* Row Headers */} +
+
+
(rowHeaderRefs.current[sheetType] = el)} + className="overflow-y-hidden" + style={{ height: gridSize.height }} + > +
+ {Array.from({ length: ROW_COUNT }).map((_, rowIndex) => ( +
+ {rowIndex + 1} +
+ ))} +
+
+
+ +
+ {/* Column Headers */} +
(headerRefs.current[sheetType] = el)} + className="overflow-x-hidden column-headers" + > +
+ {Array.from({ length: COL_COUNT }).map((_, i) => { + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + const startX = e.clientX + const startWidth = widths[i] + + const handleMouseMove = (me: MouseEvent) => { + const newWidth = startWidth + (me.clientX - startX) + handleColumnResize(sheetType, i, newWidth) + } + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + return ( +
+ {getColumnLabel(i)} +
+
+ ) + })} +
+
+ + {/* Grid */} +
+ (gridRefs.current[sheetType] = el)} + columnCount={COL_COUNT} + rowCount={ROW_COUNT} + columnWidth={(index: number) => widths[index]} + rowHeight={() => ROW_HEIGHT} + height={gridSize.height} + width={gridSize.width} + itemData={itemData} + onScroll={(e) => onGridScroll(sheetType, e)} + > + {CellRenderer} + +
+
+
+
+
+ ) +} + const DualSpreadsheet: FC = ({ templateData = {} }) => { const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine( { @@ -360,10 +561,7 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { calculations: null, }) const activeCellInputRef = useRef(null) - const formulaBarRefs = useRef>({ - report: null, - calculations: null, - }) + const formulaBarRef = useRef(null); const headerRefs = useRef>({ report: null, calculations: null, @@ -374,13 +572,11 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { }) const initialTemplateValues = useRef>({}) - // Кэш для данных ячеек const cellDataCache = useRef>>( {}, ) const lastRevision = useRef(-1) - // Загрузка данных шаблона один раз при инициализации useEffect(() => { const reportTemplate = templateData?.[SHEET_NAMES.report] if (reportTemplate) { @@ -390,9 +586,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { } }, [templateData]) - // --- Синхронизация UI --- - - // Синхронизация прокрутки грида при изменении выделенной ячейки useEffect(() => { if (selectedCell && activeSheet) { const grid = gridRefs.current[activeSheet] @@ -404,12 +597,10 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { } }, [selectedCell, activeSheet]) - // Функция для получения кэшированных данных ячеек const getCachedCellData = useCallback( (sheetType: SheetType): Record => { const cacheKey = `${sheetType}-${revision}` - // Если данные уже кэшированы для этой ревизии, возвращаем их if (cellDataCache.current[cacheKey]) { return cellDataCache.current[cacheKey] } @@ -417,7 +608,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { const sheetName = SHEET_NAMES[sheetType] const cachedData: Record = {} - // Предварительно получаем все значения для оптимизации const allCellValues: Record = {} for (let row = 0; row < ROW_COUNT; row++) { for (let col = 0; col < COL_COUNT; col++) { @@ -429,7 +619,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { } } - // Вычисляем hasRightContent для всех ячеек сразу const rightContentMap: Record = {} for (let row = 0; row < ROW_COUNT; row++) { let hasContentOnRight = false @@ -442,7 +631,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { } } - // Формируем кэшированные данные for (let row = 0; row < ROW_COUNT; row++) { for (let col = 0; col < COL_COUNT; col++) { const cellKey = `${row}-${col}` @@ -465,7 +653,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { } } - // Очищаем старые кэши для экономии памяти if (lastRevision.current !== revision) { Object.keys(cellDataCache.current).forEach((key) => { if (!key.endsWith(`-${revision}`)) { @@ -481,7 +668,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { [revision, multiSheetEngine, initialTemplateValues], ) - // Синхронизация строки формул const formulaBarValue = useMemo(() => { if (isEditing) { return editingValue @@ -504,7 +690,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { revision, ]) - // Фокус на инпуте при начале редактирования useEffect(() => { if (isEditing && activeCellInputRef.current) { const input = activeCellInputRef.current @@ -514,8 +699,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { } }, [isEditing]) - // --- Функции-обработчики --- - const startEditing = useCallback(() => { if (!selectedCell) return const sheetName = SHEET_NAMES[activeSheet] @@ -538,13 +721,11 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { selectedCell.col, editingValue, ) - // Немедленно обновляем интерфейс для быстрого отклика updateRevision() - // Запускаем отложенный пересчет для формул multiSheetEngine.debouncedRecalc() } setIsEditing(false) - setEditingValue('') // Очищаем временное значение + setEditingValue('') containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, [activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision], @@ -559,30 +740,22 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { [isEditing], ) - // Оптимизированная функция очистки ячейки const handleClearCell = useCallback(() => { if (!selectedCell) return const sheetName = SHEET_NAMES[activeSheet] - - // Используем версию без немедленного пересчета для быстрой реакции multiSheetEngine.setCellValueWithoutRecalc( sheetName, selectedCell.row, selectedCell.col, '', ) - - // Немедленно обновляем интерфейс для быстрого отклика updateRevision() - - // Запускаем отложенный пересчет multiSheetEngine.debouncedRecalc() }, [activeSheet, selectedCell, multiSheetEngine, updateRevision]) const moveSelection = useCallback( (deltaRow: number, deltaCol: number) => { if (!selectedCell) { - // Если ничего не выбрано, выбираем A1 setSelectedCell({ row: 0, col: 0 }) return } @@ -608,7 +781,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isEditing) { - // Логика внутри Cell компонента return } @@ -635,7 +807,7 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { keyHandlers[e.key]() } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { e.preventDefault() - setEditingValue(e.key) // Начинаем редактирование с введенного символа + setEditingValue(e.key) setIsEditing(true) } }, @@ -666,7 +838,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { setColumnWidths( produce((draft) => { draft[sheetType][colIndex] = Math.max(60, newWidth) - // Принудительно пересчитываем layout грида const grid = gridRefs.current[sheetType] if (grid) { grid.resetAfterColumnIndex(colIndex) @@ -677,7 +848,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { [], ) - // --- Formula Bar Specific Handlers --- const handleFormulaBarChange = useCallback( (newValue: string) => { if (selectedCell) { @@ -708,7 +878,6 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { e.preventDefault() stopEditing(true) moveSelection(1, 0) - // Предотвращаем прокрутку страницы setTimeout(() => { containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, 0) @@ -736,172 +905,61 @@ const DualSpreadsheet: FC = ({ templateData = {} }) => { [], ) - // --- Render Functions --- - const renderSpreadsheet = useCallback( - (sheetType: SheetType) => { - const widths = columnWidths[sheetType] - const cachedData = getCachedCellData(sheetType) + const [separatorRef, setSeparatorRef] = useState(null) + const [spreadsheetWidths, setSpreadsheetWidths] = useState({ + L: 0.5, + R: 0.5, + }) - const itemData = { - sheetType, - widths, - selectedCell, - activeSheet, - isEditing, - editingValue, - cachedData, - handleCellClick, - handleCellDoubleClick, - handleCellChange, - stopEditing, - setActiveSheet, - activeCellInputRef, - } - - return ( -
(containerRefs.current[sheetType] = el)} - className="flex h-full flex-col overflow-hidden rounded-lg border bg-card shadow-sm" - onKeyDown={handleKeyDown} - tabIndex={0} - > - {/* Header */} -
-
-
-

- {sheetType === 'report' ? 'Отчет' : 'Расчеты'} -

-
-
- - {/* Spreadsheet Body */} -
- {/* Row Headers */} -
-
-
(rowHeaderRefs.current[sheetType] = el)} - className="overflow-y-hidden" - style={{ height: 600 }} // This should be dynamic based on container size - > -
- {Array.from({ length: ROW_COUNT }).map((_, rowIndex) => ( -
- {rowIndex + 1} -
- ))} -
-
-
- -
- {/* Column Headers */} -
(headerRefs.current[sheetType] = el)} - className="overflow-x-hidden" - > -
- {Array.from({ length: COL_COUNT }).map((_, i) => { - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault() - const startX = e.clientX - const startWidth = widths[i] - - const handleMouseMove = (me: MouseEvent) => { - const newWidth = startWidth + (me.clientX - startX) - handleColumnResize(sheetType, i, newWidth) - } - - const handleMouseUp = () => { - document.removeEventListener( - 'mousemove', - handleMouseMove, - ) - document.removeEventListener('mouseup', handleMouseUp) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - } - return ( -
- {getColumnLabel(i)} -
-
- ) - })} -
-
- - {/* Grid */} - (gridRefs.current[sheetType] = el)} - columnCount={COL_COUNT} - rowCount={ROW_COUNT} - columnWidth={(index: number) => widths[index]} - rowHeight={() => ROW_HEIGHT} - height={600} // This should be dynamic based on container size - width={800} // This should be dynamic based on container size - itemData={itemData} - onScroll={(e) => onGridScroll(sheetType, e)} - > - {CellRenderer} - -
-
-
- ) - }, - [ - columnWidths, - getCachedCellData, - handleKeyDown, - handleColumnResize, - selectedCell, - activeSheet, - isEditing, - editingValue, - handleCellClick, - handleCellDoubleClick, - handleCellChange, - stopEditing, - setActiveSheet, - onGridScroll, - ], - ) - - // Мемоизированные компоненты листов - const memoizedReportSheet = useMemo( - () => renderSpreadsheet('report'), - [renderSpreadsheet, revision], // Добавляем revision для перерендера - ) - - const memoizedCalculationsSheet = useMemo( - () => renderSpreadsheet('calculations'), - [renderSpreadsheet, revision], // Добавляем revision для перерендера - ) + // --- Spreadsheet Component --- + + useEffect(() => { + if (!separatorRef) return; + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + document.body.style.cursor = 'col-resize'; + + const handleMouseMove = (me: MouseEvent) => { + const container = separatorRef.parentElement; + if (!container) return; + const bounds = container.getBoundingClientRect(); + const newLeftWidth = me.clientX - bounds.left; + const totalWidth = bounds.width; + + let newLeftPercentage = newLeftWidth / totalWidth; + // Ограничиваем минимальную ширину (например 20%) + newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage)); + + setSpreadsheetWidths({ + L: newLeftPercentage, + R: 1 - newLeftPercentage, + }); + }; + + const handleMouseUp = () => { + document.body.style.cursor = 'auto'; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + separatorRef.addEventListener('mousedown', handleMouseDown); + + return () => { + separatorRef.removeEventListener('mousedown', handleMouseDown); + }; + }, [separatorRef]); + return ( -
-
- + = ({ templateData = {} }) => { onFocus={handleFormulaBarFocus} onBlur={handleFormulaBarBlur} onKeyDown={handleFormulaBarKeyDown} - formulaBarRef={(el) => (formulaBarRefs.current[activeSheet] = el)} + formulaBarRef={formulaBarRef} + /> +
+ +
+
+
+ -
- {/* Двойная панель */} -
-
{memoizedReportSheet}
-
{memoizedCalculationsSheet}
) } -export default DualSpreadsheet +export default DualSpreadsheet \ No newline at end of file diff --git a/src/components/TemplateManager/ElementConstructor.tsx b/src/components/TemplateManager/ElementConstructor.tsx index a50a8ca..93fe99b 100644 --- a/src/components/TemplateManager/ElementConstructor.tsx +++ b/src/components/TemplateManager/ElementConstructor.tsx @@ -1,28 +1,20 @@ -import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; -import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { Calendar, CheckSquare, ChevronDown, Edit, FileText, GripVertical, Hash, LayoutGrid, Plus, Trash2, Type } from 'lucide-react'; +import { Calendar, CheckSquare, ChevronDown, FileText, Hash, LayoutGrid, Plus, Settings, Trash2, Type } from 'lucide-react'; import React, { useState } from 'react'; +import { autoArrangeElements, findFreePosition, generateDefaultLayout } from '../../lib/utils'; import { CellTarget, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template'; import { Button } from '../ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Checkbox } from '../ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; import { Input } from '../ui/input'; -import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { Textarea } from '../ui/textarea'; +import { VisualLayoutEditor } from './VisualLayoutEditor'; interface ElementFormProps { formData: Partial; setFormData: React.Dispatch>>; } -interface SortableElementProps { - element: TemplateElement; - onEdit: (element: TemplateElement) => void; - onDelete: (elementId: string) => void; -} - const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.ReactNode }> = [ { type: 'text', label: 'Текстовое поле', icon: }, { type: 'textarea', label: 'Многострочный текст', icon: }, @@ -33,90 +25,6 @@ const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.React { type: 'date', label: 'Дата', icon: }, ]; -const SortableElement: React.FC = ({ element, onEdit, onDelete }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: element.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( - - -
-
- -
-
- {ELEMENT_TYPES.find(t => t.type === element.type)?.icon} -
-

{element.label}

-

{element.name}

-
-
-
-
-
- {element.targetCells?.length || 0} ячеек -
- - -
-
- -
-
- Тип: {ELEMENT_TYPES.find(t => t.type === element.type)?.label} -
-
- Ячейки: -
- {element.targetCells?.map((target, index) => ( - - {target.sheet}!{target.cell} - {target.displayName && ` (${target.displayName})`} - - )) || ( - Ячейки не настроены - )} -
-
- {element.category && ( -
- Категория: {element.category} -
- )} -
-
-
- ); -}; - const CellTargetEditor: React.FC<{ targets: CellTarget[]; onChange: (targets: CellTarget[]) => void; @@ -206,6 +114,124 @@ const CellTargetEditor: React.FC<{ ); }; +// Live Preview компонент +const ElementPreview: React.FC<{ element: Partial }> = ({ element }) => { + const renderPreviewInput = () => { + switch (element.type) { + case 'text': + return ( + + ); + + case 'textarea': + return ( +