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

View File

@ -25,6 +25,14 @@ export const config = {
color_type: blueColumn,
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": {
color_type: blueColumn,
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: "Внутренний заказ",
accessorKey: "field_vnutrenniy_zakaz",
@ -837,7 +872,7 @@ export const config = {
{
header: "",
accessorKey: "data.header.internal_order",
columnLetter: "G",
columnLetter: "H",
size: 150,
filterFn: "contains",
},

View File

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

View File

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