added ticket dashboard frontend: List & Create
This commit is contained in:
parent
d54fc1c2ab
commit
a40c168f1a
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git checkout feature/ticket-dashboard)",
|
||||
"Bash(npx react-scripts build)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,24 @@ namespace InnovEnergy.App.Backend.DataTypes;
|
|||
|
||||
public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 }
|
||||
public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 }
|
||||
public enum TicketCategory { Hardware = 0, Software = 1, DataApi = 2, UserAccess = 3 }
|
||||
public enum TicketCategory { Hardware = 0, Software = 1, Network = 2, UserAccess = 3, Firmware = 4 }
|
||||
|
||||
public enum TicketSubCategory
|
||||
{
|
||||
General = 0,
|
||||
Other = 99,
|
||||
// Hardware (1xx)
|
||||
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
|
||||
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
|
||||
// Software (2xx)
|
||||
Backend = 200, Frontend = 201, Database = 202, Api = 203,
|
||||
// Network (3xx)
|
||||
Connectivity = 300, VpnAccess = 301, S3Storage = 302,
|
||||
// UserAccess (4xx)
|
||||
Permissions = 400, Login = 401,
|
||||
// Firmware (5xx)
|
||||
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502
|
||||
}
|
||||
public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 }
|
||||
|
||||
public class Ticket
|
||||
|
|
@ -17,6 +34,7 @@ public class Ticket
|
|||
[Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open;
|
||||
public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium;
|
||||
public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware;
|
||||
public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.General;
|
||||
public Int32 Source { get; set; } = (Int32)TicketSource.Manual;
|
||||
|
||||
[Indexed] public Int64 InstallationId { get; set; }
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations
|
|||
import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations';
|
||||
import { ProductIdContext } from './contexts/ProductIdContextProvider';
|
||||
import { TourProvider } from './contexts/TourContext';
|
||||
import Tickets from './content/dashboards/Tickets';
|
||||
|
||||
function App() {
|
||||
const context = useContext(UserContext);
|
||||
|
|
@ -236,6 +237,7 @@ function App() {
|
|||
}
|
||||
/>
|
||||
|
||||
<Route path={routes.tickets + '*'} element={<Tickets />} />
|
||||
<Route path={routes.users + '*'} element={<Users />} />
|
||||
<Route
|
||||
path={'*'}
|
||||
|
|
|
|||
|
|
@ -22,5 +22,6 @@
|
|||
"history": "history",
|
||||
"mainstats": "mainstats",
|
||||
"detailed_view": "detailed_view/",
|
||||
"report": "report"
|
||||
"report": "report",
|
||||
"tickets": "/tickets/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import {
|
||||
TicketPriority,
|
||||
TicketCategory,
|
||||
TicketSubCategory,
|
||||
subCategoryLabels,
|
||||
subCategoriesByCategory
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
|
||||
type Installation = {
|
||||
id: number;
|
||||
name: string;
|
||||
device: number;
|
||||
};
|
||||
|
||||
const productOptions = [
|
||||
{ value: 0, label: 'Salimax' },
|
||||
{ value: 1, label: 'Salidomo' },
|
||||
{ value: 2, label: 'Sodistore Home' },
|
||||
{ value: 3, label: 'Sodistore Max' },
|
||||
{ value: 4, label: 'Sodistore Grid' }
|
||||
];
|
||||
|
||||
const deviceOptionsByProduct: Record<number, { value: number; label: string }[]> = {
|
||||
1: [
|
||||
{ value: 1, label: 'Cerbo' },
|
||||
{ value: 2, label: 'Venus' }
|
||||
],
|
||||
2: [
|
||||
{ value: 3, label: 'Growatt' },
|
||||
{ value: 4, label: 'Sinexcel' }
|
||||
]
|
||||
};
|
||||
|
||||
const categoryLabels: Record<number, string> = {
|
||||
[TicketCategory.Hardware]: 'Hardware',
|
||||
[TicketCategory.Software]: 'Software',
|
||||
[TicketCategory.Network]: 'Network',
|
||||
[TicketCategory.UserAccess]: 'User Access',
|
||||
[TicketCategory.Firmware]: 'Firmware'
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateTicketModal({ open, onClose, onCreated }: Props) {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
|
||||
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
|
||||
const [allInstallations, setAllInstallations] = useState<Installation[]>([]);
|
||||
const [selectedInstallation, setSelectedInstallation] =
|
||||
useState<Installation | null>(null);
|
||||
const [loadingInstallations, setLoadingInstallations] = useState(false);
|
||||
const [priority, setPriority] = useState<number>(TicketPriority.Medium);
|
||||
const [category, setCategory] = useState<number>(TicketCategory.Hardware);
|
||||
const [subCategory, setSubCategory] = useState<number>(
|
||||
TicketSubCategory.General
|
||||
);
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const hasDeviceOptions =
|
||||
selectedProduct !== '' && selectedProduct in deviceOptionsByProduct;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProduct === '') {
|
||||
setAllInstallations([]);
|
||||
setSelectedInstallation(null);
|
||||
setSelectedDevice('');
|
||||
return;
|
||||
}
|
||||
setLoadingInstallations(true);
|
||||
setSelectedInstallation(null);
|
||||
setSelectedDevice('');
|
||||
axiosConfig
|
||||
.get('/GetAllInstallationsFromProduct', {
|
||||
params: { product: selectedProduct }
|
||||
})
|
||||
.then((res) => {
|
||||
const data = res.data;
|
||||
if (Array.isArray(data)) {
|
||||
setAllInstallations(
|
||||
data.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
device: item.device
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => setAllInstallations([]))
|
||||
.finally(() => setLoadingInstallations(false));
|
||||
}, [selectedProduct]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedInstallation(null);
|
||||
}, [selectedDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
setSubCategory(TicketSubCategory.General);
|
||||
}, [category]);
|
||||
|
||||
const filteredInstallations = useMemo(() => {
|
||||
if (!hasDeviceOptions || selectedDevice === '') return allInstallations;
|
||||
return allInstallations.filter((inst) => inst.device === selectedDevice);
|
||||
}, [allInstallations, selectedDevice, hasDeviceOptions]);
|
||||
|
||||
const installationReady =
|
||||
selectedProduct !== '' && (!hasDeviceOptions || selectedDevice !== '');
|
||||
|
||||
const resetForm = () => {
|
||||
setSubject('');
|
||||
setSelectedProduct('');
|
||||
setSelectedDevice('');
|
||||
setAllInstallations([]);
|
||||
setSelectedInstallation(null);
|
||||
setPriority(TicketPriority.Medium);
|
||||
setCategory(TicketCategory.Hardware);
|
||||
setSubCategory(TicketSubCategory.General);
|
||||
setDescription('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!subject.trim() || !selectedInstallation) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
axiosConfig
|
||||
.post('/CreateTicket', {
|
||||
subject,
|
||||
description,
|
||||
installationId: selectedInstallation.id,
|
||||
priority,
|
||||
category,
|
||||
subCategory
|
||||
})
|
||||
.then(() => {
|
||||
resetForm();
|
||||
onCreated();
|
||||
onClose();
|
||||
})
|
||||
.catch(() => setError('Failed to create ticket.'))
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
const availableSubCategories = subCategoriesByCategory[category] ?? [0];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<FormattedMessage id="createTicket" defaultMessage="Create Ticket" />
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}
|
||||
>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<TextField
|
||||
label={<FormattedMessage id="subject" defaultMessage="Subject" />}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="product" defaultMessage="Product" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedProduct}
|
||||
label="Product"
|
||||
onChange={(e) =>
|
||||
setSelectedProduct(
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{productOptions.map((p) => (
|
||||
<MenuItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{hasDeviceOptions && (
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="device" defaultMessage="Device" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedDevice}
|
||||
label="Device"
|
||||
onChange={(e) =>
|
||||
setSelectedDevice(
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{deviceOptionsByProduct[selectedProduct as number].map((d) => (
|
||||
<MenuItem key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Autocomplete<Installation, false, false, false>
|
||||
options={filteredInstallations}
|
||||
getOptionLabel={(opt) => opt.name}
|
||||
value={selectedInstallation}
|
||||
onChange={(_e, val) => setSelectedInstallation(val)}
|
||||
disabled={!installationReady}
|
||||
loading={loadingInstallations}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="installation"
|
||||
defaultMessage="Installation"
|
||||
/>
|
||||
}
|
||||
margin="dense"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{loadingInstallations ? (
|
||||
<CircularProgress size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="priority" defaultMessage="Priority" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={priority}
|
||||
label="Priority"
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
>
|
||||
<MenuItem value={TicketPriority.Critical}>Critical</MenuItem>
|
||||
<MenuItem value={TicketPriority.High}>High</MenuItem>
|
||||
<MenuItem value={TicketPriority.Medium}>Medium</MenuItem>
|
||||
<MenuItem value={TicketPriority.Low}>Low</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage id="category" defaultMessage="Category" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={category}
|
||||
label="Category"
|
||||
onChange={(e) => setCategory(Number(e.target.value))}
|
||||
>
|
||||
{Object.entries(categoryLabels).map(([val, label]) => (
|
||||
<MenuItem key={val} value={Number(val)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel>
|
||||
<FormattedMessage
|
||||
id="subCategory"
|
||||
defaultMessage="Sub-Category"
|
||||
/>
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={subCategory}
|
||||
label="Sub-Category"
|
||||
onChange={(e) => setSubCategory(Number(e.target.value))}
|
||||
>
|
||||
{availableSubCategories.map((val) => (
|
||||
<MenuItem key={val} value={val}>
|
||||
{subCategoryLabels[val] ?? 'Unknown'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label={
|
||||
<FormattedMessage id="description" defaultMessage="Description" />
|
||||
}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
submitting || !subject.trim() || !selectedInstallation
|
||||
}
|
||||
>
|
||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateTicketModal;
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Container,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import {
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
TicketPriority,
|
||||
TicketCategory,
|
||||
TicketSubCategory,
|
||||
subCategoryLabels
|
||||
} from 'src/interfaces/TicketTypes';
|
||||
import Footer from 'src/components/Footer';
|
||||
import CreateTicketModal from './CreateTicketModal';
|
||||
|
||||
const statusLabels: Record<number, string> = {
|
||||
[TicketStatus.Open]: 'Open',
|
||||
[TicketStatus.InProgress]: 'In Progress',
|
||||
[TicketStatus.Escalated]: 'Escalated',
|
||||
[TicketStatus.Resolved]: 'Resolved',
|
||||
[TicketStatus.Closed]: 'Closed'
|
||||
};
|
||||
|
||||
const statusColors: Record<number, 'error' | 'warning' | 'info' | 'success' | 'default'> = {
|
||||
[TicketStatus.Open]: 'error',
|
||||
[TicketStatus.InProgress]: 'warning',
|
||||
[TicketStatus.Escalated]: 'error',
|
||||
[TicketStatus.Resolved]: 'success',
|
||||
[TicketStatus.Closed]: 'default'
|
||||
};
|
||||
|
||||
const priorityLabels: Record<number, string> = {
|
||||
[TicketPriority.Critical]: 'Critical',
|
||||
[TicketPriority.High]: 'High',
|
||||
[TicketPriority.Medium]: 'Medium',
|
||||
[TicketPriority.Low]: 'Low'
|
||||
};
|
||||
|
||||
const categoryLabels: Record<number, string> = {
|
||||
[TicketCategory.Hardware]: 'Hardware',
|
||||
[TicketCategory.Software]: 'Software',
|
||||
[TicketCategory.Network]: 'Network',
|
||||
[TicketCategory.UserAccess]: 'User Access',
|
||||
[TicketCategory.Firmware]: 'Firmware'
|
||||
};
|
||||
|
||||
function TicketList() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<number | ''>('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchTickets = () => {
|
||||
axiosConfig
|
||||
.get('/GetAllTickets')
|
||||
.then((res) => setTickets(res.data))
|
||||
.catch(() => setError('Failed to load tickets'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const filtered = tickets.filter((t) => {
|
||||
const matchesSearch =
|
||||
search === '' ||
|
||||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus = statusFilter === '' || t.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ userSelect: 'none' }}>
|
||||
<Container maxWidth="xl" sx={{ marginTop: '20px' }}>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
mb={2}
|
||||
>
|
||||
<Typography variant="h3">
|
||||
<FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="createTicket"
|
||||
defaultMessage="Create Ticket"
|
||||
/>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={2} mb={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={<FormattedMessage id="search" defaultMessage="Search" />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 250 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>
|
||||
<FormattedMessage id="status" defaultMessage="Status" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<FormattedMessage
|
||||
id="allStatuses"
|
||||
defaultMessage="All Statuses"
|
||||
/>
|
||||
</MenuItem>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
<MenuItem key={val} value={Number(val)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && !error ? (
|
||||
<Alert severity="info">
|
||||
<FormattedMessage
|
||||
id="noTickets"
|
||||
defaultMessage="No tickets found."
|
||||
/>
|
||||
</Alert>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<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="category"
|
||||
defaultMessage="Category"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="createdAt"
|
||||
defaultMessage="Created"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((ticket) => (
|
||||
<TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }}>
|
||||
<TableCell>{ticket.id}</TableCell>
|
||||
<TableCell>{ticket.subject}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={statusLabels[ticket.status] ?? 'Unknown'}
|
||||
color={statusColors[ticket.status] ?? 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{priorityLabels[ticket.priority] ?? '-'}</TableCell>
|
||||
<TableCell>
|
||||
{categoryLabels[ticket.category] ?? '-'}
|
||||
{ticket.subCategory !== TicketSubCategory.General &&
|
||||
` — ${subCategoryLabels[ticket.subCategory] ?? ''}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<CreateTicketModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={fetchTickets}
|
||||
/>
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketList;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import TicketList from './TicketList';
|
||||
|
||||
function Tickets() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<TicketList />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tickets;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
export enum TicketStatus {
|
||||
Open = 0,
|
||||
InProgress = 1,
|
||||
Escalated = 2,
|
||||
Resolved = 3,
|
||||
Closed = 4
|
||||
}
|
||||
|
||||
export enum TicketPriority {
|
||||
Critical = 0,
|
||||
High = 1,
|
||||
Medium = 2,
|
||||
Low = 3
|
||||
}
|
||||
|
||||
export enum TicketCategory {
|
||||
Hardware = 0,
|
||||
Software = 1,
|
||||
Network = 2,
|
||||
UserAccess = 3,
|
||||
Firmware = 4
|
||||
}
|
||||
|
||||
export enum TicketSubCategory {
|
||||
General = 0,
|
||||
Other = 99,
|
||||
// Hardware (1xx)
|
||||
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
|
||||
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
|
||||
// Software (2xx)
|
||||
Backend = 200, Frontend = 201, Database = 202, Api = 203,
|
||||
// Network (3xx)
|
||||
Connectivity = 300, VpnAccess = 301, S3Storage = 302,
|
||||
// UserAccess (4xx)
|
||||
Permissions = 400, Login = 401,
|
||||
// Firmware (5xx)
|
||||
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502
|
||||
}
|
||||
|
||||
export const subCategoryLabels: Record<number, string> = {
|
||||
[TicketSubCategory.General]: 'General',
|
||||
[TicketSubCategory.Other]: 'Other',
|
||||
[TicketSubCategory.Battery]: 'Battery', [TicketSubCategory.Inverter]: 'Inverter',
|
||||
[TicketSubCategory.Cable]: 'Cable', [TicketSubCategory.Gateway]: 'Gateway',
|
||||
[TicketSubCategory.Metering]: 'Metering', [TicketSubCategory.Cooling]: 'Cooling',
|
||||
[TicketSubCategory.PvSolar]: 'PV / Solar', [TicketSubCategory.Safety]: 'Safety',
|
||||
[TicketSubCategory.Backend]: 'Backend', [TicketSubCategory.Frontend]: 'Frontend',
|
||||
[TicketSubCategory.Database]: 'Database', [TicketSubCategory.Api]: 'API',
|
||||
[TicketSubCategory.Connectivity]: 'Connectivity', [TicketSubCategory.VpnAccess]: 'VPN Access',
|
||||
[TicketSubCategory.S3Storage]: 'S3 Storage',
|
||||
[TicketSubCategory.Permissions]: 'Permissions', [TicketSubCategory.Login]: 'Login',
|
||||
[TicketSubCategory.BatteryFirmware]: 'Battery Firmware',
|
||||
[TicketSubCategory.InverterFirmware]: 'Inverter Firmware',
|
||||
[TicketSubCategory.ControllerFirmware]: 'Controller Firmware'
|
||||
};
|
||||
|
||||
export const subCategoriesByCategory: Record<number, number[]> = {
|
||||
[TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99],
|
||||
[TicketCategory.Software]: [0, 200, 201, 202, 203, 99],
|
||||
[TicketCategory.Network]: [0, 300, 301, 302, 99],
|
||||
[TicketCategory.UserAccess]: [0, 400, 401, 99],
|
||||
[TicketCategory.Firmware]: [0, 500, 501, 502, 99]
|
||||
};
|
||||
|
||||
export enum TicketSource {
|
||||
Manual = 0,
|
||||
AutoAlert = 1,
|
||||
Email = 2,
|
||||
Api = 3
|
||||
}
|
||||
|
||||
export enum CommentAuthorType {
|
||||
Human = 0,
|
||||
AiAgent = 1
|
||||
}
|
||||
|
||||
export enum DiagnosisStatus {
|
||||
Pending = 0,
|
||||
Analyzing = 1,
|
||||
Completed = 2,
|
||||
Failed = 3
|
||||
}
|
||||
|
||||
export enum TimelineEventType {
|
||||
Created = 0,
|
||||
StatusChanged = 1,
|
||||
Assigned = 2,
|
||||
CommentAdded = 3,
|
||||
AiDiagnosisAttached = 4,
|
||||
Escalated = 5
|
||||
}
|
||||
|
||||
export type Ticket = {
|
||||
id: number;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: number;
|
||||
priority: number;
|
||||
category: number;
|
||||
subCategory: number;
|
||||
source: number;
|
||||
installationId: number;
|
||||
assigneeId: number | null;
|
||||
createdByUserId: number;
|
||||
tags: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
resolvedAt: string | null;
|
||||
};
|
||||
|
||||
export type TicketComment = {
|
||||
id: number;
|
||||
ticketId: number;
|
||||
authorType: number;
|
||||
authorId: number | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type TicketAiDiagnosis = {
|
||||
id: number;
|
||||
ticketId: number;
|
||||
status: number;
|
||||
rootCause: string | null;
|
||||
confidence: number | null;
|
||||
recommendedActions: string | null;
|
||||
similarTicketIds: string | null;
|
||||
feedback: number | null;
|
||||
overrideText: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
export type TicketTimelineEvent = {
|
||||
id: number;
|
||||
ticketId: number;
|
||||
eventType: number;
|
||||
description: string;
|
||||
actorType: number;
|
||||
actorId: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type TicketDetail = {
|
||||
ticket: Ticket;
|
||||
comments: TicketComment[];
|
||||
diagnosis: TicketAiDiagnosis | null;
|
||||
timeline: TicketTimelineEvent[];
|
||||
};
|
||||
|
|
@ -515,5 +515,14 @@
|
|||
"tourConfigurationTitle": "Konfiguration",
|
||||
"tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.",
|
||||
"tourHistoryTitle": "Verlauf",
|
||||
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert."
|
||||
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert.",
|
||||
"tickets": "Tickets",
|
||||
"createTicket": "Ticket erstellen",
|
||||
"subject": "Betreff",
|
||||
"description": "Beschreibung",
|
||||
"priority": "Priorität",
|
||||
"category": "Kategorie",
|
||||
"allStatuses": "Alle Status",
|
||||
"createdAt": "Erstellt",
|
||||
"noTickets": "Keine Tickets gefunden."
|
||||
}
|
||||
|
|
@ -263,5 +263,14 @@
|
|||
"tourConfigurationTitle": "Configuration",
|
||||
"tourConfigurationContent": "View and modify device settings for this installation.",
|
||||
"tourHistoryTitle": "History",
|
||||
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when."
|
||||
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when.",
|
||||
"tickets": "Tickets",
|
||||
"createTicket": "Create Ticket",
|
||||
"subject": "Subject",
|
||||
"description": "Description",
|
||||
"priority": "Priority",
|
||||
"category": "Category",
|
||||
"allStatuses": "All Statuses",
|
||||
"createdAt": "Created",
|
||||
"noTickets": "No tickets found."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -515,5 +515,14 @@
|
|||
"tourConfigurationTitle": "Configuration",
|
||||
"tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.",
|
||||
"tourHistoryTitle": "Historique",
|
||||
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand."
|
||||
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand.",
|
||||
"tickets": "Tickets",
|
||||
"createTicket": "Créer un ticket",
|
||||
"subject": "Objet",
|
||||
"description": "Description",
|
||||
"priority": "Priorité",
|
||||
"category": "Catégorie",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"createdAt": "Créé",
|
||||
"noTickets": "Aucun ticket trouvé."
|
||||
}
|
||||
|
|
@ -515,5 +515,14 @@
|
|||
"tourConfigurationTitle": "Configurazione",
|
||||
"tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.",
|
||||
"tourHistoryTitle": "Cronologia",
|
||||
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando."
|
||||
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando.",
|
||||
"tickets": "Ticket",
|
||||
"createTicket": "Crea ticket",
|
||||
"subject": "Oggetto",
|
||||
"description": "Descrizione",
|
||||
"priority": "Priorità",
|
||||
"category": "Categoria",
|
||||
"allStatuses": "Tutti gli stati",
|
||||
"createdAt": "Creato",
|
||||
"noTickets": "Nessun ticket trovato."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { NavLink as RouterLink } from 'react-router-dom';
|
|||
import { SidebarContext } from 'src/contexts/SidebarContext';
|
||||
import BrightnessLowTwoToneIcon from '@mui/icons-material/BrightnessLowTwoTone';
|
||||
import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone';
|
||||
import ConfirmationNumberTwoToneIcon from '@mui/icons-material/ConfirmationNumberTwoTone';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { UserContext } from '../../../../contexts/userContext';
|
||||
import { UserType } from '../../../../interfaces/UserTypes';
|
||||
|
|
@ -310,6 +311,19 @@ function SidebarMenu() {
|
|||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
<List component="div">
|
||||
<ListItem component="div">
|
||||
<Button
|
||||
disableRipple
|
||||
component={RouterLink}
|
||||
onClick={closeSidebar}
|
||||
to="/tickets"
|
||||
startIcon={<ConfirmationNumberTwoToneIcon />}
|
||||
>
|
||||
<FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
</SubMenuWrapper>
|
||||
</List>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue