added ticket dashboard frontend: Detail & AI

This commit is contained in:
Yinyin Liu 2026-03-09 13:31:59 +01:00
parent a40c168f1a
commit 88173303d9
13 changed files with 1199 additions and 26 deletions

View File

@ -2088,14 +2088,68 @@ public class Controller : ControllerBase
var ticket = Db.GetTicketById(id);
if (ticket is null) return NotFound();
var installation = Db.GetInstallationById(ticket.InstallationId);
var creator = Db.GetUserById(ticket.CreatedByUserId);
var assignee = ticket.AssigneeId.HasValue ? Db.GetUserById(ticket.AssigneeId) : null;
return new
{
ticket,
comments = Db.GetCommentsForTicket(id),
diagnosis = Db.GetDiagnosisForTicket(id),
timeline = Db.GetTimelineForTicket(id)
timeline = Db.GetTimelineForTicket(id),
installationName = installation?.InstallationName ?? $"#{ticket.InstallationId}",
creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}",
assigneeName = assignee?.Name
};
}
[HttpGet(nameof(GetTicketSummaries))]
public ActionResult<Object> GetTicketSummaries(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var tickets = Db.GetAllTickets();
var summaries = tickets.Select(t =>
{
var installation = Db.GetInstallationById(t.InstallationId);
return new
{
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
t.InstallationId, t.CreatedAt, t.UpdatedAt,
installationName = installation?.InstallationName ?? $"#{t.InstallationId}"
};
});
return Ok(summaries);
}
[HttpGet(nameof(GetAdminUsers))]
public ActionResult<IEnumerable<Object>> GetAdminUsers(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Ok(Db.Users
.Where(u => u.UserType == 2)
.Select(u => new { u.Id, u.Name })
.ToList());
}
[HttpPost(nameof(SubmitDiagnosisFeedback))]
public ActionResult SubmitDiagnosisFeedback(Int64 ticketId, Int32 feedback, String? overrideText, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var diagnosis = Db.GetDiagnosisForTicket(ticketId);
if (diagnosis is null) return NotFound();
diagnosis.Feedback = feedback;
diagnosis.OverrideText = overrideText;
return Db.Update(diagnosis) ? Ok() : StatusCode(500, "Failed to save feedback.");
}
}

View File

