Compare commits
3 Commits
bb1efaf0e9
...
ed00b742a1
| Author | SHA1 | Date |
|---|---|---|
|
|
ed00b742a1 | |
|
|
b93c051d5f | |
|
|
7c6b86d562 |
|
|
@ -2241,6 +2241,20 @@ 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();
|
||||||
|
|
||||||
|
// 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
|
// Enforce resolution when resolving
|
||||||
if (ticket.Status == (Int32)TicketStatus.Resolved
|
if (ticket.Status == (Int32)TicketStatus.Resolved
|
||||||
&& (String.IsNullOrWhiteSpace(ticket.RootCause) || String.IsNullOrWhiteSpace(ticket.Solution)))
|
&& (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.");
|
return BadRequest("Root Cause and Solution are required to resolve a ticket.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var oldSubject = existing.Subject;
|
||||||
|
|
||||||
ticket.CreatedAt = existing.CreatedAt;
|
ticket.CreatedAt = existing.CreatedAt;
|
||||||
ticket.CreatedByUserId = existing.CreatedByUserId;
|
ticket.CreatedByUserId = existing.CreatedByUserId;
|
||||||
ticket.UpdatedAt = DateTime.UtcNow;
|
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))]
|
[HttpDelete(nameof(DeleteTicket))]
|
||||||
|
|
@ -2608,16 +2639,18 @@ public class Controller : ControllerBase
|
||||||
private static readonly HashSet<String> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<String> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||||
"application/pdf", "application/x-pdf"
|
"application/pdf", "application/x-pdf",
|
||||||
|
"video/mp4", "video/quicktime", "video/webm"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Some browsers send generic MIME types — allow them if the file extension is valid
|
// Some browsers send generic MIME types — allow them if the file extension is valid
|
||||||
private static readonly HashSet<String> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<String> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf"
|
".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf",
|
||||||
|
".mp4", ".mov", ".webm"
|
||||||
};
|
};
|
||||||
|
|
||||||
private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB
|
private const Int64 MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
||||||
|
|
||||||
private static S3Bucket DocumentBucket
|
private static S3Bucket DocumentBucket
|
||||||
{
|
{
|
||||||
|
|
@ -2629,7 +2662,7 @@ public class Controller : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost(nameof(UploadDocument))]
|
[HttpPost(nameof(UploadDocument))]
|
||||||
[RequestSizeLimit(26_214_400)]
|
[RequestSizeLimit(104_857_600)]
|
||||||
public async Task<ActionResult<Document>> UploadDocument(
|
public async Task<ActionResult<Document>> UploadDocument(
|
||||||
IFormFile file,
|
IFormFile file,
|
||||||
[FromQuery] Int32 scope,
|
[FromQuery] Int32 scope,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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
|
ResolutionAdded = 6, SubjectChanged = 7
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }
|
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import DownloadIcon from '@mui/icons-material/Download';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
|
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||||
|
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
|
||||||
|
|
@ -48,8 +50,13 @@ function isImage(contentType: string): boolean {
|
||||||
return contentType.startsWith('image/');
|
return contentType.startsWith('image/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVideo(contentType: string): boolean {
|
||||||
|
return contentType.startsWith('video/');
|
||||||
|
}
|
||||||
|
|
||||||
function getFileIcon(contentType: string) {
|
function getFileIcon(contentType: string) {
|
||||||
if (contentType === 'application/pdf') return <PictureAsPdfIcon fontSize="small" color="error" />;
|
if (contentType === 'application/pdf') return <PictureAsPdfIcon fontSize="small" color="error" />;
|
||||||
|
if (isVideo(contentType)) return <VideocamIcon fontSize="small" color="primary" />;
|
||||||
return <InsertDriveFileIcon fontSize="small" />;
|
return <InsertDriveFileIcon fontSize="small" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +70,36 @@ function DocumentList({
|
||||||
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||||
|
const [videoUrls, setVideoUrls] = useState<Record<number, string>>({});
|
||||||
|
const [loadingVideoIds, setLoadingVideoIds] = useState<Set<number>>(new Set());
|
||||||
const [expandedImage, setExpandedImage] = useState<string | null>(null);
|
const [expandedImage, setExpandedImage] = useState<string | null>(null);
|
||||||
|
const [expandedVideo, setExpandedVideo] = useState<{ url: string; contentType: string } | null>(null);
|
||||||
|
|
||||||
|
const loadVideoBlob = (doc: DocumentItem) => {
|
||||||
|
if (videoUrls[doc.id] || loadingVideoIds.has(doc.id)) return;
|
||||||
|
setLoadingVideoIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(doc.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
axiosConfig
|
||||||
|
.get('/DownloadDocument', {
|
||||||
|
params: { id: doc.id },
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data], { type: doc.contentType }));
|
||||||
|
setVideoUrls((prev) => ({ ...prev, [doc.id]: url }));
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingVideoIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(doc.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDocuments = () => {
|
const fetchDocuments = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -100,17 +136,25 @@ function DocumentList({
|
||||||
});
|
});
|
||||||
}, [documents]);
|
}, [documents]);
|
||||||
|
|
||||||
// Clean up blob URLs on unmount
|
// Revoke superseded blob URLs as state changes, and on unmount.
|
||||||
|
// Empty deps would capture the initial {} and never revoke anything.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url));
|
Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url));
|
||||||
};
|
};
|
||||||
}, []);
|
}, [previews]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(videoUrls).forEach((url) => window.URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [videoUrls]);
|
||||||
|
|
||||||
const handleDownload = (doc: DocumentItem) => {
|
const handleDownload = (doc: DocumentItem) => {
|
||||||
if (previews[doc.id]) {
|
const cached = previews[doc.id] || videoUrls[doc.id];
|
||||||
|
if (cached) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = previews[doc.id];
|
link.href = cached;
|
||||||
link.setAttribute('download', doc.originalName);
|
link.setAttribute('download', doc.originalName);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
@ -196,6 +240,53 @@ function DocumentList({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isVideo(doc.contentType) && (
|
||||||
|
videoUrls[doc.id] ? (
|
||||||
|
<Box
|
||||||
|
component="video"
|
||||||
|
controls
|
||||||
|
src={videoUrls[doc.id]}
|
||||||
|
onClick={() => setExpandedVideo({ url: videoUrls[doc.id], contentType: doc.contentType })}
|
||||||
|
sx={{
|
||||||
|
maxWidth: 240,
|
||||||
|
maxHeight: 160,
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'common.black'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
onClick={() => loadVideoBlob(doc)}
|
||||||
|
sx={{
|
||||||
|
width: 240,
|
||||||
|
height: 135,
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
cursor: loadingVideoIds.has(doc.id) ? 'progress' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.5,
|
||||||
|
'&:hover': { opacity: 0.85 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayCircleOutlineIcon sx={{ fontSize: 48, color: 'text.secondary' }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{loadingVideoIds.has(doc.id) ? (
|
||||||
|
<FormattedMessage id="videoLoading" defaultMessage="Loading…" />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id="videoClickToPlay" defaultMessage="Click to play" />
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
@ -215,6 +306,23 @@ function DocumentList({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Full-size video preview dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!expandedVideo}
|
||||||
|
onClose={() => setExpandedVideo(null)}
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
{expandedVideo && (
|
||||||
|
<Box
|
||||||
|
component="video"
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
src={expandedVideo.url}
|
||||||
|
sx={{ maxWidth: '90vw', maxHeight: '90vh', backgroundColor: 'common.black' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
|
|
||||||
const ALLOWED_TYPES = [
|
const ALLOWED_TYPES = [
|
||||||
|
|
@ -15,9 +15,13 @@ const ALLOWED_TYPES = [
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
'application/pdf'
|
'application/pdf',
|
||||||
|
'video/mp4',
|
||||||
|
'video/quicktime',
|
||||||
|
'video/webm'
|
||||||
];
|
];
|
||||||
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||||
|
const MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024);
|
||||||
|
|
||||||
export interface UploadedDocument {
|
export interface UploadedDocument {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -48,6 +52,7 @@ function FileUploadButton({
|
||||||
onUploaded,
|
onUploaded,
|
||||||
disabled = false
|
disabled = false
|
||||||
}: FileUploadButtonProps) {
|
}: FileUploadButtonProps) {
|
||||||
|
const intl = useIntl();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
@ -62,11 +67,21 @@ function FileUploadButton({
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
setError(`Invalid file type: ${file.name}`);
|
setError(
|
||||||
|
intl.formatMessage(
|
||||||
|
{ id: 'attachFileInvalidType', defaultMessage: 'Invalid file type: {name}' },
|
||||||
|
{ name: file.name }
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
setError(`File too large: ${file.name} (max 25 MB)`);
|
setError(
|
||||||
|
intl.formatMessage(
|
||||||
|
{ id: 'attachFileTooLarge', defaultMessage: 'File too large: {name} (max {limitMb} MB)' },
|
||||||
|
{ name: file.name, limitMb: MAX_FILE_SIZE_MB }
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
validFiles.push(file);
|
validFiles.push(file);
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ function DocumentsTab({ installationId }: DocumentsTabProps) {
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="documentsHint"
|
id="documentsHint"
|
||||||
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB."
|
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximum file size: 100 MB."
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,11 @@ function CommentThread({
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
const ALLOWED_TYPES = [
|
||||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf',
|
||||||
|
'video/mp4', 'video/quicktime', 'video/webm'
|
||||||
|
];
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
|
|
@ -382,7 +385,7 @@ function CommentThread({
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,video/mp4,video/quicktime,video/webm"
|
||||||
multiple
|
multiple
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
|
|
|
||||||
|
|
@ -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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
|
@ -87,6 +88,12 @@ function TicketDetailPage() {
|
||||||
const [editingDescription, setEditingDescription] = useState(false);
|
const [editingDescription, setEditingDescription] = useState(false);
|
||||||
const [savingDescription, setSavingDescription] = useState(false);
|
const [savingDescription, setSavingDescription] = useState(false);
|
||||||
const [descriptionSaved, setDescriptionSaved] = 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 [docRefreshKey, setDocRefreshKey] = useState(0);
|
||||||
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
||||||
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
@ -107,6 +114,7 @@ function TicketDetailPage() {
|
||||||
setRootCause(res.data.ticket.rootCause ?? '');
|
setRootCause(res.data.ticket.rootCause ?? '');
|
||||||
setSolution(res.data.ticket.solution ?? '');
|
setSolution(res.data.ticket.solution ?? '');
|
||||||
setDescription(res.data.ticket.description ?? '');
|
setDescription(res.data.ticket.description ?? '');
|
||||||
|
setSubject(res.data.ticket.subject ?? '');
|
||||||
setEditCustomSub(res.data.ticket.customSubCategory ?? '');
|
setEditCustomSub(res.data.ticket.customSubCategory ?? '');
|
||||||
setEditCustomCat(res.data.ticket.customCategory ?? '');
|
setEditCustomCat(res.data.ticket.customCategory ?? '');
|
||||||
setError('');
|
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 = () => {
|
const handleSaveDescription = () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setSavingDescription(true);
|
setSavingDescription(true);
|
||||||
|
|
@ -285,10 +312,66 @@ function TicketDetailPage() {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<Typography variant="h3" gutterBottom>
|
{editingSubject ? (
|
||||||
#{ticket.id} — {ticket.subject}
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1 }}>
|
||||||
</Typography>
|
<Box display="flex" gap={1} alignItems="flex-start">
|
||||||
<Box display="flex" gap={1} alignItems="center">
|
<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" />
|
<StatusChip status={ticket.status} size="medium" />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '}
|
{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.CommentAdded]: { id: 'timelineCommentAdded', defaultMessage: 'Comment Added' },
|
||||||
[TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' },
|
[TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' },
|
||||||
[TimelineEventType.Escalated]: { id: 'timelineEscalated', defaultMessage: 'Escalated' },
|
[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> = {
|
const eventTypeColors: Record<number, string> = {
|
||||||
|
|
@ -30,7 +31,8 @@ const eventTypeColors: Record<number, string> = {
|
||||||
[TimelineEventType.CommentAdded]: '#2e7d32',
|
[TimelineEventType.CommentAdded]: '#2e7d32',
|
||||||
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
|
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
|
||||||
[TimelineEventType.Escalated]: '#d32f2f',
|
[TimelineEventType.Escalated]: '#d32f2f',
|
||||||
[TimelineEventType.ResolutionAdded]: '#4caf50'
|
[TimelineEventType.ResolutionAdded]: '#4caf50',
|
||||||
|
[TimelineEventType.SubjectChanged]: '#7b1fa2'
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TimelinePanelProps {
|
interface TimelinePanelProps {
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,8 @@ export enum TimelineEventType {
|
||||||
CommentAdded = 3,
|
CommentAdded = 3,
|
||||||
AiDiagnosisAttached = 4,
|
AiDiagnosisAttached = 4,
|
||||||
Escalated = 5,
|
Escalated = 5,
|
||||||
ResolutionAdded = 6
|
ResolutionAdded = 6,
|
||||||
|
SubjectChanged = 7
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Ticket = {
|
export type Ticket = {
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,8 @@
|
||||||
"subCategory": "Unterkategorie",
|
"subCategory": "Unterkategorie",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
"subjectRequired": "Betreff ist erforderlich.",
|
||||||
|
"failedToSaveSubject": "Betreff konnte nicht gespeichert werden.",
|
||||||
"descriptionSaved": "Beschreibung gespeichert.",
|
"descriptionSaved": "Beschreibung gespeichert.",
|
||||||
"subCatGeneral": "Allgemein",
|
"subCatGeneral": "Allgemein",
|
||||||
"subCatOther": "Sonstiges",
|
"subCatOther": "Sonstiges",
|
||||||
|
|
@ -653,6 +655,7 @@
|
||||||
"timelineAiDiagnosis": "KI-Diagnose",
|
"timelineAiDiagnosis": "KI-Diagnose",
|
||||||
"timelineEscalated": "Eskaliert",
|
"timelineEscalated": "Eskaliert",
|
||||||
"timelineResolutionAdded": "Lösung hinzugefügt",
|
"timelineResolutionAdded": "Lösung hinzugefügt",
|
||||||
|
"timelineSubjectChanged": "Betreff geändert",
|
||||||
"timelineCreatedDesc": "Ticket erstellt von {name}.",
|
"timelineCreatedDesc": "Ticket erstellt von {name}.",
|
||||||
"timelineStatusChangedDesc": "Status geändert auf {status}.",
|
"timelineStatusChangedDesc": "Status geändert auf {status}.",
|
||||||
"timelineAssignedDesc": "Zugewiesen an {name}.",
|
"timelineAssignedDesc": "Zugewiesen an {name}.",
|
||||||
|
|
@ -683,8 +686,12 @@
|
||||||
"sodistorepro": "Sodistore Pro",
|
"sodistorepro": "Sodistore Pro",
|
||||||
"numberOfInverters": "Anzahl der Wechselrichter",
|
"numberOfInverters": "Anzahl der Wechselrichter",
|
||||||
"documentsTab": "Dokumente",
|
"documentsTab": "Dokumente",
|
||||||
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF. Maximale Dateigrösse: 25 MB.",
|
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximale Dateigrösse: 100 MB.",
|
||||||
"attachFiles": "Dateien anhängen",
|
"attachFiles": "Dateien anhängen",
|
||||||
|
"attachFileInvalidType": "Ungültiger Dateityp: {name}",
|
||||||
|
"attachFileTooLarge": "Datei zu gross: {name} (max. {limitMb} MB)",
|
||||||
|
"videoClickToPlay": "Zum Abspielen klicken",
|
||||||
|
"videoLoading": "Wird geladen…",
|
||||||
"attachments": "Anhänge",
|
"attachments": "Anhänge",
|
||||||
"documents": "Dokumente",
|
"documents": "Dokumente",
|
||||||
"installationDocuments": "Installationsdokumente",
|
"installationDocuments": "Installationsdokumente",
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,8 @@
|
||||||
"subCategory": "Sub-Category",
|
"subCategory": "Sub-Category",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"subjectRequired": "Subject is required.",
|
||||||
|
"failedToSaveSubject": "Failed to save subject.",
|
||||||
"descriptionSaved": "Description saved.",
|
"descriptionSaved": "Description saved.",
|
||||||
"subCatGeneral": "General",
|
"subCatGeneral": "General",
|
||||||
"subCatOther": "Other",
|
"subCatOther": "Other",
|
||||||
|
|
@ -401,6 +403,7 @@
|
||||||
"timelineAiDiagnosis": "AI Diagnosis",
|
"timelineAiDiagnosis": "AI Diagnosis",
|
||||||
"timelineEscalated": "Escalated",
|
"timelineEscalated": "Escalated",
|
||||||
"timelineResolutionAdded": "Resolution Added",
|
"timelineResolutionAdded": "Resolution Added",
|
||||||
|
"timelineSubjectChanged": "Subject Changed",
|
||||||
"timelineCreatedDesc": "Ticket created by {name}.",
|
"timelineCreatedDesc": "Ticket created by {name}.",
|
||||||
"timelineStatusChangedDesc": "Status changed to {status}.",
|
"timelineStatusChangedDesc": "Status changed to {status}.",
|
||||||
"timelineAssignedDesc": "Assigned to {name}.",
|
"timelineAssignedDesc": "Assigned to {name}.",
|
||||||
|
|
@ -431,8 +434,12 @@
|
||||||
"sodistorepro": "Sodistore Pro",
|
"sodistorepro": "Sodistore Pro",
|
||||||
"numberOfInverters": "Number of Inverters",
|
"numberOfInverters": "Number of Inverters",
|
||||||
"documentsTab": "Documents",
|
"documentsTab": "Documents",
|
||||||
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB.",
|
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximum file size: 100 MB.",
|
||||||
"attachFiles": "Attach Files",
|
"attachFiles": "Attach Files",
|
||||||
|
"attachFileInvalidType": "Invalid file type: {name}",
|
||||||
|
"attachFileTooLarge": "File too large: {name} (max {limitMb} MB)",
|
||||||
|
"videoClickToPlay": "Click to play",
|
||||||
|
"videoLoading": "Loading…",
|
||||||
"attachments": "Attachments",
|
"attachments": "Attachments",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"installationDocuments": "Installation Documents",
|
"installationDocuments": "Installation Documents",
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,8 @@
|
||||||
"subCategory": "Sous-catégorie",
|
"subCategory": "Sous-catégorie",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
|
"subjectRequired": "Le sujet est requis.",
|
||||||
|
"failedToSaveSubject": "Échec de l'enregistrement du sujet.",
|
||||||
"descriptionSaved": "Description enregistrée.",
|
"descriptionSaved": "Description enregistrée.",
|
||||||
"subCatGeneral": "Général",
|
"subCatGeneral": "Général",
|
||||||
"subCatOther": "Autre",
|
"subCatOther": "Autre",
|
||||||
|
|
@ -653,6 +655,7 @@
|
||||||
"timelineAiDiagnosis": "Diagnostic IA",
|
"timelineAiDiagnosis": "Diagnostic IA",
|
||||||
"timelineEscalated": "Escaladé",
|
"timelineEscalated": "Escaladé",
|
||||||
"timelineResolutionAdded": "Résolution ajoutée",
|
"timelineResolutionAdded": "Résolution ajoutée",
|
||||||
|
"timelineSubjectChanged": "Sujet modifié",
|
||||||
"timelineCreatedDesc": "Ticket créé par {name}.",
|
"timelineCreatedDesc": "Ticket créé par {name}.",
|
||||||
"timelineStatusChangedDesc": "Statut modifié en {status}.",
|
"timelineStatusChangedDesc": "Statut modifié en {status}.",
|
||||||
"timelineAssignedDesc": "Assigné à {name}.",
|
"timelineAssignedDesc": "Assigné à {name}.",
|
||||||
|
|
@ -683,8 +686,12 @@
|
||||||
"sodistorepro": "Sodistore Pro",
|
"sodistorepro": "Sodistore Pro",
|
||||||
"numberOfInverters": "Nombre d'onduleurs",
|
"numberOfInverters": "Nombre d'onduleurs",
|
||||||
"documentsTab": "Documents",
|
"documentsTab": "Documents",
|
||||||
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF. Taille maximale : 25 Mo.",
|
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Taille maximale : 100 Mo.",
|
||||||
"attachFiles": "Joindre des fichiers",
|
"attachFiles": "Joindre des fichiers",
|
||||||
|
"attachFileInvalidType": "Type de fichier non valide : {name}",
|
||||||
|
"attachFileTooLarge": "Fichier trop volumineux : {name} (max. {limitMb} Mo)",
|
||||||
|
"videoClickToPlay": "Cliquer pour lire",
|
||||||
|
"videoLoading": "Chargement…",
|
||||||
"attachments": "Pièces jointes",
|
"attachments": "Pièces jointes",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"installationDocuments": "Documents d'installation",
|
"installationDocuments": "Documents d'installation",
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,8 @@
|
||||||
"subCategory": "Sottocategoria",
|
"subCategory": "Sottocategoria",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
|
"subjectRequired": "L'oggetto è obbligatorio.",
|
||||||
|
"failedToSaveSubject": "Impossibile salvare l'oggetto.",
|
||||||
"descriptionSaved": "Descrizione salvata.",
|
"descriptionSaved": "Descrizione salvata.",
|
||||||
"subCatGeneral": "Generale",
|
"subCatGeneral": "Generale",
|
||||||
"subCatOther": "Altro",
|
"subCatOther": "Altro",
|
||||||
|
|
@ -653,6 +655,7 @@
|
||||||
"timelineAiDiagnosis": "Diagnosi IA",
|
"timelineAiDiagnosis": "Diagnosi IA",
|
||||||
"timelineEscalated": "Escalato",
|
"timelineEscalated": "Escalato",
|
||||||
"timelineResolutionAdded": "Risoluzione aggiunta",
|
"timelineResolutionAdded": "Risoluzione aggiunta",
|
||||||
|
"timelineSubjectChanged": "Oggetto modificato",
|
||||||
"timelineCreatedDesc": "Ticket creato da {name}.",
|
"timelineCreatedDesc": "Ticket creato da {name}.",
|
||||||
"timelineStatusChangedDesc": "Stato modificato in {status}.",
|
"timelineStatusChangedDesc": "Stato modificato in {status}.",
|
||||||
"timelineAssignedDesc": "Assegnato a {name}.",
|
"timelineAssignedDesc": "Assegnato a {name}.",
|
||||||
|
|
@ -683,8 +686,12 @@
|
||||||
"sodistorepro": "Sodistore Pro",
|
"sodistorepro": "Sodistore Pro",
|
||||||
"numberOfInverters": "Numero di inverter",
|
"numberOfInverters": "Numero di inverter",
|
||||||
"documentsTab": "Documenti",
|
"documentsTab": "Documenti",
|
||||||
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF. Dimensione massima: 25 MB.",
|
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Dimensione massima: 100 MB.",
|
||||||
"attachFiles": "Allega file",
|
"attachFiles": "Allega file",
|
||||||
|
"attachFileInvalidType": "Tipo di file non valido: {name}",
|
||||||
|
"attachFileTooLarge": "File troppo grande: {name} (max {limitMb} MB)",
|
||||||
|
"videoClickToPlay": "Clicca per riprodurre",
|
||||||
|
"videoLoading": "Caricamento…",
|
||||||
"attachments": "Allegati",
|
"attachments": "Allegati",
|
||||||
"documents": "Documenti",
|
"documents": "Documenti",
|
||||||
"installationDocuments": "Documenti dell'installazione",
|
"installationDocuments": "Documenti dell'installazione",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue