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); var existing = Db.GetTicketById(ticket.Id);
if (existing is null) return NotFound(); 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.CreatedAt = existing.CreatedAt;
ticket.CreatedByUserId = existing.CreatedByUserId; ticket.CreatedByUserId = existing.CreatedByUserId;
ticket.UpdatedAt = DateTime.UtcNow; 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 != existing.Status)
{ {
if (ticket.Status == (Int32)TicketStatus.Resolved) 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."); return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
} }
@ -2149,7 +2173,34 @@ public class Controller : ControllerBase
diagnosis.Feedback = feedback; diagnosis.Feedback = feedback;
diagnosis.OverrideText = overrideText; 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 CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; } 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 public enum TimelineEventType
{ {
Created = 0, StatusChanged = 1, Assigned = 2, 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 } 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 SmartToyIcon from '@mui/icons-material/SmartToy';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
import { CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
interface CommentThreadProps { interface CommentThreadProps {
ticketId: number; ticketId: number;
comments: TicketComment[]; comments: TicketComment[];
onCommentAdded: () => void; onCommentAdded: () => void;
adminUsers?: AdminUser[];
} }
function CommentThread({ function CommentThread({
ticketId, ticketId,
comments, comments,
onCommentAdded onCommentAdded,
adminUsers = []
}: CommentThreadProps) { }: CommentThreadProps) {
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -89,7 +91,7 @@ function CommentThread({
sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }} sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}
> >
<Typography variant="subtitle2"> <Typography variant="subtitle2">
{isAi ? 'AI' : `User #${comment.authorId ?? '?'}`} {isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)}
</Typography> </Typography>
<Typography variant="caption" color="text.disabled"> <Typography variant="caption" color="text.disabled">
{new Date(comment.createdAt).toLocaleString()} {new Date(comment.createdAt).toLocaleString()}

View File

@ -20,11 +20,13 @@ import {
InputLabel, InputLabel,
MenuItem, MenuItem,
Select, Select,
TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DeleteIcon from '@mui/icons-material/Delete'; 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 axiosConfig from 'src/Resources/axiosConfig';
import { import {
TicketDetail as TicketDetailType, TicketDetail as TicketDetailType,
@ -33,7 +35,9 @@ import {
TicketCategory, TicketCategory,
TicketSubCategory, TicketSubCategory,
AdminUser, AdminUser,
subCategoryLabels subCategoryLabels,
subCategoryKeys,
subCategoriesByCategory
} from 'src/interfaces/TicketTypes'; } from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer'; import Footer from 'src/components/Footer';
import StatusChip from './StatusChip'; import StatusChip from './StatusChip';
@ -41,32 +45,33 @@ import AiDiagnosisPanel from './AiDiagnosisPanel';
import CommentThread from './CommentThread'; import CommentThread from './CommentThread';
import TimelinePanel from './TimelinePanel'; import TimelinePanel from './TimelinePanel';
const priorityLabels: Record<number, string> = { const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Critical]: 'Critical', [TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
[TicketPriority.High]: 'High', [TicketPriority.High]: { id: 'priorityHigh', defaultMessage: 'High' },
[TicketPriority.Medium]: 'Medium', [TicketPriority.Medium]: { id: 'priorityMedium', defaultMessage: 'Medium' },
[TicketPriority.Low]: 'Low' [TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' }
}; };
const categoryLabels: Record<number, string> = { const categoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketCategory.Hardware]: 'Hardware', [TicketCategory.Hardware]: { id: 'categoryHardware', defaultMessage: 'Hardware' },
[TicketCategory.Software]: 'Software', [TicketCategory.Software]: { id: 'categorySoftware', defaultMessage: 'Software' },
[TicketCategory.Network]: 'Network', [TicketCategory.Network]: { id: 'categoryNetwork', defaultMessage: 'Network' },
[TicketCategory.UserAccess]: 'User Access', [TicketCategory.UserAccess]: { id: 'categoryUserAccess', defaultMessage: 'User Access' },
[TicketCategory.Firmware]: 'Firmware' [TicketCategory.Firmware]: { id: 'categoryFirmware', defaultMessage: 'Firmware' }
}; };
const statusOptions = [ const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
{ value: TicketStatus.Open, label: 'Open' }, { value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
{ value: TicketStatus.InProgress, label: 'In Progress' }, { value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
{ value: TicketStatus.Escalated, label: 'Escalated' }, { value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
{ value: TicketStatus.Resolved, label: 'Resolved' }, { value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
{ value: TicketStatus.Closed, label: 'Closed' } { value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
]; ];
function TicketDetailPage() { function TicketDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const intl = useIntl();
const [detail, setDetail] = useState<TicketDetailType | null>(null); const [detail, setDetail] = useState<TicketDetailType | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -75,6 +80,15 @@ function TicketDetailPage() {
const [updatingAssignee, setUpdatingAssignee] = useState(false); const [updatingAssignee, setUpdatingAssignee] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = 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(() => { const fetchDetail = useCallback(() => {
if (!id) return; if (!id) return;
@ -82,6 +96,9 @@ function TicketDetailPage() {
.get('/GetTicketDetail', { params: { id: Number(id) } }) .get('/GetTicketDetail', { params: { id: Number(id) } })
.then((res) => { .then((res) => {
setDetail(res.data); setDetail(res.data);
setRootCause(res.data.ticket.rootCause ?? '');
setSolution(res.data.ticket.solution ?? '');
setDescription(res.data.ticket.description ?? '');
setError(''); setError('');
}) })
.catch(() => setError('Failed to load ticket details.')) .catch(() => setError('Failed to load ticket details.'))
@ -98,9 +115,24 @@ function TicketDetailPage() {
const handleStatusChange = (newStatus: number) => { const handleStatusChange = (newStatus: number) => {
if (!detail) return; 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); setUpdatingStatus(true);
axiosConfig axiosConfig
.put('/UpdateTicket', { ...detail.ticket, status: newStatus }) .put('/UpdateTicket', {
...detail.ticket,
status: newStatus,
rootCause,
solution
})
.then(() => fetchDetail()) .then(() => fetchDetail())
.catch(() => setError('Failed to update status.')) .catch(() => setError('Failed to update status.'))
.finally(() => setUpdatingStatus(false)); .finally(() => setUpdatingStatus(false));
@ -119,6 +151,14 @@ function TicketDetailPage() {
.finally(() => setUpdatingAssignee(false)); .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 = () => { const handleDelete = () => {
if (!id) return; if (!id) return;
setDeleting(true); 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) { if (loading) {
return ( return (
<Container maxWidth="xl" sx={{ mt: 4, textAlign: 'center' }}> <Container maxWidth="xl" sx={{ mt: 4, textAlign: 'center' }}>
@ -183,10 +253,10 @@ function TicketDetailPage() {
<Box display="flex" gap={1} alignItems="center"> <Box display="flex" gap={1} alignItems="center">
<StatusChip status={ticket.status} size="medium" /> <StatusChip status={ticket.status} size="medium" />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{priorityLabels[ticket.priority] ?? '-'} ·{' '} {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '}
{categoryLabels[ticket.category] ?? '-'} {intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })}
{ticket.subCategory !== TicketSubCategory.General && {ticket.subCategory !== TicketSubCategory.General &&
` · ${subCategoryLabels[ticket.subCategory] ?? ''}`} ` · ${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@ -202,26 +272,76 @@ function TicketDetailPage() {
defaultMessage="Description" defaultMessage="Description"
/> />
} }
action={
!editingDescription && (
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => setEditingDescription(true)}
>
<FormattedMessage id="edit" defaultMessage="Edit" />
</Button>
)
}
/> />
<Divider /> <Divider />
<CardContent> <CardContent>
<Typography {editingDescription ? (
variant="body1" <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
sx={{ whiteSpace: 'pre-wrap' }} <TextField
> multiline
{ticket.description || ( minRows={3}
<Typography fullWidth
component="span" value={description}
variant="body2" onChange={(e) => {
color="text.secondary" setDescription(e.target.value);
> setDescriptionSaved(false);
<FormattedMessage }}
id="noDescription" />
defaultMessage="No description provided." <Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
/> {descriptionSaved && (
</Typography> <Typography variant="body2" color="success.main">
)} <FormattedMessage id="descriptionSaved" defaultMessage="Description saved." />
</Typography> </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> </CardContent>
</Card> </Card>
@ -232,10 +352,128 @@ function TicketDetailPage() {
/> />
</Box> </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 <CommentThread
ticketId={ticket.id} ticketId={ticket.id}
comments={comments} comments={comments}
onCommentAdded={fetchDetail} onCommentAdded={fetchDetail}
adminUsers={adminUsers}
/> />
</Grid> </Grid>
@ -266,9 +504,9 @@ function TicketDetailPage() {
} }
disabled={updatingStatus} disabled={updatingStatus}
> >
{statusOptions.map((opt) => ( {statusKeys.map((opt) => (
<MenuItem key={opt.value} value={opt.value}> <MenuItem key={opt.value} value={opt.value}>
{opt.label} {intl.formatMessage({ id: opt.id, defaultMessage: opt.defaultMessage })}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@ -303,6 +541,67 @@ function TicketDetailPage() {
))} ))}
</Select> </Select>
</FormControl> </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> </CardContent>
</Card> </Card>
@ -423,6 +722,7 @@ function TicketDetailPage() {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Container> </Container>
<Footer /> <Footer />
</div> </div>

View File

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

View File

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

View File

@ -54,6 +54,31 @@ export const subCategoryLabels: Record<number, string> = {
[TicketSubCategory.ControllerFirmware]: 'Controller Firmware' [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[]> = { export const subCategoriesByCategory: Record<number, number[]> = {
[TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99], [TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99],
[TicketCategory.Software]: [0, 200, 201, 202, 203, 99], [TicketCategory.Software]: [0, 200, 201, 202, 203, 99],
@ -87,7 +112,8 @@ export enum TimelineEventType {
Assigned = 2, Assigned = 2,
CommentAdded = 3, CommentAdded = 3,
AiDiagnosisAttached = 4, AiDiagnosisAttached = 4,
Escalated = 5 Escalated = 5,
ResolutionAdded = 6
} }
export type Ticket = { export type Ticket = {
@ -106,6 +132,9 @@ export type Ticket = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
resolvedAt: string | null; resolvedAt: string | null;
rootCause: string | null;
solution: string | null;
preFilledFromAi: boolean;
}; };
export type TicketComment = { export type TicketComment = {

View File

@ -552,5 +552,67 @@
"diagnosisFeedbackLabel": "War das hilfreich?", "diagnosisFeedbackLabel": "War das hilfreich?",
"feedbackSubmitted": "Feedback: {feedback}", "feedbackSubmitted": "Feedback: {feedback}",
"accept": "Akzeptieren", "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?", "diagnosisFeedbackLabel": "Was this helpful?",
"feedbackSubmitted": "Feedback: {feedback}", "feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accept", "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 ?", "diagnosisFeedbackLabel": "Était-ce utile ?",
"feedbackSubmitted": "Retour : {feedback}", "feedbackSubmitted": "Retour : {feedback}",
"accept": "Accepter", "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?", "diagnosisFeedbackLabel": "È stato utile?",
"feedbackSubmitted": "Feedback: {feedback}", "feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accetta", "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}."
} }