@ -0,0 +1,276 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
CardHeader,
Chip,
CircularProgress,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Typography
} from '@mui/material';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
DiagnosisStatus,
DiagnosisFeedback,
TicketAiDiagnosis
} from 'src/interfaces/TicketTypes';
interface AiDiagnosisPanelProps {
diagnosis: TicketAiDiagnosis | null;
onRefresh: () => void;
}
function getConfidenceColor(
confidence: number
): 'success' | 'warning' | 'error' {
if (confidence >= 0.7) return 'success';
if (confidence >= 0.4) return 'warning';
return 'error';
}
function parseActions(actionsJson: string | null): string[] {
if (!actionsJson) return [];
try {
const parsed = JSON.parse(actionsJson);
return Array.isArray(parsed) ? parsed : [];
} catch {
return actionsJson.split('\n').filter((s) => s.trim());
}
}
const feedbackLabels: Record<number, string> = {
[DiagnosisFeedback.Accepted]: 'Accepted',
[DiagnosisFeedback.Rejected]: 'Rejected',
[DiagnosisFeedback.Overridden]: 'Overridden'
};
function AiDiagnosisPanel({ diagnosis, onRefresh }: AiDiagnosisPanelProps) {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [submittingFeedback, setSubmittingFeedback] = useState(false);
const isPending =
diagnosis &&
(diagnosis.status === DiagnosisStatus.Pending ||
diagnosis.status === DiagnosisStatus.Analyzing);
useEffect(() => {
if (isPending) {
intervalRef.current = setInterval(onRefresh, 5000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPending, onRefresh]);
const handleFeedback = (feedback: DiagnosisFeedback) => {
if (!diagnosis) return;
setSubmittingFeedback(true);
axiosConfig
.post('/SubmitDiagnosisFeedback', null, {
params: {
ticketId: diagnosis.ticketId,
feedback
}
})
.then(() => onRefresh())
.finally(() => setSubmittingFeedback(false));
};
if (!diagnosis) {
return (
<Card>
<CardHeader
title={
<FormattedMessage
id="aiDiagnosis"
defaultMessage="AI Diagnosis"
/>
}
/>
<Divider />
<CardContent>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noDiagnosis"
defaultMessage="No AI diagnosis available."
/>
</Typography>
</CardContent>
</Card>
);
}
if (isPending) {
return (
<Card>
<CardHeader
title={
<FormattedMessage
id="aiDiagnosis"
defaultMessage="AI Diagnosis"
/>
}
/>
<Divider />
<CardContent>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}
>
<CircularProgress size={24} />
<Typography variant="body2">
<FormattedMessage
id="diagnosisAnalyzing"
defaultMessage="AI is analyzing this ticket..."
/>
</Typography>
</Box>
</CardContent>
</Card>
);
}
if (diagnosis.status === DiagnosisStatus.Failed) {
return (
<Card>
<CardHeader
title={
<FormattedMessage
id="aiDiagnosis"
defaultMessage="AI Diagnosis"
/>
}
/>
<Divider />
<CardContent>
<Alert severity="error">
<FormattedMessage
id="diagnosisFailed"
defaultMessage="AI diagnosis failed. Please try again later."
/>
</Alert>
</CardContent>
</Card>
);
}
const actions = parseActions(diagnosis.recommendedActions);
const hasFeedback = diagnosis.feedback != null;
return (
<Card>
<CardHeader
title={
<FormattedMessage id="aiDiagnosis" defaultMessage="AI Diagnosis" />
}
/>
<Divider />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage id="rootCause" defaultMessage="Root Cause" />
</Typography>
<Typography variant="body2">
{diagnosis.rootCause ?? '-'}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage id="confidence" defaultMessage="Confidence" />
</Typography>
{diagnosis.confidence != null ? (
<Chip
label={`${Math.round(diagnosis.confidence * 100)}%`}
color={getConfidenceColor(diagnosis.confidence)}
size="small"
/>
) : (
<Typography variant="body2">-</Typography>
)}
</Box>
{actions.length > 0 && (
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage
id="recommendedActions"
defaultMessage="Recommended Actions"
/>
</Typography>
<List dense disablePadding>
{actions.map((action, i) => (
<ListItem key={i} disableGutters>
<ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircleOutlineIcon
fontSize="small"
color="primary"
/>
</ListItemIcon>
<ListItemText primary={action} />
</ListItem>
))}
</List>
</Box>
)}
<Divider />
{hasFeedback ? (
<Box>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="feedbackSubmitted"
defaultMessage="Feedback: {feedback}"
values={{
feedback: feedbackLabels[diagnosis.feedback!] ?? 'Unknown'
}}
/>
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2">
<FormattedMessage
id="diagnosisFeedbackLabel"
defaultMessage="Was this helpful?"
/>
</Typography>
<Button
size="small"
variant="outlined"
color="success"
startIcon={<ThumbUpIcon />}
disabled={submittingFeedback}
onClick={() => handleFeedback(DiagnosisFeedback.Accepted)}
>
<FormattedMessage id="accept" defaultMessage="Accept" />
</Button>
<Button
size="small"
variant="outlined"
color="error"
startIcon={<ThumbDownIcon />}
disabled={submittingFeedback}
onClick={() => handleFeedback(DiagnosisFeedback.Rejected)}
>
<FormattedMessage id="reject" defaultMessage="Reject" />
</Button>
</Box>
)}
</CardContent>
</Card>
);
}
export default AiDiagnosisPanel;

View File

@ -0,0 +1,133 @@
import React, { useState } from 'react';
import {
Avatar,
Box,
Button,
Card,
CardContent,
CardHeader,
Divider,
TextField,
Typography
} from '@mui/material';
import PersonIcon from '@mui/icons-material/Person';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import { CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
interface CommentThreadProps {
ticketId: number;
comments: TicketComment[];
onCommentAdded: () => void;
}
function CommentThread({
ticketId,
comments,
onCommentAdded
}: CommentThreadProps) {
const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const sorted = [...comments].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const handleSubmit = () => {
if (!body.trim()) return;
setSubmitting(true);
axiosConfig
.post('/AddTicketComment', { ticketId, body })
.then(() => {
setBody('');
onCommentAdded();
})
.finally(() => setSubmitting(false));
};
return (
<Card>
<CardHeader
title={
<FormattedMessage id="comments" defaultMessage="Comments" />
}
/>
<Divider />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sorted.length === 0 && (
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noComments"
defaultMessage="No comments yet."
/>
</Typography>
)}
{sorted.map((comment) => {
const isAi = comment.authorType === CommentAuthorType.AiAgent;
return (
<Box
key={comment.id}
sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}
>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: isAi ? 'primary.main' : 'grey.500'
}}
>
{isAi ? (
<SmartToyIcon fontSize="small" />
) : (
<PersonIcon fontSize="small" />
)}
</Avatar>
<Box sx={{ flex: 1 }}>
<Box
sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}
>
<Typography variant="subtitle2">
{isAi ? 'AI' : `User #${comment.authorId ?? '?'}`}
</Typography>
<Typography variant="caption" color="text.disabled">
{new Date(comment.createdAt).toLocaleString()}
</Typography>
</Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.body}
</Typography>
</Box>
</Box>
);
})}
<Divider />
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
multiline
minRows={2}
maxRows={4}
placeholder="Add a comment..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
variant="contained"
onClick={handleSubmit}
disabled={submitting || !body.trim()}
sx={{ alignSelf: 'flex-end' }}
>
<FormattedMessage id="addComment" defaultMessage="Add" />
</Button>
</Box>
</CardContent>
</Card>
);
}
export default CommentThread;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Chip } from '@mui/material';
import { TicketStatus } from 'src/interfaces/TicketTypes';
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'
};
interface StatusChipProps {
status: number;
size?: 'small' | 'medium';
}
function StatusChip({ status, size = 'small' }: StatusChipProps) {
return (
<Chip
label={statusLabels[status] ?? 'Unknown'}
color={statusColors[status] ?? 'default'}
size={size}
/>
);
}
export default StatusChip;

