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 Unauthorized();
return user.DirectlyAccessibleInstallations() return user.DirectlyAccessibleInstallations()
.Select(i => new { i.Id, i.Name }) .Select(i => new { i.Id, i.Name, i.Product })
.ToList<Object>(); .ToList<Object>();
} }
@ -542,6 +542,24 @@ public class Controller : ControllerBase
.ToList<Object>(); .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))] [HttpGet(nameof(GetUsersWithInheritedAccessToInstallation))]
public ActionResult<IEnumerable<Object>> GetUsersWithInheritedAccessToInstallation(Int64 id, Token authToken) public ActionResult<IEnumerable<Object>> GetUsersWithInheritedAccessToInstallation(Int64 id, Token authToken)
{ {

View File

@ -10,13 +10,18 @@ import {
Alert, Alert,
Autocomplete, Autocomplete,
Box, Box,
CircularProgress,
Collapse,
Container, Container,
Divider, Divider,
FormControl, FormControl,
Grid, Grid,
IconButton, IconButton,
InputLabel, InputLabel,
Link,
List,
ListItem, ListItem,
ListItemButton,
ListSubheader, ListSubheader,
MenuItem, MenuItem,
Modal, Modal,
@ -32,6 +37,9 @@ import Avatar from '@mui/material/Avatar';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import FolderIcon from '@mui/icons-material/Folder'; 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 Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material'; import { Close as CloseIcon } from '@mui/icons-material';
import { AccessContext } from 'src/contexts/AccessContextProvider'; import { AccessContext } from 'src/contexts/AccessContextProvider';
@ -43,6 +51,8 @@ import {
I_Installation I_Installation
} from '../../../interfaces/InstallationTypes'; } from '../../../interfaces/InstallationTypes';
import axiosConfig from '../../../Resources/axiosConfig'; 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_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1];
const PRODUCT_NAMES: Record<number, string> = { const PRODUCT_NAMES: Record<number, string> = {
@ -91,7 +101,18 @@ function UserAccess(props: UserAccessProps) {
// Direct grants for this user // Direct grants for this user
const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]); 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 accessContext = useContext(AccessContext);
const { 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) => { const handleRevokeInstallation = async (installationId: number) => {
axiosConfig axiosConfig
.post(`/RevokeUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installationId}`) .post(`/RevokeUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installationId}`)
@ -357,16 +421,27 @@ function UserAccess(props: UserAccessProps) {
</Typography> </Typography>
{directFolders.map((folder, index) => { {directFolders.map((folder, index) => {
const isLast = index === directFolders.length - 1; const isLast = index === directFolders.length - 1;
const isExpanded = expandedFolderId === folder.id;
const installations = folderInstallations[folder.id];
return ( return (
<Fragment key={folder.id}> <Fragment key={folder.id}>
<ListItem <ListItem
sx={{ mb: isLast ? 1 : 0 }} sx={{ mb: isLast && !isExpanded ? 1 : 0 }}
secondaryAction={ 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"> <IconButton onClick={() => handleRevokeFolder(folder.id, folder.name)} edge="end">
<PersonRemoveIcon /> <PersonRemoveIcon />
</IconButton> </IconButton>
) )}
</Box>
} }
> >
<ListItemAvatar> <ListItemAvatar>
@ -376,6 +451,60 @@ function UserAccess(props: UserAccessProps) {
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={folder.name} /> <ListItemText primary={folder.name} />
</ListItem> </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 /> <Divider />
</Fragment> </Fragment>
); );
@ -411,7 +540,19 @@ function UserAccess(props: UserAccessProps) {
<PersonIcon /> <PersonIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={installation.name} /> <ListItemText
primary={
<Link
component="button"
type="button"
underline="hover"
align="left"
onClick={() => navigate(installationLink(installation))}
>
{installation.name}
</Link>
}
/>
</ListItem> </ListItem>
<Divider /> <Divider />
</Fragment> </Fragment>

View File

@ -13,6 +13,7 @@ import {
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
import { InnovEnergyUser } from 'src/interfaces/UserTypes'; import { InnovEnergyUser } from 'src/interfaces/UserTypes';
import User from './User'; import User from './User';
@ -22,14 +23,25 @@ interface FlatUsersViewProps {
} }
const FlatUsersView = (props: 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 => { const handleSelectOneUser = (userID: number): void => {
if (selectedUser != userID) { const next = selectedUser !== userID ? userID : -1;
setSelectedUser(userID); setSelectedUser(next);
const params = new URLSearchParams(searchParams);
if (next === -1) {
params.delete('userId');
params.delete('tab');
} else { } else {
setSelectedUser(-1); params.set('userId', String(next));
} }
setSearchParams(params, { replace: true });
}; };
const theme = useTheme(); const theme = useTheme();

View File

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

View File

@ -157,6 +157,10 @@
"errorOccured": "Ein Fehler ist aufgetreten", "errorOccured": "Ein Fehler ist aufgetreten",
"successfullyUpdated": "Erfolgreich aktualisiert", "successfullyUpdated": "Erfolgreich aktualisiert",
"grantAccess": "Zugriff gewähren", "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", "UserswithDirectAccess": "Benutzer mit direktem Zugriff",
"UserswithInheritedAccess": "Benutzer mit vererbtem Zugriff", "UserswithInheritedAccess": "Benutzer mit vererbtem Zugriff",
"noerrors": "Keine Fehler", "noerrors": "Keine Fehler",

View File

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

View File

@ -151,6 +151,10 @@
"errorOccured": "Une erreur s'est produite", "errorOccured": "Une erreur s'est produite",
"successfullyUpdated": "Mise à jour réussie", "successfullyUpdated": "Mise à jour réussie",
"grantAccess": "Accorder l'accès", "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", "UserswithDirectAccess": "Utilisateurs avec accès direct",
"UserswithInheritedAccess": "Utilisateurs avec accès hérité", "UserswithInheritedAccess": "Utilisateurs avec accès hérité",
"noerrors": "Il n'y a pas d'erreurs", "noerrors": "Il n'y a pas d'erreurs",

View File

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