From 49ae2b78060d88ff47ca8182dc70a5fa88347063 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 29 Apr 2026 14:34:20 +0200 Subject: [PATCH 01/11] city and model on list view --- .../Installations/FlatInstallationView.tsx | 74 +++++++++++++++---- .../FlatInstallationView.tsx | 12 +-- typescript/frontend-marios2/src/lang/de.json | 1 + typescript/frontend-marios2/src/lang/en.json | 1 + typescript/frontend-marios2/src/lang/fr.json | 1 + typescript/frontend-marios2/src/lang/it.json | 1 + 6 files changed, 69 insertions(+), 21 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx index d7ec91891..32db1479e 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx @@ -327,21 +327,33 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { + {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/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index c781ba065..fcdbac0ea 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", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index f871978ba..0de3dfce8 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", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 7376bacba..17dd936fc 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", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index e5790fc18..dc3bc856f 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", From a8b371e1da64bc9aee3c301a36815c1538630fe6 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 29 Apr 2026 14:48:17 +0200 Subject: [PATCH 02/11] add installation date for sodistore home and pro --- .../Information/InformationSodistoreHome.tsx | 30 +++++++++++++++++++ typescript/frontend-marios2/src/lang/de.json | 1 + typescript/frontend-marios2/src/lang/en.json | 1 + typescript/frontend-marios2/src/lang/fr.json | 1 + typescript/frontend-marios2/src/lang/it.json | 1 + 5 files changed, 34 insertions(+) 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 }} + /> +
+
Date: Wed, 29 Apr 2026 14:57:17 +0200 Subject: [PATCH 03/11] add distribution column in a ticket --- csharp/App/Backend/Controller.cs | 3 ++- .../content/dashboards/Tickets/TicketList.tsx | 21 ++++++++++++------- .../src/interfaces/TicketTypes.tsx | 1 + 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 832f84dda..6c4f5bee3 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2542,7 +2542,8 @@ public class Controller : ControllerBase 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/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx index 36e6d1080..50a91e3dc 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx @@ -167,6 +167,18 @@ function TicketList() { defaultMessage="Status" /> + + + + + + - - - + {ticket.installationName} + {ticket.distributionPartner} {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} {ticket.customCategory @@ -224,7 +232,6 @@ function TicketList() { ? ` — ${ticket.customSubCategory}` : ''} - {ticket.installationName} {new Date(ticket.createdAt).toLocaleDateString()} 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; }; From 90f6c2a5f992a34c0e67a3641595de092228666a Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 29 Apr 2026 15:01:02 +0200 Subject: [PATCH 04/11] allow multiple choices on status to filter --- .../content/dashboards/Tickets/TicketList.tsx | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx index 50a91e3dc..fb2e4d715 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,7 @@ function TicketList() { const intl = useIntl(); const [tickets, setTickets] = useState([]); const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState([]); const [createOpen, setCreateOpen] = useState(false); const [error, setError] = useState(''); @@ -73,7 +75,7 @@ function TicketList() { search === '' || t.subject.toLowerCase().includes(search.toLowerCase()) || t.installationName.toLowerCase().includes(search.toLowerCase()); - const matchesStatus = statusFilter === '' || t.status === statusFilter; + const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status); return matchesSearch && matchesStatus; }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); @@ -110,28 +112,37 @@ function TicketList() { onChange={(e) => setSearch(e.target.value)} sx={{ minWidth: 250 }} /> - + From 7f902c7271c98b1db88632992cd5f2dff42f0f5d Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 29 Apr 2026 15:06:15 +0200 Subject: [PATCH 05/11] add distribution partner on ticket list --- .../content/dashboards/Tickets/TicketList.tsx | 33 ++++++++++++++++++- typescript/frontend-marios2/src/lang/de.json | 1 + typescript/frontend-marios2/src/lang/en.json | 1 + typescript/frontend-marios2/src/lang/fr.json | 1 + typescript/frontend-marios2/src/lang/it.json | 1 + 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx index fb2e4d715..8abd24df6 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx @@ -55,6 +55,7 @@ function TicketList() { const [tickets, setTickets] = useState([]); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState([]); + const [partnerFilter, setPartnerFilter] = useState(''); const [createOpen, setCreateOpen] = useState(false); const [error, setError] = useState(''); @@ -69,6 +70,10 @@ 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 = @@ -76,7 +81,8 @@ function TicketList() { t.subject.toLowerCase().includes(search.toLowerCase()) || t.installationName.toLowerCase().includes(search.toLowerCase()); const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status); - return matchesSearch && matchesStatus; + const matchesPartner = partnerFilter === '' || t.distributionPartner === partnerFilter; + return matchesSearch && matchesStatus && matchesPartner; }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); @@ -145,6 +151,31 @@ function TicketList() { })} + + + + + + {error && ( diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 6bc61aa31..487d1f375 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -559,6 +559,7 @@ "priority": "Priorität", "category": "Kategorie", "allStatuses": "Alle Status", + "allPartners": "Alle Partner", "createdAt": "Erstellt", "noTickets": "Keine Tickets gefunden.", "backToTickets": "Zurück zu Tickets", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 369679c64..550ef6caa 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -307,6 +307,7 @@ "priority": "Priority", "category": "Category", "allStatuses": "All Statuses", + "allPartners": "All Partners", "createdAt": "Created", "noTickets": "No tickets found.", "backToTickets": "Back to Tickets", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index dae1d5cdd..43a1c3843 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -559,6 +559,7 @@ "priority": "Priorité", "category": "Catégorie", "allStatuses": "Tous les statuts", + "allPartners": "Tous les partenaires", "createdAt": "Créé", "noTickets": "Aucun ticket trouvé.", "backToTickets": "Retour aux tickets", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 2f6fdaf90..4eff76e2c 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -559,6 +559,7 @@ "priority": "Priorità", "category": "Categoria", "allStatuses": "Tutti gli stati", + "allPartners": "Tutti i partner", "createdAt": "Creato", "noTickets": "Nessun ticket trovato.", "backToTickets": "Torna ai ticket", From c21000e6589d3fa6649000ff19c5da7f0e0e249c Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 29 Apr 2026 15:13:08 +0200 Subject: [PATCH 06/11] fix: GetTicketSummaries goes from 1 + N queries to 1 + 1 --- csharp/App/Backend/Controller.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 6c4f5bee3..4d23b77ef 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2534,9 +2534,26 @@ 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, From 6f1d35016c2a1caac8470972b651983e1b097b3a Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 30 Apr 2026 10:14:07 +0200 Subject: [PATCH 07/11] obvious difference between bold, header1 and header 2 in comment of ticket --- .../Tickets/CommentFormatToolbar.tsx | 7 ------ .../dashboards/Tickets/CommentThread.tsx | 2 +- .../dashboards/Tickets/commentMarkdown.tsx | 25 ++++++++++--------- typescript/frontend-marios2/src/lang/de.json | 3 +-- typescript/frontend-marios2/src/lang/en.json | 3 +-- typescript/frontend-marios2/src/lang/fr.json | 3 +-- typescript/frontend-marios2/src/lang/it.json | 3 +-- 7 files changed, 18 insertions(+), 28 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx index af1b62fa7..09e688f76 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx @@ -48,13 +48,6 @@ 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..3efd81a3f 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -341,7 +341,7 @@ function CommentThread({ diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx index bfb83c908..39afb2626 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Typography } from '@mui/material'; -export type FormatKind = 'bold' | 'h1' | 'h2' | 'h3'; +export type FormatKind = 'bold' | 'h1' | 'h2'; function renderInline(text: string): React.ReactNode[] { const parts = text.split(/\*\*(.+?)\*\*/g); @@ -15,30 +15,31 @@ export function renderCommentBody(body: string): JSX.Element { 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'} + {line ? renderInline(line) : ' '} ); })} @@ -68,7 +69,7 @@ export function applyFormat( return; } - const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### '; + 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/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 487d1f375..21e66fd38 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -573,11 +573,10 @@ "comments": "Kommentare", "noComments": "Noch keine Kommentare.", "commentEdited": "(bearbeitet {time})", - "commentMarkdownHint": "Markdown: **fett**, #, ##, ###", + "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 550ef6caa..9f8a2d99c 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -321,11 +321,10 @@ "comments": "Comments", "noComments": "No comments yet.", "commentEdited": "(edited {time})", - "commentMarkdownHint": "Markdown: **bold**, #, ##, ###", + "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 43a1c3843..91e61e9db 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -573,11 +573,10 @@ "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", "commentEdited": "(modifié {time})", - "commentMarkdownHint": "Markdown : **gras**, #, ##, ###", + "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 4eff76e2c..488589cdf 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -573,11 +573,10 @@ "comments": "Commenti", "noComments": "Nessun commento ancora.", "commentEdited": "(modificato {time})", - "commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###", + "commentMarkdownHint": "Markdown: **grassetto**, #, ##", "commentFormatBold": "Grassetto", "commentFormatH1": "Titolo 1", "commentFormatH2": "Titolo 2", - "commentFormatH3": "Titolo 3", "addComment": "Aggiungi", "timeline": "Cronologia", "noTimelineEvents": "Nessun evento ancora.", From 53f0363da613208ac0c3501f6dd1c099ebfe319f Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 30 Apr 2026 11:03:50 +0200 Subject: [PATCH 08/11] allow to delete comment in a ticket --- csharp/App/Backend/Controller.cs | 36 ++++++++ csharp/App/Backend/Database/Db.cs | 1 + csharp/App/Backend/Database/Delete.cs | 29 +++++- .../dashboards/Tickets/CommentThread.tsx | 90 +++++++++++++++++-- typescript/frontend-marios2/src/lang/de.json | 2 + typescript/frontend-marios2/src/lang/en.json | 2 + typescript/frontend-marios2/src/lang/fr.json | 2 + typescript/frontend-marios2/src/lang/it.json | 2 + 8 files changed, 152 insertions(+), 12 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 4d23b77ef..9cd188a97 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2501,6 +2501,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) { 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/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 3efd81a3f..80de4f9f9 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -9,18 +9,27 @@ import { CardHeader, Chip, ClickAwayListener, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, Divider, + IconButton, LinearProgress, MenuItem, MenuList, Paper, Popper, 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'; @@ -49,6 +58,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); @@ -147,6 +158,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); @@ -274,14 +302,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 ? ( @@ -422,6 +464,38 @@ function CommentThread({ {uploading && } + deletingId === null && setDeleteCandidateId(null)} + > + + + + + + + + + + + + + ); } diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 21e66fd38..cf681f159 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -573,6 +573,8 @@ "comments": "Kommentare", "noComments": "Noch keine Kommentare.", "commentEdited": "(bearbeitet {time})", + "deleteComment": "Kommentar löschen", + "deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?", "commentMarkdownHint": "Markdown: **fett**, #, ##", "commentFormatBold": "Fett", "commentFormatH1": "Überschrift 1", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 9f8a2d99c..059853db3 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -321,6 +321,8 @@ "comments": "Comments", "noComments": "No comments yet.", "commentEdited": "(edited {time})", + "deleteComment": "Delete comment", + "deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?", "commentMarkdownHint": "Markdown: **bold**, #, ##", "commentFormatBold": "Bold", "commentFormatH1": "Heading 1", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 91e61e9db..f4931b4a6 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -573,6 +573,8 @@ "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", "commentEdited": "(modifié {time})", + "deleteComment": "Supprimer le commentaire", + "deleteCommentConfirm": "Le commentaire et toutes les pièces jointes seront définitivement supprimés. Continuer ?", "commentMarkdownHint": "Markdown : **gras**, #, ##", "commentFormatBold": "Gras", "commentFormatH1": "Titre 1", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 488589cdf..38fa25903 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -573,6 +573,8 @@ "comments": "Commenti", "noComments": "Nessun commento ancora.", "commentEdited": "(modificato {time})", + "deleteComment": "Elimina commento", + "deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?", "commentMarkdownHint": "Markdown: **grassetto**, #, ##", "commentFormatBold": "Grassetto", "commentFormatH1": "Titolo 1", From 99ba1b947c78a77d28877ea9c1fa3d44a4887a60 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 30 Apr 2026 12:17:10 +0200 Subject: [PATCH 09/11] add bullet and numbered list to comment in a ticket --- .../Tickets/CommentFormatToolbar.tsx | 16 ++ .../dashboards/Tickets/CommentThread.tsx | 4 +- .../dashboards/Tickets/commentMarkdown.tsx | 219 +++++++++++++++--- typescript/frontend-marios2/src/lang/de.json | 4 +- typescript/frontend-marios2/src/lang/en.json | 4 +- typescript/frontend-marios2/src/lang/fr.json | 4 +- typescript/frontend-marios2/src/lang/it.json | 4 +- 7 files changed, 216 insertions(+), 39 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx index 09e688f76..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,6 +50,20 @@ 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 80de4f9f9..12e0c141b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -35,7 +35,7 @@ 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'; interface CommentThreadProps { ticketId: number; @@ -341,6 +341,7 @@ function CommentThread({ minRows={2} value={editBody} onChange={(e) => setEditBody(e.target.value)} + onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)} inputRef={editInputRef} /> @@ -400,6 +401,7 @@ function CommentThread({ })} value={body} onChange={handleBodyChange} + onKeyDown={(e) => handleListEnter(e, body, setBody)} inputRef={commentInputRef} /> *': { mb: 0.5 } }}> - {lines.map((line, idx) => { - if (line.startsWith('## ')) { - return ( - - {renderInline(line.slice(3))} - - ); - } - if (line.startsWith('# ')) { - return ( - - {renderInline(line.slice(2))} - - ); - } - return ( - - {line ? renderInline(line) : ' '} - - ); - })} - - ); + 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) => { + 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( @@ -69,6 +206,20 @@ export function applyFormat( return; } + 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); diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index cf681f159..704ff0f13 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -575,10 +575,12 @@ "commentEdited": "(bearbeitet {time})", "deleteComment": "Kommentar löschen", "deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?", - "commentMarkdownHint": "Markdown: **fett**, #, ##", + "commentMarkdownHint": "Markdown: **fett**, #, ##, -, 1.", "commentFormatBold": "Fett", "commentFormatH1": "Überschrift 1", "commentFormatH2": "Überschrift 2", + "commentFormatBullet": "Aufzählung", + "commentFormatNumbered": "Nummerierte Liste", "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 059853db3..a7393b452 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -323,10 +323,12 @@ "commentEdited": "(edited {time})", "deleteComment": "Delete comment", "deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?", - "commentMarkdownHint": "Markdown: **bold**, #, ##", + "commentMarkdownHint": "Markdown: **bold**, #, ##, -, 1.", "commentFormatBold": "Bold", "commentFormatH1": "Heading 1", "commentFormatH2": "Heading 2", + "commentFormatBullet": "Bullet list", + "commentFormatNumbered": "Numbered list", "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 f4931b4a6..31d63d5ed 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -575,10 +575,12 @@ "commentEdited": "(modifié {time})", "deleteComment": "Supprimer le commentaire", "deleteCommentConfirm": "Le commentaire et toutes les pièces jointes seront définitivement supprimés. Continuer ?", - "commentMarkdownHint": "Markdown : **gras**, #, ##", + "commentMarkdownHint": "Markdown : **gras**, #, ##, -, 1.", "commentFormatBold": "Gras", "commentFormatH1": "Titre 1", "commentFormatH2": "Titre 2", + "commentFormatBullet": "Liste à puces", + "commentFormatNumbered": "Liste numéroté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 38fa25903..d2b4597f4 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -575,10 +575,12 @@ "commentEdited": "(modificato {time})", "deleteComment": "Elimina commento", "deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?", - "commentMarkdownHint": "Markdown: **grassetto**, #, ##", + "commentMarkdownHint": "Markdown: **grassetto**, #, ##, -, 1.", "commentFormatBold": "Grassetto", "commentFormatH1": "Titolo 1", "commentFormatH2": "Titolo 2", + "commentFormatBullet": "Elenco puntato", + "commentFormatNumbered": "Elenco numerato", "addComment": "Aggiungi", "timeline": "Cronologia", "noTimelineEvents": "Nessun evento ancora.", From 5586001b791899d5f16c8a180a1e2e5321da9f00 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 30 Apr 2026 12:38:50 +0200 Subject: [PATCH 10/11] allow format ticket's description --- .../dashboards/Tickets/CreateTicketModal.tsx | 43 +++++++++++---- .../dashboards/Tickets/TicketDetail.tsx | 52 +++++++++++++------ 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx index 4dc2ddb03..18df0ca00 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx @@ -21,6 +21,8 @@ import { 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 { TicketPriority, TicketCategory, @@ -87,6 +89,7 @@ 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); @@ -547,17 +550,35 @@ 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)} + inputRef={descriptionRef} + multiline + rows={4} + fullWidth + margin="dense" + /> + {/* File attachments */} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index 62d16128c..38b112a20 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -50,6 +50,8 @@ 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 TimelinePanel from './TimelinePanel'; import FileUploadButton from 'src/components/FileUploadButton'; import DocumentList from 'src/components/DocumentList'; @@ -98,6 +100,7 @@ function TicketDetailPage() { const [solveGateOpen, setSolveGateOpen] = useState(false); const rootCauseRef = useRef(null); const solutionRef = useRef(null); + const descriptionRef = useRef(null); // Custom "Other" editing state const [editCustomSub, setEditCustomSub] = useState(''); @@ -411,7 +414,24 @@ function TicketDetailPage() { {editingDescription ? ( - + + + { + setDescription(next); + setDescriptionSaved(false); + }} + disabled={savingDescription} + /> + + + + + handleListEnter(e, description, (next) => { + setDescription(next); + setDescriptionSaved(false); + }) + } + inputRef={descriptionRef} /> {descriptionSaved && ( @@ -447,23 +474,18 @@ function TicketDetailPage() { + ) : ticket.description ? ( + renderCommentBody(ticket.description) ) : ( - {ticket.description || ( - - - - )} + )} From c189a077fb9df1df98f9d68d72a4d5a3c7685cfc Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 30 Apr 2026 13:54:16 +0200 Subject: [PATCH 11/11] allow to paste screenshots in a ticket --- .../src/components/DocumentImage.tsx | 128 ++++++++++++++++ .../dashboards/Tickets/CommentThread.tsx | 98 ++++++++++++- .../dashboards/Tickets/CreateTicketModal.tsx | 86 ++++++++++- .../dashboards/Tickets/TicketDetail.tsx | 36 +++++ .../dashboards/Tickets/commentMarkdown.tsx | 27 ++++ .../src/hooks/usePasteImage.ts | 138 ++++++++++++++++++ typescript/frontend-marios2/src/lang/de.json | 2 + typescript/frontend-marios2/src/lang/en.json | 2 + typescript/frontend-marios2/src/lang/fr.json | 2 + typescript/frontend-marios2/src/lang/it.json | 2 + 10 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 typescript/frontend-marios2/src/components/DocumentImage.tsx create mode 100644 typescript/frontend-marios2/src/hooks/usePasteImage.ts 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/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 12e0c141b..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, @@ -21,6 +22,7 @@ import { MenuList, Paper, Popper, + Snackbar, TextField, Tooltip, Typography @@ -36,6 +38,7 @@ import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/Tick import DocumentList from 'src/components/DocumentList'; import CommentFormatToolbar from './CommentFormatToolbar'; import { renderCommentBody, handleListEnter } from './commentMarkdown'; +import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage'; interface CommentThreadProps { ticketId: number; @@ -70,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 @@ -192,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); @@ -210,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) { @@ -235,6 +306,7 @@ function CommentThread({ setMentionedIds([]); setMentionQuery(null); setSelectedFiles([]); + setPendingPastes([]); setRefreshKey((k) => k + 1); onCommentAdded(); } finally { @@ -342,6 +414,7 @@ function CommentThread({ value={editBody} onChange={(e) => setEditBody(e.target.value)} onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)} + onPaste={handlePasteEditComment} inputRef={editInputRef} /> @@ -402,6 +475,7 @@ function CommentThread({ value={body} onChange={handleBodyChange} onKeyDown={(e) => handleListEnter(e, body, setBody)} + onPaste={handlePasteNewComment} inputRef={commentInputRef} /> + 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 18df0ca00..d2dcf04f2 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx @@ -15,6 +15,7 @@ import { LinearProgress, MenuItem, Select, + Snackbar, TextField, Typography } from '@mui/material'; @@ -23,6 +24,7 @@ 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, @@ -94,6 +96,27 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: 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; @@ -257,6 +280,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: setCustomSubCategory(''); setCustomCategory(''); setSelectedFiles([]); + pendingPastes.forEach((p) => window.URL.revokeObjectURL(p.blobUrl)); + setPendingPastes([]); setError(''); }; @@ -287,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) { @@ -572,6 +635,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: value={description} onChange={(e) => setDescription(e.target.value)} onKeyDown={(e) => handleListEnter(e, description, setDescription)} + onPaste={handlePasteDescription} inputRef={descriptionRef} multiline rows={4} @@ -630,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 38b112a20..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'; @@ -52,6 +53,7 @@ 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'; @@ -101,6 +103,19 @@ function TicketDetailPage() { 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(''); @@ -447,6 +462,7 @@ function TicketDetailPage() { setDescriptionSaved(false); }) } + onPaste={handlePasteDescription} inputRef={descriptionRef} /> @@ -1014,6 +1030,26 @@ function TicketDetailPage() { + setPasteError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPasteError(null)}> + {pasteError === 'tooLarge' ? ( + + ) : ( + + )} + +