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:
Yinyin Liu 2026-02-20 06:30:48 +01:00
parent 062fd5141f
commit 80639e9169
4 changed files with 234 additions and 7 deletions

View File

@ -14,9 +14,9 @@ public static class SessionMethods
var user = session?.User;
return user is not null
&& folder is not null
&& user.UserType!=0
&& user.HasAccessTo(folder.Parent())
&& folder is not null
&& 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 });
}
@ -43,8 +43,8 @@ public static class SessionMethods
var parent = Db.GetFolderById(parentId);
return user is not null
&& folder is not null
&& user.UserType !=0
&& folder is not null
&& 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

View File

@ -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 ? (

View File

@ -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;

View File

@ -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,