diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index c0740a606..526738d0e 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -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(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(); } } diff --git a/csharp/App/Backend/DataTypes/Ticket.cs b/csharp/App/Backend/DataTypes/Ticket.cs index ef3ca3658..8513614d9 100644 --- a/csharp/App/Backend/DataTypes/Ticket.cs +++ b/csharp/App/Backend/DataTypes/Ticket.cs @@ -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; } } diff --git a/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs index 05cc6ea6e..66f2543b9 100644 --- a/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs +++ b/csharp/App/Backend/DataTypes/TicketTimelineEvent.cs @@ -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 } diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 6e0f4695c..8475d1607 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -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 }} > - {isAi ? 'AI' : `User #${comment.authorId ?? '?'}`} + {isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)} {new Date(comment.createdAt).toLocaleString()} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index 124e07b5b..14338daa2 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -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 = { - [TicketPriority.Critical]: 'Critical', - [TicketPriority.High]: 'High', - [TicketPriority.Medium]: 'Medium', - [TicketPriority.Low]: 'Low' +const priorityKeys: Record = { + [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 = { - [TicketCategory.Hardware]: 'Hardware', - [TicketCategory.Software]: 'Software', - [TicketCategory.Network]: 'Network', - [TicketCategory.UserAccess]: 'User Access', - [TicketCategory.Firmware]: 'Firmware' +const categoryKeys: Record = { + [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(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) => { + 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 ( @@ -183,10 +253,10 @@ function TicketDetailPage() { - {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] ?? ''}`} @@ -202,26 +272,76 @@ function TicketDetailPage() { defaultMessage="Description" /> } + action={ + !editingDescription && ( + + ) + } /> - - {ticket.description || ( - - - - )} - + {editingDescription ? ( + + { + setDescription(e.target.value); + setDescriptionSaved(false); + }} + /> + + {descriptionSaved && ( + + + + )} + + + + + ) : ( + + {ticket.description || ( + + + + )} + + )} @@ -232,10 +352,128 @@ function TicketDetailPage() { /> + + + } + subheader={ + detail.ticket.preFilledFromAi ? ( + + + + ) : undefined + } + /> + + + {ticket.status === TicketStatus.Resolved || + ticket.status === TicketStatus.Closed ? ( + <> + + + + + + {ticket.rootCause || '-'} + + + + + + + + {ticket.solution || '-'} + + + + ) : ( + <> + + } + multiline + minRows={2} + fullWidth + value={rootCause} + onChange={(e) => setRootCause(e.target.value)} + error={ + !!resolutionError && !rootCause.trim() + } + /> + + } + multiline + minRows={2} + fullWidth + value={solution} + onChange={(e) => setSolution(e.target.value)} + error={ + !!resolutionError && !solution.trim() + } + /> + + {resolutionSaved && ( + + + + )} + {resolutionError && ( + + {resolutionError} + + )} + + + + )} + + + @@ -266,9 +504,9 @@ function TicketDetailPage() { } disabled={updatingStatus} > - {statusOptions.map((opt) => ( + {statusKeys.map((opt) => ( - {opt.label} + {intl.formatMessage({ id: opt.id, defaultMessage: opt.defaultMessage })} ))} @@ -303,6 +541,67 @@ function TicketDetailPage() { ))} + + + + + + + + + + + + + + + + + + + + + @@ -423,6 +722,7 @@ function TicketDetailPage() { +