add link to partner/client's installationticket page from users page
This commit is contained in:
parent
444a0a29f2
commit
de73bc9211
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue