added ticket dashboard frontend: Detail & AI
This commit is contained in:
parent
a40c168f1a
commit
88173303d9
|
|
@ -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)
|
||||
comments = Db.GetCommentsForTicket(id),
|
||||
diagnosis = Db.GetDiagnosisForTicket(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.");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue