diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 696179480..90ee9d6a3 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2509,6 +2509,42 @@ public class Controller : ControllerBase return comment; } + [HttpDelete(nameof(DeleteTicketComment))] + public async Task DeleteTicketComment(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var comment = Db.TicketComments.FirstOrDefault(c => c.Id == id); + if (comment is null) return NotFound(); + + // Author-only; AI comments cannot be deleted via this endpoint. + if (comment.AuthorType != (Int32)CommentAuthorType.Human) return Unauthorized(); + if (comment.AuthorId != user.Id) return Unauthorized(); + + // Clean up S3 objects for documents attached to this comment before DB delete. + var s3Keys = Db.Documents + .Where(d => d.TicketCommentId == comment.Id) + .Select(d => d.S3Key) + .ToList(); + if (s3Keys.Count > 0) + { + try { await DocumentBucket.DeleteObjects(s3Keys); } + catch (Exception ex) { Console.WriteLine($"[Documents] S3 cleanup on comment delete failed: {ex.Message}"); } + } + + if (!Db.Delete(comment)) return StatusCode(500, "Failed to delete comment."); + + var ticket = Db.GetTicketById(comment.TicketId); + if (ticket is not null) + { + ticket.UpdatedAt = DateTime.UtcNow; + Db.Update(ticket); + } + + return Ok(); + } + [HttpGet(nameof(GetTicketDetail))] public ActionResult GetTicketDetail(Int64 id, Token authToken) { @@ -2542,15 +2578,33 @@ public class Controller : ControllerBase if (user is null || user.UserType != 2) return Unauthorized(); var tickets = Db.GetAllTickets(); + + var installationIds = tickets + .Where(t => t.InstallationId.HasValue) + .Select(t => t.InstallationId!.Value) + .Distinct() + .ToList(); + + var installationsById = installationIds.Count == 0 + ? new Dictionary() + : Db.Installations + .Where(i => installationIds.Contains(i.Id)) + .ToList() + .ToDictionary(i => i.Id); + var summaries = tickets.Select(t => { - var installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null; + Installation? installation = null; + if (t.InstallationId.HasValue) + installationsById.TryGetValue(t.InstallationId.Value, out installation); + return new { t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory, t.InstallationId, t.CreatedAt, t.UpdatedAt, t.CustomSubCategory, t.CustomCategory, - installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation") + installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation"), + distributionPartner = installation?.DistributionPartner ?? "" }; }); diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 63d0b7f10..4bdd7b2bb 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -36,6 +36,7 @@ public static partial class Db // Ticket system tables public static TableQuery Tickets => Connection.Table(); public static TableQuery TicketComments => Connection.Table(); + public static TableQuery TicketCommentMentions => Connection.Table(); public static TableQuery TicketAiDiagnoses => Connection.Table(); public static TableQuery TicketTimelineEvents => Connection.Table(); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index 1bf82931c..a2f06a600 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -129,10 +129,13 @@ public static partial class Db .Select(t => t.Id).ToList(); foreach (var tid in ticketIds) { - // Delete documents attached to ticket comments + // Delete documents and mentions attached to ticket comments var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList(); foreach (var cid in tCommentIds) - Documents.Delete(d => d.TicketCommentId == cid); + { + Documents .Delete(d => d.TicketCommentId == cid); + TicketCommentMentions .Delete(m => m.CommentId == cid); + } // Delete documents attached directly to the ticket Documents .Delete(d => d.TicketId == tid); @@ -231,13 +234,16 @@ public static partial class Db Boolean DeleteTicketAndChildren() { - // Delete documents attached to comments on this ticket + // Delete documents and mentions attached to comments on this ticket var commentIds = TicketComments .Where(c => c.TicketId == ticket.Id) .Select(c => c.Id) .ToList(); foreach (var cid in commentIds) - Documents.Delete(d => d.TicketCommentId == cid); + { + Documents .Delete(d => d.TicketCommentId == cid); + TicketCommentMentions .Delete(m => m.CommentId == cid); + } // Delete documents attached directly to the ticket Documents .Delete(d => d.TicketId == ticket.Id); @@ -256,6 +262,21 @@ public static partial class Db return success; } + public static Boolean Delete(TicketComment comment) + { + var deleteSuccess = RunTransaction(DeleteCommentAndChildren); + if (deleteSuccess) Backup(); + return deleteSuccess; + + Boolean DeleteCommentAndChildren() + { + // Document rows attached to this comment (S3 cleanup happens in the controller) + Documents .Delete(d => d.TicketCommentId == comment.Id); + TicketCommentMentions .Delete(m => m.CommentId == comment.Id); + return TicketComments .Delete(c => c.Id == comment.Id) > 0; + } + } + public static List GetS3KeysForTicketDocuments(Int64 ticketId) { // Get documents attached directly to the ticket diff --git a/typescript/frontend-marios2/src/components/DocumentImage.tsx b/typescript/frontend-marios2/src/components/DocumentImage.tsx new file mode 100644 index 000000000..a8001d1ec --- /dev/null +++ b/typescript/frontend-marios2/src/components/DocumentImage.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Dialog, DialogContent, IconButton } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import BrokenImageIcon from '@mui/icons-material/BrokenImage'; +import axiosConfig from 'src/Resources/axiosConfig'; + +interface DocumentImageProps { + docId?: number; + src?: string; + alt?: string; + maxHeight?: number; +} + +function DocumentImage({ docId, src, alt = '', maxHeight = 480 }: DocumentImageProps) { + const [blobUrl, setBlobUrl] = useState(null); + const [failed, setFailed] = useState(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + if (src) { + setBlobUrl(src); + return; + } + if (!docId) return; + let cancelled = false; + let createdUrl: string | null = null; + axiosConfig + .get('/DownloadDocument', { params: { id: docId }, responseType: 'blob' }) + .then((res) => { + if (cancelled) return; + const url = window.URL.createObjectURL(new Blob([res.data])); + createdUrl = url; + setBlobUrl(url); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { + cancelled = true; + if (createdUrl) window.URL.revokeObjectURL(createdUrl); + }; + }, [docId, src]); + + if (failed) { + return ( + + + {alt || 'Image unavailable'} + + ); + } + + if (!blobUrl) { + return ( + + ); + } + + return ( + <> + setOpen(true)} + sx={{ + display: 'block', + maxWidth: '100%', + maxHeight, + borderRadius: 1, + border: '1px solid', + borderColor: 'divider', + cursor: 'zoom-in', + my: 0.5 + }} + /> + setOpen(false)} maxWidth="lg"> + + setOpen(false)} + sx={{ + position: 'absolute', + top: 8, + right: 8, + bgcolor: 'rgba(0,0,0,0.5)', + color: 'common.white', + '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } + }} + aria-label="Close" + > + + + + + + + ); +} + +export default DocumentImage; diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx index 3c87ad13f..bdc1adc37 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/InformationSodistoreHome.tsx @@ -404,6 +404,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { const [networkProviders, setNetworkProviders] = useState([]); const [loadingProviders, setLoadingProviders] = useState(false); + const [installationDate, setInstallationDate] = useState(''); useEffect(() => { setLoadingProviders(true); @@ -414,6 +415,23 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { .finally(() => setLoadingProviders(false)); }, []); + useEffect(() => { + if (!props.values.id) return; + axiosConfig + .get('/GetChecklistForInstallation', { + params: { installationId: props.values.id } + }) + .then((res) => { + if (Array.isArray(res.data)) { + const step10 = res.data.find( + (i: { stepNumber?: number }) => i.stepNumber === 10 + ); + setInstallationDate(step10?.doneAt ?? ''); + } + }) + .catch(() => setInstallationDate('')); + }, [props.values.id]); + return ( <> {openModalDeleteInstallation && ( @@ -856,6 +874,18 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) { /> +
+ } + type="date" + value={installationDate} + variant="outlined" + fullWidth + InputLabelProps={{ shrink: true }} + inputProps={{ readOnly: true }} + /> +
+
{ + {product === 4 && ( + + + + )} - - - + {product !== 4 && ( + + + + )} + {product === 4 && ( + + + + )} @@ -396,6 +408,21 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { + {product === 4 && ( + + + {installation.installationModel || ''} + + + )} + { - - - {installation.country} - - + {product !== 4 && ( + + + {installation.country} + + + )} { + {product === 4 && ( + + + {installation.city || ''} + + + )} +
{ - + - + - + @@ -202,7 +202,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { noWrap sx={{ marginTop: '10px', fontSize: 'small' }} > - {installation.serialNumber} + {installation.installationModel || ''} @@ -228,7 +228,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { noWrap sx={{ marginTop: '10px', fontSize: 'small' }} > - {installation.canton || ''} + {installation.serialNumber} @@ -241,7 +241,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { noWrap sx={{ marginTop: '10px', fontSize: 'small' }} > - {installation.country} + {installation.city || ''} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx index af1b62fa7..3a562eb96 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Box, Button, Tooltip } from '@mui/material'; import FormatBoldIcon from '@mui/icons-material/FormatBold'; +import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted'; +import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; import { useIntl } from 'react-intl'; import { applyFormat, FormatKind } from './commentMarkdown'; @@ -48,10 +50,17 @@ function CommentFormatToolbar({ - + - + + + + + diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 71dfaef80..494317459 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -1,6 +1,7 @@ -import React, { useContext, useRef, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { UserContext } from 'src/contexts/userContext'; import { + Alert, Avatar, Box, Button, @@ -9,24 +10,35 @@ import { CardHeader, Chip, ClickAwayListener, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, Divider, + IconButton, LinearProgress, MenuItem, MenuList, Paper, Popper, + Snackbar, TextField, + Tooltip, Typography } from '@mui/material'; import PersonIcon from '@mui/icons-material/Person'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import AttachFileIcon from '@mui/icons-material/AttachFile'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import DocumentList from 'src/components/DocumentList'; import CommentFormatToolbar from './CommentFormatToolbar'; -import { renderCommentBody } from './commentMarkdown'; +import { renderCommentBody, handleListEnter } from './commentMarkdown'; +import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage'; interface CommentThreadProps { ticketId: number; @@ -49,6 +61,8 @@ function CommentThread({ const [editingId, setEditingId] = useState(null); const [editBody, setEditBody] = useState(''); const [savingEdit, setSavingEdit] = useState(false); + const [deleteCandidateId, setDeleteCandidateId] = useState(null); + const [deletingId, setDeletingId] = useState(null); const fileInputRef = useRef(null); const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); @@ -59,6 +73,37 @@ function CommentThread({ const commentInputRef = useRef(null); const editInputRef = useRef(null); + const [pendingPastes, setPendingPastes] = useState([]); + const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null); + + const pendingPastesRef = useRef([]); + pendingPastesRef.current = pendingPastes; + + useEffect(() => { + return () => { + pendingPastesRef.current.forEach((p) => window.URL.revokeObjectURL(p.blobUrl)); + }; + }, []); + + const handlePasteNewComment = usePasteImage({ + mode: 'deferred', + textareaRef: commentInputRef, + value: body, + onChange: setBody, + onError: setPasteError, + onBufferedPaste: (p) => setPendingPastes((prev) => [...prev, p]) + }); + + const handlePasteEditComment = usePasteImage({ + mode: 'immediate', + textareaRef: editInputRef, + value: editBody, + onChange: setEditBody, + onError: setPasteError, + ticketId, + ticketCommentId: editingId ?? undefined + }); + const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin']; const mentionCandidates = mentionQuery === null @@ -147,6 +192,23 @@ function CommentThread({ setEditBody(''); }; + const confirmDelete = async () => { + if (deleteCandidateId == null) return; + const id = deleteCandidateId; + setDeletingId(id); + try { + await axiosConfig.delete('/DeleteTicketComment', { params: { id } }); + setDeleteCandidateId(null); + if (editingId === id) { + setEditingId(null); + setEditBody(''); + } + onCommentAdded(); + } finally { + setDeletingId(null); + } + }; + const saveEdit = async (commentId: number) => { if (!editBody.trim()) return; setSavingEdit(true); @@ -164,11 +226,12 @@ function CommentThread({ }; const handleSubmit = async () => { - if (!body.trim() && selectedFiles.length === 0) return; + if (!body.trim() && selectedFiles.length === 0 && pendingPastes.length === 0) return; setSubmitting(true); try { let commentId: number | undefined; + let finalBody = body; if (body.trim()) { const activeMentionedIds = mentionedIds.filter((uid) => { const u = adminUsers.find((au) => au.id === uid); @@ -182,6 +245,42 @@ function CommentThread({ commentId = res.data?.id; } + // Upload buffered pasted images, then rewrite blob: URLs to /DownloadDocument URLs + if (pendingPastes.length > 0 && commentId) { + setUploading(true); + let bodyChanged = false; + for (const paste of pendingPastes) { + const formData = new FormData(); + formData.append('file', paste.blob); + try { + const res = await axiosConfig.post('/UploadDocument', formData, { + params: { scope: 0, ticketId, ticketCommentId: commentId }, + headers: { 'Content-Type': 'multipart/form-data' } + }); + const docId = res.data?.id; + if (docId) { + const realUrl = `/DownloadDocument?id=${docId}`; + if (finalBody.includes(paste.blobUrl)) { + finalBody = finalBody.split(paste.blobUrl).join(realUrl); + bodyChanged = true; + } + } + } catch (err: any) { + console.warn('[Paste] Upload failed:', err?.response?.data || err?.message); + setPasteError('uploadFailed'); + } + window.URL.revokeObjectURL(paste.blobUrl); + } + if (bodyChanged) { + try { + await axiosConfig.post('/UpdateTicketComment', { id: commentId, body: finalBody }); + } catch (err: any) { + console.warn('[Paste] Body rewrite failed:', err?.response?.data || err?.message); + } + } + setUploading(false); + } + if (selectedFiles.length > 0) { setUploading(true); for (const file of selectedFiles) { @@ -207,6 +306,7 @@ function CommentThread({ setMentionedIds([]); setMentionQuery(null); setSelectedFiles([]); + setPendingPastes([]); setRefreshKey((k) => k + 1); onCommentAdded(); } finally { @@ -274,14 +374,28 @@ function CommentThread({ )} {canEdit && !isEditing && ( - + + + startEdit(comment)} + aria-label={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })} + > + + + + + setDeleteCandidateId(comment.id)} + disabled={deletingId === comment.id} + aria-label={intl.formatMessage({ id: 'delete', defaultMessage: 'Delete' })} + > + + + + )} {isEditing ? ( @@ -299,6 +413,8 @@ function CommentThread({ minRows={2} value={editBody} onChange={(e) => setEditBody(e.target.value)} + onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)} + onPaste={handlePasteEditComment} inputRef={editInputRef} /> @@ -341,7 +457,7 @@ function CommentThread({ @@ -358,6 +474,8 @@ function CommentThread({ })} value={body} onChange={handleBodyChange} + onKeyDown={(e) => handleListEnter(e, body, setBody)} + onPaste={handlePasteNewComment} inputRef={commentInputRef} /> } + deletingId === null && setDeleteCandidateId(null)} + > + + + + + + + + + + + + + + setPasteError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPasteError(null)}> + {pasteError === 'tooLarge' ? ( + + ) : ( + + )} + + ); } diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx index 4dc2ddb03..d2dcf04f2 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx @@ -15,12 +15,16 @@ import { LinearProgress, MenuItem, Select, + Snackbar, TextField, Typography } from '@mui/material'; import AttachFileIcon from '@mui/icons-material/AttachFile'; import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; +import CommentFormatToolbar from './CommentFormatToolbar'; +import { handleListEnter } from './commentMarkdown'; +import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage'; import { TicketPriority, TicketCategory, @@ -87,10 +91,32 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: // File attachments const fileInputRef = useRef(null); + const descriptionRef = useRef(null); const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); + const [pendingPastes, setPendingPastes] = useState([]); + const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null); + + const pendingPastesRef = useRef([]); + pendingPastesRef.current = pendingPastes; + + useEffect(() => { + return () => { + pendingPastesRef.current.forEach((p) => window.URL.revokeObjectURL(p.blobUrl)); + }; + }, []); + + const handlePasteDescription = usePasteImage({ + mode: 'deferred', + textareaRef: descriptionRef, + value: description, + onChange: setDescription, + onError: setPasteError, + onBufferedPaste: (p) => setPendingPastes((prev) => [...prev, p]) + }); + const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; const MAX_FILE_SIZE = 25 * 1024 * 1024; @@ -254,6 +280,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: setCustomSubCategory(''); setCustomCategory(''); setSelectedFiles([]); + pendingPastes.forEach((p) => window.URL.revokeObjectURL(p.blobUrl)); + setPendingPastes([]); setError(''); }; @@ -284,7 +312,45 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: customCategory: isOtherCategory ? customCategory || null : null }); - const newTicketId = res.data?.id; + const createdTicket = res.data; + const newTicketId = createdTicket?.id; + + // Upload buffered pasted images, then rewrite blob: URLs in description + if (pendingPastes.length > 0 && newTicketId) { + setUploading(true); + let finalDescription = description; + let descChanged = false; + for (const paste of pendingPastes) { + const formData = new FormData(); + formData.append('file', paste.blob); + try { + const upRes = await axiosConfig.post('/UploadDocument', formData, { + params: { scope: 0, ticketId: newTicketId }, + headers: { 'Content-Type': 'multipart/form-data' } + }); + const docId = upRes.data?.id; + if (docId && finalDescription.includes(paste.blobUrl)) { + finalDescription = finalDescription.split(paste.blobUrl).join(`/DownloadDocument?id=${docId}`); + descChanged = true; + } + } catch (err: any) { + console.warn('[Paste] Upload failed:', err?.response?.data || err?.message); + setPasteError('uploadFailed'); + } + window.URL.revokeObjectURL(paste.blobUrl); + } + if (descChanged && createdTicket) { + try { + await axiosConfig.put('/UpdateTicket', { + ...createdTicket, + description: finalDescription + }); + } catch (err: any) { + console.warn('[Paste] Description rewrite failed:', err?.response?.data || err?.message); + } + } + setUploading(false); + } // Upload attached files if any if (selectedFiles.length > 0 && newTicketId) { @@ -547,17 +613,36 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: /> )} - - } - value={description} - onChange={(e) => setDescription(e.target.value)} - multiline - rows={4} - fullWidth - margin="dense" - /> + + + + + + + + + } + value={description} + onChange={(e) => setDescription(e.target.value)} + onKeyDown={(e) => handleListEnter(e, description, setDescription)} + onPaste={handlePasteDescription} + inputRef={descriptionRef} + multiline + rows={4} + fullWidth + margin="dense" + /> + {/* File attachments */} @@ -609,6 +694,26 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: + setPasteError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPasteError(null)}> + {pasteError === 'tooLarge' ? ( + + ) : ( + + )} + + ); } diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index 62d16128c..9db193c5d 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -22,6 +22,7 @@ import { InputLabel, MenuItem, Select, + Snackbar, TextField, Typography } from '@mui/material'; @@ -50,6 +51,9 @@ import Footer from 'src/components/Footer'; import StatusChip from './StatusChip'; import AiDiagnosisPanel from './AiDiagnosisPanel'; import CommentThread from './CommentThread'; +import CommentFormatToolbar from './CommentFormatToolbar'; +import { renderCommentBody, handleListEnter } from './commentMarkdown'; +import { usePasteImage } from 'src/hooks/usePasteImage'; import TimelinePanel from './TimelinePanel'; import FileUploadButton from 'src/components/FileUploadButton'; import DocumentList from 'src/components/DocumentList'; @@ -98,6 +102,20 @@ function TicketDetailPage() { const [solveGateOpen, setSolveGateOpen] = useState(false); const rootCauseRef = useRef(null); const solutionRef = useRef(null); + const descriptionRef = useRef(null); + const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null); + + const handlePasteDescription = usePasteImage({ + mode: 'immediate', + textareaRef: descriptionRef, + value: description, + onChange: (next) => { + setDescription(next); + setDescriptionSaved(false); + }, + onError: setPasteError, + ticketId: detail?.ticket?.id + }); // Custom "Other" editing state const [editCustomSub, setEditCustomSub] = useState(''); @@ -411,7 +429,24 @@ function TicketDetailPage() { {editingDescription ? ( - + + + { + setDescription(next); + setDescriptionSaved(false); + }} + disabled={savingDescription} + /> + + + + + handleListEnter(e, description, (next) => { + setDescription(next); + setDescriptionSaved(false); + }) + } + onPaste={handlePasteDescription} + inputRef={descriptionRef} /> {descriptionSaved && ( @@ -447,23 +490,18 @@ function TicketDetailPage() { + ) : ticket.description ? ( + renderCommentBody(ticket.description) ) : ( - {ticket.description || ( - - - - )} + )} @@ -992,6 +1030,26 @@ function TicketDetailPage() { + setPasteError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPasteError(null)}> + {pasteError === 'tooLarge' ? ( + + ) : ( + + )} + +
); diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx index 36e6d1080..8abd24df6 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx @@ -4,9 +4,11 @@ import { Alert, Box, Button, + Checkbox, Container, FormControl, InputLabel, + ListItemText, MenuItem, Select, Table, @@ -52,7 +54,8 @@ function TicketList() { const intl = useIntl(); const [tickets, setTickets] = useState([]); const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState([]); + const [partnerFilter, setPartnerFilter] = useState(''); const [createOpen, setCreateOpen] = useState(false); const [error, setError] = useState(''); @@ -67,14 +70,19 @@ function TicketList() { fetchTickets(); }, []); + const partnerOptions = Array.from( + new Set(tickets.map((t) => t.distributionPartner).filter((p) => p && p.trim() !== '')) + ).sort(); + const filtered = tickets .filter((t) => { const matchesSearch = search === '' || t.subject.toLowerCase().includes(search.toLowerCase()) || t.installationName.toLowerCase().includes(search.toLowerCase()); - const matchesStatus = statusFilter === '' || t.status === statusFilter; - return matchesSearch && matchesStatus; + const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status); + const matchesPartner = partnerFilter === '' || t.distributionPartner === partnerFilter; + return matchesSearch && matchesStatus && matchesPartner; }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); @@ -110,26 +118,60 @@ function TicketList() { onChange={(e) => setSearch(e.target.value)} sx={{ minWidth: 250 }} /> - + + + + + + + @@ -167,6 +209,18 @@ function TicketList() { defaultMessage="Status" />
+ + + + + + - - - + {ticket.installationName} + {ticket.distributionPartner} {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} {ticket.customCategory @@ -224,7 +274,6 @@ function TicketList() { ? ` — ${ticket.customSubCategory}` : ''} - {ticket.installationName} {new Date(ticket.createdAt).toLocaleDateString()} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx index bfb83c908..1d8fdfe80 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx @@ -1,7 +1,20 @@ import React from 'react'; import { Box, Typography } from '@mui/material'; +import DocumentImage from 'src/components/DocumentImage'; -export type FormatKind = 'bold' | 'h1' | 'h2' | 'h3'; +export type FormatKind = 'bold' | 'h1' | 'h2' | 'bullet' | 'numbered'; + +const BULLET_RE = /^- /; +const NUMBERED_RE = /^\d+\.\s/; +const IMAGE_RE = /^!\[([^\]]*)\]\((.+)\)\s*$/; +const DOC_URL_RE = /\/DownloadDocument\?(?:[^&]*&)*id=(\d+)/; + +function parseImageUrl(url: string): { docId?: number; src?: string } | null { + const docMatch = url.match(DOC_URL_RE); + if (docMatch) return { docId: parseInt(docMatch[1], 10) }; + if (url.startsWith('blob:')) return { src: url }; + return null; +} function renderInline(text: string): React.ReactNode[] { const parts = text.split(/\*\*(.+?)\*\*/g); @@ -10,40 +23,192 @@ function renderInline(text: string): React.ReactNode[] { ); } +type ListBuf = { ordered: boolean; items: string[] }; + export function renderCommentBody(body: string): JSX.Element { const lines = body.split('\n'); - return ( - *': { mb: 0.5 } }}> - {lines.map((line, idx) => { - if (line.startsWith('### ')) { - return ( - - {renderInline(line.slice(4))} - - ); - } - if (line.startsWith('## ')) { - return ( - - {renderInline(line.slice(3))} - - ); - } - if (line.startsWith('# ')) { - return ( - - {renderInline(line.slice(2))} - - ); - } - return ( - - {line ? renderInline(line) : '\u00A0'} - + const blocks: JSX.Element[] = []; + let listBuf: ListBuf | null = null; + + const flushList = () => { + if (!listBuf) return; + const key = `list-${blocks.length}`; + const items = listBuf.items.map((item, i) => ( +
  • + {renderInline(item)} +
  • + )); + blocks.push( + listBuf.ordered ? ( + + {items} + + ) : ( + + {items} + + ) + ); + listBuf = null; + }; + + lines.forEach((line, idx) => { + const imageMatch = line.match(IMAGE_RE); + if (imageMatch) { + const parsed = parseImageUrl(imageMatch[2]); + if (parsed) { + flushList(); + blocks.push( + ); - })} -
    - ); + return; + } + } + + if (BULLET_RE.test(line)) { + const item = line.replace(BULLET_RE, ''); + if (listBuf && !listBuf.ordered) { + listBuf.items.push(item); + } else { + flushList(); + listBuf = { ordered: false, items: [item] }; + } + return; + } + if (NUMBERED_RE.test(line)) { + const item = line.replace(NUMBERED_RE, ''); + if (listBuf && listBuf.ordered) { + listBuf.items.push(item); + } else { + flushList(); + listBuf = { ordered: true, items: [item] }; + } + return; + } + flushList(); + + if (line.startsWith('## ')) { + blocks.push( + + {renderInline(line.slice(3))} + + ); + return; + } + if (line.startsWith('# ')) { + blocks.push( + + {renderInline(line.slice(2))} + + ); + return; + } + blocks.push( + + {line ? renderInline(line) : ' '} + + ); + }); + flushList(); + + return *': { mb: 0.5 } }}>{blocks}; +} + +function getLineRange(value: string, start: number, end: number) { + const lineStart = value.lastIndexOf('\n', start - 1) + 1; + const tailIdx = end > start ? end - 1 : end; + const nlAfter = value.indexOf('\n', tailIdx); + const lineEnd = nlAfter === -1 ? value.length : nlAfter; + return { lineStart, lineEnd }; +} + +function toggleListLines(block: string, ordered: boolean): string { + const lines = block.split('\n'); + const re = ordered ? NUMBERED_RE : BULLET_RE; + const allMatch = lines.every((l) => re.test(l)); + + if (allMatch) { + return lines.map((l) => l.replace(re, '')).join('\n'); + } + + let n = 1; + return lines + .map((l) => { + const stripped = l.replace(BULLET_RE, '').replace(NUMBERED_RE, ''); + if (ordered) { + const out = `${n}. ${stripped}`; + n += 1; + return out; + } + return `- ${stripped}`; + }) + .join('\n'); +} + +export function handleListEnter( + e: React.KeyboardEvent, + value: string, + onChange: (next: string) => void +): void { + if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; + const el = e.target as HTMLTextAreaElement | HTMLInputElement; + if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return; + const start = el.selectionStart ?? value.length; + const end = el.selectionEnd ?? value.length; + if (start !== end) return; + + const lineStart = value.lastIndexOf('\n', start - 1) + 1; + const line = value.slice(lineStart, start); + + const bulletMatch = line.match(/^- (.*)$/); + const numberedMatch = line.match(/^(\d+)\. (.*)$/); + if (!bulletMatch && !numberedMatch) return; + + e.preventDefault(); + + const content = bulletMatch ? bulletMatch[1] : numberedMatch![2]; + if (content.length === 0) { + const next = value.slice(0, lineStart) + value.slice(start); + onChange(next); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(lineStart, lineStart); + }); + return; + } + + const prefix = bulletMatch + ? '- ' + : `${parseInt(numberedMatch![1], 10) + 1}. `; + const insertion = `\n${prefix}`; + const next = value.slice(0, start) + insertion + value.slice(end); + onChange(next); + const caret = start + insertion.length; + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(caret, caret); + }); } export function applyFormat( @@ -68,7 +233,21 @@ export function applyFormat( return; } - const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### '; + if (kind === 'bullet' || kind === 'numbered') { + const { lineStart, lineEnd } = getLineRange(value, start, end); + const block = value.slice(lineStart, lineEnd); + const newBlock = toggleListLines(block, kind === 'numbered'); + const next = value.slice(0, lineStart) + newBlock + value.slice(lineEnd); + onChange(next); + const caret = lineStart + newBlock.length; + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(caret, caret); + }); + return; + } + + const prefix = kind === 'h1' ? '# ' : '## '; const lineStart = value.lastIndexOf('\n', start - 1) + 1; const nlAfter = value.indexOf('\n', start); const lineEnd = nlAfter === -1 ? value.length : nlAfter; diff --git a/typescript/frontend-marios2/src/hooks/usePasteImage.ts b/typescript/frontend-marios2/src/hooks/usePasteImage.ts new file mode 100644 index 000000000..5346151de --- /dev/null +++ b/typescript/frontend-marios2/src/hooks/usePasteImage.ts @@ -0,0 +1,138 @@ +import { ClipboardEvent, RefObject } from 'react'; +import axiosConfig from 'src/Resources/axiosConfig'; + +export interface PendingPaste { + placeholderId: string; + blob: File; + blobUrl: string; +} + +interface ImmediateOptions { + mode: 'immediate'; + ticketId?: number; + ticketCommentId?: number; +} + +interface DeferredOptions { + mode: 'deferred'; + onBufferedPaste: (paste: PendingPaste) => void; +} + +type Options = (ImmediateOptions | DeferredOptions) & { + textareaRef: RefObject; + value: string; + onChange: (next: string) => void; + onError?: (kind: 'tooLarge' | 'uploadFailed') => void; +}; + +const MAX_BYTES = 100 * 1024 * 1024; + +function extensionFor(type: string): string { + if (type === 'image/png') return 'png'; + if (type === 'image/jpeg') return 'jpg'; + if (type === 'image/gif') return 'gif'; + if (type === 'image/webp') return 'webp'; + return 'png'; +} + +function makeFilename(type: string): string { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `pasted-${stamp}.${extensionFor(type)}`; +} + +function spliceAtCursor( + el: HTMLInputElement | HTMLTextAreaElement | null, + value: string, + insertion: string +): { next: string; caret: number } { + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + const before = value.slice(0, start); + const after = value.slice(end); + const needsLeadingNl = before.length > 0 && !before.endsWith('\n'); + const needsTrailingNl = after.length > 0 && !after.startsWith('\n'); + const wrapped = + (needsLeadingNl ? '\n' : '') + insertion + (needsTrailingNl ? '\n' : ''); + const next = before + wrapped + after; + const caret = before.length + wrapped.length; + return { next, caret }; +} + +export function usePasteImage(opts: Options) { + const { textareaRef, value, onChange, onError } = opts; + + return async (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + let imageFile: File | null = null; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file' && item.type.startsWith('image/')) { + const f = item.getAsFile(); + if (f) { + imageFile = f; + break; + } + } + } + if (!imageFile) return; + + e.preventDefault(); + + if (imageFile.size > MAX_BYTES) { + onError?.('tooLarge'); + return; + } + + const filename = makeFilename(imageFile.type); + const renamed = new File([imageFile], filename, { type: imageFile.type }); + + if (opts.mode === 'deferred') { + const blobUrl = window.URL.createObjectURL(renamed); + const placeholderId = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + opts.onBufferedPaste({ placeholderId, blob: renamed, blobUrl }); + const insertion = `![${placeholderId}](${blobUrl})`; + const { next, caret } = spliceAtCursor(textareaRef.current, value, insertion); + onChange(next); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(caret, caret); + }); + return; + } + + // Immediate mode: upload now and splice the real /DownloadDocument URL + try { + const formData = new FormData(); + formData.append('file', renamed); + const res = await axiosConfig.post('/UploadDocument', formData, { + params: { + scope: 0, + ticketId: opts.ticketId, + ticketCommentId: opts.ticketCommentId + }, + headers: { 'Content-Type': 'multipart/form-data' } + }); + const docId = res.data?.id; + if (!docId) { + onError?.('uploadFailed'); + return; + } + const insertion = `![](/DownloadDocument?id=${docId})`; + const { next, caret } = spliceAtCursor(textareaRef.current, value, insertion); + onChange(next); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(caret, caret); + }); + } catch (err: any) { + console.warn('[Paste] Upload failed:', err?.response?.data || err?.message); + onError?.('uploadFailed'); + } + }; +} diff --git a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx index ddeb15536..ed205a2bd 100644 --- a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx @@ -264,6 +264,7 @@ export type TicketSummary = { createdAt: string; updatedAt: string; installationName: string; + distributionPartner: string; customSubCategory: string | null; customCategory: string | null; }; diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 9522c3842..686c00705 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -82,6 +82,7 @@ "deleteInstallation": "Installation löschen", "confirmDeleteInstallation": "Möchten Sie diese Installation löschen?", "installationModel": "Installationsmodell", + "model": "Modell", "externalEms": "Externes EMS", "externalEmsOther": "Externes EMS (angeben)", "emsNo": "Nein", @@ -91,6 +92,7 @@ "dataCollectionEnabled": "Datenerfassung", "generalInfo": "Allgemeine Informationen", "installationSetup": "Installationseinrichtung", + "installationDate": "Installationsdatum", "couplingType": "AC/DC-Kopplung", "couplingAC": "AC-gekoppelt", "couplingDC": "DC-gekoppelt", @@ -585,6 +587,7 @@ "priority": "Priorität", "category": "Kategorie", "allStatuses": "Alle Status", + "allPartners": "Alle Partner", "createdAt": "Erstellt", "noTickets": "Keine Tickets gefunden.", "backToTickets": "Zurück zu Tickets", @@ -598,11 +601,16 @@ "comments": "Kommentare", "noComments": "Noch keine Kommentare.", "commentEdited": "(bearbeitet {time})", - "commentMarkdownHint": "Markdown: **fett**, #, ##, ###", + "deleteComment": "Kommentar löschen", + "deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?", + "commentMarkdownHint": "Markdown: **fett**, #, ##, -, 1.", "commentFormatBold": "Fett", "commentFormatH1": "Überschrift 1", "commentFormatH2": "Überschrift 2", - "commentFormatH3": "Überschrift 3", + "commentFormatBullet": "Aufzählung", + "commentFormatNumbered": "Nummerierte Liste", + "pasteImageTooLarge": "Eingefügtes Bild überschreitet das Limit von 100 MB", + "pasteImageUploadFailed": "Eingefügtes Bild konnte nicht hochgeladen werden", "addComment": "Hinzufügen", "timeline": "Zeitverlauf", "noTimelineEvents": "Noch keine Ereignisse.", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 8622a1357..592e64583 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -64,6 +64,7 @@ "deleteInstallation": "Delete Installation", "confirmDeleteInstallation": "Do you want to delete this installation?", "installationModel": "Installation Model", + "model": "Model", "externalEms": "External EMS", "externalEmsOther": "External EMS (specify)", "emsNo": "No", @@ -73,6 +74,7 @@ "dataCollectionEnabled": "Data Collection", "generalInfo": "General Info", "installationSetup": "Installation Setup", + "installationDate": "Installation Date", "couplingType": "AC/DC Coupling", "couplingAC": "AC-coupled", "couplingDC": "DC-coupled", @@ -333,6 +335,7 @@ "priority": "Priority", "category": "Category", "allStatuses": "All Statuses", + "allPartners": "All Partners", "createdAt": "Created", "noTickets": "No tickets found.", "backToTickets": "Back to Tickets", @@ -346,11 +349,16 @@ "comments": "Comments", "noComments": "No comments yet.", "commentEdited": "(edited {time})", - "commentMarkdownHint": "Markdown: **bold**, #, ##, ###", + "deleteComment": "Delete comment", + "deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?", + "commentMarkdownHint": "Markdown: **bold**, #, ##, -, 1.", "commentFormatBold": "Bold", "commentFormatH1": "Heading 1", "commentFormatH2": "Heading 2", - "commentFormatH3": "Heading 3", + "commentFormatBullet": "Bullet list", + "commentFormatNumbered": "Numbered list", + "pasteImageTooLarge": "Pasted image exceeds 100 MB limit", + "pasteImageUploadFailed": "Failed to upload pasted image", "addComment": "Add", "timeline": "Timeline", "noTimelineEvents": "No events yet.", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 6330f9206..895199376 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -76,6 +76,7 @@ "deleteInstallation": "Supprimer l'installation", "confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?", "installationModel": "Modèle d'installation", + "model": "Modèle", "externalEms": "EMS externe", "externalEmsOther": "EMS externe (préciser)", "emsNo": "Non", @@ -85,6 +86,7 @@ "dataCollectionEnabled": "Collecte de données", "generalInfo": "Informations générales", "installationSetup": "Configuration de l'installation", + "installationDate": "Date d'installation", "couplingType": "Couplage AC/DC", "couplingAC": "Couplage AC", "couplingDC": "Couplage DC", @@ -585,6 +587,7 @@ "priority": "Priorité", "category": "Catégorie", "allStatuses": "Tous les statuts", + "allPartners": "Tous les partenaires", "createdAt": "Créé", "noTickets": "Aucun ticket trouvé.", "backToTickets": "Retour aux tickets", @@ -598,11 +601,16 @@ "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", "commentEdited": "(modifié {time})", - "commentMarkdownHint": "Markdown : **gras**, #, ##, ###", + "deleteComment": "Supprimer le commentaire", + "deleteCommentConfirm": "Le commentaire et toutes les pièces jointes seront définitivement supprimés. Continuer ?", + "commentMarkdownHint": "Markdown : **gras**, #, ##, -, 1.", "commentFormatBold": "Gras", "commentFormatH1": "Titre 1", "commentFormatH2": "Titre 2", - "commentFormatH3": "Titre 3", + "commentFormatBullet": "Liste à puces", + "commentFormatNumbered": "Liste numérotée", + "pasteImageTooLarge": "L'image collée dépasse la limite de 100 Mo", + "pasteImageUploadFailed": "Échec du téléversement de l'image collée", "addComment": "Ajouter", "timeline": "Chronologie", "noTimelineEvents": "Aucun événement pour le moment.", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 0855d1158..22fedbda9 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -64,6 +64,7 @@ "deleteInstallation": "Elimina installazione", "confirmDeleteInstallation": "Vuoi eliminare questa installazione?", "installationModel": "Modello di installazione", + "model": "Modello", "externalEms": "EMS esterno", "externalEmsOther": "EMS esterno (specificare)", "emsNo": "No", @@ -73,6 +74,7 @@ "dataCollectionEnabled": "Raccolta dati", "generalInfo": "Informazioni generali", "installationSetup": "Configurazione installazione", + "installationDate": "Data di installazione", "couplingType": "Accoppiamento AC/DC", "couplingAC": "Accoppiamento AC", "couplingDC": "Accoppiamento DC", @@ -585,6 +587,7 @@ "priority": "Priorità", "category": "Categoria", "allStatuses": "Tutti gli stati", + "allPartners": "Tutti i partner", "createdAt": "Creato", "noTickets": "Nessun ticket trovato.", "backToTickets": "Torna ai ticket", @@ -598,11 +601,16 @@ "comments": "Commenti", "noComments": "Nessun commento ancora.", "commentEdited": "(modificato {time})", - "commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###", + "deleteComment": "Elimina commento", + "deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?", + "commentMarkdownHint": "Markdown: **grassetto**, #, ##, -, 1.", "commentFormatBold": "Grassetto", "commentFormatH1": "Titolo 1", "commentFormatH2": "Titolo 2", - "commentFormatH3": "Titolo 3", + "commentFormatBullet": "Elenco puntato", + "commentFormatNumbered": "Elenco numerato", + "pasteImageTooLarge": "L'immagine incollata supera il limite di 100 MB", + "pasteImageUploadFailed": "Caricamento dell'immagine incollata non riuscito", "addComment": "Aggiungi", "timeline": "Cronologia", "noTimelineEvents": "Nessun evento ancora.",