ticket dashboard beta version

This commit is contained in:
Yinyin Liu 2026-03-16 12:07:18 +01:00
parent 88173303d9
commit bf47a82b25
12 changed files with 738 additions and 94 deletions

View File

@ -2004,10 +2004,21 @@ public class Controller : ControllerBase
var existing = Db.GetTicketById(ticket.Id);
if (existing is null) return NotFound();
// Enforce resolution when resolving
if (ticket.Status == (Int32)TicketStatus.Resolved
&& (String.IsNullOrWhiteSpace(ticket.RootCause) || String.IsNullOrWhiteSpace(ticket.Solution)))
{
return BadRequest("Root Cause and Solution are required to resolve a ticket.");
}
ticket.CreatedAt = existing.CreatedAt;
ticket.CreatedByUserId = existing.CreatedByUserId;
ticket.UpdatedAt = DateTime.UtcNow;
// Track resolution added
var resolutionAdded = String.IsNullOrWhiteSpace(existing.RootCause)
&& !String.IsNullOrWhiteSpace(ticket.RootCause);
if (ticket.Status != existing.Status)
{
if (ticket.Status == (Int32)TicketStatus.Resolved)
@ -2024,6 +2035,19 @@ public class Controller : ControllerBase
});
}
if (resolutionAdded)
{
Db.Create(new TicketTimelineEvent
{
TicketId = ticket.Id,
EventType = (Int32)TimelineEventType.ResolutionAdded,
Description = $"Resolution added by {user.Name}.",
ActorType = (Int32)TimelineActorType.Human,
ActorId = user.Id,
CreatedAt = DateTime.UtcNow
});
}
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
}
@ -2149,7 +2173,34 @@ public class Controller : ControllerBase
diagnosis.Feedback = feedback;
diagnosis.OverrideText = overrideText;
return Db.Update(diagnosis) ? Ok() : StatusCode(500, "Failed to save feedback.");
if (!Db.Update(diagnosis)) return StatusCode(500, "Failed to save feedback.");
// On Accept: pre-fill ticket resolution from AI (only if not already filled)
if (feedback == (Int32)DiagnosisFeedback.Accepted)
{
var ticket = Db.GetTicketById(ticketId);
if (ticket is not null && String.IsNullOrWhiteSpace(ticket.RootCause))
{
ticket.RootCause = diagnosis.RootCause ?? "";
// RecommendedActions is stored as JSON array — parse to readable text
try
{
var actions = JsonConvert.DeserializeObject<string[]>(diagnosis.RecommendedActions ?? "[]");
ticket.Solution = actions is not null ? String.Join("\n", actions) : "";
}
catch
{
ticket.Solution = diagnosis.RecommendedActions ?? "";
}
ticket.PreFilledFromAi = true;
ticket.UpdatedAt = DateTime.UtcNow;
Db.Update(ticket);
}
}
return Ok();
}
}

View File

@ -45,4 +45,8 @@ public class Ticket
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; }
public String? RootCause { get; set; }
public String? Solution { get; set; }
public Boolean PreFilledFromAi { get; set; }
}

View File

@ -5,7 +5,8 @@ namespace InnovEnergy.App.Backend.DataTypes;
public enum TimelineEventType
{
Created = 0, StatusChanged = 1, Assigned = 2,
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5,
ResolutionAdded = 6
}
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }

View File

@ -14,18 +14,20 @@ 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';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
interface CommentThreadProps {
ticketId: number;
comments: TicketComment[];
onCommentAdded: () => void;
adminUsers?: AdminUser[];
}
function CommentThread({
ticketId,
comments,
onCommentAdded
onCommentAdded,
adminUsers = []
}: CommentThreadProps) {
const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false);
@ -89,7 +91,7 @@ function CommentThread({
sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}
>
<Typography variant="subtitle2">
{isAi ? 'AI' : `User #${comment.authorId ?? '?'}`}
{isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)}
</Typography>
<Typography variant="caption" color="text.disabled">
{new Date(comment.createdAt).toLocaleString()}

View File

