Merge pull request 'модалка для выбора всп' (#11) from AURORA-894 into test

Reviewed-on: #11
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
This commit is contained in:
PotapovaA 2026-06-29 14:29:35 +03:00
commit 3c1722293c
10 changed files with 294 additions and 47 deletions

View File

@ -21,4 +21,7 @@ export const DictVspApi = {
responseType: "blob", responseType: "blob",
}); });
}, },
getDropdownVsp(params){
return api.get(`/dict/info/dropdown`, params).then((r) => r.data);
}
}; };

View File

@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import {
Box,
Button,
Divider,
FormControl,
FormLabel,
IconButton,
Stack,
Select,
MenuItem,
} from '@mui/material';
import {
ModalBackdrop,
ModalContainer,
HeaderContainer,
ModalTitle,
} from '../../common/Modal/ModalStyled';
import { Cancel } from '../../common/icons/icons';
import { DictVspApi } from '../../../api/dict-vsp';
import { toast } from 'react-toastify';
export const SelectVspModal = ({
isOpen,
onClose,
onSelect,
formId,
title = 'Выбор ВСП',
isSaving = false,
}) => {
const [selectedValue, setSelectedValue] = useState('');
const [vspOptions, setVspOption] = useState([])
useEffect(() => {
if (formId === null) return;
DictVspApi.getDropdownVsp({ form_id: formId }).then(data => {
if (data.success) {
setVspOption(data.result)
return;
}
toast.error('Ошибка получения ВСП')
})
}, [formId])
useEffect(() => {
if (!isOpen) {
setSelectedValue('');
}
}, [isOpen])
const handleCancel = () => {
setSelectedValue('');
onClose();
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
handleCancel();
}
};
const handleSave = () => {
onSelect?.(selectedValue);
onClose();
};
const isFormValid = selectedValue;
if (!isOpen) return null;
return (
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
<ModalContainer height={'max-content'} >
<HeaderContainer>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<ModalTitle>{title}</ModalTitle>
</Stack>
<IconButton size="small" onClick={handleCancel}>
<Cancel />
</IconButton>
</HeaderContainer>
<Box>
<Divider sx={{ marginBottom: '0.75rem' }} />
{/* Выбор ВСП */}
<Stack sx={{ marginBottom: '0.75rem', width: '100%' }}>
<FormControl fullWidth>
<Select
value={selectedValue}
onChange={(e) => setSelectedValue(e.target.value)}
displayEmpty
size="small"
>
<MenuItem value="" disabled>
Выберите ВСП
</MenuItem>
{vspOptions.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.registration_number}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Stack
direction="row"
spacing={0.75}
sx={{
justifyContent: 'flex-end',
marginTop: '1.5rem',
width: '100%',
}}
>
<Button
variant="outlined"
color="default"
onClick={handleCancel}
style={{ height: '2.5rem' }}
disabled={isSaving}
>
Отмена
</Button>
<Button
variant="contained"
onClick={handleSave}
style={{ height: '2.5rem' }}
disabled={!isFormValid || isSaving}
loading={isSaving}
>
Выбрать
</Button>
</Stack>
</Box>
</ModalContainer>
</ModalBackdrop>
);
};

View File

@ -25,6 +25,9 @@ import {
import { useRealtime } from './contexts/RealtimeContext'; import { useRealtime } from './contexts/RealtimeContext';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { additionVspRowTable } from './constants/addingRowConfig';
import { SelectVspModal } from './Modals/selectVspModal';
import { getRowId } from './utils/rowUtils';
const RealtimeTable = ({ formType, formId, sheetName, direction }) => { const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction); const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction);
@ -36,6 +39,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null); const [editingCell, setEditingCell] = useState(null);
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
const isVspAdditionRow = useMemo(() => {
if (!formType || !sheetName) return false;
if (!additionVspRowTable[formType]) return false;
return additionVspRowTable[formType].includes(sheetName);
}, [formType, sheetName]);
const handleGlobalFilterChange = (value) => { const handleGlobalFilterChange = (value) => {
startTransition(() => { startTransition(() => {
setGlobalFilter(value); setGlobalFilter(value);
@ -306,6 +317,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
contextAddRow({ expense_item_id: expense_item_id }) contextAddRow({ expense_item_id: expense_item_id })
}, [rowSelection, table]) }, [rowSelection, table])
const handleAddVspRow = useCallback((vsp_id) => {
contextAddRow({ vsp_id: vsp_id });
}, [rowSelection, table]);
const handleOpenModalSelectVsp = useCallback(() => {
setIsOpenModalSelectVsp(true);
}, [rowSelection, table])
const handleDeleteRow = useCallback(() => { const handleDeleteRow = useCallback(() => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
@ -315,8 +334,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
return; return;
} }
const row = selectedRows[0]; const row = selectedRows[0];
const expense_item_id = row.original.data.line_id; const rowId = getRowId(row);
contextDeleteRow(expense_item_id) contextDeleteRow(rowId);
setRowSelection({}); setRowSelection({});
}, [rowSelection, table]) }, [rowSelection, table])
@ -335,7 +354,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
onGlobalFilterChange={handleGlobalFilterChange} onGlobalFilterChange={handleGlobalFilterChange}
sizeMult={sizeMult} sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters} onChangeShowColumnFilters={setShowColumnFilters}
onAddRow={handleAddRow} onAddRow={isVspAdditionRow ? handleOpenModalSelectVsp : handleAddRow}
onDeleteRow={handleDeleteRow} onDeleteRow={handleDeleteRow}
isLoadingData={isLoadingData} isLoadingData={isLoadingData}
formId={formId} formId={formId}
@ -396,6 +415,15 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => {
</div> </div>
</div> </div>
</div> </div>
<SelectVspModal
isOpen={isOpenModalSelectVsp}
onClose={() => setIsOpenModalSelectVsp(false)}
formId={formId}
onSelect={handleAddVspRow}
// vspOptions={}
// selectedVSP={}
title="Выбор ВСП"
/>
</> </>
); );
}; };

View File

@ -0,0 +1,3 @@
export const additionVspRowTable = {
FORM_2: ["AHR_SECURITY", "AHR_RENT", "AHR_UTILITY"],
};

View File

@ -8,6 +8,7 @@ import React, {
} from "react"; } from "react";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { getRowId } from "../utils/rowUtils";
const RealtimeContext = createContext(null); const RealtimeContext = createContext(null);
@ -33,7 +34,8 @@ export const RealtimeProvider = ({
const handleMessage = useCallback((data) => { const handleMessage = useCallback((data) => {
// Обработка ошибок // Обработка ошибок
if (data.error) { if (data.error) {
toast.error("Server error:", data.error); console.log(data.error.message)
toast.error("Server error:" + data.error.message || '');
onErrorRef.current?.(data.error); onErrorRef.current?.(data.error);
return; return;
} }
@ -48,7 +50,7 @@ export const RealtimeProvider = ({
case "cell_edit_end": case "cell_edit_end":
console.log("Cell edit ended:", data); console.log("Cell edit ended:", data);
if ( onCellEditEndRef.current) { if (onCellEditEndRef.current) {
onCellEditEndRef.current(data); onCellEditEndRef.current(data);
} }
break; break;
@ -148,7 +150,7 @@ export const RealtimeProvider = ({
async (row, column) => { async (row, column) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const rowId = row.id;
if (!isConnected) { if (!isConnected) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.("WebSocket не подключен");
return false; return false;
@ -156,7 +158,7 @@ export const RealtimeProvider = ({
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: {
@ -180,7 +182,7 @@ export const RealtimeProvider = ({
async (row, column) => { async (row, column) => {
const columnId = column.id; const columnId = column.id;
const rowId = row.id; const rowId = row.id;
if (!isConnected) { if (!isConnected) {
onErrorRef.current?.("WebSocket не подключен"); onErrorRef.current?.("WebSocket не подключен");
return false; return false;
@ -188,7 +190,7 @@ export const RealtimeProvider = ({
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: {
@ -216,7 +218,7 @@ export const RealtimeProvider = ({
return false; return false;
} }
const lineId = row.original?.data?.line_id || null; const lineId = getRowId(row);
const colId = columnId.slice(5); const colId = columnId.slice(5);
const message = { const message = {
event: "cell_updated", event: "cell_updated",
@ -358,4 +360,4 @@ export const RealtimeProvider = ({
{children} {children}
</RealtimeContext.Provider> </RealtimeContext.Provider>
); );
}; };

View File

@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { FormsSheetApi } from "../../../api/form_sheet"; import { FormsSheetApi } from "../../../api/form_sheet";
import { useRealtime } from "../contexts/RealtimeContext"; import { useRealtime } from "../contexts/RealtimeContext";
import { deleteRow, insertCell, updateCells } from "../utils/cellUtils"; import { deleteRow, insertCell, updateCells } from "../utils/cellUtils";
import { toast } from "react-toastify";
import { isVspNewRow, isVspRow } from "../utils/rowUtils";
const useRealtimeData = (formId, sheetName, direction) => { const useRealtimeData = (formId, sheetName, direction) => {
const [data, setData] = useState([]); const [data, setData] = useState([]);
@ -176,11 +178,11 @@ const useRealtimeData = (formId, sheetName, direction) => {
const unsubscribeRowAdds = subscribeToRowAdds((data) => { const unsubscribeRowAdds = subscribeToRowAdds((data) => {
const updates = data.result.data; const updates = data.result.data;
const expense_item_id = data.data.expense_item_id; const expense_item_id = data.data.expense_item_id || null;
const newLineId = data.result.new_line_id; const newLineId = data.result.new_line_id;
const [updatedCells, newRow] = updates.reduce( const [updatedCells, newRow] = updates.reduce(
(res, row) => { (res, row) => {
if (row[3]?.line_id === newLineId) { if (row[3]?.line_id === newLineId || isVspNewRow(row)) {
res[1] = row; res[1] = row;
} else { } else {
res[0].push(row); res[0].push(row);
@ -189,6 +191,10 @@ const useRealtimeData = (formId, sheetName, direction) => {
}, },
[[], null], [[], null],
); );
if (newRow === null) {
toast.error("Ошибка получения данных");
return;
}
setData((prevData) => { setData((prevData) => {
const updateData = updateCells(prevData, updatedCells); const updateData = updateCells(prevData, updatedCells);
const newData = insertCell(updateData, newRow, expense_item_id); const newData = insertCell(updateData, newRow, expense_item_id);

View File

@ -1,22 +1,27 @@
import { useEffect, useRef, useCallback, useState } from 'react'; import { useEffect, useRef, useCallback, useState } from "react";
export const useWebSocket = (url, onMessage) => { export const useWebSocket = (url, onMessage) => {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const isConnectedRef = useRef(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const wsRef = useRef(null); const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null); const reconnectTimeoutRef = useRef(null);
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
useEffect(() => {
isConnectedRef.current = isConnected;
}, [isConnected]);
const connect = useCallback(() => { const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return; if (wsRef.current?.readyState === WebSocket.OPEN) return;
try { try {
console.log('Connecting to WebSocket:', url); console.log("Connecting to WebSocket:", url);
const ws = new WebSocket(url); const ws = new WebSocket(url);
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
console.log('WebSocket connected successfully'); console.log("WebSocket connected successfully");
setIsConnected(true); setIsConnected(true);
setError(null); setError(null);
reconnectAttempts.current = 0; reconnectAttempts.current = 0;
@ -25,43 +30,54 @@ export const useWebSocket = (url, onMessage) => {
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log('WebSocket message received:', data); console.log("WebSocket message received:", data);
onMessage(data); onMessage(data);
} catch (err) { } catch (err) {
console.error('Failed to parse WebSocket message:', err); console.error("Failed to parse WebSocket message:", err);
} }
}; };
ws.onerror = (event) => { ws.onerror = (event) => {
console.error('WebSocket error:', event); console.error("WebSocket error:", event);
console.error('WebSocket readyState:', ws.readyState); console.error("WebSocket readyState:", ws.readyState);
setError('Connection error occurred'); setError("Connection error occurred");
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason, 'Clean:', event.wasClean); console.log(
"WebSocket closed:",
event.code,
event.reason,
"Clean:",
event.wasClean,
);
setIsConnected(false); setIsConnected(false);
// Не переподключаемся если закрыто intentionally или с ошибкой авторизации // Не переподключаемся если закрыто intentionally или с ошибкой авторизации
if (event.code === 1008 || event.code === 1000) { if (event.code === 1008 || event.code === 1000) {
console.log('Connection closed intentionally or auth error, not reconnecting'); console.log(
"Connection closed intentionally or auth error, not reconnecting",
);
return; return;
} }
// Reconnect with exponential backoff // Reconnect with exponential backoff
if (reconnectAttempts.current < 5) { if (reconnectAttempts.current < 5) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000); const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts.current),
30000,
);
console.log(`Reconnecting in ${delay}ms...`); console.log(`Reconnecting in ${delay}ms...`);
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttempts.current++; reconnectAttempts.current++;
connect(); connect();
}, delay); }, delay);
} else { } else {
setError('Max reconnection attempts reached'); setError("Max reconnection attempts reached");
} }
}; };
} catch (err) { } catch (err) {
console.error('Failed to create WebSocket:', err); console.error("Failed to create WebSocket:", err);
setError(err.message); setError(err.message);
} }
}, [url, onMessage]); }, [url, onMessage]);
@ -71,7 +87,7 @@ export const useWebSocket = (url, onMessage) => {
clearTimeout(reconnectTimeoutRef.current); clearTimeout(reconnectTimeoutRef.current);
} }
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(1000, 'Intentional disconnect'); wsRef.current.close(1000, "Intentional disconnect");
wsRef.current = null; wsRef.current = null;
} }
setIsConnected(false); setIsConnected(false);
@ -80,24 +96,30 @@ export const useWebSocket = (url, onMessage) => {
const sendMessage = useCallback((message) => { const sendMessage = useCallback((message) => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message)); wsRef.current.send(JSON.stringify(message));
console.log('Message sent:', message); console.log("Message sent:", message);
return true; return true;
} }
console.warn('WebSocket is not connected, readyState:', wsRef.current?.readyState); console.warn(
setError('Cannot send message: WebSocket not connected'); "WebSocket is not connected, readyState:",
wsRef.current?.readyState,
);
setError("Cannot send message: WebSocket not connected");
return false; return false;
}, []); }, []);
const login = useCallback((token) => { const login = useCallback(
if (!token) { (token) => {
console.warn('No token provided for login'); if (!token) {
return false; console.warn("No token provided for login");
} return false;
return sendMessage({ }
event: 'user_login', return sendMessage({
data: { token } event: "user_login",
}); data: { token },
}, [sendMessage]); });
},
[sendMessage],
);
useEffect(() => { useEffect(() => {
connect(); connect();
@ -105,10 +127,10 @@ export const useWebSocket = (url, onMessage) => {
}, [connect, disconnect]); }, [connect, disconnect]);
return { return {
isConnected, isConnected: isConnectedRef,
error, error,
sendMessage, sendMessage,
login, login,
disconnect disconnect,
}; };
}; };

View File

@ -90,7 +90,6 @@ export function updateCells(data, newRows) {
return updatedRows; return updatedRows;
} }
export function insertCell(data, newRow, expense_item_id) { export function insertCell(data, newRow, expense_item_id) {
const updatedRows = JSON.parse(JSON.stringify(data)); const updatedRows = JSON.parse(JSON.stringify(data));
const [row_type, depth, sort_order, dataRow] = newRow; const [row_type, depth, sort_order, dataRow] = newRow;
@ -102,6 +101,12 @@ export function insertCell(data, newRow, expense_item_id) {
data: dataRow, data: dataRow,
}; };
// Если expense_item_id === null, добавляем в конец
if (expense_item_id === null) {
updatedRows.push(newRowObject);
return updatedRows;
}
function findAndUpdate(rows) { function findAndUpdate(rows) {
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];

View File

@ -0,0 +1,15 @@
export function getRowId(row) {
return row.original.data.id || row.original.data.line_id;
}
export function isVspRow(row) {
if (row?.original?.data?.id) {
return true;
}
return false;
}
export function isVspNewRow(rowData) {
if (!rowData) return false;
const newRow = rowData[3];
return !!newRow.id;
}

View File

@ -6,7 +6,7 @@ const ModalContainer = styled.div`
/* top: 3rem; /* top: 3rem;
left: 0; */ left: 0; */
width: 50rem; width: 50rem;
height: 40rem; height: ${({ height }) => height || '40rem'};
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
@ -288,6 +288,28 @@ const EditContainer = styled.div`
} }
`; `;
const ModalBackdrop = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.05);
display: ${(props) => (props.isOpen ? 'flex' : 'none')};
align-items: center;
justify-content: center;
z-index: 3;
`;
const ModalTitle = styled.h2`
color: rgba(31, 31, 31, 1);
font-size: 1.125rem;
font-weight: 600;
margin: 0;
`;
export { export {
ModalContainer, ModalContainer,
HeaderContainer, HeaderContainer,
@ -304,4 +326,6 @@ export {
StageName, StageName,
StageListContainer, StageListContainer,
EditContainer, EditContainer,
ModalBackdrop,
ModalTitle
}; };