From 4a6caa9ed31072162407f98370178cd146dc88b0 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Thu, 16 Apr 2026 14:21:24 +0200 Subject: [PATCH 01/21] add filters to fast search and disable data collection installations go bottom --- .../Installations/FlatInstallationView.tsx | 6 +- .../FlatInstallationView.tsx | 50 +++--- .../SodiohomeInstallations/Installation.tsx | 6 +- .../InstallationSearch.tsx | 167 +++++++++++++----- .../dashboards/Tree/InstallationTree.tsx | 10 +- 5 files changed, 166 insertions(+), 73 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx index cf25147d2..20d5b621a 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/FlatInstallationView.tsx @@ -96,8 +96,12 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { break; } - // Sort by status (alarms first) + // Sort by status (alarms first); data-collection-disabled sinks below offline. return filtered.sort((a, b) => { + const aDisabled = a.dataCollectionEnabled === false; + const bDisabled = b.dataCollectionEnabled === false; + if (aDisabled !== bDisabled) return aDisabled ? 1 : -1; + const a_status = a.status; const b_status = b.status; diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx index 374900ece..fe4139668 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/FlatInstallationView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Card, CircularProgress, @@ -31,34 +31,40 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { const [selectedInstallation, setSelectedInstallation] = useState(-1); const currentLocation = useLocation(); const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations; - // - const sortedInstallations = [...props.installations].sort((a, b) => { - // Compare the status field of each installation and sort them based on the status. - //Installations with alarms go first - let a_status = a.status; - let b_status = b.status; - if (a_status > b_status) { - return -1; - } - if (a_status < b_status) { - return 1; - } - return 0; - }); + const sortedInstallations = useMemo(() => { + return [...props.installations].sort((a, b) => { + // Data-collection-disabled installations sink below everything (even offline). + const aDisabled = a.dataCollectionEnabled === false; + const bDisabled = b.dataCollectionEnabled === false; + if (aDisabled !== bDisabled) return aDisabled ? 1 : -1; + + // Then sort by status (alarms first) + const a_status = a.status; + const b_status = b.status; + + if (a_status > b_status) return -1; + if (a_status < b_status) return 1; + return 0; + }); + }, [props.installations]); const handleSelectOneInstallation = (installationID: number): void => { if (selectedInstallation != installationID) { setSelectedInstallation(installationID); setSelectedInstallation(-1); + const target = props.installations.find((i) => i.id === installationID); + const landingTab = + target?.dataCollectionEnabled === false ? routes.information : routes.live; + navigate( baseRoute + routes.list + routes.installation + `${installationID}` + '/' + - routes.live, + landingTab, { replace: true } @@ -77,18 +83,16 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => { } })); + const isListView = + currentLocation.pathname === baseRoute + 'list' || + currentLocation.pathname === baseRoute + routes.list; + return ( diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index c881f9ff5..19460ce48 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -489,12 +489,14 @@ function SodioHomeInstallation(props: singleInstallationProps) { {loading && + !dataCollectionDisabled && currentTab != 'information' && // currentTab != 'manage' && currentTab != 'history' && currentTab != 'log' && currentTab != 'report' && - currentTab != 'installationTickets' && ( + currentTab != 'installationTickets' && + currentTab != 'documents' && ( } + element={} /> diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/InstallationSearch.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/InstallationSearch.tsx index eac127311..7e7176a12 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/InstallationSearch.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/InstallationSearch.tsx @@ -1,7 +1,15 @@ import React, { useMemo, useState } from 'react'; -import { FormControl, Grid, InputAdornment, TextField } from '@mui/material'; +import { + FormControl, + Grid, + InputAdornment, + InputLabel, + MenuItem, + Select, + TextField +} from '@mui/material'; import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone'; -import { useIntl } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { I_Installation } from '../../../interfaces/InstallationTypes'; import { Route, Routes, useLocation } from 'react-router-dom'; import routes from '../../../Resources/routes.json'; @@ -16,9 +24,10 @@ interface installationSearchProps { function InstallationSearch(props: installationSearchProps) { const intl = useIntl(); const [searchTerm, setSearchTerm] = useState(''); + const [sortByStatus, setSortByStatus] = useState('All Installations'); + const [sortByAction, setSortByAction] = useState('All Installations'); const currentLocation = useLocation(); const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations; - // const [filteredData, setFilteredData] = useState(props.installations); const indexedData = useMemo(() => { return props.installations.map((item) => ({ @@ -30,56 +39,126 @@ function InstallationSearch(props: installationSearchProps) { }, [props.installations]); const filteredData = useMemo(() => { - return indexedData.filter( + let list = indexedData.filter( (item) => item.nameLower.includes(searchTerm.toLowerCase()) || item.locationLower.includes(searchTerm.toLowerCase()) || item.regionLower.includes(searchTerm.toLowerCase()) ); - }, [searchTerm, indexedData]); + + switch (sortByStatus) { + case 'Installations With Alarm': + list = list.filter((i) => i.status === 2); + break; + case 'Installations with Warning': + list = list.filter((i) => i.status === 1); + break; + case 'Functional Installations': + list = list.filter((i) => i.status === 0); + break; + case 'Offline Installations': + list = list.filter((i) => i.status === -1); + break; + case 'Installations Without Data Collection': + list = list.filter((i) => i.dataCollectionEnabled === false); + break; + } + + switch (sortByAction) { + case 'Installations With Action Flag': + list = list.filter((i) => i.testingMode === true); + break; + case 'Installations Without Action Flag': + list = list.filter((i) => i.testingMode === false); + break; + } + + return list; + }, [searchTerm, indexedData, sortByStatus, sortByAction]); + + const isListView = + currentLocation.pathname === baseRoute + 'list' || + currentLocation.pathname === baseRoute + routes.list; return ( <> - - -
- - setSearchTerm(e.target.value)} - fullWidth - InputProps={{ - startAdornment: ( - - - - ) - }} - /> - -
+ {isListView && ( + + +
+ + setSearchTerm(e.target.value)} + fullWidth + InputProps={{ + startAdornment: ( + + + + ) + }} + /> + + + + + + + + + + + + + + + +
+
-
+ )} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tree/InstallationTree.tsx b/typescript/frontend-marios2/src/content/dashboards/Tree/InstallationTree.tsx index f31558379..ebd696978 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tree/InstallationTree.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tree/InstallationTree.tsx @@ -17,12 +17,16 @@ function InstallationTree() { useContext(InstallationsContext); const sortedInstallations = [...foldersAndInstallations].sort((a, b) => { - // Compare the status field of each installation and sort them based on the status. - //Installations with alarms go first - + // Folders stay on top (existing behavior). if (a.type == 'Folder') { return -1; } + // Data-collection-disabled installations sink below everything (even offline). + const aDisabled = (a as any).dataCollectionEnabled === false; + const bDisabled = (b as any).dataCollectionEnabled === false; + if (aDisabled !== bDisabled) return aDisabled ? 1 : -1; + + // Then sort by status (alarms first). let a_status = a.status; let b_status = b.status; From 5666191a6b1e6f528b75587f3f2001c5ca3d7d0e Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Mon, 20 Apr 2026 10:00:48 +0200 Subject: [PATCH 02/21] fix battery volatge unit in overview for sodistore home, pro, grid --- .../src/content/dashboards/Overview/chartOptions.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx b/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx index 64ec1133f..a44863d5c 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Overview/chartOptions.tsx @@ -244,6 +244,8 @@ export const getChartOptions = ( const seriesName = w.config.series[seriesIndex].name; if (seriesName === 'Battery SOC') { return val.toFixed(2) + ' %'; + } else if (seriesName === 'Battery Voltage') { + return val.toFixed(2) + ' (V)'; } else { return ( formatPowerForGraph(val, chartInfo.magnitude).value.toFixed( From 11940b4684a51f6753d549609a699b43b795f3b2 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 21 Apr 2026 13:35:56 +0200 Subject: [PATCH 03/21] checklist page version1.0 --- csharp/App/Backend/Controller.cs | 90 ++++++ csharp/App/Backend/DataTypes/ChecklistItem.cs | 23 ++ .../DataTypes/ChecklistStepDefinitions.cs | 54 ++++ csharp/App/Backend/DataTypes/Methods/User.cs | 57 ++++ csharp/App/Backend/Database/Create.cs | 3 + csharp/App/Backend/Database/Db.cs | 9 + csharp/App/Backend/Database/Delete.cs | 3 + csharp/App/Backend/Database/Read.cs | 14 + csharp/App/Backend/Database/Update.cs | 3 + .../src/Resources/routes.json | 1 + .../dashboards/Checklist/ChecklistStepRow.tsx | 301 ++++++++++++++++++ .../Checklist/InstallationChecklistTab.tsx | 264 +++++++++++++++ .../dashboards/Installations/Installation.tsx | 14 + .../dashboards/Installations/index.tsx | 24 +- .../SodiohomeInstallations/Installation.tsx | 14 + .../SodiohomeInstallations/index.tsx | 11 +- .../src/interfaces/ChecklistTypes.tsx | 43 +++ typescript/frontend-marios2/src/lang/de.json | 21 +- typescript/frontend-marios2/src/lang/en.json | 21 +- typescript/frontend-marios2/src/lang/fr.json | 21 +- typescript/frontend-marios2/src/lang/it.json | 21 +- 21 files changed, 1003 insertions(+), 9 deletions(-) create mode 100644 csharp/App/Backend/DataTypes/ChecklistItem.cs create mode 100644 csharp/App/Backend/DataTypes/ChecklistStepDefinitions.cs create mode 100644 typescript/frontend-marios2/src/content/dashboards/Checklist/ChecklistStepRow.tsx create mode 100644 typescript/frontend-marios2/src/content/dashboards/Checklist/InstallationChecklistTab.tsx create mode 100644 typescript/frontend-marios2/src/interfaces/ChecklistTypes.tsx diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index ab756e82f..e976e3a48 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2740,4 +2740,94 @@ public class Controller : ControllerBase return Ok(); } + // ── Checklist ─────────────────────────────────────────────────────── + + [HttpGet(nameof(GetChecklistForInstallation))] + public ActionResult> GetChecklistForInstallation(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null) return NotFound("Installation not found."); + + if (!Db.ChecklistExistsForInstallation(installationId)) + { + foreach (var def in ChecklistStepDefinitions.Steps) + { + Db.Create(new ChecklistItem + { + InstallationId = installationId, + StepNumber = def.Number, + StepTitle = def.Title, + Subtasks = def.SubtasksJson, + }); + } + } + + return Ok(Db.GetChecklistForInstallation(installationId)); + } + + [HttpPut(nameof(UpdateChecklistItem))] + public ActionResult UpdateChecklistItem( + Int64 checklistItemId, + Int32? status, + String? comments, + Int64? assigneeId, + Boolean clearAssignee, + String? doneAt, + String? subtasks, + Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var item = Db.GetChecklistItemById(checklistItemId); + if (item is null) return NotFound("Checklist item not found."); + + if (status.HasValue) item.Status = status.Value; + if (comments is not null) item.Comments = comments; + if (clearAssignee) item.AssigneeId = null; + else if (assigneeId.HasValue) item.AssigneeId = assigneeId.Value; + if (doneAt is not null) item.DoneAt = String.IsNullOrWhiteSpace(doneAt) ? null : doneAt; + if (subtasks is not null) item.Subtasks = subtasks; + + // Auto-fill DoneAt when status transitions to Done and no date provided + if (item.Status == (Int32)ChecklistStatus.Done && String.IsNullOrWhiteSpace(item.DoneAt)) + item.DoneAt = DateTime.UtcNow.ToString("yyyy-MM-dd"); + + item.UpdatedAt = DateTime.UtcNow; + + return Db.Update(item) ? item : StatusCode(500, "Update failed."); + } + + [HttpPost(nameof(NotifyChecklistAssignee))] + public async Task NotifyChecklistAssignee(Int64 checklistItemId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var item = Db.GetChecklistItemById(checklistItemId); + if (item is null) return NotFound("Checklist item not found."); + + if (item.AssigneeId is null) return BadRequest("No assignee set for this step."); + + var assignee = Db.GetUserById(item.AssigneeId); + if (assignee is null) return BadRequest("Assignee user not found."); + + var installation = Db.GetInstallationById(item.InstallationId); + if (installation is null) return NotFound("Installation not found."); + + try + { + await assignee.SendChecklistAssignedEmail(item, installation, user.Name); + return Ok(); + } + catch (Exception ex) + { + Console.WriteLine($"[Checklist] Failed to send assignee email: {ex}"); + return StatusCode(500, "Failed to send notification email."); + } + } + } diff --git a/csharp/App/Backend/DataTypes/ChecklistItem.cs b/csharp/App/Backend/DataTypes/ChecklistItem.cs new file mode 100644 index 000000000..4591a59ac --- /dev/null +++ b/csharp/App/Backend/DataTypes/ChecklistItem.cs @@ -0,0 +1,23 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum ChecklistStatus { NotStarted = 0, InProgress = 1, Done = 2 } + +public class ChecklistItem +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 InstallationId { get; set; } + + public Int32 StepNumber { get; set; } + public String StepTitle { get; set; } = ""; + public Int32 Status { get; set; } = (Int32)ChecklistStatus.NotStarted; + public String Comments { get; set; } = ""; + public Int64? AssigneeId { get; set; } + public String? DoneAt { get; set; } + public String? Subtasks { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/DataTypes/ChecklistStepDefinitions.cs b/csharp/App/Backend/DataTypes/ChecklistStepDefinitions.cs new file mode 100644 index 000000000..f98290541 --- /dev/null +++ b/csharp/App/Backend/DataTypes/ChecklistStepDefinitions.cs @@ -0,0 +1,54 @@ +namespace InnovEnergy.App.Backend.DataTypes; + +public record ChecklistStepDefinition(Int32 Number, String Title, String? SubtasksJson); + +public static class ChecklistStepDefinitions +{ + private const String NoSubtasks = null!; + + public static readonly IReadOnlyList Steps = new List + { + new( 1, "Order created, customer and partner info recorded in CRM", NoSubtasks), + new( 2, "Hardware assembled at Vebo", NoSubtasks), + new( 3, "Installation created on Monitor under correct product and folder", NoSubtasks), + new( 4, "Gateway SD card configured, VPN and gateway name registered", NoSubtasks), + new( 5, "Information tab filled out (customer, serials, VPN)", + """ + [ + {"text":"Customer information (email, address)","checked":false}, + {"text":"Installation information (external EMS, network provider, data collection)","checked":false}, + {"text":"Battery serial number","checked":false}, + {"text":"Inverter serial number","checked":false}, + {"text":"Data logger serial number","checked":false}, + {"text":"VPN details","checked":false} + ] + """), + new( 6, "Installation configured and tested electrically / hardware-wise at Vebo", + """ + [ + {"text":"Inverter firmware and configuration verified","checked":false}, + {"text":"Battery firmware and configuration verified","checked":false}, + {"text":"Internet for gateway configured","checked":false}, + {"text":"Communication cable between gateway and inverter correct","checked":false} + ] + """), + new( 7, "Installation tested software-wise at Vebo", + """ + [ + {"text":"S3 bucket number and key credentials copied from Information tab into config.json","checked":false}, + {"text":"Product ID configured in config.json","checked":false}, + {"text":"USB ID configured in config.json","checked":false}, + {"text":"Inverter data reading from inverter tested","checked":false} + ] + """), + new( 8, "Installation delivered to customer site", NoSubtasks), + new( 9, "Installation connected to grid", NoSubtasks), + new(10, "Hardware verified on site", NoSubtasks), + new(11, "Software verified on site", NoSubtasks), + new(12, "Installation online on Monitor", NoSubtasks), + new(13, "Customer informed about Monitor account and reports", NoSubtasks), + new(14, "User account created with correct folders and access", NoSubtasks), + new(15, "Customer follow-up completed, feedback collected", NoSubtasks), + new(16, "Further issues tracked via Ticket system", NoSubtasks), + }; +} diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index 8dd43ce5e..3eb708713 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -518,4 +518,61 @@ public static class UserMethods return user.SendEmail(subject, body); } + public static Task SendChecklistAssignedEmail( + this User user, + ChecklistItem item, + Installation installation, + String notifiedByName) + { + var checklistLink = $"https://monitor.inesco.energy/sodiohome_installations/installation/{installation.Id}/checklist"; + var installationName = String.IsNullOrEmpty(installation.Name) ? $"#{installation.Id}" : installation.Name; + var commentsBlock = String.IsNullOrWhiteSpace(item.Comments) ? "" : item.Comments; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + "inesco energy – Sie wurden einem Checklisten-Schritt zugewiesen", + $"Sehr geehrte/r {user.Name},\n\n" + + $"{notifiedByName} hat Sie einem Schritt der Installations-Checkliste zugewiesen:\n\n" + + $"Installation: {installationName}\n" + + $"Schritt {item.StepNumber}: {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Kommentare:\n{commentsBlock}\n\n" : "") + + $"Checkliste öffnen: {checklistLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + "inesco energy – Une étape de checklist vous a été attribuée", + $"Cher/Chère {user.Name},\n\n" + + $"{notifiedByName} vous a attribué une étape de la checklist d'installation :\n\n" + + $"Installation : {installationName}\n" + + $"Étape {item.StepNumber} : {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Commentaires :\n{commentsBlock}\n\n" : "") + + $"Ouvrir la checklist : {checklistLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + "inesco energy – Le è stato assegnato un passo della checklist", + $"Gentile {user.Name},\n\n" + + $"{notifiedByName} le ha assegnato un passo della checklist di installazione:\n\n" + + $"Installazione: {installationName}\n" + + $"Passo {item.StepNumber}: {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Commenti:\n{commentsBlock}\n\n" : "") + + $"Aprire la checklist: {checklistLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + "inesco energy – You have been assigned to a checklist step", + $"Dear {user.Name},\n\n" + + $"{notifiedByName} has assigned you to an installation checklist step:\n\n" + + $"Installation: {installationName}\n" + + $"Step {item.StepNumber}: {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Comments:\n{commentsBlock}\n\n" : "") + + $"Open the checklist: {checklistLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index a6759de3a..a94884eae 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -91,6 +91,9 @@ public static partial class Db // Document storage public static Boolean Create(Document document) => Insert(document); + + // Checklist + public static Boolean Create(ChecklistItem item) => Insert(item); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index f909b67eb..5735b88a8 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -42,6 +42,9 @@ public static partial class Db // Document storage public static TableQuery Documents => Connection.Table(); + // Checklist + public static TableQuery ChecklistItems => Connection.Table(); + public static void Init() { @@ -83,6 +86,9 @@ public static partial class Db // Document storage Connection.CreateTable(); + + // Checklist + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -150,6 +156,9 @@ public static partial class Db // Document storage fileConnection.CreateTable(); + // Checklist + fileConnection.CreateTable(); + // Migrate new columns: set defaults for existing rows where NULL or empty fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index b36b7229d..1bf82931c 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -145,6 +145,9 @@ public static partial class Db // Clean up installation-level documents Documents.Delete(d => d.InstallationId == installation.Id); + // Clean up checklist items for this installation + ChecklistItems.Delete(c => c.InstallationId == installation.Id); + return Installations.Delete(i => i.Id == installation.Id) > 0; } } diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index a144075d6..9d1813439 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -233,4 +233,18 @@ public static partial class Db .Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument) .OrderBy(d => d.CreatedAt) .ToList(); + + // ── Checklist Queries ─────────────────────────────────────────────── + + public static List GetChecklistForInstallation(Int64 installationId) + => ChecklistItems + .Where(c => c.InstallationId == installationId) + .OrderBy(c => c.StepNumber) + .ToList(); + + public static Boolean ChecklistExistsForInstallation(Int64 installationId) + => ChecklistItems.Any(c => c.InstallationId == installationId); + + public static ChecklistItem? GetChecklistItemById(Int64 id) + => ChecklistItems.FirstOrDefault(c => c.Id == id); } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Update.cs b/csharp/App/Backend/Database/Update.cs index ed5b2cf6a..0c694358e 100644 --- a/csharp/App/Backend/Database/Update.cs +++ b/csharp/App/Backend/Database/Update.cs @@ -72,4 +72,7 @@ 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); + + // Checklist + public static Boolean Update(ChecklistItem item) => Update(obj: item); } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/Resources/routes.json b/typescript/frontend-marios2/src/Resources/routes.json index b2409023a..ec2072a2e 100644 --- a/typescript/frontend-marios2/src/Resources/routes.json +++ b/typescript/frontend-marios2/src/Resources/routes.json @@ -26,5 +26,6 @@ "report": "report", "installationTickets": "installationTickets", "documents": "documents", + "checklist": "checklist", "tickets": "/tickets/" } diff --git a/typescript/frontend-marios2/src/content/dashboards/Checklist/ChecklistStepRow.tsx b/typescript/frontend-marios2/src/content/dashboards/Checklist/ChecklistStepRow.tsx new file mode 100644 index 000000000..a7225224d --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Checklist/ChecklistStepRow.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Box, + Checkbox, + Chip, + FormControl, + FormControlLabel, + IconButton, + MenuItem, + Select, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, + Typography +} from '@mui/material'; +import EmailIcon from '@mui/icons-material/Email'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + ChecklistItem, + ChecklistStatus, + ChecklistSubtask, + parseSubtasks, + serializeSubtasks +} from 'src/interfaces/ChecklistTypes'; +import { AdminUser } from 'src/interfaces/TicketTypes'; + +type EmailIconState = 'idle' | 'loading' | 'success' | 'error'; + +interface Props { + item: ChecklistItem; + adminUsers: AdminUser[]; + onUpdate: ( + id: number, + patch: Partial<{ + status: number; + comments: string; + assigneeId: number | null; + doneAt: string | null; + subtasks: string; + }> + ) => Promise; + onNotify: (id: number) => Promise; +} + +const statusColors: Record = { + [ChecklistStatus.NotStarted]: '#9e9e9e', + [ChecklistStatus.InProgress]: '#ed6c02', + [ChecklistStatus.Done]: '#2e7d32' +}; + +function ChecklistStepRow({ item, adminUsers, onUpdate, onNotify }: Props) { + const intl = useIntl(); + const [comments, setComments] = useState(item.comments ?? ''); + const [emailState, setEmailState] = useState('idle'); + const [subtasksOpen, setSubtasksOpen] = useState(false); + const [subtasks, setSubtasks] = useState(() => + parseSubtasks(item.subtasks) + ); + + useEffect(() => { + setComments(item.comments ?? ''); + }, [item.comments]); + + useEffect(() => { + setSubtasks(parseSubtasks(item.subtasks)); + }, [item.subtasks]); + + const subtaskSummary = useMemo(() => { + if (subtasks.length === 0) return null; + const done = subtasks.filter((s) => s.checked).length; + return `${done}/${subtasks.length}`; + }, [subtasks]); + + const handleStatusChange = async (value: number) => { + await onUpdate(item.id, { status: value }); + }; + + const handleAssigneeChange = async (rawValue: string) => { + if (rawValue === '') { + await onUpdate(item.id, { assigneeId: null }); + } else { + await onUpdate(item.id, { assigneeId: Number(rawValue) }); + } + }; + + const handleDoneAtChange = async (value: string) => { + await onUpdate(item.id, { doneAt: value || null }); + }; + + const handleCommentsBlur = async () => { + if (comments !== (item.comments ?? '')) { + await onUpdate(item.id, { comments }); + } + }; + + const handleSubtaskToggle = async (index: number) => { + const updated = subtasks.map((s, i) => + i === index ? { ...s, checked: !s.checked } : s + ); + setSubtasks(updated); + await onUpdate(item.id, { subtasks: serializeSubtasks(updated) }); + }; + + const handleNotifyClick = async () => { + if (!item.assigneeId || emailState === 'loading') return; + setEmailState('loading'); + const ok = await onNotify(item.id); + setEmailState(ok ? 'success' : 'error'); + setTimeout(() => setEmailState('idle'), 2500); + }; + + const renderEmailIcon = () => { + if (emailState === 'success') return ; + if (emailState === 'error') return ; + return ; + }; + + const filteredAdmins = useMemo( + () => + adminUsers.filter((u) => { + const name = (u.name ?? '').toLowerCase(); + return ( + !name.includes('inesco energy master admin') && + !name.includes('paal myhre') + ); + }), + [adminUsers] + ); + + return ( + <> + + + + {item.stepNumber} + + + + + + {item.stepTitle} + {subtasks.length > 0 && ( + <> + s.checked) ? 'success' : 'default' + } + /> + setSubtasksOpen((o) => !o)} + aria-label={intl.formatMessage({ + id: 'checklistToggleSubtasks', + defaultMessage: 'Toggle subtasks' + })} + > + {subtasksOpen ? : } + + + )} + + + + + + + + + + + + + + + + + + {renderEmailIcon()} + + + + + + + + handleDoneAtChange(e.target.value)} + InputLabelProps={{ shrink: true }} + fullWidth + /> + + + + setComments(e.target.value)} + onBlur={handleCommentsBlur} + fullWidth + placeholder={intl.formatMessage({ + id: 'checklistCommentsPlaceholder', + defaultMessage: 'Notes, contact info, observations…' + })} + /> + + + + {subtasks.length > 0 && subtasksOpen && ( + + + + {subtasks.map((s, i) => ( + handleSubtaskToggle(i)} + /> + } + label={{s.text}} + sx={{ display: 'block', ml: 0 }} + /> + ))} + + + + )} + + ); +} + +export default ChecklistStepRow; diff --git a/typescript/frontend-marios2/src/content/dashboards/Checklist/InstallationChecklistTab.tsx b/typescript/frontend-marios2/src/content/dashboards/Checklist/InstallationChecklistTab.tsx new file mode 100644 index 000000000..8637acee7 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Checklist/InstallationChecklistTab.tsx @@ -0,0 +1,264 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + LinearProgress, + Snackbar, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Paper +} from '@mui/material'; +import { FormattedMessage, useIntl } from 'react-intl'; +import axiosConfig from 'src/Resources/axiosConfig'; +import { + ChecklistItem, + ChecklistStatus +} from 'src/interfaces/ChecklistTypes'; +import { AdminUser } from 'src/interfaces/TicketTypes'; +import ChecklistStepRow from './ChecklistStepRow'; + +interface Props { + installationId: number; +} + +type ToastState = { + open: boolean; + severity: 'success' | 'error'; + message: string; +}; + +function InstallationChecklistTab({ installationId }: Props) { + const intl = useIntl(); + const [items, setItems] = useState([]); + const [adminUsers, setAdminUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [toast, setToast] = useState({ + open: false, + severity: 'success', + message: '' + }); + + const fetchItems = useCallback(() => { + setLoading(true); + axiosConfig + .get('/GetChecklistForInstallation', { params: { installationId } }) + .then((res) => { + setItems(Array.isArray(res.data) ? res.data : []); + setError(''); + }) + .catch(() => setError('Failed to load checklist.')) + .finally(() => setLoading(false)); + }, [installationId]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + useEffect(() => { + axiosConfig + .get('/GetAdminUsers') + .then((res) => { + if (Array.isArray(res.data)) setAdminUsers(res.data); + }) + .catch(() => setAdminUsers([])); + }, []); + + const progress = useMemo(() => { + const total = items.length; + const done = items.filter( + (i) => i.status === ChecklistStatus.Done + ).length; + const percent = total === 0 ? 0 : Math.round((done / total) * 100); + return { total, done, percent }; + }, [items]); + + const handleUpdate = useCallback( + async ( + id: number, + patch: Partial<{ + status: number; + comments: string; + assigneeId: number | null; + doneAt: string | null; + subtasks: string; + }> + ) => { + const params: Record = { checklistItemId: id }; + if (patch.status !== undefined) params.status = patch.status; + if (patch.comments !== undefined) params.comments = patch.comments; + if (patch.subtasks !== undefined) params.subtasks = patch.subtasks; + + if ('assigneeId' in patch) { + if (patch.assigneeId === null) { + params.clearAssignee = true; + } else if (typeof patch.assigneeId === 'number') { + params.assigneeId = patch.assigneeId; + } + } + + if ('doneAt' in patch) { + params.doneAt = patch.doneAt ?? ''; + } + + try { + const res = await axiosConfig.put('/UpdateChecklistItem', null, { + params + }); + const updated = res.data as ChecklistItem; + setItems((prev) => + prev.map((it) => (it.id === id ? updated : it)) + ); + return true; + } catch { + setToast({ + open: true, + severity: 'error', + message: intl.formatMessage({ + id: 'checklistSaveFailed', + defaultMessage: 'Failed to save change' + }) + }); + return false; + } + }, + [intl] + ); + + const handleNotify = useCallback( + async (id: number) => { + const item = items.find((i) => i.id === id); + if (!item || !item.assigneeId) return false; + const assignee = adminUsers.find((u) => u.id === item.assigneeId); + const assigneeName = assignee?.name ?? ''; + try { + await axiosConfig.post('/NotifyChecklistAssignee', null, { + params: { checklistItemId: id } + }); + setToast({ + open: true, + severity: 'success', + message: intl.formatMessage( + { + id: 'checklistEmailSent', + defaultMessage: 'Email sent to {name}' + }, + { name: assigneeName } + ) + }); + return true; + } catch { + setToast({ + open: true, + severity: 'error', + message: intl.formatMessage({ + id: 'checklistEmailFailed', + defaultMessage: 'Failed to send email — try again' + }) + }); + return false; + } + }, + [items, adminUsers, intl] + ); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + + + + + + + + + + + + + + + + # + + + + + + + + + + + + + + + + + + + {items.map((item) => ( + + ))} + +
+
+ + setToast((t) => ({ ...t, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setToast((t) => ({ ...t, open: false }))} + > + {toast.message} + + +
+ ); +} + +export default InstallationChecklistTab; diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 0439276b5..f7187b695 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -31,6 +31,8 @@ import Configuration from '../Configuration/Configuration'; import PvView from '../PvView/PvView'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import DocumentsTab from '../Documents/DocumentsTab'; +import InstallationChecklistTab from '../Checklist/InstallationChecklistTab'; +import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes'; interface singleInstallationProps { current_installation?: I_Installation; @@ -569,6 +571,18 @@ function Installation(props: singleInstallationProps) { /> )} + {currentUser.userType == UserType.admin && + CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx index 1dff83f2e..0c2cfdd84 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx @@ -34,7 +34,8 @@ function InstallationTabs(props: InstallationTabsProps) { 'history', 'pvview', 'installationTickets', - 'documents' + 'documents', + 'checklist' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -105,6 +106,10 @@ function InstallationTabs(props: InstallationTabsProps) { // TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready const hidePvView = props.product === 4; + // Checklist is only shown for Sodistore Grid (product=4) in the Installations view. + // Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows. + const showChecklist = props.product === 4; + const singleInstallationTabs = ( currentUser.userType == UserType.admin ? [ @@ -175,6 +180,10 @@ function InstallationTabs(props: InstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : currentUser.userType == UserType.partner @@ -229,7 +238,9 @@ function InstallationTabs(props: InstallationTabsProps) { ) } ] - ).filter((tab) => !(hidePvView && tab.value === 'pvview')); + ) + .filter((tab) => !(hidePvView && tab.value === 'pvview')) + .filter((tab) => !(!showChecklist && tab.value === 'checklist')); const tabs = currentTab != 'list' && @@ -316,6 +327,10 @@ function InstallationTabs(props: InstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : currentUser.userType == UserType.partner @@ -410,9 +425,10 @@ function InstallationTabs(props: InstallationTabsProps) { ]; // Filter out PV View for SodistoreGrid - const filteredTabs = hidePvView + const filteredTabs = (hidePvView ? tabs.filter((tab) => tab.value !== 'pvview') - : tabs; + : tabs + ).filter((tab) => !(!showChecklist && tab.value === 'checklist')); return installations.length > 1 ? ( <> diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index 19460ce48..40493171c 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -30,6 +30,8 @@ import Overview from '../Overview/overview'; import WeeklyReport from './WeeklyReport'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import DocumentsTab from '../Documents/DocumentsTab'; +import InstallationChecklistTab from '../Checklist/InstallationChecklistTab'; +import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes'; interface singleInstallationProps { current_installation?: I_Installation; @@ -670,6 +672,18 @@ function SodioHomeInstallation(props: singleInstallationProps) { /> )} + {currentUser.userType == UserType.admin && + CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx index 1b55d00e7..cdf0710d1 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx @@ -53,7 +53,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { 'configuration', 'report', 'installationTickets', - 'documents' + 'documents', + 'checklist' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -192,6 +193,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : currentUser.userType == UserType.partner @@ -361,6 +366,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : inInstallationView && currentUser.userType == UserType.partner diff --git a/typescript/frontend-marios2/src/interfaces/ChecklistTypes.tsx b/typescript/frontend-marios2/src/interfaces/ChecklistTypes.tsx new file mode 100644 index 000000000..7cc733128 --- /dev/null +++ b/typescript/frontend-marios2/src/interfaces/ChecklistTypes.tsx @@ -0,0 +1,43 @@ +export enum ChecklistStatus { + NotStarted = 0, + InProgress = 1, + Done = 2 +} + +export type ChecklistSubtask = { + text: string; + checked: boolean; +}; + +export type ChecklistItem = { + id: number; + installationId: number; + stepNumber: number; + stepTitle: string; + status: number; + comments: string; + assigneeId: number | null; + doneAt: string | null; + subtasks: string | null; + createdAt: string; + updatedAt: string; +}; + +export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet = new Set([2, 4, 5]); + +export function parseSubtasks(raw: string | null | undefined): ChecklistSubtask[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((x) => x && typeof x.text === 'string') + .map((x) => ({ text: String(x.text), checked: Boolean(x.checked) })); + } catch { + return []; + } +} + +export function serializeSubtasks(subtasks: ChecklistSubtask[]): string { + return JSON.stringify(subtasks); +} diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index ecb97852c..8186e07dc 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -685,5 +685,24 @@ "fileTooLarge": "Datei überschreitet die maximale Grösse von 25 MB.", "invalidFileType": "Ungültiger Dateityp.", "uploadFailed": "Hochladen fehlgeschlagen.", - "uploadSuccess": "Hochladen erfolgreich." + "uploadSuccess": "Hochladen erfolgreich.", + "checklist": "Checkliste", + "checklistTitle": "Schritte zur Anbindung der Installation an Monitor", + "checklistProgress": "Fortschritt: {done}/{total} ({percent}%)", + "checklistStep": "Schritt", + "checklistStatus": "Status", + "checklistAssignee": "Zuständig", + "checklistDateDone": "Erledigungsdatum", + "checklistComments": "Kommentare", + "checklistNotStarted": "Nicht gestartet", + "checklistInProgress": "In Bearbeitung", + "checklistDone": "Erledigt", + "checklistNoAssignee": "Nicht zugewiesen", + "checklistNotifyTooltip": "E-Mail-Benachrichtigung an Zuständige senden", + "checklistNotifyDisabledTooltip": "Weisen Sie zuerst jemanden zu, um eine Benachrichtigung zu senden", + "checklistToggleSubtasks": "Unteraufgaben umschalten", + "checklistCommentsPlaceholder": "Notizen, Kontaktinformationen, Beobachtungen…", + "checklistEmailSent": "E-Mail an {name} gesendet", + "checklistEmailFailed": "E-Mail-Versand fehlgeschlagen — bitte erneut versuchen", + "checklistSaveFailed": "Änderung konnte nicht gespeichert werden" } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 5b5e67b91..9e92d0e07 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -433,5 +433,24 @@ "fileTooLarge": "File exceeds maximum size of 25 MB.", "invalidFileType": "Invalid file type.", "uploadFailed": "Upload failed.", - "uploadSuccess": "Upload successful." + "uploadSuccess": "Upload successful.", + "checklist": "Checklist", + "checklistTitle": "Steps to Bring Installation to Monitor", + "checklistProgress": "Progress: {done}/{total} ({percent}%)", + "checklistStep": "Step", + "checklistStatus": "Status", + "checklistAssignee": "Assignee", + "checklistDateDone": "Date Done", + "checklistComments": "Comments", + "checklistNotStarted": "Not Started", + "checklistInProgress": "In Progress", + "checklistDone": "Done", + "checklistNoAssignee": "Unassigned", + "checklistNotifyTooltip": "Send email notification to assignee", + "checklistNotifyDisabledTooltip": "Assign someone first to send a notification", + "checklistToggleSubtasks": "Toggle subtasks", + "checklistCommentsPlaceholder": "Notes, contact info, observations…", + "checklistEmailSent": "Email sent to {name}", + "checklistEmailFailed": "Failed to send email — try again", + "checklistSaveFailed": "Failed to save change" } diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index d6cdc6f62..489848c72 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -685,5 +685,24 @@ "fileTooLarge": "Le fichier dépasse la taille maximale de 25 Mo.", "invalidFileType": "Type de fichier non valide.", "uploadFailed": "Échec du téléchargement.", - "uploadSuccess": "Téléchargement réussi." + "uploadSuccess": "Téléchargement réussi.", + "checklist": "Checklist", + "checklistTitle": "Étapes pour connecter l'installation à Monitor", + "checklistProgress": "Progression : {done}/{total} ({percent}%)", + "checklistStep": "Étape", + "checklistStatus": "Statut", + "checklistAssignee": "Responsable", + "checklistDateDone": "Date de réalisation", + "checklistComments": "Commentaires", + "checklistNotStarted": "Non commencé", + "checklistInProgress": "En cours", + "checklistDone": "Terminé", + "checklistNoAssignee": "Non attribué", + "checklistNotifyTooltip": "Envoyer une notification par e-mail au responsable", + "checklistNotifyDisabledTooltip": "Attribuez d'abord un responsable pour envoyer une notification", + "checklistToggleSubtasks": "Afficher/masquer les sous-tâches", + "checklistCommentsPlaceholder": "Notes, coordonnées, observations…", + "checklistEmailSent": "E-mail envoyé à {name}", + "checklistEmailFailed": "Échec de l'envoi — veuillez réessayer", + "checklistSaveFailed": "Échec de l'enregistrement" } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 59a8c11ce..fc5df8d5f 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -685,5 +685,24 @@ "fileTooLarge": "Il file supera la dimensione massima di 25 MB.", "invalidFileType": "Tipo di file non valido.", "uploadFailed": "Caricamento fallito.", - "uploadSuccess": "Caricamento riuscito." + "uploadSuccess": "Caricamento riuscito.", + "checklist": "Checklist", + "checklistTitle": "Passi per collegare l'installazione a Monitor", + "checklistProgress": "Avanzamento: {done}/{total} ({percent}%)", + "checklistStep": "Passo", + "checklistStatus": "Stato", + "checklistAssignee": "Assegnatario", + "checklistDateDone": "Data completamento", + "checklistComments": "Commenti", + "checklistNotStarted": "Non avviato", + "checklistInProgress": "In corso", + "checklistDone": "Completato", + "checklistNoAssignee": "Non assegnato", + "checklistNotifyTooltip": "Invia notifica e-mail all'assegnatario", + "checklistNotifyDisabledTooltip": "Assegnare prima qualcuno per inviare una notifica", + "checklistToggleSubtasks": "Mostra/nascondi sottoattività", + "checklistCommentsPlaceholder": "Note, contatti, osservazioni…", + "checklistEmailSent": "E-mail inviata a {name}", + "checklistEmailFailed": "Invio e-mail non riuscito — riprovare", + "checklistSaveFailed": "Salvataggio non riuscito" } From 64c8abd108d276ea8f7efa07acc291fc66a501a2 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 21 Apr 2026 14:05:35 +0200 Subject: [PATCH 04/21] email support team when an installation turns to eror status --- .../Backend/DataTypes/Methods/Installation.cs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/csharp/App/Backend/DataTypes/Methods/Installation.cs b/csharp/App/Backend/DataTypes/Methods/Installation.cs index 074b62638..fd01696cc 100644 --- a/csharp/App/Backend/DataTypes/Methods/Installation.cs +++ b/csharp/App/Backend/DataTypes/Methods/Installation.cs @@ -1,5 +1,6 @@ using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.Relations; +using InnovEnergy.Lib.Mailer; using InnovEnergy.Lib.Utils; namespace InnovEnergy.App.Backend.DataTypes.Methods; @@ -171,5 +172,39 @@ public static class InstallationMethods return true; } - + + private const String SupportEmail = "support@inesco.energy"; + private const String SupportName = "inesco energy Support Team"; + + public static Task SendAlarmNotificationToSupport(this Installation installation, Int32 prevStatus) + { + var productName = ProductName(installation.Product); + var fromStatus = StatusName(prevStatus); + + var subject = $"[inesco energy] Alarm: {installation.Name}"; + var body = + $"Installation \"{installation.Name}\" (ID {installation.Id}, {productName})\n" + + $"status changed from {fromStatus} to Alarm.\n\n" + + "Please check the Log tab on the Monitor to see detailed errors and warnings.\n"; + + return Mailer.Send(SupportName, SupportEmail, subject, body); + } + + private static String StatusName(Int32 status) => status switch + { + -1 => "Offline", + 0 => "Green", + 1 => "Warning", + 2 => "Alarm", + _ => "Unknown" + }; + + private static String ProductName(Int32 product) => product switch + { + 2 => "Sodistore Home", + 3 => "Sodistore Max", + 4 => "Sodistore Grid", + 5 => "Sodistore Pro", + _ => $"Product {product}" + }; } \ No newline at end of file From 334c1cdbe12705fc72f651f6668c258662d70d12 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 21 Apr 2026 14:06:26 +0200 Subject: [PATCH 05/21] email support team when an installation turns red --- csharp/App/Backend/Websockets/RabbitMQManager.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/csharp/App/Backend/Websockets/RabbitMQManager.cs b/csharp/App/Backend/Websockets/RabbitMQManager.cs index 5d4328224..39f67a6e4 100644 --- a/csharp/App/Backend/Websockets/RabbitMQManager.cs +++ b/csharp/App/Backend/Websockets/RabbitMQManager.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; +using InnovEnergy.App.Backend.DataTypes.Methods; using InnovEnergy.Lib.Utils; using RabbitMQ.Client; using RabbitMQ.Client.Events; @@ -186,6 +187,20 @@ public static class RabbitMqManager Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status); + const int AlarmStatus = 2; + var isSodistore = installation.Product is 2 or 3 or 4 or 5; + if (isSodistore + && prevStatus != AlarmStatus + && receivedStatusMessage.Status == AlarmStatus) + { + var prev = prevStatus; + _ = Task.Run(async () => + { + try { await installation.SendAlarmNotificationToSupport(prev); } + catch (Exception ex) { Console.WriteLine($"[AlarmNotify] failed for {installationId}: {ex.Message}"); } + }); + } + //Console.WriteLine("----------------------------------------------"); //If the status has changed, update all the connected front-ends regarding this installation if(prevStatus != receivedStatusMessage.Status && WebsocketManager.InstallationConnections[installationId].Connections.Count > 0) From a7c3a8f5a8a5331585ca3b78ece24e65f517c22d Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 21 Apr 2026 15:20:08 +0200 Subject: [PATCH 06/21] allow to edit comment --- csharp/App/Backend/Controller.cs | 34 +++++++ csharp/App/Backend/DataTypes/TicketComment.cs | 1 + csharp/App/Backend/Database/Update.cs | 1 + .../dashboards/Tickets/CommentThread.tsx | 93 ++++++++++++++++++- .../src/interfaces/TicketTypes.tsx | 1 + 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 + 9 files changed, 129 insertions(+), 5 deletions(-) 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/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 6bc3ccb64..07e33f38f 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, @@ -39,8 +40,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); @@ -125,6 +131,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 +228,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 +258,59 @@ function CommentThread({ {new Date(comment.createdAt).toLocaleString()} + {comment.editedAt && ( + + + + )} + {canEdit && !isEditing && ( + + )} - - {comment.body} - + {isEditing ? ( + + setEditBody(e.target.value)} + /> + + + + + + ) : ( + + {comment.body} + + )} 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 ecb97852c..8ed453573 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -567,6 +567,7 @@ "noDiagnosis": "Keine KI-Diagnose verfügbar.", "comments": "Kommentare", "noComments": "Noch keine Kommentare.", + "commentEdited": "(bearbeitet {time})", "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 5b5e67b91..6ae525ddb 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -315,6 +315,7 @@ "noDiagnosis": "No AI diagnosis available.", "comments": "Comments", "noComments": "No comments yet.", + "commentEdited": "(edited {time})", "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 d6cdc6f62..747f15ce5 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -567,6 +567,7 @@ "noDiagnosis": "Aucun diagnostic IA disponible.", "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", + "commentEdited": "(modifié {time})", "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 59a8c11ce..326452359 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -567,6 +567,7 @@ "noDiagnosis": "Nessuna diagnosi IA disponibile.", "comments": "Commenti", "noComments": "Nessun commento ancora.", + "commentEdited": "(modificato {time})", "addComment": "Aggiungi", "timeline": "Cronologia", "noTimelineEvents": "Nessun evento ancora.", From 3bbf72d1d58adf7c18e85f68c5410ad899e61169 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 21 Apr 2026 15:52:53 +0200 Subject: [PATCH 07/21] allow to markdown and format in comment section in ticket dashboard --- .../Tickets/CommentFormatToolbar.tsx | 62 ++++++++++++++ .../dashboards/Tickets/CommentThread.tsx | 28 +++++- .../dashboards/Tickets/commentMarkdown.tsx | 85 +++++++++++++++++++ typescript/frontend-marios2/src/lang/de.json | 5 ++ typescript/frontend-marios2/src/lang/en.json | 5 ++ typescript/frontend-marios2/src/lang/fr.json | 5 ++ typescript/frontend-marios2/src/lang/it.json | 5 ++ 7 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx create mode 100644 typescript/frontend-marios2/src/content/dashboards/Tickets/commentMarkdown.tsx 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 07e33f38f..8802dc883 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -25,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; @@ -55,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']; @@ -280,6 +283,12 @@ function CommentThread({ {isEditing ? ( + setEditBody(e.target.value)} + inputRef={editInputRef} /> + + + + ) : ( + + + #{ticket.id} — {ticket.subject} + + {currentUser?.id === ticket.createdByUserId && ( + + )} + + )} + {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx index 31701f660..c0569ea8b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TimelinePanel.tsx @@ -20,7 +20,8 @@ const eventTypeKeys: Record = { [TimelineEventType.CommentAdded]: { id: 'timelineCommentAdded', defaultMessage: 'Comment Added' }, [TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' }, [TimelineEventType.Escalated]: { id: 'timelineEscalated', defaultMessage: 'Escalated' }, - [TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' } + [TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' }, + [TimelineEventType.SubjectChanged]: { id: 'timelineSubjectChanged', defaultMessage: 'Subject Changed' } }; const eventTypeColors: Record = { @@ -30,7 +31,8 @@ const eventTypeColors: Record = { [TimelineEventType.CommentAdded]: '#2e7d32', [TimelineEventType.AiDiagnosisAttached]: '#0288d1', [TimelineEventType.Escalated]: '#d32f2f', - [TimelineEventType.ResolutionAdded]: '#4caf50' + [TimelineEventType.ResolutionAdded]: '#4caf50', + [TimelineEventType.SubjectChanged]: '#7b1fa2' }; interface TimelinePanelProps { diff --git a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx index 888dc58ee..ddeb15536 100644 --- a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx @@ -181,7 +181,8 @@ export enum TimelineEventType { CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5, - ResolutionAdded = 6 + ResolutionAdded = 6, + SubjectChanged = 7 } export type Ticket = { diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index f9ef3cfa4..c4345f916 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -623,6 +623,8 @@ "subCategory": "Unterkategorie", "edit": "Bearbeiten", "save": "Speichern", + "subjectRequired": "Betreff ist erforderlich.", + "failedToSaveSubject": "Betreff konnte nicht gespeichert werden.", "descriptionSaved": "Beschreibung gespeichert.", "subCatGeneral": "Allgemein", "subCatOther": "Sonstiges", @@ -653,6 +655,7 @@ "timelineAiDiagnosis": "KI-Diagnose", "timelineEscalated": "Eskaliert", "timelineResolutionAdded": "Lösung hinzugefügt", + "timelineSubjectChanged": "Betreff geändert", "timelineCreatedDesc": "Ticket erstellt von {name}.", "timelineStatusChangedDesc": "Status geändert auf {status}.", "timelineAssignedDesc": "Zugewiesen an {name}.", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 9f0911d59..4fbfac1d9 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -371,6 +371,8 @@ "subCategory": "Sub-Category", "edit": "Edit", "save": "Save", + "subjectRequired": "Subject is required.", + "failedToSaveSubject": "Failed to save subject.", "descriptionSaved": "Description saved.", "subCatGeneral": "General", "subCatOther": "Other", @@ -401,6 +403,7 @@ "timelineAiDiagnosis": "AI Diagnosis", "timelineEscalated": "Escalated", "timelineResolutionAdded": "Resolution Added", + "timelineSubjectChanged": "Subject Changed", "timelineCreatedDesc": "Ticket created by {name}.", "timelineStatusChangedDesc": "Status changed to {status}.", "timelineAssignedDesc": "Assigned to {name}.", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 1277120a7..114ac199a 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -623,6 +623,8 @@ "subCategory": "Sous-catégorie", "edit": "Modifier", "save": "Enregistrer", + "subjectRequired": "Le sujet est requis.", + "failedToSaveSubject": "Échec de l'enregistrement du sujet.", "descriptionSaved": "Description enregistrée.", "subCatGeneral": "Général", "subCatOther": "Autre", @@ -653,6 +655,7 @@ "timelineAiDiagnosis": "Diagnostic IA", "timelineEscalated": "Escaladé", "timelineResolutionAdded": "Résolution ajoutée", + "timelineSubjectChanged": "Sujet modifié", "timelineCreatedDesc": "Ticket créé par {name}.", "timelineStatusChangedDesc": "Statut modifié en {status}.", "timelineAssignedDesc": "Assigné à {name}.", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 2959bca2c..524bf4e7d 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -623,6 +623,8 @@ "subCategory": "Sottocategoria", "edit": "Modifica", "save": "Salva", + "subjectRequired": "L'oggetto è obbligatorio.", + "failedToSaveSubject": "Impossibile salvare l'oggetto.", "descriptionSaved": "Descrizione salvata.", "subCatGeneral": "Generale", "subCatOther": "Altro", @@ -653,6 +655,7 @@ "timelineAiDiagnosis": "Diagnosi IA", "timelineEscalated": "Escalato", "timelineResolutionAdded": "Risoluzione aggiunta", + "timelineSubjectChanged": "Oggetto modificato", "timelineCreatedDesc": "Ticket creato da {name}.", "timelineStatusChangedDesc": "Stato modificato in {status}.", "timelineAssignedDesc": "Assegnato a {name}.", From b93c051d5fa97f51b4471d2db91a0f10401998a8 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 28 Apr 2026 15:26:39 +0200 Subject: [PATCH 20/21] allow upload video on monitor --- csharp/App/Backend/Controller.cs | 10 +- .../src/components/DocumentList.tsx | 106 +++++++++++++++++- .../src/components/FileUploadButton.tsx | 25 ++++- .../dashboards/Documents/DocumentsTab.tsx | 2 +- .../dashboards/Tickets/CommentThread.tsx | 9 +- typescript/frontend-marios2/src/lang/de.json | 6 +- typescript/frontend-marios2/src/lang/en.json | 6 +- typescript/frontend-marios2/src/lang/fr.json | 6 +- typescript/frontend-marios2/src/lang/it.json | 6 +- 9 files changed, 157 insertions(+), 19 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index a80045590..832f84dda 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2639,16 +2639,18 @@ public class Controller : ControllerBase private static readonly HashSet AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/gif", "image/webp", - "application/pdf", "application/x-pdf" + "application/pdf", "application/x-pdf", + "video/mp4", "video/quicktime", "video/webm" }; // Some browsers send generic MIME types — allow them if the file extension is valid private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", + ".mp4", ".mov", ".webm" }; - private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB + private const Int64 MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB private static S3Bucket DocumentBucket { @@ -2660,7 +2662,7 @@ public class Controller : ControllerBase } [HttpPost(nameof(UploadDocument))] - [RequestSizeLimit(26_214_400)] + [RequestSizeLimit(104_857_600)] public async Task> UploadDocument( IFormFile file, [FromQuery] Int32 scope, diff --git a/typescript/frontend-marios2/src/components/DocumentList.tsx b/typescript/frontend-marios2/src/components/DocumentList.tsx index 41f3ad042..9c92b589e 100644 --- a/typescript/frontend-marios2/src/components/DocumentList.tsx +++ b/typescript/frontend-marios2/src/components/DocumentList.tsx @@ -13,6 +13,8 @@ import DownloadIcon from '@mui/icons-material/Download'; import DeleteIcon from '@mui/icons-material/Delete'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import VideocamIcon from '@mui/icons-material/Videocam'; +import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; import { FormattedMessage } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; @@ -48,8 +50,13 @@ function isImage(contentType: string): boolean { return contentType.startsWith('image/'); } +function isVideo(contentType: string): boolean { + return contentType.startsWith('video/'); +} + function getFileIcon(contentType: string) { if (contentType === 'application/pdf') return ; + if (isVideo(contentType)) return ; return ; } @@ -63,7 +70,36 @@ function DocumentList({ const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); const [previews, setPreviews] = useState>({}); + const [videoUrls, setVideoUrls] = useState>({}); + const [loadingVideoIds, setLoadingVideoIds] = useState>(new Set()); const [expandedImage, setExpandedImage] = useState(null); + const [expandedVideo, setExpandedVideo] = useState<{ url: string; contentType: string } | null>(null); + + const loadVideoBlob = (doc: DocumentItem) => { + if (videoUrls[doc.id] || loadingVideoIds.has(doc.id)) return; + setLoadingVideoIds((prev) => { + const next = new Set(prev); + next.add(doc.id); + return next; + }); + axiosConfig + .get('/DownloadDocument', { + params: { id: doc.id }, + responseType: 'blob' + }) + .then((res) => { + const url = window.URL.createObjectURL(new Blob([res.data], { type: doc.contentType })); + setVideoUrls((prev) => ({ ...prev, [doc.id]: url })); + }) + .catch(() => {}) + .finally(() => { + setLoadingVideoIds((prev) => { + const next = new Set(prev); + next.delete(doc.id); + return next; + }); + }); + }; const fetchDocuments = () => { setLoading(true); @@ -104,13 +140,15 @@ function DocumentList({ useEffect(() => { return () => { Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url)); + Object.values(videoUrls).forEach((url) => window.URL.revokeObjectURL(url)); }; }, []); const handleDownload = (doc: DocumentItem) => { - if (previews[doc.id]) { + const cached = previews[doc.id] || videoUrls[doc.id]; + if (cached) { const link = document.createElement('a'); - link.href = previews[doc.id]; + link.href = cached; link.setAttribute('download', doc.originalName); document.body.appendChild(link); link.click(); @@ -196,6 +234,53 @@ function DocumentList({ }} /> )} + {isVideo(doc.contentType) && ( + videoUrls[doc.id] ? ( + setExpandedVideo({ url: videoUrls[doc.id], contentType: doc.contentType })} + sx={{ + maxWidth: 240, + maxHeight: 160, + borderRadius: 1, + border: '1px solid', + borderColor: 'divider', + cursor: 'pointer', + backgroundColor: 'common.black' + }} + /> + ) : ( + loadVideoBlob(doc)} + sx={{ + width: 240, + height: 135, + borderRadius: 1, + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'action.hover', + cursor: loadingVideoIds.has(doc.id) ? 'progress' : 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: 0.5, + '&:hover': { opacity: 0.85 } + }} + > + + + {loadingVideoIds.has(doc.id) ? ( + + ) : ( + + )} + + + ) + )} ))} @@ -215,6 +300,23 @@ function DocumentList({ /> )} + + {/* Full-size video preview dialog */} + setExpandedVideo(null)} + maxWidth="lg" + > + {expandedVideo && ( + + )} + ); } diff --git a/typescript/frontend-marios2/src/components/FileUploadButton.tsx b/typescript/frontend-marios2/src/components/FileUploadButton.tsx index 992531db4..f78ea2c7c 100644 --- a/typescript/frontend-marios2/src/components/FileUploadButton.tsx +++ b/typescript/frontend-marios2/src/components/FileUploadButton.tsx @@ -7,7 +7,7 @@ import { Typography } from '@mui/material'; import AttachFileIcon from '@mui/icons-material/AttachFile'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; const ALLOWED_TYPES = [ @@ -15,9 +15,13 @@ const ALLOWED_TYPES = [ 'image/png', 'image/gif', 'image/webp', - 'application/pdf' + 'application/pdf', + 'video/mp4', + 'video/quicktime', + 'video/webm' ]; -const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB +const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB +const MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024); export interface UploadedDocument { id: number; @@ -48,6 +52,7 @@ function FileUploadButton({ onUploaded, disabled = false }: FileUploadButtonProps) { + const intl = useIntl(); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); @@ -62,11 +67,21 @@ function FileUploadButton({ for (let i = 0; i < files.length; i++) { const file = files[i]; if (!ALLOWED_TYPES.includes(file.type)) { - setError(`Invalid file type: ${file.name}`); + setError( + intl.formatMessage( + { id: 'attachFileInvalidType', defaultMessage: 'Invalid file type: {name}' }, + { name: file.name } + ) + ); return; } if (file.size > MAX_FILE_SIZE) { - setError(`File too large: ${file.name} (max 25 MB)`); + setError( + intl.formatMessage( + { id: 'attachFileTooLarge', defaultMessage: 'File too large: {name} (max {limitMb} MB)' }, + { name: file.name, limitMb: MAX_FILE_SIZE_MB } + ) + ); return; } validFiles.push(file); diff --git a/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx b/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx index 7d9cc6407..79dc807f6 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx @@ -51,7 +51,7 @@ function DocumentsTab({ installationId }: DocumentsTabProps) { diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 8802dc883..71dfaef80 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -117,8 +117,11 @@ function CommentThread({ }, 0); }; - const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; - const MAX_FILE_SIZE = 25 * 1024 * 1024; + const ALLOWED_TYPES = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', + 'video/mp4', 'video/quicktime', 'video/webm' + ]; + const MAX_FILE_SIZE = 100 * 1024 * 1024; const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files; @@ -382,7 +385,7 @@ function CommentThread({ Date: Tue, 28 Apr 2026 15:45:10 +0200 Subject: [PATCH 21/21] fix --- .../frontend-marios2/src/components/DocumentList.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/typescript/frontend-marios2/src/components/DocumentList.tsx b/typescript/frontend-marios2/src/components/DocumentList.tsx index 9c92b589e..51e44e2c6 100644 --- a/typescript/frontend-marios2/src/components/DocumentList.tsx +++ b/typescript/frontend-marios2/src/components/DocumentList.tsx @@ -136,13 +136,19 @@ function DocumentList({ }); }, [documents]); - // Clean up blob URLs on unmount + // Revoke superseded blob URLs as state changes, and on unmount. + // Empty deps would capture the initial {} and never revoke anything. useEffect(() => { return () => { Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url)); + }; + }, [previews]); + + useEffect(() => { + return () => { Object.values(videoUrls).forEach((url) => window.URL.revokeObjectURL(url)); }; - }, []); + }, [videoUrls]); const handleDownload = (doc: DocumentItem) => { const cached = previews[doc.id] || videoUrls[doc.id];