feat: add move installation/folder UI with admin-only access control
- Add MoveModal component with folder dropdown and circular-reference protection - Add move icon button to each tree row (admin only) - Add moveInstallation and moveFolder functions to InstallationsContext - Restrict CreateFolder, MoveFolder, MoveInstallation to admin only (UserType==2) in backend Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
062fd5141f
commit
80639e9169
|
|
@ -15,7 +15,7 @@ public static class SessionMethods
|
|||
|
||||
return user is not null
|
||||
&& folder is not null
|
||||
&& user.UserType!=0
|
||||
&& user.UserType==2
|
||||
&& user.HasAccessTo(folder.Parent())
|
||||
&& Db.Create(folder) // TODO: these two in a transaction
|
||||
&& Db.Create(new FolderAccess { UserId = user.Id, FolderId = folder.Id });
|
||||
|
|
@ -44,7 +44,7 @@ public static class SessionMethods
|
|||
|
||||
return user is not null
|
||||
&& folder is not null
|
||||
&& user.UserType !=0
|
||||
&& user.UserType==2
|
||||
&& user.HasAccessTo(folder)
|
||||
&& user.HasAccessTo(parent)
|
||||
&& folder
|
||||
|
|
@ -61,7 +61,7 @@ public static class SessionMethods
|
|||
if(installation == null || installation.ParentId == parentId) return false;
|
||||
|
||||
return user is not null
|
||||
&& user.UserType !=0
|
||||
&& user.UserType==2
|
||||
&& user.HasAccessTo(installation)
|
||||
&& user.HasAccessTo(parent)
|
||||
&& installation
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import React, { ReactNode, useContext, useState } from 'react';
|
||||
import { CircularProgress, ListItemIcon, useTheme } from '@mui/material';
|
||||
import { CircularProgress, IconButton, ListItemIcon, useTheme } from '@mui/material';
|
||||
import { TreeItem } from '@mui/lab';
|
||||
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import routes from 'src/Resources/routes.json';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
|
||||
import { UserContext } from '../../../contexts/userContext';
|
||||
import { UserType } from '../../../interfaces/UserTypes';
|
||||
import MoveModal from './MoveModal';
|
||||
|
||||
interface CustomTreeItemProps {
|
||||
node: I_Installation | I_Folder;
|
||||
|
|
@ -41,8 +45,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
|||
const status = props.node.status;
|
||||
const navigate = useNavigate();
|
||||
const [selected, setSelected] = useState(false);
|
||||
const [openMoveModal, setOpenMoveModal] = useState(false);
|
||||
const currentLocation = useLocation();
|
||||
const { product } = useContext(ProductIdContext);
|
||||
const { currentUser } = useContext(UserContext);
|
||||
|
||||
const handleSelectOneInstallation = (): void => {
|
||||
let installation = props.node;
|
||||
|
|
@ -126,6 +132,24 @@ function CustomTreeItem(props: CustomTreeItemProps) {
|
|||
{props.node.name}
|
||||
</Typography>
|
||||
|
||||
{currentUser.userType === UserType.admin && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setOpenMoveModal(true)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<DriveFileMoveOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{openMoveModal && (
|
||||
<MoveModal
|
||||
node={props.node}
|
||||
onClose={() => setOpenMoveModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.node.type === 'Installation' && (
|
||||
<div>
|
||||
{status === -1 ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
import React, { useContext, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Select,
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
|
||||
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
|
||||
import { ProductIdContext } from 'src/contexts/ProductIdContextProvider';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
interface MoveModalProps {
|
||||
node: I_Installation | I_Folder;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Returns the IDs of a folder and all its descendants (to prevent circular moves)
|
||||
function getDescendantIds(folderId: number, allItems: any[]): Set<number> {
|
||||
const result = new Set<number>([folderId]);
|
||||
const queue = [folderId];
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift();
|
||||
allItems
|
||||
.filter((item) => item.parentId === currentId && item.type === 'Folder')
|
||||
.forEach((child) => {
|
||||
result.add(child.id);
|
||||
queue.push(child.id);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function MoveModal(props: MoveModalProps) {
|
||||
const theme = useTheme();
|
||||
const { foldersAndInstallations, moveInstallation, moveFolder, loading, setLoading, error, setError } =
|
||||
useContext(InstallationsContext);
|
||||
const { product } = useContext(ProductIdContext);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | ''>('');
|
||||
|
||||
// For folders: exclude self and all descendants to prevent circular nesting
|
||||
// For installations: any folder is valid
|
||||
const excludedIds =
|
||||
props.node.type === 'Folder'
|
||||
? getDescendantIds(props.node.id, foldersAndInstallations)
|
||||
: new Set<number>();
|
||||
|
||||
const availableFolders = foldersAndInstallations.filter(
|
||||
(item) => item.type === 'Folder' && !excludedIds.has(item.id)
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedFolderId === '') return;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
if (props.node.type === 'Installation') {
|
||||
await moveInstallation(props.node.id, selectedFolderId as number, product);
|
||||
} else {
|
||||
await moveFolder(props.node.id, selectedFolderId as number, product);
|
||||
}
|
||||
setLoading(false);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
aria-labelledby="move-modal"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 500,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
boxShadow: 24,
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold" mb={2}>
|
||||
<FormattedMessage id="moveTo" defaultMessage="Move to" />:{' '}
|
||||
<span style={{ color: theme.palette.primary.main }}>
|
||||
{props.node.name}
|
||||
</span>
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
|
||||
<FormattedMessage id="folder" defaultMessage="Folder" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedFolderId}
|
||||
onChange={(e) => setSelectedFolderId(e.target.value as number)}
|
||||
>
|
||||
{availableFolders.map((folder: I_Folder) => (
|
||||
<MenuItem key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 10 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedFolderId === ''}
|
||||
sx={{ marginLeft: '10px' }}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={props.onClose}
|
||||
sx={{ marginLeft: '10px' }}
|
||||
>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
|
||||
{loading && (
|
||||
<CircularProgress
|
||||
sx={{ color: theme.palette.primary.main, marginLeft: '20px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="errorOccured"
|
||||
defaultMessage="An error has occurred"
|
||||
/>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(false)}
|
||||
sx={{ marginLeft: '4px' }}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default MoveModal;
|
||||
|
|
@ -349,6 +349,42 @@ const InstallationsContextProvider = ({
|
|||
[fetchAllFoldersAndInstallations, navigate, removeToken]
|
||||
);
|
||||
|
||||
const moveInstallation = useCallback(
|
||||
async (installationId: number, parentId: number, product: number) => {
|
||||
try {
|
||||
await axiosConfig.put(
|
||||
`/MoveInstallation?installationId=${installationId}&parentId=${parentId}`
|
||||
);
|
||||
await fetchAllFoldersAndInstallations(product);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
if (error.response?.status === 401) {
|
||||
removeToken();
|
||||
navigate(routes.login);
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchAllFoldersAndInstallations, navigate, removeToken]
|
||||
);
|
||||
|
||||
const moveFolder = useCallback(
|
||||
async (folderId: number, parentId: number, product: number) => {
|
||||
try {
|
||||
await axiosConfig.put(
|
||||
`/MoveFolder?folderId=${folderId}&parentId=${parentId}`
|
||||
);
|
||||
await fetchAllFoldersAndInstallations(product);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
if (error.response?.status === 401) {
|
||||
removeToken();
|
||||
navigate(routes.login);
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchAllFoldersAndInstallations, navigate, removeToken]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
salimax_or_sodistore_Installations,
|
||||
|
|
@ -369,6 +405,8 @@ const InstallationsContextProvider = ({
|
|||
createFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
moveInstallation,
|
||||
moveFolder,
|
||||
//currentProduct,
|
||||
socket,
|
||||
openSocket,
|
||||
|
|
|
|||
Loading…
Reference in New Issue