WIP: vsp-choose-form2: выбор ВСП на фронте #72

Closed
tsygankoviva wants to merge 1 commits from vsp-choose-form2 into test
8 changed files with 4651 additions and 4471 deletions

View File

@ -1,214 +1,228 @@
import React, { memo, useMemo, useCallback } from 'react'; import React, { useMemo, useCallback } from 'react';
import { useRealtime } from '../../contexts/RealtimeContext'; import { useRealtime } from '../../contexts/RealtimeContext';
// Константы вне компонента // Константы вне компонента
const BASE_CELL_STYLES = { const BASE_CELL_STYLES = {
width: '100%', width: '100%',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
boxSizing: 'border-box', boxSizing: 'border-box',
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
border: '2px solid transparent', border: '2px solid transparent',
padding: '8px', padding: '8px',
}; };
const LOCKED_STYLES = { const LOCKED_STYLES = {
border: '2px solid #e0e0e0', border: '2px solid #e0e0e0',
backgroundColor: '#f5f5f5', backgroundColor: '#f5f5f5',
cursor: 'not-allowed', cursor: 'not-allowed',
opacity: 0.85, opacity: 0.85,
}; };
const INVALID_STYLES = { const INVALID_STYLES = {
border: '2px solid #D32F2F', border: '2px solid #D32F2F',
backgroundColor: '#FDEDED', backgroundColor: '#FDEDED',
color: '#C60C0C', color: '#C60C0C',
}; };
const LOCK_BADGE_STYLES = { const LOCK_BADGE_STYLES = {
position: 'absolute', position: 'absolute',
top: '2px', top: '2px',
right: '2px', right: '2px',
background: 'rgba(0, 0, 0, 0.7)', background: 'rgba(0, 0, 0, 0.7)',
color: 'white', color: 'white',
fontSize: '9px', fontSize: '9px',
padding: '1px 4px', padding: '1px 4px',
borderRadius: '3px', borderRadius: '3px',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 10, zIndex: 10,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
letterSpacing: '0.3px', letterSpacing: '0.3px',
backdropFilter: 'blur(4px)', backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.1)', border: '1px solid rgba(255, 255, 255, 0.1)',
}; };
const CONTAINER_STYLES = { const CONTAINER_STYLES = {
display: '-webkit-box', display: '-webkit-box',
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
WebkitLineClamp: 3, WebkitLineClamp: 3,
overflow: 'hidden', overflow: 'hidden',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}; };
const INTEGER_COLUMN_IDS = new Set([ const INTEGER_COLUMN_IDS = new Set([
'data.header.num_group', 'data.header.num_group',
'data.nomenclature_group_id', 'data.nomenclature_group_id',
'data.header.item_id', 'data.header.item_id',
'data.article_id', 'data.article_id',
'data.code_razdela', 'data.code_razdela',
]); ]);
const getColorBrightness = (hexColor) => { const getColorBrightness = (hexColor) => {
if (!hexColor) return 255; if (!hexColor) return 255;
const color = hexColor.replace('#', ''); const color = hexColor.replace('#', '');
let r, g, b; let r;
if (color.length === 3) { let g;
r = parseInt(color[0] + color[0], 16); let b;
g = parseInt(color[1] + color[1], 16); if (color.length === 3) {
b = parseInt(color[2] + color[2], 16); r = Number.parseInt(color[0] + color[0], 16);
} else if (color.length === 6) { g = Number.parseInt(color[1] + color[1], 16);
r = parseInt(color.substring(0, 2), 16); b = Number.parseInt(color[2] + color[2], 16);
g = parseInt(color.substring(2, 4), 16); } else if (color.length === 6) {
b = parseInt(color.substring(4, 6), 16); r = Number.parseInt(color.substring(0, 2), 16);
} else { g = Number.parseInt(color.substring(2, 4), 16);
return 255; b = Number.parseInt(color.substring(4, 6), 16);
} } else {
return (0.299 * r + 0.587 * g + 0.114 * b); return 255;
}
return 0.299 * r + 0.587 * g + 0.114 * b;
}; };
const formatNumber = (value, asInteger = false) => { const formatNumber = (value, asInteger = false) => {
if (value === null || value === undefined || value === '') return String(value || ''); if (value === null || value === undefined || value === '') return String(value || '');
const num = Number(value); const num = Number(value);
if (isNaN(num)) return String(value); if (Number.isNaN(num)) return String(value);
return num.toLocaleString('ru-RU', { return num.toLocaleString('ru-RU', {
minimumFractionDigits: asInteger ? 0 : 1, minimumFractionDigits: asInteger ? 0 : 1,
maximumFractionDigits: asInteger ? 0 : 1, maximumFractionDigits: asInteger ? 0 : 1,
useGrouping: true, useGrouping: true,
}); });
}; };
// Оптимизированная функция подсветки // Оптимизированная функция подсветки
const createHighlightedContent = (originalValue, displayValue, searchQueries) => { const createHighlightedContent = (originalValue, displayValue, searchQueries) => {
const cleanQueries = searchQueries.filter(Boolean); const cleanQueries = searchQueries.filter(Boolean);
if (cleanQueries.length === 0) return displayValue; if (cleanQueries.length === 0) return displayValue;
const searchStr = String(originalValue ?? displayValue); const searchStr = String(originalValue ?? displayValue);
const escapedQueries = cleanQueries.map(q => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const escapedQueries = cleanQueries.map((q) => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
if (!regex.test(searchStr)) return displayValue; if (!regex.test(searchStr)) return displayValue;
const parts = searchStr.split(regex); const parts = searchStr.split(regex);
return parts.map((part, index) => { return parts.map((part, index) => {
if (!regex.test(part)) return part; if (!regex.test(part)) return part;
return React.createElement('mark', { return React.createElement(
key: index, 'mark',
style: { {
backgroundColor: '#ffeb3b', key: index,
color: '#000', style: {
fontWeight: 'bold', backgroundColor: '#ffeb3b',
padding: '0 2px', color: '#000',
borderRadius: '2px', fontWeight: 'bold',
}, padding: '0 2px',
}, part); borderRadius: '2px',
}); },
},
part,
);
});
}; };
const CellComponent = ({ const CellComponent = ({
cell, cell,
row, row,
column, column,
onClick, onClick,
globalFilter, globalFilter,
columnFilter, columnFilter,
backgroundColor, backgroundColor,
color, color,
isEditable, isEditable,
isUpdating, isUpdating,
onCellNumberClick, onCellNumberClick,
isInvalid, isInvalid,
vspOptions,
}) => { }) => {
const { lockedCells } = useRealtime(); const { lockedCells } = useRealtime();
const cellKey = `${row.id}_${column.id}`; const cellKey = `${row.id}_${column.id}`;
const isLocked = lockedCells.includes(cellKey); const isLocked = lockedCells.includes(cellKey);
// Мемоизация значения const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown';
const { rawValue, displayValue, isNumeric } = useMemo(() => {
const value = cell.getValue();
const isNum = !isNaN(Number(value)) && value !== null && value !== undefined && value !== '';
let display = String(value || '');
if (isNum) { const { rawValue, displayValue, isNumeric } = useMemo(() => {
const asInteger = INTEGER_COLUMN_IDS.has(column.id); const value = cell.getValue();
display = formatNumber(value, asInteger); const isNum = !Number.isNaN(Number(value)) && value !== null && value !== undefined && value !== '';
} let display = String(value || '');
return { rawValue: value, displayValue: display, isNumeric: isNum }; if (isVspDropdown && value != null) {
}, [cell, column.id]); const vsp = vspOptions?.find((v) => v.id === Number(value));
if (vsp) {
return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false };
}
}
const highlightedContent = useMemo(() => { if (isNum) {
const searchQueries = [globalFilter, columnFilter]; const asInteger = INTEGER_COLUMN_IDS.has(column.id);
return createHighlightedContent(rawValue, displayValue, searchQueries); display = formatNumber(value, asInteger);
}, [rawValue, displayValue, globalFilter, columnFilter]); }
const textColor = useMemo(() => { return { rawValue: value, displayValue: display, isNumeric: isNum };
if (isLocked) return '#999999'; }, [cell, column.id, isVspDropdown, vspOptions]);
const brightness = getColorBrightness(backgroundColor);
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor, isLocked]);
const cellStyles = useMemo(() => ({ const highlightedContent = useMemo(() => {
...BASE_CELL_STYLES, const searchQueries = [globalFilter, columnFilter];
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor, return createHighlightedContent(rawValue, displayValue, searchQueries);
color: textColor, }, [rawValue, displayValue, globalFilter, columnFilter]);
...(isLocked ? LOCKED_STYLES : {}),
...(isInvalid ? INVALID_STYLES : {}),
}), [backgroundColor, isInvalid, isLocked, textColor]);
const lockBadge = useMemo(() => { const textColor = useMemo(() => {
if (!isLocked) return null; if (isLocked) return '#999999';
return <div style={LOCK_BADGE_STYLES}>🔒</div>; const brightness = getColorBrightness(backgroundColor);
}, [isLocked]); return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor, isLocked]);
const handleClick = useCallback(() => { const cellStyles = useMemo(
if (!isLocked && onClick) { () => ({
onClick(); ...BASE_CELL_STYLES,
} backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
}, [isLocked, onClick]); color: textColor,
...(isLocked ? LOCKED_STYLES : {}),
...(isInvalid ? INVALID_STYLES : {}),
}),
[backgroundColor, isInvalid, isLocked, textColor],
);
return ( const lockBadge = useMemo(() => {
<div if (!isLocked) return null;
className="cell" return <div style={LOCK_BADGE_STYLES}>🔒</div>;
style={cellStyles} }, [isLocked]);
onClick={handleClick}
> const handleClick = useCallback(() => {
<span style={CONTAINER_STYLES}>{highlightedContent}</span> if (!isLocked && onClick) {
{lockBadge} onClick();
</div> }
); }, [isLocked, onClick]);
return (
<div className='cell' style={cellStyles} onClick={handleClick}>
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
{lockBadge}
</div>
);
}; };
const Cell = React.memo(CellComponent, (prevProps, nextProps) => { const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
// Возвращаем true если пропсы равны (не нужно перерендеривать) return (
return ( prevProps.cell.getValue() === nextProps.cell.getValue() &&
prevProps.cell.getValue() === nextProps.cell.getValue() && prevProps.row.id === nextProps.row.id &&
prevProps.row.id === nextProps.row.id && prevProps.column.id === nextProps.column.id &&
prevProps.column.id === nextProps.column.id && prevProps.globalFilter === nextProps.globalFilter &&
prevProps.globalFilter === nextProps.globalFilter && prevProps.columnFilter === nextProps.columnFilter &&
prevProps.columnFilter === nextProps.columnFilter && prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.backgroundColor === nextProps.backgroundColor && prevProps.isInvalid === nextProps.isInvalid &&
prevProps.isInvalid === nextProps.isInvalid && prevProps.isEditable === nextProps.isEditable &&
prevProps.isEditable === nextProps.isEditable && prevProps.isUpdating === nextProps.isUpdating &&
prevProps.isUpdating === nextProps.isUpdating && prevProps.onCellNumberClick === nextProps.onCellNumberClick &&
prevProps.onCellNumberClick === nextProps.onCellNumberClick prevProps.vspOptions === nextProps.vspOptions
); );
}); });
export default Cell; export default Cell;

