added Tickets tab to each installation and allowed to create ticket from there with pre-filled installation information and connected between them
This commit is contained in:
parent
bf47a82b25
commit
ac21c46c0e
|
|
@ -23,5 +23,6 @@
|
||||||
"mainstats": "mainstats",
|
"mainstats": "mainstats",
|
||||||
"detailed_view": "detailed_view/",
|
"detailed_view": "detailed_view/",
|
||||||
"report": "report",
|
"report": "report",
|
||||||
|
"installationTickets": "installationTickets",
|
||||||
"tickets": "/tickets/"
|
"tickets": "/tickets/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import Topology from '../Topology/Topology';
|
||||||
import BatteryView from '../BatteryView/BatteryView';
|
import BatteryView from '../BatteryView/BatteryView';
|
||||||
import Configuration from '../Configuration/Configuration';
|
import Configuration from '../Configuration/Configuration';
|
||||||
import PvView from '../PvView/PvView';
|
import PvView from '../PvView/PvView';
|
||||||
|
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||||
|
|
||||||
interface singleInstallationProps {
|
interface singleInstallationProps {
|
||||||
current_installation?: I_Installation;
|
current_installation?: I_Installation;
|
||||||
|
|
@ -381,7 +382,8 @@ function Installation(props: singleInstallationProps) {
|
||||||
currentTab != 'information' &&
|
currentTab != 'information' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'manage' &&
|
currentTab != 'manage' &&
|
||||||
currentTab != 'log' && (
|
currentTab != 'log' &&
|
||||||
|
currentTab != 'installationTickets' && (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="xl"
|
maxWidth="xl"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -550,6 +552,17 @@ function Installation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentUser.userType == UserType.admin && (
|
||||||
|
<Route
|
||||||
|
path={routes.installationTickets}
|
||||||
|
element={
|
||||||
|
<InstallationTicketsTab
|
||||||
|
installationId={props.current_installation.id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.live}></Navigate>}
|
element={<Navigate to={routes.live}></Navigate>}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
'information',
|
'information',
|
||||||
'configuration',
|
'configuration',
|
||||||
'history',
|
'history',
|
||||||
'pvview'
|
'pvview',
|
||||||
|
'installationTickets'
|
||||||
];
|
];
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
|
|
@ -165,6 +166,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
{
|
{
|
||||||
value: 'pvview',
|
value: 'pvview',
|
||||||
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
|
label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentUser.userType == UserType.partner
|
: currentUser.userType == UserType.partner
|
||||||
|
|
@ -294,6 +299,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
defaultMessage="History Of Actions"
|
defaultMessage="History Of Actions"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentUser.userType == UserType.partner
|
: currentUser.userType == UserType.partner
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import SalidomoOverview from '../Overview/salidomoOverview';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
import HistoryOfActions from '../History/History';
|
import HistoryOfActions from '../History/History';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
|
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||||
import AccessContextProvider from '../../../contexts/AccessContextProvider';
|
import AccessContextProvider from '../../../contexts/AccessContextProvider';
|
||||||
import Access from '../ManageAccess/Access';
|
import Access from '../ManageAccess/Access';
|
||||||
|
|
||||||
|
|
@ -324,7 +325,8 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
currentTab != 'information' &&
|
currentTab != 'information' &&
|
||||||
currentTab != 'manage' &&
|
currentTab != 'manage' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'log' && (
|
currentTab != 'log' &&
|
||||||
|
currentTab != 'installationTickets' && (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="xl"
|
maxWidth="xl"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -428,6 +430,17 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentUser.userType == UserType.admin && (
|
||||||
|
<Route
|
||||||
|
path={routes.installationTickets}
|
||||||
|
element={
|
||||||
|
<InstallationTicketsTab
|
||||||
|
installationId={props.current_installation.id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.batteryview}></Navigate>}
|
element={<Navigate to={routes.batteryview}></Navigate>}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
'manage',
|
'manage',
|
||||||
'overview',
|
'overview',
|
||||||
'log',
|
'log',
|
||||||
'history'
|
'history',
|
||||||
|
'installationTickets'
|
||||||
];
|
];
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
|
|
@ -136,6 +137,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
defaultMessage="History Of Actions"
|
defaultMessage="History Of Actions"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
@ -217,6 +222,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
defaultMessage="History Of Actions"
|
defaultMessage="History Of Actions"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentTab != 'list' &&
|
: currentTab != 'list' &&
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
|
||||||
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||||
import Overview from '../Overview/overview';
|
import Overview from '../Overview/overview';
|
||||||
import WeeklyReport from './WeeklyReport';
|
import WeeklyReport from './WeeklyReport';
|
||||||
|
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||||
|
|
||||||
interface singleInstallationProps {
|
interface singleInstallationProps {
|
||||||
current_installation?: I_Installation;
|
current_installation?: I_Installation;
|
||||||
|
|
@ -473,7 +474,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
currentTab != 'manage' &&
|
currentTab != 'manage' &&
|
||||||
currentTab != 'history' &&
|
currentTab != 'history' &&
|
||||||
currentTab != 'log' &&
|
currentTab != 'log' &&
|
||||||
currentTab != 'report' && (
|
currentTab != 'report' &&
|
||||||
|
currentTab != 'installationTickets' && (
|
||||||
<Container
|
<Container
|
||||||
maxWidth="xl"
|
maxWidth="xl"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -618,6 +620,17 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentUser.userType == UserType.admin && (
|
||||||
|
<Route
|
||||||
|
path={routes.installationTickets}
|
||||||
|
element={
|
||||||
|
<InstallationTicketsTab
|
||||||
|
installationId={props.current_installation.id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.live}></Navigate>}
|
element={<Navigate to={routes.live}></Navigate>}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
'log',
|
'log',
|
||||||
'history',
|
'history',
|
||||||
'configuration',
|
'configuration',
|
||||||
'report'
|
'report',
|
||||||
|
'installationTickets'
|
||||||
];
|
];
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
|
|
@ -159,6 +160,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
defaultMessage="Report"
|
defaultMessage="Report"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentUser.userType == UserType.partner
|
: currentUser.userType == UserType.partner
|
||||||
|
|
@ -310,6 +315,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
defaultMessage="Report"
|
defaultMessage="Report"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: inInstallationView && currentUser.userType == UserType.partner
|
: inInstallationView && currentUser.userType == UserType.partner
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,10 @@ interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
|
defaultInstallationId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateTicketModal({ open, onClose, onCreated }: Props) {
|
function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) {
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
||||||
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
||||||
|
|
@ -100,13 +101,19 @@ function CreateTicketModal({ open, onClose, onCreated }: Props) {
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setAllInstallations(
|
const mapped = data.map((item: any) => ({
|
||||||
data.map((item: any) => ({
|
id: item.id,
|
||||||
id: item.id,
|
name: item.name,
|
||||||
name: item.name,
|
device: item.device
|
||||||
device: item.device
|
}));
|
||||||
}))
|
setAllInstallations(mapped);
|
||||||
);
|
if (defaultInstallationId != null) {
|
||||||
|
const match = mapped.find((inst: Installation) => inst.id === defaultInstallationId);
|
||||||
|
if (match) {
|
||||||
|
setSelectedInstallation(match);
|
||||||
|
setSelectedDevice(match.device ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setAllInstallations([]))
|
.catch(() => setAllInstallations([]))
|
||||||
|
|
@ -114,7 +121,20 @@ function CreateTicketModal({ open, onClose, onCreated }: Props) {
|
||||||
}, [selectedProduct]);
|
}, [selectedProduct]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedInstallation(null);
|
if (defaultInstallationId == null || !open) return;
|
||||||
|
axiosConfig
|
||||||
|
.get('/GetInstallationById', { params: { id: defaultInstallationId } })
|
||||||
|
.then((res) => {
|
||||||
|
const inst = res.data;
|
||||||
|
if (inst) {
|
||||||
|
setSelectedProduct(inst.product);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [defaultInstallationId, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultInstallationId == null) setSelectedInstallation(null);
|
||||||
}, [selectedDevice]);
|
}, [selectedDevice]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
import { Ticket, TicketStatus } from 'src/interfaces/TicketTypes';
|
||||||
|
import StatusChip from './StatusChip';
|
||||||
|
import CreateTicketModal from './CreateTicketModal';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
installationId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCountKeys: {
|
||||||
|
status: number;
|
||||||
|
id: string;
|
||||||
|
defaultMessage: string;
|
||||||
|
color: string;
|
||||||
|
}[] = [
|
||||||
|
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
||||||
|
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
||||||
|
{ status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
|
||||||
|
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
|
||||||
|
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function InstallationTicketsTab({ installationId }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchTickets = () => {
|
||||||
|
axiosConfig
|
||||||
|
.get('/GetTicketsForInstallation', { params: { installationId } })
|
||||||
|
.then((res) => {
|
||||||
|
setTickets(res.data);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch(() => setError('Failed to load tickets.'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTickets();
|
||||||
|
}, [installationId]);
|
||||||
|
|
||||||
|
const statusCounts = statusCountKeys.map((s) => ({
|
||||||
|
...s,
|
||||||
|
count: tickets.filter((t) => t.status === s.status).length
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h4">
|
||||||
|
<FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="createTicket" defaultMessage="Create Ticket" />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box display="flex" gap={1.5} mb={2} flexWrap="wrap">
|
||||||
|
{statusCounts.map((s) => (
|
||||||
|
<Chip
|
||||||
|
key={s.status}
|
||||||
|
label={`${intl.formatMessage({ id: s.id, defaultMessage: s.defaultMessage })}: ${s.count}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: s.count > 0 ? s.color : '#e0e0e0',
|
||||||
|
color: s.count > 0 ? '#fff' : '#757575',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tickets.length === 0 && !error ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
<FormattedMessage id="noTickets" defaultMessage="No tickets found." />
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>#</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormattedMessage id="subject" defaultMessage="Subject" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormattedMessage id="status" defaultMessage="Status" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormattedMessage id="createdAt" defaultMessage="Created" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{tickets
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() -
|
||||||
|
new Date(a.createdAt).getTime()
|
||||||
|
)
|
||||||
|
.map((ticket) => (
|
||||||
|
<TableRow
|
||||||
|
key={ticket.id}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/tickets/${ticket.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell>{ticket.id}</TableCell>
|
||||||
|
<TableCell>{ticket.subject}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusChip status={ticket.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: `priority${['Critical', 'High', 'Medium', 'Low'][ticket.priority]}`,
|
||||||
|
defaultMessage: ['Critical', 'High', 'Medium', 'Low'][
|
||||||
|
ticket.priority
|
||||||
|
]
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateTicketModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
fetchTickets();
|
||||||
|
}}
|
||||||
|
defaultInstallationId={installationId}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InstallationTicketsTab;
|
||||||
Loading…
Reference in New Issue