View File

@ -0,0 +1,432 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CardContent,
CardHeader,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Typography
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DeleteIcon from '@mui/icons-material/Delete';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
TicketDetail as TicketDetailType,
TicketStatus,
TicketPriority,
TicketCategory,
TicketSubCategory,
AdminUser,
subCategoryLabels
} from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer';
import StatusChip from './StatusChip';
import AiDiagnosisPanel from './AiDiagnosisPanel';
import CommentThread from './CommentThread';
import TimelinePanel from './TimelinePanel';
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'
};
const statusOptions = [
{ value: TicketStatus.Open, label: 'Open' },
{ value: TicketStatus.InProgress, label: 'In Progress' },
{ value: TicketStatus.Escalated, label: 'Escalated' },
{ value: TicketStatus.Resolved, label: 'Resolved' },
{ value: TicketStatus.Closed, label: 'Closed' }
];
function TicketDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [detail, setDetail] = useState<TicketDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [updatingStatus, setUpdatingStatus] = useState(false);
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [updatingAssignee, setUpdatingAssignee] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchDetail = useCallback(() => {
if (!id) return;
axiosConfig
.get('/GetTicketDetail', { params: { id: Number(id) } })
.then((res) => {
setDetail(res.data);
setError('');
})
.catch(() => setError('Failed to load ticket details.'))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchDetail();
axiosConfig
.get('/GetAdminUsers')
.then((res) => setAdminUsers(res.data))
.catch(() => {});
}, [fetchDetail]);
const handleStatusChange = (newStatus: number) => {
if (!detail) return;
setUpdatingStatus(true);
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, status: newStatus })
.then(() => fetchDetail())
.catch(() => setError('Failed to update status.'))
.finally(() => setUpdatingStatus(false));
};
const handleAssigneeChange = (assigneeId: number | '') => {
if (!detail) return;
setUpdatingAssignee(true);
axiosConfig
.put('/UpdateTicket', {
...detail.ticket,
assigneeId: assigneeId === '' ? null : assigneeId
})
.then(() => fetchDetail())
.catch(() => setError('Failed to update assignee.'))
.finally(() => setUpdatingAssignee(false));
};
const handleDelete = () => {
if (!id) return;
setDeleting(true);
axiosConfig
.delete('/DeleteTicket', { params: { id: Number(id) } })
.then(() => navigate('/tickets'))
.catch(() => {
setError('Failed to delete ticket.');
setDeleting(false);
setDeleteOpen(false);
});
};
if (loading) {
return (
<Container maxWidth="xl" sx={{ mt: 4, textAlign: 'center' }}>
<CircularProgress />
</Container>
);
}
if (error || !detail) {
return (
<Container maxWidth="xl" sx={{ mt: 4 }}>
<Alert severity="error">{error || 'Ticket not found.'}</Alert>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/tickets')}
sx={{ mt: 2 }}
>
<FormattedMessage id="backToTickets" defaultMessage="Back to Tickets" />
</Button>
</Container>
);
}
const { ticket, comments, diagnosis, timeline } = detail;
return (
<div style={{ userSelect: 'none' }}>
<Container maxWidth="xl" sx={{ mt: '20px' }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/tickets')}
>
<FormattedMessage id="backToTickets" defaultMessage="Back to Tickets" />
</Button>
<Button
color="error"
startIcon={<DeleteIcon />}
onClick={() => setDeleteOpen(true)}
>
<FormattedMessage id="deleteTicket" defaultMessage="Delete" />
</Button>
</Box>
<Box mb={3}>
<Typography variant="h3" gutterBottom>
#{ticket.id} {ticket.subject}
</Typography>
<Box display="flex" gap={1} alignItems="center">
<StatusChip status={ticket.status} size="medium" />
<Typography variant="body2" color="text.secondary">
{priorityLabels[ticket.priority] ?? '-'} ·{' '}
{categoryLabels[ticket.category] ?? '-'}
{ticket.subCategory !== TicketSubCategory.General &&
` · ${subCategoryLabels[ticket.subCategory] ?? ''}`}
</Typography>
</Box>
</Box>
<Grid container spacing={3}>
{/* Left column: Description, AI Diagnosis, Comments */}
<Grid item xs={12} md={8}>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage
id="description"
defaultMessage="Description"
/>
}
/>
<Divider />
<CardContent>
<Typography
variant="body1"
sx={{ whiteSpace: 'pre-wrap' }}
>
{ticket.description || (
<Typography
component="span"
variant="body2"
color="text.secondary"
>
<FormattedMessage
id="noDescription"
defaultMessage="No description provided."
/>
</Typography>
)}
</Typography>
</CardContent>
</Card>
<Box sx={{ mb: 3 }}>
<AiDiagnosisPanel
diagnosis={diagnosis}
onRefresh={fetchDetail}
/>
</Box>
<CommentThread
ticketId={ticket.id}
comments={comments}
onCommentAdded={fetchDetail}
/>
</Grid>
{/* Right column: Status, Assignee, Details, Timeline */}
<Grid item xs={12} md={4}>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage
id="updateStatus"
defaultMessage="Update Status"
/>
}
/>
<Divider />
<CardContent
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="status" defaultMessage="Status" />
</InputLabel>
<Select
value={ticket.status}
label="Status"
onChange={(e) =>
handleStatusChange(Number(e.target.value))
}
disabled={updatingStatus}
>
{statusOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="assignee" defaultMessage="Assignee" />
</InputLabel>
<Select
value={ticket.assigneeId ?? ''}
label="Assignee"
onChange={(e) =>
handleAssigneeChange(
e.target.value === '' ? '' : Number(e.target.value)
)
}
disabled={updatingAssignee}
>
<MenuItem value="">
<em>
<FormattedMessage
id="unassigned"
defaultMessage="Unassigned"
/>
</em>
</MenuItem>
{adminUsers.map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.name}
</MenuItem>
))}
</Select>
</FormControl>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage id="details" defaultMessage="Details" />
}
/>
<Divider />
<CardContent
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="installation"
defaultMessage="Installation"
/>
</Typography>
<Typography variant="body2">
{detail.installationName}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="createdBy"
defaultMessage="Created By"
/>
</Typography>
<Typography variant="body2">
{detail.creatorName}
</Typography>
</Box>
{detail.assigneeName && (
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="assignee"
defaultMessage="Assignee"
/>
</Typography>
<Typography variant="body2">
{detail.assigneeName}
</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="createdAt"
defaultMessage="Created"
/>
</Typography>
<Typography variant="body2">
{new Date(ticket.createdAt).toLocaleString()}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="updatedAt"
defaultMessage="Updated"
/>
</Typography>
<Typography variant="body2">
{new Date(ticket.updatedAt).toLocaleString()}
</Typography>
</Box>
{ticket.resolvedAt && (
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="resolvedAt"
defaultMessage="Resolved"
/>
</Typography>
<Typography variant="body2">
{new Date(ticket.resolvedAt).toLocaleString()}
</Typography>
</Box>
)}
</CardContent>
</Card>
<TimelinePanel events={timeline} />
</Grid>
</Grid>
{/* Delete confirmation dialog */}
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
<DialogTitle>
<FormattedMessage
id="confirmDeleteTicket"
defaultMessage="Delete Ticket?"
/>
</DialogTitle>
<DialogContent>
<DialogContentText>
<FormattedMessage
id="confirmDeleteTicketMessage"
defaultMessage="This will permanently delete this ticket, its comments, AI diagnosis, and timeline. This action cannot be undone."
/>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteOpen(false)} disabled={deleting}>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
color="error"
variant="contained"
onClick={handleDelete}
disabled={deleting}
>
<FormattedMessage id="deleteTicket" defaultMessage="Delete" />
</Button>
</DialogActions>
</Dialog>
</Container>
<Footer />
</div>
);
}
export default TicketDetailPage;

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Chip,
Container,
FormControl,
InputLabel,
@ -22,7 +22,7 @@ import AddIcon from '@mui/icons-material/Add';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
Ticket,
TicketSummary,
TicketStatus,
TicketPriority,
TicketCategory,
@ -31,6 +31,7 @@ import {
} from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer';
import CreateTicketModal from './CreateTicketModal';
import StatusChip from './StatusChip';
const statusLabels: Record<number, string> = {
[TicketStatus.Open]: 'Open',
@ -40,14 +41,6 @@ const statusLabels: Record<number, string> = {
[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',
@ -64,7 +57,8 @@ const categoryLabels: Record<number, string> = {
};
function TicketList() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const navigate = useNavigate();
const [tickets, setTickets] = useState<TicketSummary[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<number | ''>('');
const [createOpen, setCreateOpen] = useState(false);
@ -72,7 +66,7 @@ function TicketList() {
const fetchTickets = () => {
axiosConfig
.get('/GetAllTickets')
.get('/GetTicketSummaries')
.then((res) => setTickets(res.data))
.catch(() => setError('Failed to load tickets'));
};
@ -85,7 +79,7 @@ function TicketList() {
const matchesSearch =
search === '' ||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase());
t.installationName.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === '' || t.status === statusFilter;
return matchesSearch && matchesStatus;
});
@ -191,6 +185,12 @@ function TicketList() {
defaultMessage="Category"
/>
</TableCell>
<TableCell>
<FormattedMessage
id="installation"
defaultMessage="Installation"
/>
</TableCell>
<TableCell>
<FormattedMessage
id="createdAt"
@ -201,15 +201,11 @@ function TicketList() {
</TableHead>
<TableBody>
{filtered.map((ticket) => (
<TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }}>
<TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/tickets/${ticket.id}`)}>
<TableCell>{ticket.id}</TableCell>
<TableCell>{ticket.subject}</TableCell>
<TableCell>
<Chip
label={statusLabels[ticket.status] ?? 'Unknown'}
color={statusColors[ticket.status] ?? 'default'}
size="small"
/>
<StatusChip status={ticket.status} />
</TableCell>
<TableCell>{priorityLabels[ticket.priority] ?? '-'}</TableCell>
<TableCell>
@ -217,6 +213,7 @@ function TicketList() {
{ticket.subCategory !== TicketSubCategory.General &&
`${subCategoryLabels[ticket.subCategory] ?? ''}`}
</TableCell>
<TableCell>{ticket.installationName}</TableCell>
<TableCell>
{new Date(ticket.createdAt).toLocaleDateString()}
</TableCell>

View File

@ -0,0 +1,101 @@
import React from 'react';
import {
Box,
Card,
CardContent,
CardHeader,
Divider,
Typography
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import {
TicketTimelineEvent,
TimelineEventType
} from 'src/interfaces/TicketTypes';
const eventTypeLabels: Record<number, string> = {
[TimelineEventType.Created]: 'Created',
[TimelineEventType.StatusChanged]: 'Status Changed',
[TimelineEventType.Assigned]: 'Assigned',
[TimelineEventType.CommentAdded]: 'Comment Added',
[TimelineEventType.AiDiagnosisAttached]: 'AI Diagnosis',
[TimelineEventType.Escalated]: 'Escalated'
};
const eventTypeColors: Record<number, string> = {
[TimelineEventType.Created]: '#1976d2',
[TimelineEventType.StatusChanged]: '#ed6c02',
[TimelineEventType.Assigned]: '#9c27b0',
[TimelineEventType.CommentAdded]: '#2e7d32',
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
[TimelineEventType.Escalated]: '#d32f2f'
};
interface TimelinePanelProps {
events: TicketTimelineEvent[];
}
function TimelinePanel({ events }: TimelinePanelProps) {
const sorted = [...events].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return (
<Card>
<CardHeader
title={
<FormattedMessage id="timeline" defaultMessage="Timeline" />
}
/>
<Divider />
<CardContent>
{sorted.length === 0 ? (
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noTimelineEvents"
defaultMessage="No events yet."
/>
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{sorted.map((event) => (
<Box
key={event.id}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor:
eventTypeColors[event.eventType] ?? '#757575',
mt: 0.8,
flexShrink: 0
}}
/>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{eventTypeLabels[event.eventType] ?? 'Event'}
</Typography>
<Typography variant="body2" color="text.secondary">
{event.description}
</Typography>
<Typography variant="caption" color="text.disabled">
{new Date(event.createdAt).toLocaleString()}
</Typography>
</Box>
</Box>
))}
</Box>
)}
</CardContent>
</Card>
);
}
export default TimelinePanel;

View File

@ -1,11 +1,13 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import TicketList from './TicketList';
import TicketDetailPage from './TicketDetail';
function Tickets() {
return (
<Routes>
<Route index element={<TicketList />} />
<Route path=":id" element={<TicketDetailPage />} />
</Routes>
);
}

View File

@ -146,4 +146,31 @@ export type TicketDetail = {
comments: TicketComment[];
diagnosis: TicketAiDiagnosis | null;
timeline: TicketTimelineEvent[];
installationName: string;
creatorName: string;
assigneeName: string | null;
};
export type TicketSummary = {
id: number;
subject: string;
status: number;
priority: number;
category: number;
subCategory: number;
installationId: number;
createdAt: string;
updatedAt: string;
installationName: string;
};
export type AdminUser = {
id: number;
name: string;
};
export enum DiagnosisFeedback {
Accepted = 0,
Rejected = 1,
Overridden = 2
}

View File

@ -524,5 +524,33 @@
"category": "Kategorie",
"allStatuses": "Alle Status",
"createdAt": "Erstellt",
"noTickets": "Keine Tickets gefunden."
"noTickets": "Keine Tickets gefunden.",
"backToTickets": "Zurück zu Tickets",
"aiDiagnosis": "KI-Diagnose",
"rootCause": "Ursache",
"confidence": "Zuverlässigkeit",
"recommendedActions": "Empfohlene Massnahmen",
"diagnosisAnalyzing": "KI analysiert dieses Ticket...",
"diagnosisFailed": "KI-Diagnose fehlgeschlagen. Bitte versuchen Sie es später erneut.",
"noDiagnosis": "Keine KI-Diagnose verfügbar.",
"comments": "Kommentare",
"noComments": "Noch keine Kommentare.",
"addComment": "Hinzufügen",
"timeline": "Zeitverlauf",
"noTimelineEvents": "Noch keine Ereignisse.",
"updateStatus": "Status aktualisieren",
"details": "Details",
"createdBy": "Erstellt von",
"updatedAt": "Aktualisiert",
"resolvedAt": "Gelöst",
"noDescription": "Keine Beschreibung vorhanden.",
"assignee": "Zuständig",
"unassigned": "Nicht zugewiesen",
"deleteTicket": "Löschen",
"confirmDeleteTicket": "Ticket löschen?",
"confirmDeleteTicketMessage": "Dieses Ticket wird mit allen Kommentaren, KI-Diagnosen und dem Zeitverlauf dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"diagnosisFeedbackLabel": "War das hilfreich?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Akzeptieren",
"reject": "Ablehnen"
}

View File

@ -272,5 +272,33 @@
"category": "Category",
"allStatuses": "All Statuses",
"createdAt": "Created",
"noTickets": "No tickets found."
"noTickets": "No tickets found.",
"backToTickets": "Back to Tickets",
"aiDiagnosis": "AI Diagnosis",
"rootCause": "Root Cause",
"confidence": "Confidence",
"recommendedActions": "Recommended Actions",
"diagnosisAnalyzing": "AI is analyzing this ticket...",
"diagnosisFailed": "AI diagnosis failed. Please try again later.",
"noDiagnosis": "No AI diagnosis available.",
"comments": "Comments",
"noComments": "No comments yet.",
"addComment": "Add",
"timeline": "Timeline",
"noTimelineEvents": "No events yet.",
"updateStatus": "Update Status",
"details": "Details",
"createdBy": "Created By",
"updatedAt": "Updated",
"resolvedAt": "Resolved",
"noDescription": "No description provided.",
"assignee": "Assignee",
"unassigned": "Unassigned",
"deleteTicket": "Delete",
"confirmDeleteTicket": "Delete Ticket?",
"confirmDeleteTicketMessage": "This will permanently delete this ticket, its comments, AI diagnosis, and timeline. This action cannot be undone.",
"diagnosisFeedbackLabel": "Was this helpful?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accept",
"reject": "Reject"
}

View File

@ -524,5 +524,33 @@
"category": "Catégorie",
"allStatuses": "Tous les statuts",
"createdAt": "Créé",
"noTickets": "Aucun ticket trouvé."
"noTickets": "Aucun ticket trouvé.",
"backToTickets": "Retour aux tickets",
"aiDiagnosis": "Diagnostic IA",
"rootCause": "Cause principale",
"confidence": "Confiance",
"recommendedActions": "Actions recommandées",
"diagnosisAnalyzing": "L'IA analyse ce ticket...",
"diagnosisFailed": "Le diagnostic IA a échoué. Veuillez réessayer plus tard.",
"noDiagnosis": "Aucun diagnostic IA disponible.",
"comments": "Commentaires",
"noComments": "Aucun commentaire pour le moment.",
"addComment": "Ajouter",
"timeline": "Chronologie",
"noTimelineEvents": "Aucun événement pour le moment.",
"updateStatus": "Mettre à jour le statut",
"details": "Détails",
"createdBy": "Créé par",
"updatedAt": "Mis à jour",
"resolvedAt": "Résolu",
"noDescription": "Aucune description fournie.",
"assignee": "Responsable",
"unassigned": "Non assigné",
"deleteTicket": "Supprimer",
"confirmDeleteTicket": "Supprimer le ticket ?",
"confirmDeleteTicketMessage": "Ce ticket sera définitivement supprimé avec tous ses commentaires, diagnostics IA et sa chronologie. Cette action est irréversible.",
"diagnosisFeedbackLabel": "Était-ce utile ?",
"feedbackSubmitted": "Retour : {feedback}",
"accept": "Accepter",
"reject": "Rejeter"
}

View File

@ -524,5 +524,33 @@
"category": "Categoria",
"allStatuses": "Tutti gli stati",
"createdAt": "Creato",
"noTickets": "Nessun ticket trovato."
"noTickets": "Nessun ticket trovato.",
"backToTickets": "Torna ai ticket",
"aiDiagnosis": "Diagnosi IA",
"rootCause": "Causa principale",
"confidence": "Affidabilità",
"recommendedActions": "Azioni consigliate",
"diagnosisAnalyzing": "L'IA sta analizzando questo ticket...",
"diagnosisFailed": "Diagnosi IA fallita. Riprovare più tardi.",
"noDiagnosis": "Nessuna diagnosi IA disponibile.",
"comments": "Commenti",
"noComments": "Nessun commento ancora.",
"addComment": "Aggiungi",
"timeline": "Cronologia",
"noTimelineEvents": "Nessun evento ancora.",
"updateStatus": "Aggiorna stato",
"details": "Dettagli",
"createdBy": "Creato da",
"updatedAt": "Aggiornato",
"resolvedAt": "Risolto",
"noDescription": "Nessuna descrizione fornita.",
"assignee": "Assegnatario",
"unassigned": "Non assegnato",
"deleteTicket": "Elimina",
"confirmDeleteTicket": "Eliminare il ticket?",
"confirmDeleteTicketMessage": "Questo ticket verrà eliminato definitivamente con tutti i commenti, la diagnosi IA e la cronologia. Questa azione non può essere annullata.",
"diagnosisFeedbackLabel": "È stato utile?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accetta",
"reject": "Rifiuta"
}