allow edit ticket name
This commit is contained in:
parent
bb1efaf0e9
commit
7c6b86d562
|
|
@ -2241,6 +2241,20 @@ public class Controller : ControllerBase
|
|||
var existing = Db.GetTicketById(ticket.Id);
|
||||
if (existing is null) return NotFound();
|
||||
|
||||
// Subject is creator-only. Non-creators submitting a different Subject
|
||||
// (typically a stale client-side view during a concurrent edit) are
|
||||
// silently coerced to the existing value rather than 403'd, so an
|
||||
// unrelated update (status, assignee, ...) never fails on a stale subject.
|
||||
if (existing.CreatedByUserId != user.Id)
|
||||
{
|
||||
ticket.Subject = existing.Subject;
|
||||
}
|
||||
else if (String.IsNullOrWhiteSpace(ticket.Subject))
|
||||
{
|
||||
return BadRequest("Subject is required.");
|
||||
}
|
||||
var subjectChanged = !String.Equals(ticket.Subject, existing.Subject);
|
||||
|
||||
// Enforce resolution when resolving
|
||||
if (ticket.Status == (Int32)TicketStatus.Resolved
|
||||
&& (String.IsNullOrWhiteSpace(ticket.RootCause) || String.IsNullOrWhiteSpace(ticket.Solution)))
|
||||
|
|
@ -2248,6 +2262,8 @@ public class Controller : ControllerBase
|
|||
return BadRequest("Root Cause and Solution are required to resolve a ticket.");
|
||||
}
|
||||
|
||||
var oldSubject = existing.Subject;
|
||||
|
||||
ticket.CreatedAt = existing.CreatedAt;
|
||||
ticket.CreatedByUserId = existing.CreatedByUserId;
|
||||
ticket.UpdatedAt = DateTime.UtcNow;
|
||||
|
|
@ -2344,7 +2360,22 @@ public class Controller : ControllerBase
|
|||
});
|
||||
}
|
||||
|
||||
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
|
||||
if (!Db.Update(ticket)) return StatusCode(500, "Update failed.");
|
||||
|
||||
if (subjectChanged)
|
||||
{
|
||||
Db.Create(new TicketTimelineEvent
|
||||
{
|
||||
TicketId = ticket.Id,
|
||||
EventType = (Int32)TimelineEventType.SubjectChanged,
|
||||
Description = $"Subject changed: \"{oldSubject}\" → \"{ticket.Subject}\".",
|
||||
ActorType = (Int32)TimelineActorType.Human,
|
||||
ActorId = user.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
[HttpDelete(nameof(DeleteTicket))]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ public enum TimelineEventType
|
|||
{
|
||||
Created = 0, StatusChanged = 1, Assigned = 2,
|
||||
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5,
|
||||
ResolutionAdded = 6
|
||||
ResolutionAdded = 6, SubjectChanged = 7
|
||||
}
|
||||
|
||||
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { UserContext } from 'src/contexts/userContext';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -87,6 +88,12 @@ function TicketDetailPage() {
|
|||
const [editingDescription, setEditingDescription] = useState(false);
|
||||
const [savingDescription, setSavingDescription] = useState(false);
|
||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [editingSubject, setEditingSubject] = useState(false);
|
||||
const [savingSubject, setSavingSubject] = useState(false);
|
||||
const [subjectError, setSubjectError] = useState('');
|
||||
const userCtx = useContext(UserContext);
|
||||
const currentUser = userCtx?.currentUser;
|
||||
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
||||
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
||||
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
||||
|
|
@ -107,6 +114,7 @@ function TicketDetailPage() {
|
|||
setRootCause(res.data.ticket.rootCause ?? '');
|
||||
setSolution(res.data.ticket.solution ?? '');
|
||||
setDescription(res.data.ticket.description ?? '');
|
||||
setSubject(res.data.ticket.subject ?? '');
|
||||
setEditCustomSub(res.data.ticket.customSubCategory ?? '');
|
||||
setEditCustomCat(res.data.ticket.customCategory ?? '');
|
||||
setError('');
|
||||
|
|
@ -205,6 +213,25 @@ function TicketDetailPage() {
|
|||
});
|
||||
};
|
||||
|
||||
const handleSaveSubject = () => {
|
||||
if (!detail) return;
|
||||
const trimmed = subject.trim();
|
||||
if (!trimmed) {
|
||||
setSubjectError(intl.formatMessage({ id: 'subjectRequired', defaultMessage: 'Subject is required.' }));
|
||||
return;
|
||||
}
|
||||
setSavingSubject(true);
|
||||
setSubjectError('');
|
||||
axiosConfig
|
||||
.put('/UpdateTicket', { ...detail.ticket, subject: trimmed })
|
||||
.then(() => {
|
||||
fetchDetail();
|
||||
setEditingSubject(false);
|
||||
})
|
||||
.catch(() => setSubjectError(intl.formatMessage({ id: 'failedToSaveSubject', defaultMessage: 'Failed to save subject.' })))
|
||||
.finally(() => setSavingSubject(false));
|
||||
};
|
||||
|
||||
const handleSaveDescription = () => {
|
||||
if (!detail) return;
|
||||
setSavingDescription(true);
|
||||
|
|
@ -285,10 +312,66 @@ function TicketDetailPage() {
|
|||
</Box>
|
||||
|
||||
<Box mb={3}>
|
||||
<Typography variant="h3" gutterBottom>
|
||||
#{ticket.id} — {ticket.subject}
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} alignItems="center">
|
||||
{editingSubject ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1 }}>
|
||||
<Box display="flex" gap={1} alignItems="flex-start">
|
||||
<Typography variant="h3" sx={{ whiteSpace: 'nowrap', pt: 0.5 }}>
|
||||
#{ticket.id} —
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
autoFocus
|
||||
value={subject}
|
||||
onChange={(e) => {
|
||||
setSubject(e.target.value);
|
||||
setSubjectError('');
|
||||
}}
|
||||
error={!!subjectError}
|
||||
helperText={subjectError}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="flex-end" gap={1}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingSubject(false);
|
||||
setSubject(ticket.subject);
|
||||
setSubjectError('');
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleSaveSubject}
|
||||
disabled={savingSubject}
|
||||
>
|
||||
<FormattedMessage id="save" defaultMessage="Save" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box display="flex" alignItems="center" gap={1} flexWrap="wrap">
|
||||
<Typography variant="h3" gutterBottom sx={{ mb: 0 }}>
|
||||
#{ticket.id} — {ticket.subject}
|
||||
</Typography>
|
||||
{currentUser?.id === ticket.createdByUserId && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => {
|
||||
setSubject(ticket.subject);
|
||||
setSubjectError('');
|
||||
setEditingSubject(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="edit" defaultMessage="Edit" />
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box display="flex" gap={1} alignItems="center" mt={1}>
|
||||
<StatusChip status={ticket.status} size="medium" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ const eventTypeKeys: Record<number, { id: string; defaultMessage: string }> = {
|
|||
[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' }
|
||||
[TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' },
|
||||
[TimelineEventType.SubjectChanged]: { id: 'timelineSubjectChanged', defaultMessage: 'Subject Changed' }
|
||||
};
|
||||
|
||||
const eventTypeColors: Record<number, string> = {
|
||||
|
|
@ -30,7 +31,8 @@ const eventTypeColors: Record<number, string> = {
|
|||
[TimelineEventType.CommentAdded]: '#2e7d32',
|
||||
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
|
||||
[TimelineEventType.Escalated]: '#d32f2f',
|
||||
[TimelineEventType.ResolutionAdded]: '#4caf50'
|
||||
[TimelineEventType.ResolutionAdded]: '#4caf50',
|
||||
[TimelineEventType.SubjectChanged]: '#7b1fa2'
|
||||
};
|
||||
|
||||
interface TimelinePanelProps {
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@ export enum TimelineEventType {
|
|||
CommentAdded = 3,
|
||||
AiDiagnosisAttached = 4,
|
||||
Escalated = 5,
|
||||
ResolutionAdded = 6
|
||||
ResolutionAdded = 6,
|
||||
SubjectChanged = 7
|
||||
}
|
||||
|
||||
export type Ticket = {
|
||||
|
|
|
|||
|
|
@ -623,6 +623,8 @@
|
|||
"subCategory": "Unterkategorie",
|
||||
"edit": "Bearbeiten",
|
||||
"save": "Speichern",
|
||||
"subjectRequired": "Betreff ist erforderlich.",
|
||||
"failedToSaveSubject": "Betreff konnte nicht gespeichert werden.",
|
||||
"descriptionSaved": "Beschreibung gespeichert.",
|
||||
"subCatGeneral": "Allgemein",
|
||||
"subCatOther": "Sonstiges",
|
||||
|
|
@ -653,6 +655,7 @@
|
|||
"timelineAiDiagnosis": "KI-Diagnose",
|
||||
"timelineEscalated": "Eskaliert",
|
||||
"timelineResolutionAdded": "Lösung hinzugefügt",
|
||||
"timelineSubjectChanged": "Betreff geändert",
|
||||
"timelineCreatedDesc": "Ticket erstellt von {name}.",
|
||||
"timelineStatusChangedDesc": "Status geändert auf {status}.",
|
||||
"timelineAssignedDesc": "Zugewiesen an {name}.",
|
||||
|
|
|
|||
|
|
@ -371,6 +371,8 @@
|
|||
"subCategory": "Sub-Category",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"subjectRequired": "Subject is required.",
|
||||
"failedToSaveSubject": "Failed to save subject.",
|
||||
"descriptionSaved": "Description saved.",
|
||||
"subCatGeneral": "General",
|
||||
"subCatOther": "Other",
|
||||
|
|
@ -401,6 +403,7 @@
|
|||
"timelineAiDiagnosis": "AI Diagnosis",
|
||||
"timelineEscalated": "Escalated",
|
||||
"timelineResolutionAdded": "Resolution Added",
|
||||
"timelineSubjectChanged": "Subject Changed",
|
||||
"timelineCreatedDesc": "Ticket created by {name}.",
|
||||
"timelineStatusChangedDesc": "Status changed to {status}.",
|
||||
"timelineAssignedDesc": "Assigned to {name}.",
|
||||
|
|
|
|||
|
|
@ -623,6 +623,8 @@
|
|||
"subCategory": "Sous-catégorie",
|
||||
"edit": "Modifier",
|
||||
"save": "Enregistrer",
|
||||
"subjectRequired": "Le sujet est requis.",
|
||||
"failedToSaveSubject": "Échec de l'enregistrement du sujet.",
|
||||
"descriptionSaved": "Description enregistrée.",
|
||||
"subCatGeneral": "Général",
|
||||
"subCatOther": "Autre",
|
||||
|
|
@ -653,6 +655,7 @@
|
|||
"timelineAiDiagnosis": "Diagnostic IA",
|
||||
"timelineEscalated": "Escaladé",
|
||||
"timelineResolutionAdded": "Résolution ajoutée",
|
||||
"timelineSubjectChanged": "Sujet modifié",
|
||||
"timelineCreatedDesc": "Ticket créé par {name}.",
|
||||
"timelineStatusChangedDesc": "Statut modifié en {status}.",
|
||||
"timelineAssignedDesc": "Assigné à {name}.",
|
||||
|
|
|
|||
|
|
@ -623,6 +623,8 @@
|
|||
"subCategory": "Sottocategoria",
|
||||
"edit": "Modifica",
|
||||
"save": "Salva",
|
||||
"subjectRequired": "L'oggetto è obbligatorio.",
|
||||
"failedToSaveSubject": "Impossibile salvare l'oggetto.",
|
||||
"descriptionSaved": "Descrizione salvata.",
|
||||
"subCatGeneral": "Generale",
|
||||
"subCatOther": "Altro",
|
||||
|
|
@ -653,6 +655,7 @@
|
|||
"timelineAiDiagnosis": "Diagnosi IA",
|
||||
"timelineEscalated": "Escalato",
|
||||
"timelineResolutionAdded": "Risoluzione aggiunta",
|
||||
"timelineSubjectChanged": "Oggetto modificato",
|
||||
"timelineCreatedDesc": "Ticket creato da {name}.",
|
||||
"timelineStatusChangedDesc": "Stato modificato in {status}.",
|
||||
"timelineAssignedDesc": "Assegnato a {name}.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue