add link to partner/client's installationticket page from users page

This commit is contained in:
Yinyin Liu 2026-06-15 10:30:39 +02:00
parent 444a0a29f2
commit de73bc9211
8 changed files with 208 additions and 13 deletions

View File

@ -522,7 +522,7 @@ public class Controller : ControllerBase
return Unauthorized();
return user.DirectlyAccessibleInstallations()
.Select(i => new { i.Id, i.Name })
.Select(i => new { i.Id, i.Name, i.Product })
.ToList<Object>();
}
@ -542,6 +542,24 @@ public class Controller : ControllerBase
.ToList<Object>();
}
[HttpGet(nameof(GetInstallationsUnderFolder))]
public ActionResult<IEnumerable<Object>> GetInstallationsUnderFolder(Int64 folderId, Token authToken)
{
var sessionUser = Db.GetSession(authToken)?.User;
if (sessionUser == null)
return Unauthorized();
var folder = Db.GetFolderById(folderId);
if (folder == null || !sessionUser.HasAccessTo(folder))
return Unauthorized();
return folder
.DescendantFoldersAndSelf() // self + all nested subfolders
.SelectMany(f => f.ChildInstallations()) // installations directly in each
.Select(i => new { i.Id, i.Name, i.Product })
.ToList<Object>();
}
[HttpGet(nameof(GetUsersWithInheritedAccessToInstallation))]
public ActionResult<IEnumerable<Object>> GetUsersWithInheritedAccessToInstallation(Int64 id, Token authToken)
{

View File

@ -10,13 +10,18 @@ import {
Alert,
Autocomplete,
Box,
CircularProgress,
Collapse,
Container,
Divider,
FormControl,
Grid,
IconButton,
InputLabel,
Link,
List,
ListItem,
ListItemButton,
ListSubheader,
MenuItem,
Modal,
@ -32,6 +37,9 @@ import Avatar from '@mui/material/Avatar';
import ListItemText from '@mui/material/ListItemText';
import PersonIcon from '@mui/icons-material/Person';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { AccessContext } from 'src/contexts/AccessContextProvider';
@ -43,6 +51,8 @@ import {
I_Installation
} from '../../../interfaces/InstallationTypes';
import axiosConfig from '../../../Resources/axiosConfig';
import routes from 'src/Resources/routes.json';
import { useNavigate } from 'react-router-dom';
const PRODUCT_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1];
const PRODUCT_NAMES: Record<number, string> = {
@ -91,7 +101,18 @@ function UserAccess(props: UserAccessProps) {
// Direct grants for this user
const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]);
const [directInstallations, setDirectInstallations] = useState<{ id: number; name: string }[]>([]);
const [directInstallations, setDirectInstallations] = useState<
{ id: number; name: string; product: number }[]
>([]);
// Installations under each folder (lazy-loaded + cached), with expand/loading state
const navigate = useNavigate();
const [expandedFolderId, setExpandedFolderId] = useState<number | null>(null);
const [folderInstallations, setFolderInstallations] = useState<
Record<number, { id: number; name: string; product: number }[]>
>({});
const [loadingFolderId, setLoadingFolderId] = useState<number | null>(null);
const [folderError, setFolderError] = useState<Record<number, boolean>>({});
const accessContext = useContext(AccessContext);
const {
@ -169,6 +190,49 @@ function UserAccess(props: UserAccessProps) {
});
};
// Build the route to an installation from its product (mirrors CustomTreeItem).
// Targets the Tickets tab; the installation router redirects to `live` when the
// Tickets tab isn't available (non-admin), via its catch-all <Navigate to live>.
const installationLink = (inst: { id: number; product: number }): string => {
const base =
inst.product === 0
? routes.installations
: inst.product === 1
? routes.salidomo_installations
: inst.product === 2
? routes.sodiohome_installations
: inst.product === 4
? routes.sodistoregrid_installations
: inst.product === 5
? routes.sodistorepro_installations
: routes.sodistore_installations;
return base + routes.tree + routes.installation + inst.id + '/' + routes.installationTickets;
};
const fetchFolderInstallations = async (folderId: number) => {
setLoadingFolderId(folderId);
setFolderError((prev) => ({ ...prev, [folderId]: false }));
try {
const res = await axiosConfig.get(`/GetInstallationsUnderFolder?folderId=${folderId}`);
setFolderInstallations((prev) => ({ ...prev, [folderId]: res.data }));
} catch (err) {
if (err.response && err.response.status === 401) removeToken();
else setFolderError((prev) => ({ ...prev, [folderId]: true }));
} finally {
setLoadingFolderId(null);
}
};
const handleToggleFolder = (folderId: number) => {
if (expandedFolderId === folderId) {
setExpandedFolderId(null);
return;
}
setExpandedFolderId(folderId);
if (folderInstallations[folderId]) return; // already cached
fetchFolderInstallations(folderId);
};
const handleRevokeInstallation = async (installationId: number) => {
axiosConfig
.post(`/RevokeUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installationId}`)
@ -357,16 +421,27 @@ function UserAccess(props: UserAccessProps) {
</Typography>
{directFolders.map((folder, index) => {
const isLast = index === directFolders.length - 1;
const isExpanded = expandedFolderId === folder.id;
const installations = folderInstallations[folder.id];
return (
<Fragment key={folder.id}>
<ListItem
sx={{ mb: isLast ? 1 : 0 }}
sx={{ mb: isLast && !isExpanded ? 1 : 0 }}
secondaryAction={
currentUser.userType === UserType.admin && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<IconButton
onClick={() => handleToggleFolder(folder.id)}
edge="end"
aria-label={intl.formatMessage({ id: 'viewInstallations' })}
>
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
{currentUser.userType === UserType.admin && (
<IconButton onClick={() => handleRevokeFolder(folder.id, folder.name)} edge="end">
<PersonRemoveIcon />
</IconButton>
)
)}
</Box>
}
>
<ListItemAvatar>
@ -376,6 +451,60 @@ function UserAccess(props: UserAccessProps) {
</ListItemAvatar>
<ListItemText primary={folder.name} />
</ListItem>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
{loadingFolderId === folder.id ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={20} />
</Box>
) : folderError[folder.id] ? (
<Alert
severity="error"
sx={{ ml: 6, mr: 2, my: 1 }}
action={
<Button
color="inherit"
size="small"
onClick={() => fetchFolderInstallations(folder.id)}
>
<FormattedMessage id="retry" defaultMessage="Retry" />
</Button>
}
>
<FormattedMessage
id="couldNotLoadInstallations"
defaultMessage="Could not load installations"
/>
</Alert>
) : installations && installations.length > 0 ? (
<List disablePadding sx={{ pl: 6 }}>
{installations.map((inst) => (
<ListItemButton
key={inst.id}
onClick={() => navigate(installationLink(inst))}
sx={{ py: 0.25 }}
>
<ListItemAvatar sx={{ minWidth: 36 }}>
<InsertDriveFileIcon fontSize="small" color="action" />
</ListItemAvatar>
<ListItemText
primary={
<Link component="span" underline="hover">
{inst.name}
</Link>
}
/>
</ListItemButton>
))}
</List>
) : installations ? (
<Typography variant="body2" sx={{ pl: 6, py: 1, color: 'text.secondary' }}>
<FormattedMessage
id="noInstallationsInFolder"
defaultMessage="No installations in this folder"
/>
</Typography>
) : null}
</Collapse>
<Divider />
</Fragment>
);
@ -411,7 +540,19 @@ function UserAccess(props: UserAccessProps) {
<PersonIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={installation.name} />
<ListItemText
primary={
<Link
component="button"
type="button"
underline="hover"
align="left"
onClick={() => navigate(installationLink(installation))}
>
{installation.name}
</Link>
}
/>
</ListItem>
<Divider />
</Fragment>

View File

@ -13,6 +13,7 @@ import {
useTheme
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
import { InnovEnergyUser } from 'src/interfaces/UserTypes';
import User from './User';
@ -22,14 +23,25 @@ interface FlatUsersViewProps {
}
const FlatUsersView = (props: FlatUsersViewProps) => {
const [selectedUser, setSelectedUser] = useState<number>(-1);
// Selected user is kept in the URL (?userId=) so navigating away to an
// installation and pressing Back restores this user's detail pane.
const [searchParams, setSearchParams] = useSearchParams();
const userIdParam = searchParams.get('userId');
const [selectedUser, setSelectedUser] = useState<number>(
userIdParam ? Number(userIdParam) : -1
);
const handleSelectOneUser = (userID: number): void => {
if (selectedUser != userID) {
setSelectedUser(userID);
const next = selectedUser !== userID ? userID : -1;
setSelectedUser(next);
const params = new URLSearchParams(searchParams);
if (next === -1) {
params.delete('userId');
params.delete('tab');
} else {
setSelectedUser(-1);
params.set('userId', String(next));
}
setSearchParams(params, { replace: true });
};
const theme = useTheme();

View File

@ -28,6 +28,7 @@ import { TokenContext } from 'src/contexts/tokenContext';
import { UserContext } from 'src/contexts/userContext';
import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper';
import { FormattedMessage, useIntl } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
import UserAccess from '../ManageAccess/UserAccess';
interface singleUserProps {
@ -41,7 +42,11 @@ function User(props: singleUserProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [updated, setUpdated] = useState(false);
const [currentTab, setCurrentTab] = useState<string>('user');
// Active tab is kept in the URL (?tab=) alongside ?userId= so Back restores it.
const [searchParams, setSearchParams] = useSearchParams();
const [currentTab, setCurrentTab] = useState<string>(
searchParams.get('tab') === 'manage' ? 'manage' : 'user'
);
const [formValues, setFormValues] = useState(props.current_user);
const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext;
@ -86,6 +91,9 @@ function User(props: singleUserProps) {
const handleTabsChange = (_event: ChangeEvent<{}>, value: string): void => {
setCurrentTab(value);
setError(false);
const params = new URLSearchParams(searchParams);
params.set('tab', value);
setSearchParams(params, { replace: true });
};
const handleChange = (e) => {

View File

@ -157,6 +157,10 @@
"errorOccured": "Ein Fehler ist aufgetreten",
"successfullyUpdated": "Erfolgreich aktualisiert",
"grantAccess": "Zugriff gewähren",
"viewInstallations": "Anlagen anzeigen",
"noInstallationsInFolder": "Keine Anlagen in diesem Ordner",
"couldNotLoadInstallations": "Anlagen konnten nicht geladen werden",
"retry": "Erneut versuchen",
"UserswithDirectAccess": "Benutzer mit direktem Zugriff",
"UserswithInheritedAccess": "Benutzer mit vererbtem Zugriff",
"noerrors": "Keine Fehler",

View File

@ -139,6 +139,10 @@
"errorOccured": "An error has occurred",
"successfullyUpdated": "Successfully updated",
"grantAccess": "Grant Access",
"viewInstallations": "View installations",
"noInstallationsInFolder": "No installations in this folder",
"couldNotLoadInstallations": "Could not load installations",
"retry": "Retry",
"UserswithDirectAccess": "Users with Direct Access",
"UserswithInheritedAccess": "Users with Inherited Access",
"noerrors": "There are no errors",

View File

@ -151,6 +151,10 @@
"errorOccured": "Une erreur s'est produite",
"successfullyUpdated": "Mise à jour réussie",
"grantAccess": "Accorder l'accès",
"viewInstallations": "Voir les installations",
"noInstallationsInFolder": "Aucune installation dans ce dossier",
"couldNotLoadInstallations": "Impossible de charger les installations",
"retry": "Réessayer",
"UserswithDirectAccess": "Utilisateurs avec accès direct",
"UserswithInheritedAccess": "Utilisateurs avec accès hérité",
"noerrors": "Il n'y a pas d'erreurs",

View File

@ -139,6 +139,10 @@
"errorOccured": "Si è verificato un errore",
"successfullyUpdated": "Aggiornamento riuscito",
"grantAccess": "Concedi accesso",
"viewInstallations": "Mostra installazioni",
"noInstallationsInFolder": "Nessuna installazione in questa cartella",
"couldNotLoadInstallations": "Impossibile caricare le installazioni",
"retry": "Riprova",
"UserswithDirectAccess": "Utenti con accesso diretto",
"UserswithInheritedAccess": "Utenti con accesso ereditato",
"noerrors": "Non ci sono errori",