From 876a82bf82a545c2c8d1136ba74881586181c43f Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 18 Mar 2026 15:45:09 +0100 Subject: [PATCH] allow create ticket for instalation not on monitor and link installation live page from ticket and allow type Other to create new category --- csharp/App/Backend/Controller.cs | 35 +++- csharp/App/Backend/DataTypes/Ticket.cs | 52 +++-- csharp/App/Backend/Database/Read.cs | 16 ++ .../Services/ReportAggregationService.cs | 14 +- .../Services/TicketDiagnosticService.cs | 39 ++-- .../App/Backend/Websockets/RabbitMQManager.cs | 4 +- .../dashboards/Tickets/CreateTicketModal.tsx | 169 +++++++++++---- .../dashboards/Tickets/TicketDetail.tsx | 197 ++++++++++++++---- .../content/dashboards/Tickets/TicketList.tsx | 72 ++++--- .../src/interfaces/TicketTypes.tsx | 191 ++++++++++++----- 10 files changed, 594 insertions(+), 195 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 526738d0e..5f38d6266 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -1990,7 +1990,8 @@ public class Controller : ControllerBase }); // Fire-and-forget AI diagnosis - TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning(); + var lang = user.Language ?? "en"; + TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning(); return ticket; } @@ -2112,7 +2113,7 @@ public class Controller : ControllerBase var ticket = Db.GetTicketById(id); if (ticket is null) return NotFound(); - var installation = Db.GetInstallationById(ticket.InstallationId); + var installation = ticket.InstallationId.HasValue ? Db.GetInstallationById(ticket.InstallationId.Value) : null; var creator = Db.GetUserById(ticket.CreatedByUserId); var assignee = ticket.AssigneeId.HasValue ? Db.GetUserById(ticket.AssigneeId) : null; @@ -2122,9 +2123,10 @@ public class Controller : ControllerBase comments = Db.GetCommentsForTicket(id), diagnosis = Db.GetDiagnosisForTicket(id), timeline = Db.GetTimelineForTicket(id), - installationName = installation?.InstallationName ?? $"#{ticket.InstallationId}", - creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}", - assigneeName = assignee?.Name + installationName = installation?.Name ?? (ticket.InstallationId.HasValue ? $"#{ticket.InstallationId}" : "No installation"), + installationProduct = installation?.Product, + creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}", + assigneeName = assignee?.Name }; } @@ -2137,18 +2139,37 @@ public class Controller : ControllerBase var tickets = Db.GetAllTickets(); var summaries = tickets.Select(t => { - var installation = Db.GetInstallationById(t.InstallationId); + var installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null; return new { t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory, t.InstallationId, t.CreatedAt, t.UpdatedAt, - installationName = installation?.InstallationName ?? $"#{t.InstallationId}" + t.CustomSubCategory, t.CustomCategory, + installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation") }; }); return Ok(summaries); } + [HttpGet(nameof(GetCustomSubCategories))] + public ActionResult> GetCustomSubCategories(Int32 category, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Ok(Db.GetCustomSubCategoriesForCategory(category)); + } + + [HttpGet(nameof(GetCustomCategories))] + public ActionResult> GetCustomCategories(Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + return Ok(Db.GetCustomCategories()); + } + [HttpGet(nameof(GetAdminUsers))] public ActionResult> GetAdminUsers(Token authToken) { diff --git a/csharp/App/Backend/DataTypes/Ticket.cs b/csharp/App/Backend/DataTypes/Ticket.cs index 8513614d9..8ef385495 100644 --- a/csharp/App/Backend/DataTypes/Ticket.cs +++ b/csharp/App/Backend/DataTypes/Ticket.cs @@ -4,25 +4,50 @@ namespace InnovEnergy.App.Backend.DataTypes; public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 } public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 } -public enum TicketCategory { Hardware = 0, Software = 1, Network = 2, UserAccess = 3, Firmware = 4 } + +public enum TicketCategory +{ + Hardware = 0, + Software = 1, + // Network = 2 removed — value reserved for legacy data + UserAccess = 3, + Firmware = 4, + Configuration = 5, + Other = 6 +} public enum TicketSubCategory { - General = 0, - Other = 99, + General = 0, // legacy only — not offered for new tickets + OtherLegacy = 99, // legacy catch-all — not offered for new tickets + // Hardware (1xx) - Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, - Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107, + Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, + Metering = 104, PV = 105, + HardwareOther = 199, + // Software (2xx) - Backend = 200, Frontend = 201, Database = 202, Api = 203, - // Network (3xx) + Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203, + SoftwareOther = 299, + + // Network (3xx) — legacy, not offered for new tickets Connectivity = 300, VpnAccess = 301, S3Storage = 302, + // UserAccess (4xx) - Permissions = 400, Login = 401, + Login = 400, Permissions = 401, + UserAccessOther = 499, + // Firmware (5xx) - BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502 + BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502, + ExternalEmsFirmware = 503, + FirmwareOther = 599, + + // Configuration (6xx) + BMS = 600, ConfigMonitor = 601, ExternalEMS = 602, + ConfigurationOther = 699 } -public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 } + +public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 } public class Ticket { @@ -34,10 +59,10 @@ public class Ticket [Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open; public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium; public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware; - public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.General; + public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.Battery; public Int32 Source { get; set; } = (Int32)TicketSource.Manual; - [Indexed] public Int64 InstallationId { get; set; } + [Indexed] public Int64? InstallationId { get; set; } public Int64? AssigneeId { get; set; } [Indexed] public Int64 CreatedByUserId { get; set; } @@ -49,4 +74,7 @@ public class Ticket public String? RootCause { get; set; } public String? Solution { get; set; } public Boolean PreFilledFromAi { get; set; } + + public String? CustomSubCategory { get; set; } + public String? CustomCategory { get; set; } } diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index b817a3f26..debcccb2e 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -189,4 +189,20 @@ public static partial class Db .Where(e => e.TicketId == ticketId) .OrderBy(e => e.CreatedAt) .ToList(); + + public static List GetCustomSubCategoriesForCategory(Int32 category) + => Tickets + .Where(t => t.Category == category && t.CustomSubCategory != null) + .Select(t => t.CustomSubCategory!) + .Distinct() + .OrderBy(s => s) + .ToList(); + + public static List GetCustomCategories() + => Tickets + .Where(t => t.CustomCategory != null) + .Select(t => t.CustomCategory!) + .Distinct() + .OrderBy(s => s) + .ToList(); } \ No newline at end of file diff --git a/csharp/App/Backend/Services/ReportAggregationService.cs b/csharp/App/Backend/Services/ReportAggregationService.cs index 6f135ca0d..4dbab2985 100644 --- a/csharp/App/Backend/Services/ReportAggregationService.cs +++ b/csharp/App/Backend/Services/ReportAggregationService.cs @@ -115,12 +115,12 @@ public static class ReportAggregationService try { var report = await WeeklyReportService.GenerateReportAsync( - installation.Id, installation.InstallationName, "en"); + installation.Id, installation.Name, "en"); SaveWeeklySummary(installation.Id, report, "en"); generated++; - Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.InstallationName})"); + Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.Name})"); } catch (Exception ex) { @@ -361,7 +361,7 @@ public static class ReportAggregationService // Get installation name for AI insight var installation = Db.GetInstallationById(installationId); - var installationName = installation?.InstallationName ?? $"Installation {installationId}"; + var installationName = installation?.Name ?? $"Installation {installationId}"; var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy"); var aiInsight = await GenerateMonthlyAiInsightAsync( @@ -477,7 +477,7 @@ public static class ReportAggregationService var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1); var installation = Db.GetInstallationById(installationId); - var installationName = installation?.InstallationName ?? $"Installation {installationId}"; + var installationName = installation?.Name ?? $"Installation {installationId}"; var aiInsight = await GenerateYearlyAiInsightAsync( installationName, year, monthlies.Count, @@ -558,7 +558,7 @@ public static class ReportAggregationService public static Task GetOrGenerateWeeklyInsightAsync( WeeklyReportSummary report, String language) { - var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName + var installationName = Db.GetInstallationById(report.InstallationId)?.Name ?? $"Installation {report.InstallationId}"; return GetOrGenerateInsightAsync("weekly", report.Id, language, () => GenerateWeeklySummaryAiInsightAsync(report, installationName, language)); @@ -588,7 +588,7 @@ public static class ReportAggregationService MonthlyReportSummary report, String language) { var installation = Db.GetInstallationById(report.InstallationId); - var installationName = installation?.InstallationName + var installationName = installation?.Name ?? $"Installation {report.InstallationId}"; var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy"); return GetOrGenerateInsightAsync("monthly", report.Id, language, @@ -606,7 +606,7 @@ public static class ReportAggregationService public static Task GetOrGenerateYearlyInsightAsync( YearlyReportSummary report, String language) { - var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName + var installationName = Db.GetInstallationById(report.InstallationId)?.Name ?? $"Installation {report.InstallationId}"; return GetOrGenerateInsightAsync("yearly", report.Id, language, () => GenerateYearlyAiInsightAsync( diff --git a/csharp/App/Backend/Services/TicketDiagnosticService.cs b/csharp/App/Backend/Services/TicketDiagnosticService.cs index 75191d89e..8db680a8b 100644 --- a/csharp/App/Backend/Services/TicketDiagnosticService.cs +++ b/csharp/App/Backend/Services/TicketDiagnosticService.cs @@ -31,13 +31,14 @@ public static class TicketDiagnosticService /// Called fire-and-forget after ticket creation. /// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed). /// - public static async Task DiagnoseTicketAsync(Int64 ticketId) + public static async Task DiagnoseTicketAsync(Int64 ticketId, String language = "en") { var ticket = Db.GetTicketById(ticketId); if (ticket is null) return; - var installation = Db.GetInstallationById(ticket.InstallationId); - if (installation is null) return; + var installation = ticket.InstallationId.HasValue + ? Db.GetInstallationById(ticket.InstallationId.Value) + : null; var diagnosis = new TicketAiDiagnosis { @@ -59,18 +60,22 @@ public static class TicketDiagnosticService try { - var productName = ((ProductType)installation.Product).ToString(); + var productName = installation != null + ? ((ProductType)installation.Product).ToString() + : "Unknown"; - var recentErrors = Db.Errors - .Where(e => e.InstallationId == ticket.InstallationId) - .OrderByDescending(e => e.Date) - .ToList() - .Select(e => e.Description) - .Distinct() - .Take(5) - .ToList(); + var recentErrors = ticket.InstallationId.HasValue + ? Db.Errors + .Where(e => e.InstallationId == ticket.InstallationId.Value) + .OrderByDescending(e => e.Date) + .ToList() + .Select(e => e.Description) + .Distinct() + .Take(5) + .ToList() + : new List(); - var prompt = BuildPrompt(ticket, productName, recentErrors); + var prompt = BuildPrompt(ticket, productName, recentErrors, language); var result = await CallMistralAsync(prompt); if (result is null) @@ -106,12 +111,16 @@ public static class TicketDiagnosticService }); } - private static string BuildPrompt(Ticket ticket, string productName, List recentErrors) + private static string BuildPrompt(Ticket ticket, string productName, List recentErrors, string language = "en") { var recentList = recentErrors.Count > 0 ? string.Join(", ", recentErrors) : "none"; + var langInstruction = language != "en" + ? $"\nIMPORTANT: Write all text values (rootCause, recommendedActions) in the language with code \"{language}\". The JSON keys must remain in English." + : ""; + return $@"You are a senior field technician for {productName} battery energy storage systems. A support ticket has been submitted with the following details: Subject: {ticket.Subject} @@ -126,7 +135,7 @@ Analyze this ticket and respond in JSON only — no markdown, no explanation out ""confidence"": 0.85, ""recommendedActions"": [""Action 1"", ""Action 2"", ""Action 3""] }} -Confidence must be a number between 0.0 and 1.0."; +Confidence must be a number between 0.0 and 1.0.{langInstruction}"; } private static async Task CallMistralAsync(string prompt) diff --git a/csharp/App/Backend/Websockets/RabbitMQManager.cs b/csharp/App/Backend/Websockets/RabbitMQManager.cs index 2c8c65521..3ff62a7d7 100644 --- a/csharp/App/Backend/Websockets/RabbitMQManager.cs +++ b/csharp/App/Backend/Websockets/RabbitMQManager.cs @@ -130,10 +130,10 @@ public static class RabbitMqManager { Console.WriteLine("Send replace battery email to the support team for installation "+installationId); string recipient = "support@innov.energy"; - string subject = $"Battery Alarm from {installation.InstallationName}: 2 or more strings broken"; + string subject = $"Battery Alarm from {installation.Name}: 2 or more strings broken"; string text = $"Dear inesco Energy Support Team,\n" + $"\n"+ - $"Installation Name: {installation.InstallationName}\n"+ + $"Installation Name: {installation.Name}\n"+ $"\n"+ $"Installation Monitor Link: {monitorLink}\n"+ $"\n"+ diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx index 39f803775..f65602627 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx @@ -21,7 +21,9 @@ import { TicketCategory, TicketSubCategory, subCategoryLabels, - subCategoriesByCategory + subCategoriesByCategory, + categoryLabels, + otherSubCategoryValues } from 'src/interfaces/TicketTypes'; type Installation = { @@ -49,14 +51,6 @@ const deviceOptionsByProduct: Record ] }; -const categoryLabels: Record = { - [TicketCategory.Hardware]: 'Hardware', - [TicketCategory.Software]: 'Software', - [TicketCategory.Network]: 'Network', - [TicketCategory.UserAccess]: 'User Access', - [TicketCategory.Firmware]: 'Firmware' -}; - interface Props { open: boolean; onClose: () => void; @@ -75,15 +69,52 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: const [priority, setPriority] = useState(TicketPriority.Medium); const [category, setCategory] = useState(TicketCategory.Hardware); const [subCategory, setSubCategory] = useState( - TicketSubCategory.General + TicketSubCategory.Battery ); const [description, setDescription] = useState(''); const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); + // Custom "Other" fields + const [customSubCategory, setCustomSubCategory] = useState(''); + const [customCategory, setCustomCategory] = useState(''); + const [customSubSuggestions, setCustomSubSuggestions] = useState([]); + const [customCatSuggestions, setCustomCatSuggestions] = useState([]); + const hasDeviceOptions = selectedProduct !== '' && selectedProduct in deviceOptionsByProduct; + const isOtherCategory = category === TicketCategory.Other; + const isOtherSubCategory = otherSubCategoryValues.has(subCategory); + + // Fetch custom subcategory suggestions when category changes + useEffect(() => { + if (!isOtherSubCategory && !isOtherCategory) { + setCustomSubSuggestions([]); + return; + } + axiosConfig + .get('/GetCustomSubCategories', { params: { category } }) + .then((res) => { + if (Array.isArray(res.data)) setCustomSubSuggestions(res.data); + }) + .catch(() => setCustomSubSuggestions([])); + }, [category, isOtherSubCategory, isOtherCategory]); + + // Fetch custom category suggestions when "Other" category is selected + useEffect(() => { + if (!isOtherCategory) { + setCustomCatSuggestions([]); + return; + } + axiosConfig + .get('/GetCustomCategories') + .then((res) => { + if (Array.isArray(res.data)) setCustomCatSuggestions(res.data); + }) + .catch(() => setCustomCatSuggestions([])); + }, [isOtherCategory]); + useEffect(() => { if (selectedProduct === '') { setAllInstallations([]); @@ -137,8 +168,16 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: if (defaultInstallationId == null) setSelectedInstallation(null); }, [selectedDevice]); + // Reset subcategory when category changes useEffect(() => { - setSubCategory(TicketSubCategory.General); + if (isOtherCategory) { + setSubCategory(0); // no subcategory for Other category + } else { + const subs = subCategoriesByCategory[category]; + setSubCategory(subs?.[0] ?? 0); + } + setCustomSubCategory(''); + setCustomCategory(''); }, [category]); const filteredInstallations = useMemo(() => { @@ -157,13 +196,15 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: setSelectedInstallation(null); setPriority(TicketPriority.Medium); setCategory(TicketCategory.Hardware); - setSubCategory(TicketSubCategory.General); + setSubCategory(TicketSubCategory.Battery); setDescription(''); + setCustomSubCategory(''); + setCustomCategory(''); setError(''); }; const handleSubmit = () => { - if (!subject.trim() || !selectedInstallation) return; + if (!subject.trim()) return; setSubmitting(true); setError(''); @@ -171,10 +212,12 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: .post('/CreateTicket', { subject, description, - installationId: selectedInstallation.id, + installationId: selectedInstallation?.id ?? null, priority, category, - subCategory + subCategory: isOtherCategory ? 0 : subCategory, + customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null, + customCategory: isOtherCategory ? customCategory || null : null }) .then(() => { resetForm(); @@ -185,7 +228,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: .finally(() => setSubmitting(false)); }; - const availableSubCategories = subCategoriesByCategory[category] ?? [0]; + const availableSubCategories = subCategoriesByCategory[category] ?? []; return ( @@ -315,25 +358,77 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: - - - - - - + {/* Custom category label when "Other" category is selected */} + {isOtherCategory && ( + + freeSolo + options={customCatSuggestions} + value={customCategory} + onInputChange={(_e, val) => setCustomCategory(val)} + renderInput={(params) => ( + + } + placeholder="Type or select from existing..." + margin="dense" + /> + )} + /> + )} + + {/* Subcategory dropdown — hidden when category is "Other" */} + {!isOtherCategory && availableSubCategories.length > 0 && ( + + + + + + + )} + + {/* Custom subcategory free-text with autocomplete when "Other" sub is selected */} + {(isOtherSubCategory || isOtherCategory) && ( + + freeSolo + options={customSubSuggestions} + value={customSubCategory} + onInputChange={(_e, val) => setCustomSubCategory(val)} + renderInput={(params) => ( + + } + placeholder="Type or select from existing..." + margin="dense" + /> + )} + /> + )} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index 14338daa2..60c35f955 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, + Autocomplete, Box, Button, Card, @@ -28,16 +29,21 @@ import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; +import routes from 'src/Resources/routes.json'; import { TicketDetail as TicketDetailType, TicketStatus, TicketPriority, TicketCategory, - TicketSubCategory, AdminUser, subCategoryLabels, subCategoryKeys, - subCategoriesByCategory + subCategoriesByCategory, + categoryLabels, + categoryKeys, + otherSubCategoryValues, + getCategoryDisplayLabel, + getSubCategoryDisplayLabel } from 'src/interfaces/TicketTypes'; import Footer from 'src/components/Footer'; import StatusChip from './StatusChip'; @@ -52,14 +58,6 @@ const priorityKeys: Record = { [TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' } }; -const categoryKeys: Record = { - [TicketCategory.Hardware]: { id: 'categoryHardware', defaultMessage: 'Hardware' }, - [TicketCategory.Software]: { id: 'categorySoftware', defaultMessage: 'Software' }, - [TicketCategory.Network]: { id: 'categoryNetwork', defaultMessage: 'Network' }, - [TicketCategory.UserAccess]: { id: 'categoryUserAccess', defaultMessage: 'User Access' }, - [TicketCategory.Firmware]: { id: 'categoryFirmware', defaultMessage: 'Firmware' } -}; - const statusKeys: { value: number; id: string; defaultMessage: string }[] = [ { value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' }, { value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' }, @@ -90,6 +88,12 @@ function TicketDetailPage() { const [savingDescription, setSavingDescription] = useState(false); const [descriptionSaved, setDescriptionSaved] = useState(false); + // Custom "Other" editing state + const [editCustomSub, setEditCustomSub] = useState(''); + const [editCustomCat, setEditCustomCat] = useState(''); + const [customSubSuggestions, setCustomSubSuggestions] = useState([]); + const [customCatSuggestions, setCustomCatSuggestions] = useState([]); + const fetchDetail = useCallback(() => { if (!id) return; axiosConfig @@ -99,6 +103,8 @@ function TicketDetailPage() { setRootCause(res.data.ticket.rootCause ?? ''); setSolution(res.data.ticket.solution ?? ''); setDescription(res.data.ticket.description ?? ''); + setEditCustomSub(res.data.ticket.customSubCategory ?? ''); + setEditCustomCat(res.data.ticket.customCategory ?? ''); setError(''); }) .catch(() => setError('Failed to load ticket details.')) @@ -113,6 +119,31 @@ function TicketDetailPage() { .catch(() => {}); }, [fetchDetail]); + // Fetch custom subcategory suggestions when ticket's category changes + useEffect(() => { + if (!detail) return; + axiosConfig + .get('/GetCustomSubCategories', { params: { category: detail.ticket.category } }) + .then((res) => { + if (Array.isArray(res.data)) setCustomSubSuggestions(res.data); + }) + .catch(() => setCustomSubSuggestions([])); + }, [detail?.ticket.category]); + + // Fetch custom category suggestions when "Other" category + useEffect(() => { + if (!detail || detail.ticket.category !== TicketCategory.Other) { + setCustomCatSuggestions([]); + return; + } + axiosConfig + .get('/GetCustomCategories') + .then((res) => { + if (Array.isArray(res.data)) setCustomCatSuggestions(res.data); + }) + .catch(() => setCustomCatSuggestions([])); + }, [detail?.ticket.category]); + const handleStatusChange = (newStatus: number) => { if (!detail) return; if ( @@ -227,6 +258,11 @@ function TicketDetailPage() { const { ticket, comments, diagnosis, timeline } = detail; + const isOtherCategory = ticket.category === TicketCategory.Other; + const isOtherSubCategory = otherSubCategoryValues.has(ticket.subCategory); + const catDisplay = getCategoryDisplayLabel(ticket.category, ticket.customCategory); + const subDisplay = getSubCategoryDisplayLabel(ticket.subCategory, ticket.customSubCategory); + return (
@@ -254,9 +290,12 @@ function TicketDetailPage() { {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '} - {intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })} - {ticket.subCategory !== TicketSubCategory.General && - ` · ${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`} + {catDisplay} + {subDisplay !== 'Other' && subDisplay !== 'Unknown' && subDisplay !== 'General' + ? ` · ${subDisplay}` + : ticket.customSubCategory + ? ` · ${ticket.customSubCategory}` + : ''} @@ -570,38 +609,105 @@ function TicketDetailPage() { label="Category" onChange={(e) => { const newCat = Number(e.target.value); + const isNewOther = newCat === TicketCategory.Other; + const firstSub = subCategoriesByCategory[newCat]?.[0] ?? 0; handleTicketFieldChange({ category: newCat, - subCategory: TicketSubCategory.General + subCategory: isNewOther ? 0 : firstSub, + customSubCategory: null, + customCategory: null }); }} > - {Object.entries(categoryKeys).map(([value, msg]) => ( + {Object.entries(categoryLabels).map(([value, label]) => ( - {intl.formatMessage(msg)} + {label} ))} - - - - - - + {/* Custom category label when "Other" category */} + {isOtherCategory && ( + + freeSolo + options={customCatSuggestions} + value={editCustomCat} + onInputChange={(_e, val) => setEditCustomCat(val)} + onBlur={() => { + if (editCustomCat !== (ticket.customCategory ?? '')) { + handleTicketFieldChange({ customCategory: editCustomCat || null }); + } + }} + size="small" + renderInput={(params) => ( + + } + placeholder="Type category name..." + /> + )} + /> + )} + + {/* Subcategory dropdown — hidden when category is "Other" */} + {!isOtherCategory && ( + + + + + + + )} + + {/* Custom subcategory when "Other" sub is selected */} + {(isOtherSubCategory || isOtherCategory) && ( + + freeSolo + options={customSubSuggestions} + value={editCustomSub} + onInputChange={(_e, val) => setEditCustomSub(val)} + onBlur={() => { + if (editCustomSub !== (ticket.customSubCategory ?? '')) { + handleTicketFieldChange({ customSubCategory: editCustomSub || null }); + } + }} + size="small" + renderInput={(params) => ( + + } + placeholder="Type or select from existing..." + /> + )} + /> + )} @@ -622,7 +728,28 @@ function TicketDetailPage() { defaultMessage="Installation" /> - + { + if (detail.installationProduct == null || detail.ticket.installationId == null) return; + const productRoutes: Record = { + 0: routes.installations, + 1: routes.salidomo_installations, + 2: routes.sodiohome_installations, + 3: routes.sodistore_installations, + 4: routes.sodistoregrid_installations + }; + const prefix = productRoutes[detail.installationProduct] ?? routes.installations; + navigate( + prefix + routes.list + routes.installation + detail.ticket.installationId + '/' + routes.live + ); + }} + > {detail.installationName} diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx index e195904d1..ccce78008 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx @@ -25,10 +25,10 @@ import { TicketSummary, TicketStatus, TicketPriority, - TicketCategory, - TicketSubCategory, - subCategoryLabels, - subCategoryKeys + categoryKeys, + subCategoryKeys, + getCategoryDisplayLabel, + getSubCategoryDisplayLabel } from 'src/interfaces/TicketTypes'; import Footer from 'src/components/Footer'; import CreateTicketModal from './CreateTicketModal'; @@ -49,14 +49,6 @@ const priorityKeys: Record = { [TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' } }; -const categoryKeys: Record = { - [TicketCategory.Hardware]: { id: 'categoryHardware', defaultMessage: 'Hardware' }, - [TicketCategory.Software]: { id: 'categorySoftware', defaultMessage: 'Software' }, - [TicketCategory.Network]: { id: 'categoryNetwork', defaultMessage: 'Network' }, - [TicketCategory.UserAccess]: { id: 'categoryUserAccess', defaultMessage: 'User Access' }, - [TicketCategory.Firmware]: { id: 'categoryFirmware', defaultMessage: 'Firmware' } -}; - function TicketList() { const navigate = useNavigate(); const intl = useIntl(); @@ -204,25 +196,43 @@ function TicketList() { - {filtered.map((ticket) => ( - navigate(`/tickets/${ticket.id}`)}> - {ticket.id} - {ticket.subject} - - - - {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} - - {intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })} - {ticket.subCategory !== TicketSubCategory.General && - ` — ${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`} - - {ticket.installationName} - - {new Date(ticket.createdAt).toLocaleDateString()} - - - ))} + {filtered.map((ticket) => { + const catLabel = getCategoryDisplayLabel(ticket.category, ticket.customCategory); + const catKey = categoryKeys[ticket.category]; + const catDisplay = catKey + ? intl.formatMessage(catKey) + : catLabel; + const subLabel = getSubCategoryDisplayLabel(ticket.subCategory, ticket.customSubCategory); + const subKey = subCategoryKeys[ticket.subCategory]; + const subDisplay = subKey + ? intl.formatMessage(subKey) + : subLabel; + + return ( + navigate(`/tickets/${ticket.id}`)}> + {ticket.id} + {ticket.subject} + + + + {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} + + {ticket.customCategory + ? ticket.customCategory + : catDisplay} + {subLabel !== 'Other' && subLabel !== 'Unknown' && subLabel !== 'General' + ? ` — ${ticket.customSubCategory || subDisplay}` + : ticket.customSubCategory + ? ` — ${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 78be60059..e0e986e41 100644 --- a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx @@ -16,75 +16,143 @@ export enum TicketPriority { export enum TicketCategory { Hardware = 0, Software = 1, - Network = 2, + // Network = 2 removed — value reserved for legacy data UserAccess = 3, - Firmware = 4 + Firmware = 4, + Configuration = 5, + Other = 6 } export enum TicketSubCategory { - General = 0, - Other = 99, + General = 0, // legacy only + OtherLegacy = 99, // legacy catch-all + // Hardware (1xx) Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, - Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107, + Metering = 104, PV = 105, + HardwareOther = 199, + // Software (2xx) - Backend = 200, Frontend = 201, Database = 202, Api = 203, - // Network (3xx) + Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203, + SoftwareOther = 299, + + // Network (3xx) — legacy Connectivity = 300, VpnAccess = 301, S3Storage = 302, + // UserAccess (4xx) - Permissions = 400, Login = 401, + Login = 400, Permissions = 401, + UserAccessOther = 499, + // Firmware (5xx) - BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502 + BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502, + ExternalEmsFirmware = 503, + FirmwareOther = 599, + + // Configuration (6xx) + BMS = 600, ConfigMonitor = 601, ExternalEMS = 602, + ConfigurationOther = 699 } +// Display labels for all subcategories (including legacy ones for backward compat) export const subCategoryLabels: Record = { [TicketSubCategory.General]: 'General', - [TicketSubCategory.Other]: 'Other', - [TicketSubCategory.Battery]: 'Battery', [TicketSubCategory.Inverter]: 'Inverter', - [TicketSubCategory.Cable]: 'Cable', [TicketSubCategory.Gateway]: 'Gateway', - [TicketSubCategory.Metering]: 'Metering', [TicketSubCategory.Cooling]: 'Cooling', - [TicketSubCategory.PvSolar]: 'PV / Solar', [TicketSubCategory.Safety]: 'Safety', - [TicketSubCategory.Backend]: 'Backend', [TicketSubCategory.Frontend]: 'Frontend', - [TicketSubCategory.Database]: 'Database', [TicketSubCategory.Api]: 'API', - [TicketSubCategory.Connectivity]: 'Connectivity', [TicketSubCategory.VpnAccess]: 'VPN Access', + [TicketSubCategory.OtherLegacy]: 'Other', + [TicketSubCategory.Battery]: 'Battery', + [TicketSubCategory.Inverter]: 'Inverter', + [TicketSubCategory.Cable]: 'Cable', + [TicketSubCategory.Gateway]: 'Gateway', + [TicketSubCategory.Metering]: 'Metering', + [TicketSubCategory.PV]: 'PV', + [TicketSubCategory.HardwareOther]: 'Other', + [TicketSubCategory.Monitor]: 'Monitor', + [TicketSubCategory.ControllerService]: 'Controller Service', + [TicketSubCategory.ModbusTcpService]: 'Modbus TCP Service', + [TicketSubCategory.EMS]: 'EMS', + [TicketSubCategory.SoftwareOther]: 'Other', + [TicketSubCategory.Connectivity]: 'Connectivity', + [TicketSubCategory.VpnAccess]: 'VPN Access', [TicketSubCategory.S3Storage]: 'S3 Storage', - [TicketSubCategory.Permissions]: 'Permissions', [TicketSubCategory.Login]: 'Login', + [TicketSubCategory.Login]: 'Login', + [TicketSubCategory.Permissions]: 'Permissions', + [TicketSubCategory.UserAccessOther]: 'Other', [TicketSubCategory.BatteryFirmware]: 'Battery Firmware', [TicketSubCategory.InverterFirmware]: 'Inverter Firmware', - [TicketSubCategory.ControllerFirmware]: 'Controller Firmware' + [TicketSubCategory.ControllerFirmware]: 'Controller Firmware', + [TicketSubCategory.ExternalEmsFirmware]: 'External EMS Firmware', + [TicketSubCategory.FirmwareOther]: 'Other', + [TicketSubCategory.BMS]: 'BMS', + [TicketSubCategory.ConfigMonitor]: 'Monitor', + [TicketSubCategory.ExternalEMS]: 'External EMS', + [TicketSubCategory.ConfigurationOther]: 'Other' }; export const subCategoryKeys: Record = { - [TicketSubCategory.General]: { id: 'subCatGeneral', defaultMessage: 'General' }, - [TicketSubCategory.Other]: { id: 'subCatOther', defaultMessage: 'Other' }, - [TicketSubCategory.Battery]: { id: 'subCatBattery', defaultMessage: 'Battery' }, - [TicketSubCategory.Inverter]: { id: 'subCatInverter', defaultMessage: 'Inverter' }, - [TicketSubCategory.Cable]: { id: 'subCatCable', defaultMessage: 'Cable' }, - [TicketSubCategory.Gateway]: { id: 'subCatGateway', defaultMessage: 'Gateway' }, - [TicketSubCategory.Metering]: { id: 'subCatMetering', defaultMessage: 'Metering' }, - [TicketSubCategory.Cooling]: { id: 'subCatCooling', defaultMessage: 'Cooling' }, - [TicketSubCategory.PvSolar]: { id: 'subCatPvSolar', defaultMessage: 'PV / Solar' }, - [TicketSubCategory.Safety]: { id: 'subCatSafety', defaultMessage: 'Safety' }, - [TicketSubCategory.Backend]: { id: 'subCatBackend', defaultMessage: 'Backend' }, - [TicketSubCategory.Frontend]: { id: 'subCatFrontend', defaultMessage: 'Frontend' }, - [TicketSubCategory.Database]: { id: 'subCatDatabase', defaultMessage: 'Database' }, - [TicketSubCategory.Api]: { id: 'subCatApi', defaultMessage: 'API' }, - [TicketSubCategory.Connectivity]: { id: 'subCatConnectivity', defaultMessage: 'Connectivity' }, - [TicketSubCategory.VpnAccess]: { id: 'subCatVpnAccess', defaultMessage: 'VPN Access' }, - [TicketSubCategory.S3Storage]: { id: 'subCatS3Storage', defaultMessage: 'S3 Storage' }, - [TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' }, - [TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' }, - [TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' }, - [TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' }, - [TicketSubCategory.ControllerFirmware]: { id: 'subCatControllerFirmware', defaultMessage: 'Controller Firmware' } + [TicketSubCategory.General]: { id: 'subCatGeneral', defaultMessage: 'General' }, + [TicketSubCategory.OtherLegacy]: { id: 'subCatOtherLegacy', defaultMessage: 'Other' }, + [TicketSubCategory.Battery]: { id: 'subCatBattery', defaultMessage: 'Battery' }, + [TicketSubCategory.Inverter]: { id: 'subCatInverter', defaultMessage: 'Inverter' }, + [TicketSubCategory.Cable]: { id: 'subCatCable', defaultMessage: 'Cable' }, + [TicketSubCategory.Gateway]: { id: 'subCatGateway', defaultMessage: 'Gateway' }, + [TicketSubCategory.Metering]: { id: 'subCatMetering', defaultMessage: 'Metering' }, + [TicketSubCategory.PV]: { id: 'subCatPV', defaultMessage: 'PV' }, + [TicketSubCategory.HardwareOther]: { id: 'subCatHardwareOther', defaultMessage: 'Other' }, + [TicketSubCategory.Monitor]: { id: 'subCatMonitor', defaultMessage: 'Monitor' }, + [TicketSubCategory.ControllerService]: { id: 'subCatControllerService', defaultMessage: 'Controller Service' }, + [TicketSubCategory.ModbusTcpService]: { id: 'subCatModbusTcpService', defaultMessage: 'Modbus TCP Service' }, + [TicketSubCategory.EMS]: { id: 'subCatEMS', defaultMessage: 'EMS' }, + [TicketSubCategory.SoftwareOther]: { id: 'subCatSoftwareOther', defaultMessage: 'Other' }, + [TicketSubCategory.Connectivity]: { id: 'subCatConnectivity', defaultMessage: 'Connectivity' }, + [TicketSubCategory.VpnAccess]: { id: 'subCatVpnAccess', defaultMessage: 'VPN Access' }, + [TicketSubCategory.S3Storage]: { id: 'subCatS3Storage', defaultMessage: 'S3 Storage' }, + [TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' }, + [TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' }, + [TicketSubCategory.UserAccessOther]: { id: 'subCatUserAccessOther', defaultMessage: 'Other' }, + [TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' }, + [TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' }, + [TicketSubCategory.ControllerFirmware]: { id: 'subCatControllerFirmware', defaultMessage: 'Controller Firmware' }, + [TicketSubCategory.ExternalEmsFirmware]: { id: 'subCatExternalEmsFirmware', defaultMessage: 'External EMS Firmware' }, + [TicketSubCategory.FirmwareOther]: { id: 'subCatFirmwareOther', defaultMessage: 'Other' }, + [TicketSubCategory.BMS]: { id: 'subCatBMS', defaultMessage: 'BMS' }, + [TicketSubCategory.ConfigMonitor]: { id: 'subCatConfigMonitor', defaultMessage: 'Monitor' }, + [TicketSubCategory.ExternalEMS]: { id: 'subCatExternalEMS', defaultMessage: 'External EMS' }, + [TicketSubCategory.ConfigurationOther]: { id: 'subCatConfigurationOther', defaultMessage: 'Other' } }; +// Active subcategories per category (for new ticket creation — no legacy entries) export const subCategoriesByCategory: Record = { - [TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99], - [TicketCategory.Software]: [0, 200, 201, 202, 203, 99], - [TicketCategory.Network]: [0, 300, 301, 302, 99], - [TicketCategory.UserAccess]: [0, 400, 401, 99], - [TicketCategory.Firmware]: [0, 500, 501, 502, 99] + [TicketCategory.Hardware]: [100, 101, 102, 103, 104, 105, 199], + [TicketCategory.Software]: [200, 201, 202, 203, 299], + [TicketCategory.UserAccess]: [400, 401, 499], + [TicketCategory.Firmware]: [500, 501, 502, 503, 599], + [TicketCategory.Configuration]: [600, 601, 602, 699] + // TicketCategory.Other (6) has no predefined subcategories — uses free-text only +}; + +// "Other" subcategory values per category — triggers free-text input +export const otherSubCategoryValues = new Set([199, 299, 499, 599, 699]); + +// Category display labels (active categories only — used in dropdowns) +export const categoryLabels: Record = { + [TicketCategory.Hardware]: 'Hardware', + [TicketCategory.Software]: 'Software', + [TicketCategory.UserAccess]: 'User Access', + [TicketCategory.Firmware]: 'Firmware', + [TicketCategory.Configuration]: 'Configuration', + [TicketCategory.Other]: 'Other' +}; + +export const categoryKeys: Record = { + [TicketCategory.Hardware]: { id: 'categoryHardware', defaultMessage: 'Hardware' }, + [TicketCategory.Software]: { id: 'categorySoftware', defaultMessage: 'Software' }, + [TicketCategory.UserAccess]: { id: 'categoryUserAccess', defaultMessage: 'User Access' }, + [TicketCategory.Firmware]: { id: 'categoryFirmware', defaultMessage: 'Firmware' }, + [TicketCategory.Configuration]: { id: 'categoryConfiguration', defaultMessage: 'Configuration' }, + [TicketCategory.Other]: { id: 'categoryOther', defaultMessage: 'Other' } +}; + +// Legacy category labels (for displaying old tickets with Network category) +export const legacyCategoryLabels: Record = { + 2: 'Network (legacy)' }; export enum TicketSource { @@ -125,7 +193,7 @@ export type Ticket = { category: number; subCategory: number; source: number; - installationId: number; + installationId: number | null; assigneeId: number | null; createdByUserId: number; tags: string; @@ -135,6 +203,8 @@ export type Ticket = { rootCause: string | null; solution: string | null; preFilledFromAi: boolean; + customSubCategory: string | null; + customCategory: string | null; }; export type TicketComment = { @@ -176,6 +246,7 @@ export type TicketDetail = { diagnosis: TicketAiDiagnosis | null; timeline: TicketTimelineEvent[]; installationName: string; + installationProduct: number | null; creatorName: string; assigneeName: string | null; }; @@ -187,10 +258,12 @@ export type TicketSummary = { priority: number; category: number; subCategory: number; - installationId: number; + installationId: number | null; createdAt: string; updatedAt: string; installationName: string; + customSubCategory: string | null; + customCategory: string | null; }; export type AdminUser = { @@ -203,3 +276,25 @@ export enum DiagnosisFeedback { Rejected = 1, Overridden = 2 } + +// Helper: get display label for a subcategory, preferring custom label when available +export function getSubCategoryDisplayLabel( + subCategory: number, + customSubCategory: string | null | undefined +): string { + if (otherSubCategoryValues.has(subCategory) && customSubCategory) { + return customSubCategory; + } + return subCategoryLabels[subCategory] ?? 'Unknown'; +} + +// Helper: get display label for a category, preferring custom label when available +export function getCategoryDisplayLabel( + category: number, + customCategory: string | null | undefined +): string { + if (category === TicketCategory.Other && customCategory) { + return customCategory; + } + return categoryLabels[category] ?? legacyCategoryLabels[category] ?? 'Unknown'; +}