View File

@ -0,0 +1,121 @@
import { FormControl, MenuItem, Select } from '@mui/material';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useRealtime } from '../../contexts/RealtimeContext';
const EDITOR_STYLES = {
position: 'absolute',
zIndex: 12,
top: 0,
};
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table }) => {
const { endEditing: contextEndEditing, vspOptions } = useRealtime();
const [selectedValue, setSelectedValue] = useState(() => value ?? '');
const [position, setPosition] = useState();
const [open, setOpen] = useState(false);
const refFinished = useRef(false);
useEffect(() => {
const timer = setTimeout(() => {
setOpen(true);
}, 80);
return () => clearTimeout(timer);
}, []);
useLayoutEffect(() => {
if (!refCell?.current) return;
const td = refCell.current.offsetParent;
const tr = td?.offsetParent;
if (td && tr) {
setPosition({
left: td.offsetLeft,
width: td.offsetWidth,
translateY: tr.style.transform || '',
});
}
}, [refCell]);
const finishEditing = useCallback(() => {
if (refFinished.current) return;
refFinished.current = true;
table.setEditingCell(null);
const editingCell = table.getState().editingCell;
if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column);
}
}, [table, contextEndEditing]);
const handleSelectChange = useCallback(
(e) => {
const val = e.target.value;
if (val == null || val === '') return;
refFinished.current = true;
setSelectedValue(val);
onChange?.(val);
const editingCell = table.getState().editingCell;
if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column);
}
},
[onChange, table, contextEndEditing],
);
const handleSelectClose = useCallback(
(_event, reason) => {
if (reason === 'selectOption') return;
finishEditing();
},
[finishEditing],
);
const editorStyles = useMemo(
() => ({
...EDITOR_STYLES,
left: position?.left || 0,
width: position?.width || 0,
transform: position?.translateY || 0,
}),
[position],
);
return (
<tr>
<td>
{position && (
<div style={editorStyles}>
<FormControl fullWidth size='small'>
<Select
value={selectedValue}
onChange={handleSelectChange}
open={open}
onClose={handleSelectClose}
displayEmpty
disabled={disabled}
MenuProps={{
anchorOrigin: { vertical: 'bottom', horizontal: 'left' },
transformOrigin: { vertical: 'top', horizontal: 'left' },
}}>
<MenuItem value='' disabled>
Выберите ВСП
</MenuItem>
{vspOptions.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.registration_number}
</MenuItem>
))}
</Select>
</FormControl>
</div>
)}
</td>
</tr>
);
});
VspDropdownEditCell.displayName = 'VspDropdownEditCell';
export default VspDropdownEditCell;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,14 @@ export const config = {
color_type: orangeColumn, color_type: orangeColumn,
accessorKey: "data.header.name", accessorKey: "data.header.name",
}, },
"data.header.vsp_id": {
color_type: orangeColumn,
accessorKey: "data.header.vsp_id",
},
"data.header.vsp_address": {
color_type: orangeColumn,
accessorKey: "data.header.vsp_address",
},
"data.header.internal_order": { "data.header.internal_order": {
color_type: orangeColumn, color_type: orangeColumn,
accessorKey: "data.header.internal_order", accessorKey: "data.header.internal_order",
@ -830,6 +838,33 @@ export const config = {
}, },
], ],
}, },
{
header: "ВСП РФ",
accessorKey: "field_vsp_rf",
columns: [
{
header: "",
accessorKey: "data.header.vsp_id",
columnLetter: "G",
size: 150,
filterFn: "contains",
editType: "vsp_dropdown",
},
],
},
{
header: "Адрес ВСП",
accessorKey: "field_adres_vsp",
columns: [
{
header: "",
accessorKey: "data.header.vsp_address",
columnLetter: "G1",
size: 220,
filterFn: "contains",
},
],
},
{ {
header: "Внутренний заказ", header: "Внутренний заказ",
accessorKey: "field_vnutrenniy_zakaz", accessorKey: "field_vnutrenniy_zakaz",
@ -837,7 +872,7 @@ export const config = {
{ {
header: "", header: "",
accessorKey: "data.header.internal_order", accessorKey: "data.header.internal_order",
columnLetter: "G", columnLetter: "H",
size: 150, size: 150,
filterFn: "contains", filterFn: "contains",
}, },

View File

@ -25,6 +25,14 @@ export const config = {
color_type: blueColumn, color_type: blueColumn,
accessorKey: "data.header.name", accessorKey: "data.header.name",
}, },
"data.header.vsp_id": {
color_type: blueColumn,
accessorKey: "data.header.vsp_id",
},
"data.header.vsp_address": {
color_type: blueColumn,
accessorKey: "data.header.vsp_address",
},
"data.header.internal_order": { "data.header.internal_order": {
color_type: blueColumn, color_type: blueColumn,
accessorKey: "data.header.internal_order", accessorKey: "data.header.internal_order",
@ -830,6 +838,33 @@ export const config = {
}, },
], ],
}, },
{
header: "ВСП РФ",
accessorKey: "field_vsp_rf",
columns: [
{
header: "",
accessorKey: "data.header.vsp_id",
columnLetter: "G",
size: 150,
filterFn: "contains",
editType: "vsp_dropdown",
},
],
},
{
header: "Адрес ВСП",
accessorKey: "field_adres_vsp",
columns: [
{
header: "",
accessorKey: "data.header.vsp_address",
columnLetter: "G1",
size: 220,
filterFn: "contains",
},
],
},
{ {
header: "Внутренний заказ", header: "Внутренний заказ",
accessorKey: "field_vnutrenniy_zakaz", accessorKey: "field_vnutrenniy_zakaz",
@ -837,7 +872,7 @@ export const config = {
{ {
header: "", header: "",
accessorKey: "data.header.internal_order", accessorKey: "data.header.internal_order",
columnLetter: "G", columnLetter: "H",
size: 150, size: 150,
filterFn: "contains", filterFn: "contains",
}, },

View File

@ -1,454 +1,425 @@
import React, { import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
createContext, import { toast } from 'react-toastify';
useContext, import { useWebSocket } from '../hooks/useWebSocket';
useCallback, import { getRowId } from '../utils/rowUtils';
useRef,
useState,
useEffect,
} from "react";
import { useWebSocket } from "../hooks/useWebSocket";
import { toast } from "react-toastify";
import { getRowId } from "../utils/rowUtils";
const RealtimeContext = createContext(null); const RealtimeContext = createContext(null);
export const useRealtime = () => { export const useRealtime = () => {
const context = useContext(RealtimeContext); const context = useContext(RealtimeContext);
if (!context) { if (!context) {
throw new Error("useRealtime must be used within RealtimeProvider"); throw new Error('useRealtime must be used within RealtimeProvider');
} }
return context; return context;
}; };
export const RealtimeProvider = ({ export const RealtimeProvider = ({ children, formId, sheetName, direction, year, userId, isProject = false }) => {
children, // Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
formId, const wsUrl = `${(
sheetName, process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || 'ws://localhost:8000'
direction, ).replace(/^http/, 'ws')}${
year, !isProject
userId, ? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ''}`
isProject = false, : `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}`
}) => { }`;
// Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
const wsUrl =
`${(
process.env.REACT_APP_API_URL ||
process.env.REACT_APP_API_URL ||
process.env.REACT_APP_ROOT_PATH ||
"ws://localhost:8000"
).replace(/^http/, "ws")}` +
(!isProject
? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`
: `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}`);
//для ячеек которые редактируются другими пользователями //для ячеек которые редактируются другими пользователями
const [lockedCells, setLockedCells] = useState([]); const [lockedCells, setLockedCells] = useState([]);
const handleMessage = useCallback((data) => { const [vspOptions, setVspOptions] = useState([]);
// Обработка ошибок const handleMessage = useCallback((data) => {
if (data.error) { // Обработка ошибок
console.log(data.error.message); if (data.error) {
toast.error("Server error:" + data.error.message || ""); toast.error(`Server error:${data.error.message}` || '');
onErrorRef.current?.(data.error); onErrorRef.current?.(data.error);
return; return;
} }
switch (data.event) { switch (data.event) {
case "cell_edit_start": case 'cell_edit_start':
console.log("Cell edit started:", data); if (onCellEditStartRef.current) {
if (onCellEditStartRef.current) { onCellEditStartRef.current(data);
onCellEditStartRef.current(data); }
} break;
break;
case "cell_edit_end": case 'cell_edit_end':
console.log("Cell edit ended:", data); if (onCellEditEndRef.current) {
if (onCellEditEndRef.current) { onCellEditEndRef.current(data);
onCellEditEndRef.current(data); }
} break;
break;
case "cell_updated": case 'cell_updated':
if (data.result && onCellUpdateRef.current) { if (data.result && onCellUpdateRef.current) {
const updatedCells = Array.isArray(data.result) const updatedCells = Array.isArray(data.result) ? data.result : [data.result];
? data.result onCellUpdateRef.current(updatedCells);
: [data.result]; }
onCellUpdateRef.current(updatedCells); break;
}
break;
case "row_added": case 'row_added':
if (data.result && onRowAddRef.current) { if (data.result && onRowAddRef.current) {
onRowAddRef.current(data); onRowAddRef.current(data);
} }
break; break;
case "row_deleted": case 'row_deleted':
if (data.result && onRowDeleteRef.current) { if (data.result && onRowDeleteRef.current) {
onRowDeleteRef.current(data); onRowDeleteRef.current(data);
} }
break; break;
case "program_added": case 'program_added':
console.log("Program added:", data); if (data.result && onProgramAddRef.current) {
if (data.result && onProgramAddRef.current) { onProgramAddRef.current(data);
onProgramAddRef.current(data); }
} break;
break;
case "project_added": case 'project_added':
console.log("Project added:", data); if (data.result && onProjectAddRef.current) {
if (data.result && onProjectAddRef.current) { onProjectAddRef.current(data);
onProjectAddRef.current(data); }
} break;
break;
default: default:
console.log("Unknown event:", data.event); }
} }, []);
}, []);
const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } = const { isConnectedRef, isConnected, sendMessage, disconnect, lastError } = useWebSocket(wsUrl, handleMessage);
useWebSocket(wsUrl, handleMessage);
const onCellUpdateRef = useRef(null); const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null); const onRowAddRef = useRef(null);
const onRowDeleteRef = useRef(null); const onRowDeleteRef = useRef(null);
const onProgramAddRef = useRef(null); const onProgramAddRef = useRef(null);
const onProjectAddRef = useRef(null); const onProjectAddRef = useRef(null);
const onErrorRef = useRef(null); const onErrorRef = useRef(null);
const onAuthSuccessRef = useRef(null); const onAuthSuccessRef = useRef(null);
const onCellEditStartRef = useRef(null); const onCellEditStartRef = useRef(null);
const onCellEditEndRef = useRef(null); const onCellEditEndRef = useRef(null);
const authAttemptedRef = useRef(false); const authAttemptedRef = useRef(false);
const reconnectTimerRef = useRef(null); const _reconnectTimerRef = useRef(null);
// Функция для получения токена из localStorage // Функция для получения токена из localStorage
const getAccessToken = useCallback(() => { const getAccessToken = useCallback(() => {
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem('access_token');
if (!token) { if (!token) {
console.warn("No access token found in localStorage"); return null;
return null; }
}
return token; return token;
} catch (error) { } catch (_error) {
console.error("Error reading token from localStorage:", error); return null;
return null; }
} }, []);
}, []);
const sendLogin = useCallback(() => { const sendLogin = useCallback(() => {
const token = getAccessToken(); const token = getAccessToken();
if (!token) { if (!token) {
console.error("Cannot login: no token available"); onErrorRef.current?.('Токен авторизации не найден');
onErrorRef.current?.("Токен авторизации не найден"); return false;
return false; }
}
const loginMessage = { const loginMessage = {
event: "user_login", event: 'user_login',
data: { token }, data: { token },
}; };
return sendMessage(loginMessage); return sendMessage(loginMessage);
}, [getAccessToken, sendMessage]); }, [getAccessToken, sendMessage]);
useEffect(() => { useEffect(() => {
if (isConnected && !authAttemptedRef.current) { if (isConnected && !authAttemptedRef.current) {
authAttemptedRef.current = true; authAttemptedRef.current = true;
console.log("WebSocket connected, sending login..."); setTimeout(() => {
setTimeout(() => { sendLogin();
sendLogin(); }, 100);
}, 100); }
} }, [isConnected, sendLogin]);
}, [isConnected, sendLogin]);
useEffect(() => { useEffect(() => {
if (!isConnected) { if (!isConnected) {
authAttemptedRef.current = false; authAttemptedRef.current = false;
} }
}, [isConnected]); }, [isConnected]);
const addCommonField = (message) => { const addCommonField = (message) => {
if (isProject) { if (isProject) {
message.report_type = sheetName; message.report_type = sheetName;
message.project_id = formId; message.project_id = formId;
message.year = year; message.year = year;
} else { } else {
message.sheet = sheetName; message.sheet = sheetName;
message.form_id = formId; message.form_id = formId;
message.direction = direction; message.direction = direction;
} }
return message; return message;
}; };
// Функция для начала редактирования ячейки // Функция для начала редактирования ячейки
const startEditing = useCallback( const startEditing = useCallback(
async (row, column) => { async (row, column) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const _rowId = row.id;
if (!isConnectedRef) { if (!isConnectedRef) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.('WebSocket не подключен');
return false; return false;
} }
const lineId = row.id || null; const lineId = row.id || null;
const colId = columnId.slice(5); // убираем префикс "data." const colId = columnId.slice(5); // убираем префикс "data."
const message = { const message = {
event: "cell_edit_start", event: 'cell_edit_start',
data: { data: {
line_id: lineId, line_id: lineId,
column: colId, column: colId,
}, },
}; };
const sent = sendMessage(message); const sent = sendMessage(message);
if (!sent) { if (!sent) {
return false; return false;
} }
return true; return true;
}, },
[isConnected, sendMessage], [isConnected, sendMessage],
); );
// Функция для завершения редактирования ячейки // Функция для завершения редактирования ячейки
const endEditing = useCallback( const endEditing = useCallback(
async (row, column) => { async (row, column) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const _rowId = row.id;
if (!isConnectedRef) { if (!isConnectedRef) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.('WebSocket не подключен');
return false; return false;
} }
const lineId = row.id || null; const lineId = row.id || null;
const colId = columnId.slice(5); const colId = columnId.slice(5);
const message = { const message = {
event: "cell_edit_end", event: 'cell_edit_end',
data: { data: {
line_id: lineId, line_id: lineId,
column: colId, column: colId,
}, },
}; };
const sent = sendMessage(message); const sent = sendMessage(message);
if (!sent) { if (!sent) {
return false; return false;
} }
return true; return true;
}, },
[isConnected, sendMessage], [isConnected, sendMessage],
); );
const updateCell = useCallback( const updateCell = useCallback(
async (row, column, value) => { async (row, column, value) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const _rowId = row.id;
if (!isConnectedRef) { if (!isConnectedRef) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.('WebSocket не подключен');
return false; return false;
} }
const lineId = getRowId(row); const lineId = getRowId(row);
const colId = columnId.slice(5); const colId = columnId.slice(5);
const message = { const message = {
event: "cell_updated", event: 'cell_updated',
data: { data: {
line_id: lineId, line_id: lineId,
line_id_code: row.id, line_id_code: row.id,
column: colId, column: colId,
value: value, value: value,
}, },
}; };
const sent = sendMessage(message); const sent = sendMessage(message);
if (!sent) { if (!sent) {
return false; return false;
} }
return true; return true;
}, },
[isConnected, sendMessage], [isConnected, sendMessage],
); );
const addRow = useCallback( const addRow = useCallback(
async (rowData) => { async (rowData) => {
if (!isConnectedRef) { if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "row_added", event: 'row_added',
data: rowData, data: rowData,
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [isConnected, sendMessage, sheetName, formId, direction],
); );
const addProject = useCallback( const addProject = useCallback(
async (rowData) => { async (rowData) => {
if (!isConnectedRef) { if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "project_added", event: 'project_added',
data: rowData, data: rowData,
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [isConnected, sendMessage, sheetName, formId, direction],
); );
const addProgram = useCallback( const addProgram = useCallback(
async (rowData) => { async (rowData) => {
if (!isConnectedRef) { if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "program_added", event: 'program_added',
data: rowData, data: rowData,
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [isConnected, sendMessage, sheetName, formId, direction],
); );
const deleteRow = useCallback( const deleteRow = useCallback(
async (rowId) => { async (rowId) => {
if (!isConnectedRef) { if (!isConnectedRef) {
onErrorRef.current?.("Нет подключения или авторизации"); onErrorRef.current?.('Нет подключения или авторизации');
return false; return false;
} }
const message = { const message = {
event: "row_deleted", event: 'row_deleted',
data: { row_id: rowId }, data: { row_id: rowId },
}; };
addCommonField(message); addCommonField(message);
return sendMessage(message); return sendMessage(message);
}, },
[isConnected, sendMessage, sheetName, formId, direction], [isConnected, sendMessage, sheetName, formId, direction],
); );
const subscribeToCellUpdates = useCallback((callback) => { const subscribeToCellUpdates = useCallback((callback) => {
onCellUpdateRef.current = callback; onCellUpdateRef.current = callback;
return () => { return () => {
onCellUpdateRef.current = null; onCellUpdateRef.current = null;
}; };
}, []); }, []);
const subscribeToRowAdds = useCallback((callback) => { const subscribeToRowAdds = useCallback((callback) => {
onRowAddRef.current = callback; onRowAddRef.current = callback;
return () => { return () => {
onRowAddRef.current = null; onRowAddRef.current = null;
}; };
}, []); }, []);
const subscribeToRowDeletes = useCallback((callback) => { const subscribeToRowDeletes = useCallback((callback) => {
onRowDeleteRef.current = callback; onRowDeleteRef.current = callback;
return () => { return () => {
onRowDeleteRef.current = null; onRowDeleteRef.current = null;
}; };
}, []); }, []);
const subscribeToProgramAdds = useCallback((callback) => { const subscribeToProgramAdds = useCallback((callback) => {
onProgramAddRef.current = callback; onProgramAddRef.current = callback;
return () => { return () => {
onProgramAddRef.current = null; onProgramAddRef.current = null;
}; };
}, []); }, []);
const subscribeToProjectAdds = useCallback((callback) => { const subscribeToProjectAdds = useCallback((callback) => {
onProjectAddRef.current = callback; onProjectAddRef.current = callback;
return () => { return () => {
onProjectAddRef.current = null; onProjectAddRef.current = null;
}; };
}, []); }, []);
const subscribeToErrors = useCallback((callback) => { const subscribeToErrors = useCallback((callback) => {
onErrorRef.current = callback; onErrorRef.current = callback;
return () => { return () => {
onErrorRef.current = null; onErrorRef.current = null;
}; };
}, []); }, []);
const subscribeToAuthSuccess = useCallback((callback) => { const subscribeToAuthSuccess = useCallback((callback) => {
onAuthSuccessRef.current = callback; onAuthSuccessRef.current = callback;
return () => { return () => {
onAuthSuccessRef.current = null; onAuthSuccessRef.current = null;
}; };
}, []); }, []);
const subscribeToCellEditStart = useCallback((callback) => { const subscribeToCellEditStart = useCallback((callback) => {
onCellEditStartRef.current = callback; onCellEditStartRef.current = callback;
return () => { return () => {
onCellEditStartRef.current = null; onCellEditStartRef.current = null;
}; };
}, []); }, []);
const subscribeToCellEditEnd = useCallback((callback) => { const subscribeToCellEditEnd = useCallback((callback) => {
onCellEditEndRef.current = callback; onCellEditEndRef.current = callback;
return () => { return () => {
onCellEditEndRef.current = null; onCellEditEndRef.current = null;
}; };
}, []); }, []);
const retryLogin = useCallback(() => { const retryLogin = useCallback(() => {
if (isConnected) { if (isConnected) {
authAttemptedRef.current = false; authAttemptedRef.current = false;
sendLogin(); sendLogin();
} }
}, [isConnected, sendLogin]); }, [isConnected, sendLogin]);
return ( return (
<RealtimeContext.Provider <RealtimeContext.Provider
value={{ value={{
isConnected, isConnected,
error: lastError, error: lastError,
startEditing, startEditing,
endEditing, endEditing,
updateCell, updateCell,
addRow, addRow,
addProgram, addProgram,
addProject, addProject,
deleteRow, deleteRow,
subscribeToRowAdds, subscribeToRowAdds,
subscribeToRowDeletes, subscribeToRowDeletes,
subscribeToProgramAdds, subscribeToProgramAdds,
subscribeToProjectAdds, subscribeToProjectAdds,
subscribeToErrors, subscribeToErrors,
subscribeToAuthSuccess, subscribeToAuthSuccess,
subscribeToCellEditStart, subscribeToCellEditStart,
subscribeToCellEditEnd, subscribeToCellEditEnd,
subscribeToCellUpdates, subscribeToCellUpdates,
retryLogin, retryLogin,
lockedCells, lockedCells,
setLockedCells, setLockedCells,
releaseAllLocks: () => releaseAllLocks(userId), vspOptions,
}} setVspOptions,
> releaseAllLocks: () => releaseAllLocks(userId),
{children} }}>
</RealtimeContext.Provider> {children}
); </RealtimeContext.Provider>
);
}; };

View File

@ -1,210 +1,205 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
import { useRealtime } from './contexts/RealtimeContext'; import { useRealtime } from './contexts/RealtimeContext';
const EditCellPortal = React.memo(({ const EditCellPortal = React.memo(({ cell, table, EditCell, onSaveStart, onSaveEnd, onError, isEditable }) => {
cell, const { formId, formType, sheetName, direction } = useParams();
table, const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
EditCell, const ref = useRef(null);
onSaveStart, const [refTbody, setRefTbody] = useState(null);
onSaveEnd, const [isSaving, _setIsSaving] = useState(false);
onError,
isEditable
}) => {
const { formId, formType, sheetName, direction } = useParams();
const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
const ref = useRef(null);
const [refTbody, setRefTbody] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const { endEditing: contextEndEditing } = useRealtime(); const { endEditing: contextEndEditing } = useRealtime();
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent; const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent;
if (tbodyRef && tbodyRef !== refTbody) { if (tbodyRef && tbodyRef !== refTbody) {
setRefTbody(tbodyRef); setRefTbody(tbodyRef);
} }
} }
}, []); }, []);
const handleChange = useCallback( (val) => { const handleChange = useCallback(
table.setEditingCell(null); (val) => {
try { table.setEditingCell(null);
if (table.options.meta?.updateCell) { try {
const success = table.options.meta.updateCell( if (table.options.meta?.updateCell) {
cell.row, const success = table.options.meta.updateCell(cell.row, cell.column, val);
cell.column, if (!success) {
val onError?.('Не удалось сохранить изменение');
); }
if (!success) { }
console.error('Failed to update cell via WebSocket'); } catch (error) {
onError?.('Не удалось сохранить изменение'); onError?.(error.message);
} }
} },
} catch (error) { [table, cell, onError],
console.error('Error updating cell:', error); );
onError?.(error.message);
}
}, [table, cell, onError]);
const editCellProps = useMemo(() => ({ const editCellProps = useMemo(
refCell: ref, () => ({
cell, refCell: ref,
value: cell.getValue(), cell,
disabled: !isEditable, value: cell.getValue(),
onChange: handleChange, disabled: !isEditable,
tableId: tableKey, onChange: handleChange,
table, tableId: tableKey,
cellId: `${cell.row.id}_${cell.column.id}`, table,
}), [cell, isEditable, handleChange, tableKey, table]); cellId: `${cell.row.id}_${cell.column.id}`,
}),
[cell, isEditable, handleChange, tableKey, table],
);
const portalContent = useMemo(() => { const editType = cell.column.columnDef?.editType;
if (!refTbody || isSaving) return null;
return createPortal(
<EditCell {...editCellProps} />,
refTbody
);
}, [refTbody, isSaving, editCellProps, EditCell]);
return ( const portalContent = useMemo(() => {
<> if (!refTbody || isSaving) return null;
<div ref={ref} style={{ width: '100%', height: '100%' }} />
{portalContent} if (editType === 'vsp_dropdown') {
</> return createPortal(<VspDropdownEditCell {...editCellProps} />, refTbody);
); }
return createPortal(<EditCell {...editCellProps} />, refTbody);
}, [refTbody, isSaving, editCellProps, EditCell, editType]);
return (
<>
<div ref={ref} style={{ width: '100%', height: '100%' }} />
{portalContent}
</>
);
}); });
export const getTableColumns = ({ Cell, EditCell, columnsConfig, onCellUpdateError, onCellNumberClick, isCellInvalid }) => {
const columnColors = { ...columnsConfig.colors };
const columns = structuredClone(columnsConfig.columns);
export const getTableColumns = ({ const cellPropsCache = new WeakMap();
Cell, const editPropsCache = new WeakMap();
EditCell,
columnsConfig,
onCellUpdateError,
onCellNumberClick,
isCellInvalid,
}) => {
const columnColors = { ...columnsConfig.colors };
const columns = structuredClone(columnsConfig.columns);
const cellPropsCache = new WeakMap(); const getCachedCellProps = (row, column, table) => {
const editPropsCache = new WeakMap(); const key = `${row.id}_${column.id}`;
const value = row.getValue(column.id);
const isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
const getCachedCellProps = (row, column, table) => { if (!cellPropsCache.has(row)) {
const key = `${row.id}_${column.id}`; cellPropsCache.set(row, {});
const value = row.getValue(column.id); }
const isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
if (!cellPropsCache.has(row)) { const rowCache = cellPropsCache.get(row);
cellPropsCache.set(row, {}); if (rowCache[key]) {
} const cached = rowCache[key];
const currentGlobalFilter = table.getState().globalFilter;
const currentColumnFilter = table.getState().columnFilters?.find((f) => f.id === column.id)?.value;
const rowCache = cellPropsCache.get(row); if (
if (rowCache[key]) { cached.globalFilter === currentGlobalFilter &&
const cached = rowCache[key]; cached.columnFilter === currentColumnFilter &&
const currentGlobalFilter = table.getState().globalFilter; cached.value === value &&
const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value; cached.isInvalid === isInvalid
) {
return cached;
}
}
if (cached.globalFilter === currentGlobalFilter && const globalFilter = table.getState().globalFilter;
cached.columnFilter === currentColumnFilter && const columnFilter = table.getState().columnFilters?.find((f) => f.id === column.id)?.value;
cached.value === value &&
cached.isInvalid === isInvalid) {
return cached;
}
}
const globalFilter = table.getState().globalFilter; const props = {
const columnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value; globalFilter,
columnFilter,
isEditable: row.original?.row_type === 'INPUT' || false,
backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type],
isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`],
isInvalid,
value,
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}_${value}_${isInvalid}`,
};
const props = { rowCache[key] = props;
globalFilter, return props;
columnFilter, };
isEditable: row.original?.row_type === 'INPUT' || false,
backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type],
isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`],
isInvalid,
value,
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}_${value}_${isInvalid}`,
};
rowCache[key] = props; const getCachedEditProps = (row, column, _table) => {
return props; const key = `${row.id}_${column.id}`;
};
const getCachedEditProps = (row, column, table) => { if (!editPropsCache.has(row)) {
const key = `${row.id}_${column.id}`; editPropsCache.set(row, {});
}
if (!editPropsCache.has(row)) { const rowCache = editPropsCache.get(row);
editPropsCache.set(row, {}); if (rowCache[key]) {
} return rowCache[key];
}
const rowCache = editPropsCache.get(row); const props = {
if (rowCache[key]) { isEditable: row.original?.row_type === 'INPUT' || false,
return rowCache[key]; };
}
const props = { rowCache[key] = props;
isEditable: row.original?.row_type === 'INPUT' || false, return props;
}; };
rowCache[key] = props; // Оптимизированная функция processColumns
return props; const processColumns = (columns) => {
}; columns.forEach((col) => {
col.Cell = ({ cell, table, column, row }) => {
const { vspOptions } = useRealtime();
const props = getCachedCellProps(row, column, table);
// Оптимизированная функция processColumns const cellProps = useMemo(
const processColumns = (columns) => { () => ({
columns.forEach((col) => { row,
col.Cell = ({ cell, table, column, row }) => { column,
const props = getCachedCellProps(row, column, table); cell,
vspOptions,
...props,
}),
[row.id, column.id, cell.getValue(), props._hash, vspOptions],
);
const cellProps = useMemo(() => ({ return <Cell {...cellProps} />;
row, };
column,
cell,
...props,
}), [row.id, column.id, cell.getValue(), props._hash]);
return <Cell {...cellProps} />; col.Edit = ({ cell, table, row, column }) => {
}; const props = getCachedEditProps(row, column, table);
col.Edit = ({ cell, table, row, column }) => { const editComponent = useMemo(
const props = getCachedEditProps(row, column, table); () => <EditCellPortal cell={cell} table={table} EditCell={EditCell} onError={onCellUpdateError} isEditable={props.isEditable} />,
[cell, table, EditCell, onCellUpdateError, props.isEditable],
);
const editComponent = useMemo(() => ( return editComponent;
<EditCellPortal };
cell={cell}
table={table}
EditCell={EditCell}
onError={onCellUpdateError}
isEditable={props.isEditable}
/>
), [cell, table, EditCell, onCellUpdateError, props.isEditable]);
return editComponent; if (col.editType === 'vsp_dropdown') {
}; col.enableEditing = (row) => row?.original?.row_type === 'INPUT';
}
if (col.columns?.length) { if (col.columns?.length) {
processColumns(col.columns); processColumns(col.columns);
} }
}); });
}; };
processColumns(columns); processColumns(columns);
const rowNumber = { const rowNumber = {
accessorKey: 'sort_order', accessorKey: 'sort_order',
header: '#', header: '#',
size: 50, size: 50,
enableEditing: false, enableEditing: false,
Cell: ({ cell, row }) => { Cell: ({ cell, row }) => {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onCellNumberClick(row.id); onCellNumberClick(row.id);
}, [row.id, onCellNumberClick]); }, [row.id, onCellNumberClick]);
return <button onClick={handleClick}>{cell.getValue() + ' '}</button>; return <button onClick={handleClick}>{`${cell.getValue()} `}</button>;
}, },
}; };
return [rowNumber, ...columns]; return [rowNumber, ...columns];
}; };