Compare commits

..

No commits in common. "ed00b742a1ff7a27fe537cc7f8b43b4e8dd75e05" and "bb1efaf0e98c1d20e8304d6a29f320836c4e22a3" have entirely different histories.

13 changed files with 31 additions and 304 deletions

View File

@ -2241,20 +2241,6 @@ 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)))
@ -2262,8 +2248,6 @@ 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;
@ -2360,22 +2344,7 @@ public class Controller : ControllerBase
}); });
} }
if (!Db.Update(ticket)) return StatusCode(500, "Update failed."); return Db.Update(ticket) ? ticket : 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))]
@ -2639,18 +2608,16 @@ 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 = 100 * 1024 * 1024; // 100 MB private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB
private static S3Bucket DocumentBucket private static S3Bucket DocumentBucket
{ {
@ -2662,7 +2629,7 @@ public class Controller : ControllerBase
} }
[HttpPost(nameof(UploadDocument))] [HttpPost(nameof(UploadDocument))]
[RequestSizeLimit(104_857_600)] [RequestSizeLimit(26_214_400)]
public async Task<ActionResult<Document>> UploadDocument( public async Task<ActionResult<Document>> UploadDocument(
IFormFile file, IFormFile file,
[FromQuery] Int32 scope, [FromQuery] Int32 scope,

View File

@ -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, SubjectChanged = 7 ResolutionAdded = 6
} }
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 } public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }

View File

@ -13,8 +13,6 @@ 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';
@ -50,13 +48,8 @@ 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" />;
} }
@ -70,36 +63,7 @@ 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);
@ -136,25 +100,17 @@ function DocumentList({
}); });
}, [documents]); }, [documents]);
// Revoke superseded blob URLs as state changes, and on unmount. // Clean up blob URLs 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) => {
const cached = previews[doc.id] || videoUrls[doc.id]; if (previews[doc.id]) {
if (cached) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = cached; link.href = previews[doc.id];
link.setAttribute('download', doc.originalName); link.setAttribute('download', doc.originalName);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@ -240,53 +196,6 @@ 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>
@ -306,23 +215,6 @@ 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>
); );
} }

View File

@ -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, useIntl } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
const ALLOWED_TYPES = [ const ALLOWED_TYPES = [
@ -15,13 +15,9 @@ 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 = 100 * 1024 * 1024; // 100 MB const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
const MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024);
export interface UploadedDocument { export interface UploadedDocument {
id: number; id: number;
@ -52,7 +48,6 @@ 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);
@ -67,21 +62,11 @@ 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( setError(`Invalid file type: ${file.name}`);
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( setError(`File too large: ${file.name} (max 25 MB)`);
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);

View 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, MP4, MOV, WebM. Maximum file size: 100 MB." defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB."
/> />
</Typography> </Typography>
</CardContent> </CardContent>

View File

@ -117,11 +117,8 @@ function CommentThread({
}, 0); }, 0);
}; };
const ALLOWED_TYPES = [ const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', const MAX_FILE_SIZE = 25 * 1024 * 1024;
'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;
@ -385,7 +382,7 @@ function CommentThread({
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,video/mp4,video/quicktime,video/webm" accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
multiple multiple
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileSelect} onChange={handleFileSelect}

View File

@ -1,5 +1,4 @@
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import React, { useCallback, 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,
@ -88,12 +87,6 @@ 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);
@ -114,7 +107,6 @@ 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('');
@ -213,25 +205,6 @@ 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);
@ -312,66 +285,10 @@ function TicketDetailPage() {
</Box> </Box>
<Box mb={3}> <Box mb={3}>
{editingSubject ? ( <Typography variant="h3" gutterBottom>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1 }}> #{ticket.id} {ticket.subject}
<Box display="flex" gap={1} alignItems="flex-start"> </Typography>
<Typography variant="h3" sx={{ whiteSpace: 'nowrap', pt: 0.5 }}> <Box display="flex" gap={1} alignItems="center">
#{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: '-' })} ·{' '}

View File

@ -20,8 +20,7 @@ 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> = {
@ -31,8 +30,7 @@ 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 {

View File

@ -181,8 +181,7 @@ 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 = {

View File

@ -623,8 +623,6 @@
"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",
@ -655,7 +653,6 @@
"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}.",
@ -686,12 +683,8 @@
"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, MP4, MOV, WebM. Maximale Dateigrösse: 100 MB.", "documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF. Maximale Dateigrösse: 25 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",

View File

@ -371,8 +371,6 @@
"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",
@ -403,7 +401,6 @@
"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}.",
@ -434,12 +431,8 @@
"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, MP4, MOV, WebM. Maximum file size: 100 MB.", "documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 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",

View File

@ -623,8 +623,6 @@
"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",
@ -655,7 +653,6 @@
"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}.",
@ -686,12 +683,8 @@
"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, MP4, MOV, WebM. Taille maximale : 100 Mo.", "documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF. Taille maximale : 25 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",

View File

@ -623,8 +623,6 @@
"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",
@ -655,7 +653,6 @@
"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}.",
@ -686,12 +683,8 @@
"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, MP4, MOV, WebM. Dimensione massima: 100 MB.", "documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF. Dimensione massima: 25 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",