diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index ab756e82f..4900be461 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2398,6 +2398,40 @@ public class Controller : ControllerBase return comment; } + public class UpdateTicketCommentRequest + { + public Int64 Id { get; set; } + public String Body { get; set; } = ""; + } + + [HttpPost(nameof(UpdateTicketComment))] + public ActionResult UpdateTicketComment([FromBody] UpdateTicketCommentRequest req, 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 == req.Id); + if (comment is null) return NotFound(); + + if (comment.AuthorType != (Int32)CommentAuthorType.Human) return Forbid(); + if (comment.AuthorId != user.Id) return Forbid(); + + if (String.IsNullOrWhiteSpace(req.Body)) return BadRequest("Body required."); + + comment.Body = req.Body; + comment.EditedAt = DateTime.UtcNow; + if (!Db.Update(comment)) return StatusCode(500, "Failed to update comment."); + + var ticket = Db.GetTicketById(comment.TicketId); + if (ticket is not null) + { + ticket.UpdatedAt = DateTime.UtcNow; + Db.Update(ticket); + } + + return comment; + } + [HttpGet(nameof(GetTicketDetail))] public ActionResult GetTicketDetail(Int64 id, Token authToken) { diff --git a/csharp/App/Backend/DataTypes/TicketComment.cs b/csharp/App/Backend/DataTypes/TicketComment.cs index df0df19a6..6f08d97e5 100644 --- a/csharp/App/Backend/DataTypes/TicketComment.cs +++ b/csharp/App/Backend/DataTypes/TicketComment.cs @@ -13,6 +13,7 @@ public class TicketComment public Int64? AuthorId { get; set; } public String Body { get; set; } = ""; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? EditedAt { get; set; } [Ignore] public List MentionedUserIds { get; set; } = new(); } diff --git a/csharp/App/Backend/Database/Update.cs b/csharp/App/Backend/Database/Update.cs index ed5b2cf6a..fe6f66a73 100644 --- a/csharp/App/Backend/Database/Update.cs +++ b/csharp/App/Backend/Database/Update.cs @@ -72,4 +72,5 @@ public static partial class Db // Ticket system public static Boolean Update(Ticket ticket) => Update(obj: ticket); public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis); + public static Boolean Update(TicketComment comment) => Update(obj: comment); } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx new file mode 100644 index 000000000..af1b62fa7 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Box, Button, Tooltip } from '@mui/material'; +import FormatBoldIcon from '@mui/icons-material/FormatBold'; +import { useIntl } from 'react-intl'; +import { applyFormat, FormatKind } from './commentMarkdown'; + +interface CommentFormatToolbarProps { + textareaRef: React.RefObject; + value: string; + onChange: (next: string) => void; + disabled?: boolean; +} + +function CommentFormatToolbar({ + textareaRef, + value, + onChange, + disabled +}: CommentFormatToolbarProps) { + const intl = useIntl(); + + const handle = (kind: FormatKind) => () => { + applyFormat(textareaRef.current, value, kind, onChange); + }; + + const btnSx = { minWidth: 32, px: 1, py: 0.25, fontSize: 12, textTransform: 'none' as const }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default CommentFormatToolbar; diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 6bc3ccb64..8802dc883 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -1,4 +1,5 @@ -import React, { useRef, useState } from 'react'; +import React, { useContext, useRef, useState } from 'react'; +import { UserContext } from 'src/contexts/userContext'; import { Avatar, Box, @@ -24,6 +25,8 @@ 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'; interface CommentThreadProps { ticketId: number; @@ -39,8 +42,13 @@ function CommentThread({ adminUsers = [] }: CommentThreadProps) { const intl = useIntl(); + const userCtx = useContext(UserContext); + const currentUserId = userCtx?.currentUser?.id; const [body, setBody] = useState(''); const [submitting, setSubmitting] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editBody, setEditBody] = useState(''); + const [savingEdit, setSavingEdit] = useState(false); const fileInputRef = useRef(null); const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); @@ -49,6 +57,7 @@ function CommentThread({ const [mentionedIds, setMentionedIds] = useState([]); const [mentionQuery, setMentionQuery] = useState(null); const commentInputRef = useRef(null); + const editInputRef = useRef(null); const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin']; @@ -125,6 +134,32 @@ function CommentThread({ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); + const startEdit = (comment: TicketComment) => { + setEditingId(comment.id); + setEditBody(comment.body); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditBody(''); + }; + + const saveEdit = async (commentId: number) => { + if (!editBody.trim()) return; + setSavingEdit(true); + try { + await axiosConfig.post('/UpdateTicketComment', { + id: commentId, + body: editBody + }); + setEditingId(null); + setEditBody(''); + onCommentAdded(); + } finally { + setSavingEdit(false); + } + }; + const handleSubmit = async () => { if (!body.trim() && selectedFiles.length === 0) return; setSubmitting(true); @@ -196,6 +231,8 @@ function CommentThread({ {sorted.map((comment) => { const isAi = comment.authorType === CommentAuthorType.AiAgent; + const canEdit = !isAi && currentUserId != null && comment.authorId === currentUserId; + const isEditing = editingId === comment.id; return ( {isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)} @@ -224,10 +261,64 @@ function CommentThread({ {new Date(comment.createdAt).toLocaleString()} + {comment.editedAt && ( + + + + )} + {canEdit && !isEditing && ( + + )} - - {comment.body} - + {isEditing ? ( + + + setEditBody(e.target.value)} + inputRef={editInputRef} + /> + + + + + + ) : ( + renderCommentBody(comment.body) + )} @@ -237,6 +328,20 @@ function CommentThread({ + + + + + + + i % 2 === 1 ? {p} : {p} + ); +} + +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'} + + ); + })} + + ); +} + +export function applyFormat( + el: HTMLTextAreaElement | HTMLInputElement | null, + value: string, + kind: FormatKind, + onChange: (next: string) => void +): void { + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + + if (kind === 'bold') { + const selected = value.slice(start, end); + const wrapped = `**${selected}**`; + const next = value.slice(0, start) + wrapped + value.slice(end); + onChange(next); + const caret = selected.length > 0 ? start + wrapped.length : start + 2; + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(caret, caret); + }); + return; + } + + const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### '; + const lineStart = value.lastIndexOf('\n', start - 1) + 1; + const nlAfter = value.indexOf('\n', start); + const lineEnd = nlAfter === -1 ? value.length : nlAfter; + const line = value.slice(lineStart, lineEnd); + const stripped = line.replace(/^#{1,3}\s/, ''); + const newLine = prefix + stripped; + const next = value.slice(0, lineStart) + newLine + value.slice(lineEnd); + onChange(next); + const caret = lineStart + newLine.length; + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(caret, caret); + }); +} diff --git a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx index e0e986e41..888dc58ee 100644 --- a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx @@ -214,6 +214,7 @@ export type TicketComment = { authorId: number | null; body: string; createdAt: string; + editedAt?: string | null; }; export type TicketAiDiagnosis = { diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 458a189db..51818bd91 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -569,6 +569,12 @@ "noDiagnosis": "Keine KI-Diagnose verfügbar.", "comments": "Kommentare", "noComments": "Noch keine Kommentare.", + "commentEdited": "(bearbeitet {time})", + "commentMarkdownHint": "Markdown: **fett**, #, ##, ###", + "commentFormatBold": "Fett", + "commentFormatH1": "Überschrift 1", + "commentFormatH2": "Überschrift 2", + "commentFormatH3": "Überschrift 3", "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 f365ac63c..d2156f8f8 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -317,6 +317,12 @@ "noDiagnosis": "No AI diagnosis available.", "comments": "Comments", "noComments": "No comments yet.", + "commentEdited": "(edited {time})", + "commentMarkdownHint": "Markdown: **bold**, #, ##, ###", + "commentFormatBold": "Bold", + "commentFormatH1": "Heading 1", + "commentFormatH2": "Heading 2", + "commentFormatH3": "Heading 3", "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 fe27d6a3f..1ba48bb9d 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -569,6 +569,12 @@ "noDiagnosis": "Aucun diagnostic IA disponible.", "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", + "commentEdited": "(modifié {time})", + "commentMarkdownHint": "Markdown : **gras**, #, ##, ###", + "commentFormatBold": "Gras", + "commentFormatH1": "Titre 1", + "commentFormatH2": "Titre 2", + "commentFormatH3": "Titre 3", "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 825ebc0bd..03ad3e188 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -569,6 +569,12 @@ "noDiagnosis": "Nessuna diagnosi IA disponibile.", "comments": "Commenti", "noComments": "Nessun commento ancora.", + "commentEdited": "(modificato {time})", + "commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###", + "commentFormatBold": "Grassetto", + "commentFormatH1": "Titolo 1", + "commentFormatH2": "Titolo 2", + "commentFormatH3": "Titolo 3", "addComment": "Aggiungi", "timeline": "Cronologia", "noTimelineEvents": "Nessun evento ancora.",