@ -20,11 +20,13 @@ import {
InputLabel,
MenuItem,
Select,
TextField,
Typography
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DeleteIcon from '@mui/icons-material/Delete';
import { FormattedMessage } from 'react-intl';
import EditIcon from '@mui/icons-material/Edit';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
TicketDetail as TicketDetailType,
@ -33,7 +35,9 @@ import {
TicketCategory,
TicketSubCategory,
AdminUser,
subCategoryLabels
subCategoryLabels,
subCategoryKeys,
subCategoriesByCategory
} from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer';
import StatusChip from './StatusChip';
@ -41,32 +45,33 @@ 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 priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
[TicketPriority.High]: { id: 'priorityHigh', defaultMessage: 'High' },
[TicketPriority.Medium]: { id: 'priorityMedium', defaultMessage: 'Medium' },
[TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' }
};
const categoryLabels: Record<number, string> = {
[TicketCategory.Hardware]: 'Hardware',
[TicketCategory.Software]: 'Software',
[TicketCategory.Network]: 'Network',
[TicketCategory.UserAccess]: 'User Access',
[TicketCategory.Firmware]: 'Firmware'
const categoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketCategory.Hardware]: { id: 'categoryHardware', defaultMessage: 'Hardware' },
[TicketCategory.Software]: { id: 'categorySoftware', defaultMessage: 'Software' },
[TicketCategory.Network]: { id: 'categoryNetwork', defaultMessage: 'Network' },
[TicketCategory.UserAccess]: { id: 'categoryUserAccess', defaultMessage: 'User Access' },
[TicketCategory.Firmware]: { id: 'categoryFirmware', defaultMessage: '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' }
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
{ value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
];
function TicketDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const intl = useIntl();
const [detail, setDetail] = useState<TicketDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@ -75,6 +80,15 @@ function TicketDetailPage() {
const [updatingAssignee, setUpdatingAssignee] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [rootCause, setRootCause] = useState('');
const [solution, setSolution] = useState('');
const [savingResolution, setSavingResolution] = useState(false);
const [resolutionError, setResolutionError] = useState('');
const [resolutionSaved, setResolutionSaved] = useState(false);
const [description, setDescription] = useState('');
const [editingDescription, setEditingDescription] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const [descriptionSaved, setDescriptionSaved] = useState(false);
const fetchDetail = useCallback(() => {
if (!id) return;
@ -82,6 +96,9 @@ function TicketDetailPage() {
.get('/GetTicketDetail', { params: { id: Number(id) } })
.then((res) => {
setDetail(res.data);
setRootCause(res.data.ticket.rootCause ?? '');
setSolution(res.data.ticket.solution ?? '');
setDescription(res.data.ticket.description ?? '');
setError('');
})
.catch(() => setError('Failed to load ticket details.'))
@ -98,9 +115,24 @@ function TicketDetailPage() {
const handleStatusChange = (newStatus: number) => {
if (!detail) return;
if (
newStatus === TicketStatus.Resolved &&
(!rootCause.trim() || !solution.trim())
) {
setResolutionError(
'Root Cause and Solution are required to resolve a ticket.'
);
return;
}
setResolutionError('');
setUpdatingStatus(true);
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, status: newStatus })
.put('/UpdateTicket', {
...detail.ticket,
status: newStatus,
rootCause,
solution
})
.then(() => fetchDetail())
.catch(() => setError('Failed to update status.'))
.finally(() => setUpdatingStatus(false));
@ -119,6 +151,14 @@ function TicketDetailPage() {
.finally(() => setUpdatingAssignee(false));
};
const handleTicketFieldChange = (fields: Partial<typeof detail.ticket>) => {
if (!detail) return;
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, ...fields })
.then(() => fetchDetail())
.catch(() => setError('Failed to update ticket.'));
};
const handleDelete = () => {
if (!id) return;
setDeleting(true);
@ -132,6 +172,36 @@ function TicketDetailPage() {
});
};
const handleSaveDescription = () => {
if (!detail) return;
setSavingDescription(true);
setDescriptionSaved(false);
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, description })
.then(() => {
fetchDetail();
setDescriptionSaved(true);
setEditingDescription(false);
})
.catch(() => setError('Failed to save description.'))
.finally(() => setSavingDescription(false));
};
const handleSaveResolution = () => {
if (!detail) return;
setSavingResolution(true);
setResolutionError('');
setResolutionSaved(false);
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, rootCause, solution })
.then(() => {
fetchDetail();
setResolutionSaved(true);
})
.catch(() => setResolutionError('Failed to save resolution.'))
.finally(() => setSavingResolution(false));
};
if (loading) {
return (
<Container maxWidth="xl" sx={{ mt: 4, textAlign: 'center' }}>
@ -183,10 +253,10 @@ function TicketDetailPage() {
<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] ?? '-'}
{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '}
{intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })}
{ticket.subCategory !== TicketSubCategory.General &&
` · ${subCategoryLabels[ticket.subCategory] ?? ''}`}
` · ${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`}
</Typography>
</Box>
</Box>
@ -202,26 +272,76 @@ function TicketDetailPage() {
defaultMessage="Description"
/>
}
action={
!editingDescription && (
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => setEditingDescription(true)}
>
<FormattedMessage id="edit" defaultMessage="Edit" />
</Button>
)
}
/>
<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>
{editingDescription ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
multiline
minRows={3}
fullWidth
value={description}
onChange={(e) => {
setDescription(e.target.value);
setDescriptionSaved(false);
}}
/>
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
{descriptionSaved && (
<Typography variant="body2" color="success.main">
<FormattedMessage id="descriptionSaved" defaultMessage="Description saved." />
</Typography>
)}
<Button
size="small"
onClick={() => {
setEditingDescription(false);
setDescription(ticket.description ?? '');
}}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
variant="contained"
size="small"
onClick={handleSaveDescription}
disabled={savingDescription}
>
<FormattedMessage id="save" defaultMessage="Save" />
</Button>
</Box>
</Box>
) : (
<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>
@ -232,10 +352,128 @@ function TicketDetailPage() {
/>
</Box>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage
id="resolution"
defaultMessage="Resolution"
/>
}
subheader={
detail.ticket.preFilledFromAi ? (
<Typography variant="caption" color="info.main">
<FormattedMessage
id="preFilledFromAi"
defaultMessage="Pre-filled from AI diagnosis"
/>
</Typography>
) : undefined
}
/>
<Divider />
<CardContent
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{ticket.status === TicketStatus.Resolved ||
ticket.status === TicketStatus.Closed ? (
<>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage
id="rootCauseLabel"
defaultMessage="Root Cause"
/>
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{ticket.rootCause || '-'}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage
id="solutionLabel"
defaultMessage="Solution"
/>
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{ticket.solution || '-'}
</Typography>
</Box>
</>
) : (
<>
<TextField
label={
<FormattedMessage
id="rootCauseLabel"
defaultMessage="Root Cause"
/>
}
multiline
minRows={2}
fullWidth
value={rootCause}
onChange={(e) => setRootCause(e.target.value)}
error={
!!resolutionError && !rootCause.trim()
}
/>
<TextField
label={
<FormattedMessage
id="solutionLabel"
defaultMessage="Solution"
/>
}
multiline
minRows={2}
fullWidth
value={solution}
onChange={(e) => setSolution(e.target.value)}
error={
!!resolutionError && !solution.trim()
}
/>
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
{resolutionSaved && (
<Typography variant="body2" color="success.main">
<FormattedMessage
id="resolutionSaved"
defaultMessage="Resolution saved successfully."
/>
</Typography>
)}
{resolutionError && (
<Typography variant="body2" color="error.main">
{resolutionError}
</Typography>
)}
<Button
variant="contained"
size="small"
onClick={handleSaveResolution}
disabled={
savingResolution ||
(!rootCause.trim() && !solution.trim())
}
>
<FormattedMessage
id="saveResolution"
defaultMessage="Save Resolution"
/>
</Button>
</Box>
</>
)}
</CardContent>
</Card>
<CommentThread
ticketId={ticket.id}
comments={comments}
onCommentAdded={fetchDetail}
adminUsers={adminUsers}
/>
</Grid>
@ -266,9 +504,9 @@ function TicketDetailPage() {
}
disabled={updatingStatus}
>
{statusOptions.map((opt) => (
{statusKeys.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
{intl.formatMessage({ id: opt.id, defaultMessage: opt.defaultMessage })}
</MenuItem>
))}
</Select>
@ -303,6 +541,67 @@ function TicketDetailPage() {
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="priority" defaultMessage="Priority" />
</InputLabel>
<Select
value={ticket.priority}
label="Priority"
onChange={(e) =>
handleTicketFieldChange({ priority: Number(e.target.value) })
}
>
{Object.entries(priorityKeys).map(([value, msg]) => (
<MenuItem key={value} value={Number(value)}>
{intl.formatMessage(msg)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="category" defaultMessage="Category" />
</InputLabel>
<Select
value={ticket.category}
label="Category"
onChange={(e) => {
const newCat = Number(e.target.value);
handleTicketFieldChange({
category: newCat,
subCategory: TicketSubCategory.General
});
}}
>
{Object.entries(categoryKeys).map(([value, msg]) => (
<MenuItem key={value} value={Number(value)}>
{intl.formatMessage(msg)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="subCategory" defaultMessage="Sub-Category" />
</InputLabel>
<Select
value={ticket.subCategory}
label="Sub-Category"
onChange={(e) =>
handleTicketFieldChange({ subCategory: Number(e.target.value) })
}
>
{(subCategoriesByCategory[ticket.category] ?? [0]).map((sc) => (
<MenuItem key={sc} value={sc}>
{subCategoryKeys[sc] ? intl.formatMessage(subCategoryKeys[sc]) : subCategoryLabels[sc] ?? 'Unknown'}
</MenuItem>
))}
</Select>
</FormControl>
</CardContent>
</Card>
@ -423,6 +722,7 @@ function TicketDetailPage() {
</Button>
</DialogActions>
</Dialog>
</Container>
<Footer />
</div>

View File

@ -19,7 +19,7 @@ import {
Typography
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
TicketSummary,
@ -27,37 +27,39 @@ import {
TicketPriority,
TicketCategory,
TicketSubCategory,
subCategoryLabels
subCategoryLabels,
subCategoryKeys
} 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',
[TicketStatus.InProgress]: 'In Progress',
[TicketStatus.Escalated]: 'Escalated',
[TicketStatus.Resolved]: 'Resolved',
[TicketStatus.Closed]: 'Closed'
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
[TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
};
const priorityLabels: Record<number, string> = {
[TicketPriority.Critical]: 'Critical',
[TicketPriority.High]: 'High',
[TicketPriority.Medium]: 'Medium',
[TicketPriority.Low]: 'Low'
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
[TicketPriority.High]: { id: 'priorityHigh', defaultMessage: 'High' },
[TicketPriority.Medium]: { id: 'priorityMedium', defaultMessage: 'Medium' },
[TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' }
};
const categoryLabels: Record<number, string> = {
[TicketCategory.Hardware]: 'Hardware',
[TicketCategory.Software]: 'Software',
[TicketCategory.Network]: 'Network',
[TicketCategory.UserAccess]: 'User Access',
[TicketCategory.Firmware]: 'Firmware'
const categoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketCategory.Hardware]: { id: 'categoryHardware', defaultMessage: 'Hardware' },
[TicketCategory.Software]: { id: 'categorySoftware', defaultMessage: 'Software' },
[TicketCategory.Network]: { id: 'categoryNetwork', defaultMessage: 'Network' },
[TicketCategory.UserAccess]: { id: 'categoryUserAccess', defaultMessage: 'User Access' },
[TicketCategory.Firmware]: { id: 'categoryFirmware', defaultMessage: 'Firmware' }
};
function TicketList() {
const navigate = useNavigate();
const intl = useIntl();
const [tickets, setTickets] = useState<TicketSummary[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<number | ''>('');
@ -75,14 +77,16 @@ function TicketList() {
fetchTickets();
}, []);
const filtered = tickets.filter((t) => {
const matchesSearch =
search === '' ||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
t.installationName.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === '' || t.status === statusFilter;
return matchesSearch && matchesStatus;
});
const filtered = tickets
.filter((t) => {
const matchesSearch =
search === '' ||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
t.installationName.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === '' || t.status === statusFilter;
return matchesSearch && matchesStatus;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return (
<div style={{ userSelect: 'none' }}>
@ -133,9 +137,9 @@ function TicketList() {
defaultMessage="All Statuses"
/>
</MenuItem>
{Object.entries(statusLabels).map(([val, label]) => (
{Object.entries(statusKeys).map(([val, msg]) => (
<MenuItem key={val} value={Number(val)}>
{label}
{intl.formatMessage(msg)}
</MenuItem>
))}
</Select>
@ -207,11 +211,11 @@ function TicketList() {
<TableCell>
<StatusChip status={ticket.status} />
</TableCell>
<TableCell>{priorityLabels[ticket.priority] ?? '-'}</TableCell>
<TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell>
<TableCell>
{categoryLabels[ticket.category] ?? '-'}
{intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })}
{ticket.subCategory !== TicketSubCategory.General &&
`${subCategoryLabels[ticket.subCategory] ?? ''}`}
`${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`}
</TableCell>
<TableCell>{ticket.installationName}</TableCell>
<TableCell>

View File

@ -7,19 +7,20 @@ import {
Divider,
Typography
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } 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 eventTypeKeys: Record<number, { id: string; defaultMessage: string }> = {
[TimelineEventType.Created]: { id: 'timelineCreated', defaultMessage: 'Created' },
[TimelineEventType.StatusChanged]: { id: 'timelineStatusChanged', defaultMessage: 'Status Changed' },
[TimelineEventType.Assigned]: { id: 'timelineAssigned', defaultMessage: 'Assigned' },
[TimelineEventType.CommentAdded]: { id: 'timelineCommentAdded', defaultMessage: 'Comment Added' },
[TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' },
[TimelineEventType.Escalated]: { id: 'timelineEscalated', defaultMessage: 'Escalated' },
[TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' }
};
const eventTypeColors: Record<number, string> = {
@ -28,7 +29,8 @@ const eventTypeColors: Record<number, string> = {
[TimelineEventType.Assigned]: '#9c27b0',
[TimelineEventType.CommentAdded]: '#2e7d32',
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
[TimelineEventType.Escalated]: '#d32f2f'
[TimelineEventType.Escalated]: '#d32f2f',
[TimelineEventType.ResolutionAdded]: '#4caf50'
};
interface TimelinePanelProps {
@ -36,6 +38,7 @@ interface TimelinePanelProps {
}
function TimelinePanel({ events }: TimelinePanelProps) {
const intl = useIntl();
const sorted = [...events].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
@ -80,7 +83,9 @@ function TimelinePanel({ events }: TimelinePanelProps) {
/>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{eventTypeLabels[event.eventType] ?? 'Event'}
{eventTypeKeys[event.eventType]
? intl.formatMessage(eventTypeKeys[event.eventType])
: 'Event'}
</Typography>
<Typography variant="body2" color="text.secondary">
{event.description}

View File

@ -54,6 +54,31 @@ export const subCategoryLabels: Record<number, string> = {
[TicketSubCategory.ControllerFirmware]: 'Controller Firmware'
};
export const subCategoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketSubCategory.General]: { id: 'subCatGeneral', defaultMessage: 'General' },
[TicketSubCategory.Other]: { id: 'subCatOther', defaultMessage: 'Other' },
[TicketSubCategory.Battery]: { id: 'subCatBattery', defaultMessage: 'Battery' },
[TicketSubCategory.Inverter]: { id: 'subCatInverter', defaultMessage: 'Inverter' },
[TicketSubCategory.Cable]: { id: 'subCatCable', defaultMessage: 'Cable' },
[TicketSubCategory.Gateway]: { id: 'subCatGateway', defaultMessage: 'Gateway' },
[TicketSubCategory.Metering]: { id: 'subCatMetering', defaultMessage: 'Metering' },
[TicketSubCategory.Cooling]: { id: 'subCatCooling', defaultMessage: 'Cooling' },
[TicketSubCategory.PvSolar]: { id: 'subCatPvSolar', defaultMessage: 'PV / Solar' },
[TicketSubCategory.Safety]: { id: 'subCatSafety', defaultMessage: 'Safety' },
[TicketSubCategory.Backend]: { id: 'subCatBackend', defaultMessage: 'Backend' },
[TicketSubCategory.Frontend]: { id: 'subCatFrontend', defaultMessage: 'Frontend' },
[TicketSubCategory.Database]: { id: 'subCatDatabase', defaultMessage: 'Database' },
[TicketSubCategory.Api]: { id: 'subCatApi', defaultMessage: 'API' },
[TicketSubCategory.Connectivity]: { id: 'subCatConnectivity', defaultMessage: 'Connectivity' },
[TicketSubCategory.VpnAccess]: { id: 'subCatVpnAccess', defaultMessage: 'VPN Access' },
[TicketSubCategory.S3Storage]: { id: 'subCatS3Storage', defaultMessage: 'S3 Storage' },
[TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' },
[TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' },
[TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' },
[TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' },
[TicketSubCategory.ControllerFirmware]: { id: 'subCatControllerFirmware', defaultMessage: '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],
@ -87,7 +112,8 @@ export enum TimelineEventType {
Assigned = 2,
CommentAdded = 3,
AiDiagnosisAttached = 4,
Escalated = 5
Escalated = 5,
ResolutionAdded = 6
}
export type Ticket = {
@ -106,6 +132,9 @@ export type Ticket = {
createdAt: string;
updatedAt: string;
resolvedAt: string | null;
rootCause: string | null;
solution: string | null;
preFilledFromAi: boolean;
};
export type TicketComment = {

View File

@ -552,5 +552,67 @@
"diagnosisFeedbackLabel": "War das hilfreich?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Akzeptieren",
"reject": "Ablehnen"
"reject": "Ablehnen",
"resolution": "Lösung",
"rootCauseLabel": "Ursache",
"solutionLabel": "Lösung",
"saveResolution": "Lösung speichern",
"preFilledFromAi": "Vorausgefüllt durch KI-Diagnose",
"resolutionRequired": "Ursache und Lösung sind erforderlich, um ein Ticket zu lösen.",
"resolutionSaved": "Lösung erfolgreich gespeichert.",
"statusOpen": "Offen",
"statusInProgress": "In Bearbeitung",
"statusEscalated": "Eskaliert",
"statusResolved": "Gelöst",
"statusClosed": "Geschlossen",
"priorityCritical": "Kritisch",
"priorityHigh": "Hoch",
"priorityMedium": "Mittel",
"priorityLow": "Niedrig",
"categoryHardware": "Hardware",
"categorySoftware": "Software",
"categoryNetwork": "Netzwerk",
"categoryUserAccess": "Benutzerzugang",
"categoryFirmware": "Firmware",
"subCategory": "Unterkategorie",
"edit": "Bearbeiten",
"save": "Speichern",
"descriptionSaved": "Beschreibung gespeichert.",
"subCatGeneral": "Allgemein",
"subCatOther": "Sonstiges",
"subCatBattery": "Batterie",
"subCatInverter": "Wechselrichter",
"subCatCable": "Kabel",
"subCatGateway": "Gateway",
"subCatMetering": "Messung",
"subCatCooling": "Kühlung",
"subCatPvSolar": "PV / Solar",
"subCatSafety": "Sicherheit",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Datenbank",
"subCatApi": "API",
"subCatConnectivity": "Konnektivität",
"subCatVpnAccess": "VPN-Zugang",
"subCatS3Storage": "S3-Speicher",
"subCatPermissions": "Berechtigungen",
"subCatLogin": "Anmeldung",
"subCatBatteryFirmware": "Batterie-Firmware",
"subCatInverterFirmware": "Wechselrichter-Firmware",
"subCatControllerFirmware": "Controller-Firmware",
"timelineCreated": "Erstellt",
"timelineStatusChanged": "Status geändert",
"timelineAssigned": "Zugewiesen",
"timelineCommentAdded": "Kommentar hinzugefügt",
"timelineAiDiagnosis": "KI-Diagnose",
"timelineEscalated": "Eskaliert",
"timelineResolutionAdded": "Lösung hinzugefügt",
"timelineCreatedDesc": "Ticket erstellt von {name}.",
"timelineStatusChangedDesc": "Status geändert auf {status}.",
"timelineAssignedDesc": "Zugewiesen an {name}.",
"timelineCommentAddedDesc": "Kommentar hinzugefügt von {name}.",
"timelineAiDiagnosisCompletedDesc": "KI-Diagnose abgeschlossen.",
"timelineAiDiagnosisFailedDesc": "KI-Diagnose fehlgeschlagen.",
"timelineEscalatedDesc": "Ticket eskaliert.",
"timelineResolutionAddedDesc": "Lösung hinzugefügt von {name}."
}

View File

@ -300,5 +300,67 @@
"diagnosisFeedbackLabel": "Was this helpful?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accept",
"reject": "Reject"
"reject": "Reject",
"resolution": "Resolution",
"rootCauseLabel": "Root Cause",
"solutionLabel": "Solution",
"saveResolution": "Save Resolution",
"preFilledFromAi": "Pre-filled from AI diagnosis",
"resolutionRequired": "Root Cause and Solution are required to resolve a ticket.",
"resolutionSaved": "Resolution saved successfully.",
"statusOpen": "Open",
"statusInProgress": "In Progress",
"statusEscalated": "Escalated",
"statusResolved": "Resolved",
"statusClosed": "Closed",
"priorityCritical": "Critical",
"priorityHigh": "High",
"priorityMedium": "Medium",
"priorityLow": "Low",
"categoryHardware": "Hardware",
"categorySoftware": "Software",
"categoryNetwork": "Network",
"categoryUserAccess": "User Access",
"categoryFirmware": "Firmware",
"subCategory": "Sub-Category",
"edit": "Edit",
"save": "Save",
"descriptionSaved": "Description saved.",
"subCatGeneral": "General",
"subCatOther": "Other",
"subCatBattery": "Battery",
"subCatInverter": "Inverter",
"subCatCable": "Cable",
"subCatGateway": "Gateway",
"subCatMetering": "Metering",
"subCatCooling": "Cooling",
"subCatPvSolar": "PV / Solar",
"subCatSafety": "Safety",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Database",
"subCatApi": "API",
"subCatConnectivity": "Connectivity",
"subCatVpnAccess": "VPN Access",
"subCatS3Storage": "S3 Storage",
"subCatPermissions": "Permissions",
"subCatLogin": "Login",
"subCatBatteryFirmware": "Battery Firmware",
"subCatInverterFirmware": "Inverter Firmware",
"subCatControllerFirmware": "Controller Firmware",
"timelineCreated": "Created",
"timelineStatusChanged": "Status Changed",
"timelineAssigned": "Assigned",
"timelineCommentAdded": "Comment Added",
"timelineAiDiagnosis": "AI Diagnosis",
"timelineEscalated": "Escalated",
"timelineResolutionAdded": "Resolution Added",
"timelineCreatedDesc": "Ticket created by {name}.",
"timelineStatusChangedDesc": "Status changed to {status}.",
"timelineAssignedDesc": "Assigned to {name}.",
"timelineCommentAddedDesc": "Comment added by {name}.",
"timelineAiDiagnosisCompletedDesc": "AI diagnosis completed.",
"timelineAiDiagnosisFailedDesc": "AI diagnosis failed.",
"timelineEscalatedDesc": "Ticket escalated.",
"timelineResolutionAddedDesc": "Resolution added by {name}."
}

View File

@ -552,5 +552,67 @@
"diagnosisFeedbackLabel": "Était-ce utile ?",
"feedbackSubmitted": "Retour : {feedback}",
"accept": "Accepter",
"reject": "Rejeter"
"reject": "Rejeter",
"resolution": "Résolution",
"rootCauseLabel": "Cause principale",
"solutionLabel": "Solution",
"saveResolution": "Enregistrer la résolution",
"preFilledFromAi": "Pré-rempli par le diagnostic IA",
"resolutionRequired": "La cause principale et la solution sont requises pour résoudre un ticket.",
"resolutionSaved": "Résolution enregistrée avec succès.",
"statusOpen": "Ouvert",
"statusInProgress": "En cours",
"statusEscalated": "Escaladé",
"statusResolved": "Résolu",
"statusClosed": "Fermé",
"priorityCritical": "Critique",
"priorityHigh": "Élevée",
"priorityMedium": "Moyenne",
"priorityLow": "Faible",
"categoryHardware": "Matériel",
"categorySoftware": "Logiciel",
"categoryNetwork": "Réseau",
"categoryUserAccess": "Accès utilisateur",
"categoryFirmware": "Firmware",
"subCategory": "Sous-catégorie",
"edit": "Modifier",
"save": "Enregistrer",
"descriptionSaved": "Description enregistrée.",
"subCatGeneral": "Général",
"subCatOther": "Autre",
"subCatBattery": "Batterie",
"subCatInverter": "Onduleur",
"subCatCable": "Câble",
"subCatGateway": "Passerelle",
"subCatMetering": "Comptage",
"subCatCooling": "Refroidissement",
"subCatPvSolar": "PV / Solaire",
"subCatSafety": "Sécurité",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Base de données",
"subCatApi": "API",
"subCatConnectivity": "Connectivité",
"subCatVpnAccess": "Accès VPN",
"subCatS3Storage": "Stockage S3",
"subCatPermissions": "Autorisations",
"subCatLogin": "Connexion",
"subCatBatteryFirmware": "Firmware batterie",
"subCatInverterFirmware": "Firmware onduleur",
"subCatControllerFirmware": "Firmware contrôleur",
"timelineCreated": "Créé",
"timelineStatusChanged": "Statut modifié",
"timelineAssigned": "Assigné",
"timelineCommentAdded": "Commentaire ajouté",
"timelineAiDiagnosis": "Diagnostic IA",
"timelineEscalated": "Escaladé",
"timelineResolutionAdded": "Résolution ajoutée",
"timelineCreatedDesc": "Ticket créé par {name}.",
"timelineStatusChangedDesc": "Statut modifié en {status}.",
"timelineAssignedDesc": "Assigné à {name}.",
"timelineCommentAddedDesc": "Commentaire ajouté par {name}.",
"timelineAiDiagnosisCompletedDesc": "Diagnostic IA terminé.",
"timelineAiDiagnosisFailedDesc": "Diagnostic IA échoué.",
"timelineEscalatedDesc": "Ticket escaladé.",
"timelineResolutionAddedDesc": "Résolution ajoutée par {name}."
}

View File

@ -552,5 +552,67 @@
"diagnosisFeedbackLabel": "È stato utile?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accetta",
"reject": "Rifiuta"
"reject": "Rifiuta",
"resolution": "Risoluzione",
"rootCauseLabel": "Causa principale",
"solutionLabel": "Soluzione",
"saveResolution": "Salva risoluzione",
"preFilledFromAi": "Precompilato dalla diagnosi IA",
"resolutionRequired": "Causa principale e soluzione sono necessarie per risolvere un ticket.",
"resolutionSaved": "Risoluzione salvata con successo.",
"statusOpen": "Aperto",
"statusInProgress": "In corso",
"statusEscalated": "Escalato",
"statusResolved": "Risolto",
"statusClosed": "Chiuso",
"priorityCritical": "Critica",
"priorityHigh": "Alta",
"priorityMedium": "Media",
"priorityLow": "Bassa",
"categoryHardware": "Hardware",
"categorySoftware": "Software",
"categoryNetwork": "Rete",
"categoryUserAccess": "Accesso utente",
"categoryFirmware": "Firmware",
"subCategory": "Sottocategoria",
"edit": "Modifica",
"save": "Salva",
"descriptionSaved": "Descrizione salvata.",
"subCatGeneral": "Generale",
"subCatOther": "Altro",
"subCatBattery": "Batteria",
"subCatInverter": "Inverter",
"subCatCable": "Cavo",
"subCatGateway": "Gateway",
"subCatMetering": "Misurazione",
"subCatCooling": "Raffreddamento",
"subCatPvSolar": "PV / Solare",
"subCatSafety": "Sicurezza",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Database",
"subCatApi": "API",
"subCatConnectivity": "Connettività",
"subCatVpnAccess": "Accesso VPN",
"subCatS3Storage": "Storage S3",
"subCatPermissions": "Permessi",
"subCatLogin": "Accesso",
"subCatBatteryFirmware": "Firmware batteria",
"subCatInverterFirmware": "Firmware inverter",
"subCatControllerFirmware": "Firmware controller",
"timelineCreated": "Creato",
"timelineStatusChanged": "Stato modificato",
"timelineAssigned": "Assegnato",
"timelineCommentAdded": "Commento aggiunto",
"timelineAiDiagnosis": "Diagnosi IA",
"timelineEscalated": "Escalato",
"timelineResolutionAdded": "Risoluzione aggiunta",
"timelineCreatedDesc": "Ticket creato da {name}.",
"timelineStatusChangedDesc": "Stato modificato in {status}.",
"timelineAssignedDesc": "Assegnato a {name}.",
"timelineCommentAddedDesc": "Commento aggiunto da {name}.",
"timelineAiDiagnosisCompletedDesc": "Diagnosi IA completata.",
"timelineAiDiagnosisFailedDesc": "Diagnosi IA fallita.",
"timelineEscalatedDesc": "Ticket escalato.",
"timelineResolutionAddedDesc": "Risoluzione aggiunta da {name}."
}