diff --git a/web/src/api/dict-vsp.js b/web/src/api/dict-vsp.js index 9e7e9de..6bf40c6 100644 --- a/web/src/api/dict-vsp.js +++ b/web/src/api/dict-vsp.js @@ -21,4 +21,7 @@ export const DictVspApi = { responseType: "blob", }); }, + getDropdownVsp(params){ + return api.get(`/dict/info/dropdown`, params).then((r) => r.data); + } }; diff --git a/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx b/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx new file mode 100644 index 0000000..29a8ee4 --- /dev/null +++ b/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx @@ -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 ( + + + + + {title} + + + + + + + + + + {/* Выбор ВСП */} + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 6b37f2a..1328e53 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -24,6 +24,9 @@ import { import { useRealtime } from './contexts/RealtimeContext'; import { toast } from 'react-toastify'; 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 { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction); @@ -35,6 +38,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { const [isPending, startTransition] = useTransition(); 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) => { startTransition(() => { setGlobalFilter(value); @@ -284,6 +295,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { contextAddRow({ expense_item_id: expense_item_id }) }, [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 allRows = table.getRowModel().rows; @@ -293,8 +312,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { return; } const row = selectedRows[0]; - const expense_item_id = row.original.data.line_id; - contextDeleteRow(expense_item_id) + const rowId = getRowId(row); + contextDeleteRow(rowId); setRowSelection({}); }, [rowSelection, table]) @@ -312,7 +331,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { onGlobalFilterChange={handleGlobalFilterChange} sizeMult={sizeMult} onChangeShowColumnFilters={setShowColumnFilters} - onAddRow={handleAddRow} + onAddRow={isVspAdditionRow ? handleOpenModalSelectVsp : handleAddRow} onDeleteRow={handleDeleteRow} isLoadingData={isLoadingData} formId={formId} @@ -364,6 +383,15 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { + setIsOpenModalSelectVsp(false)} + formId={formId} + onSelect={handleAddVspRow} + // vspOptions={} + // selectedVSP={} + title="Выбор ВСП" + /> ); }; diff --git a/web/src/components/RealtimeTable/constants/addingRowConfig.js b/web/src/components/RealtimeTable/constants/addingRowConfig.js new file mode 100644 index 0000000..04e069b --- /dev/null +++ b/web/src/components/RealtimeTable/constants/addingRowConfig.js @@ -0,0 +1,3 @@ +export const additionVspRowTable = { + FORM_2: ["AHR_SECURITY", "AHR_RENT", "AHR_UTILITY"], +}; diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index d1196e6..ff40381 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -8,6 +8,7 @@ import React, { } from "react"; import { useWebSocket } from "../hooks/useWebSocket"; import { toast } from "react-toastify"; +import { getRowId } from "../utils/rowUtils"; const RealtimeContext = createContext(null); @@ -33,7 +34,8 @@ export const RealtimeProvider = ({ const handleMessage = useCallback((data) => { // Обработка ошибок 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); return; } @@ -48,7 +50,7 @@ export const RealtimeProvider = ({ case "cell_edit_end": console.log("Cell edit ended:", data); - if ( onCellEditEndRef.current) { + if (onCellEditEndRef.current) { onCellEditEndRef.current(data); } break; @@ -148,7 +150,7 @@ export const RealtimeProvider = ({ async (row, column) => { const columnId = column.id; const rowId = row.id; - + if (!isConnected) { onErrorRef.current?.("WebSocket не подключен"); return false; @@ -156,7 +158,7 @@ export const RealtimeProvider = ({ const lineId = row.id || null; const colId = columnId.slice(5); // убираем префикс "data." - + const message = { event: "cell_edit_start", data: { @@ -180,7 +182,7 @@ export const RealtimeProvider = ({ async (row, column) => { const columnId = column.id; const rowId = row.id; - + if (!isConnected) { onErrorRef.current?.("WebSocket не подключен"); return false; @@ -188,7 +190,7 @@ export const RealtimeProvider = ({ const lineId = row.id || null; const colId = columnId.slice(5); - + const message = { event: "cell_edit_end", data: { @@ -216,7 +218,7 @@ export const RealtimeProvider = ({ return false; } - const lineId = row.original?.data?.line_id || null; + const lineId = getRowId(row); const colId = columnId.slice(5); const message = { event: "cell_updated", @@ -358,4 +360,4 @@ export const RealtimeProvider = ({ {children} ); -}; \ No newline at end of file +}; diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js index 439d632..964f1ab 100644 --- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js +++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { FormsSheetApi } from "../../../api/form_sheet"; import { useRealtime } from "../contexts/RealtimeContext"; import { deleteRow, insertCell, updateCells } from "../utils/cellUtils"; +import { toast } from "react-toastify"; +import { isVspNewRow, isVspRow } from "../utils/rowUtils"; const useRealtimeData = (formId, sheetName, direction) => { const [data, setData] = useState([]); @@ -176,11 +178,11 @@ const useRealtimeData = (formId, sheetName, direction) => { const unsubscribeRowAdds = subscribeToRowAdds((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 [updatedCells, newRow] = updates.reduce( (res, row) => { - if (row[3]?.line_id === newLineId) { + if (row[3]?.line_id === newLineId || isVspNewRow(row)) { res[1] = row; } else { res[0].push(row); @@ -189,6 +191,10 @@ const useRealtimeData = (formId, sheetName, direction) => { }, [[], null], ); + if (newRow === null) { + toast.error("Ошибка получения данных"); + return; + } setData((prevData) => { const updateData = updateCells(prevData, updatedCells); const newData = insertCell(updateData, newRow, expense_item_id); diff --git a/web/src/components/RealtimeTable/hooks/useWebSocket.js b/web/src/components/RealtimeTable/hooks/useWebSocket.js index a395e53..57e3958 100644 --- a/web/src/components/RealtimeTable/hooks/useWebSocket.js +++ b/web/src/components/RealtimeTable/hooks/useWebSocket.js @@ -1,22 +1,27 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; +import { useEffect, useRef, useCallback, useState } from "react"; export const useWebSocket = (url, onMessage) => { const [isConnected, setIsConnected] = useState(false); + const isConnectedRef = useRef(false); const [error, setError] = useState(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); + useEffect(() => { + isConnectedRef.current = isConnected; + }, [isConnected]); + const connect = useCallback(() => { if (wsRef.current?.readyState === WebSocket.OPEN) return; try { - console.log('Connecting to WebSocket:', url); + console.log("Connecting to WebSocket:", url); const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => { - console.log('WebSocket connected successfully'); + console.log("WebSocket connected successfully"); setIsConnected(true); setError(null); reconnectAttempts.current = 0; @@ -25,43 +30,54 @@ export const useWebSocket = (url, onMessage) => { ws.onmessage = (event) => { try { const data = JSON.parse(event.data); - console.log('WebSocket message received:', data); + console.log("WebSocket message received:", data); onMessage(data); } catch (err) { - console.error('Failed to parse WebSocket message:', err); + console.error("Failed to parse WebSocket message:", err); } }; ws.onerror = (event) => { - console.error('WebSocket error:', event); - console.error('WebSocket readyState:', ws.readyState); - setError('Connection error occurred'); + console.error("WebSocket error:", event); + console.error("WebSocket readyState:", ws.readyState); + setError("Connection error occurred"); }; 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); - + // Не переподключаемся если закрыто intentionally или с ошибкой авторизации 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; } - + // Reconnect with exponential backoff 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...`); reconnectTimeoutRef.current = setTimeout(() => { reconnectAttempts.current++; connect(); }, delay); } else { - setError('Max reconnection attempts reached'); + setError("Max reconnection attempts reached"); } }; } catch (err) { - console.error('Failed to create WebSocket:', err); + console.error("Failed to create WebSocket:", err); setError(err.message); } }, [url, onMessage]); @@ -71,7 +87,7 @@ export const useWebSocket = (url, onMessage) => { clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { - wsRef.current.close(1000, 'Intentional disconnect'); + wsRef.current.close(1000, "Intentional disconnect"); wsRef.current = null; } setIsConnected(false); @@ -80,24 +96,30 @@ export const useWebSocket = (url, onMessage) => { const sendMessage = useCallback((message) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(message)); - console.log('Message sent:', message); + console.log("Message sent:", message); return true; } - console.warn('WebSocket is not connected, readyState:', wsRef.current?.readyState); - setError('Cannot send message: WebSocket not connected'); + console.warn( + "WebSocket is not connected, readyState:", + wsRef.current?.readyState, + ); + setError("Cannot send message: WebSocket not connected"); return false; }, []); - const login = useCallback((token) => { - if (!token) { - console.warn('No token provided for login'); - return false; - } - return sendMessage({ - event: 'user_login', - data: { token } - }); - }, [sendMessage]); + const login = useCallback( + (token) => { + if (!token) { + console.warn("No token provided for login"); + return false; + } + return sendMessage({ + event: "user_login", + data: { token }, + }); + }, + [sendMessage], + ); useEffect(() => { connect(); @@ -105,10 +127,10 @@ export const useWebSocket = (url, onMessage) => { }, [connect, disconnect]); return { - isConnected, + isConnected: isConnectedRef, error, sendMessage, login, - disconnect + disconnect, }; -}; \ No newline at end of file +}; diff --git a/web/src/components/RealtimeTable/utils/cellUtils.js b/web/src/components/RealtimeTable/utils/cellUtils.js index 509286a..3a89885 100644 --- a/web/src/components/RealtimeTable/utils/cellUtils.js +++ b/web/src/components/RealtimeTable/utils/cellUtils.js @@ -90,7 +90,6 @@ export function updateCells(data, newRows) { return updatedRows; } - export function insertCell(data, newRow, expense_item_id) { const updatedRows = JSON.parse(JSON.stringify(data)); const [row_type, depth, sort_order, dataRow] = newRow; @@ -102,6 +101,12 @@ export function insertCell(data, newRow, expense_item_id) { data: dataRow, }; + // Если expense_item_id === null, добавляем в конец + if (expense_item_id === null) { + updatedRows.push(newRowObject); + return updatedRows; + } + function findAndUpdate(rows) { for (let i = 0; i < rows.length; i++) { const row = rows[i]; diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js new file mode 100644 index 0000000..5ba9a51 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -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; +} diff --git a/web/src/components/common/Modal/ModalStyled.jsx b/web/src/components/common/Modal/ModalStyled.jsx index 181e752..78cbb2c 100644 --- a/web/src/components/common/Modal/ModalStyled.jsx +++ b/web/src/components/common/Modal/ModalStyled.jsx @@ -6,7 +6,7 @@ const ModalContainer = styled.div` /* top: 3rem; left: 0; */ width: 50rem; - height: 40rem; + height: ${({ height }) => height || '40rem'}; display: flex; flex-direction: column; 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 { ModalContainer, HeaderContainer, @@ -304,4 +326,6 @@ export { StageName, StageListContainer, EditContainer, + ModalBackdrop, + ModalTitle };