Compare commits

..

No commits in common. "876a82bf82a545c2c8d1136ba74881586181c43f" and "ac21c46c0e513e6141622bd2176476d991663722" have entirely different histories.

13 changed files with 204 additions and 603 deletions

View File

@ -1990,8 +1990,7 @@ public class Controller : ControllerBase
}); });
// Fire-and-forget AI diagnosis // Fire-and-forget AI diagnosis
var lang = user.Language ?? "en"; TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning();
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
return ticket; return ticket;
} }
@ -2113,7 +2112,7 @@ public class Controller : ControllerBase
var ticket = Db.GetTicketById(id); var ticket = Db.GetTicketById(id);
if (ticket is null) return NotFound(); if (ticket is null) return NotFound();
var installation = ticket.InstallationId.HasValue ? Db.GetInstallationById(ticket.InstallationId.Value) : null; var installation = Db.GetInstallationById(ticket.InstallationId);
var creator = Db.GetUserById(ticket.CreatedByUserId); var creator = Db.GetUserById(ticket.CreatedByUserId);
var assignee = ticket.AssigneeId.HasValue ? Db.GetUserById(ticket.AssigneeId) : null; var assignee = ticket.AssigneeId.HasValue ? Db.GetUserById(ticket.AssigneeId) : null;
@ -2123,10 +2122,9 @@ public class Controller : ControllerBase
comments = Db.GetCommentsForTicket(id), comments = Db.GetCommentsForTicket(id),
diagnosis = Db.GetDiagnosisForTicket(id), diagnosis = Db.GetDiagnosisForTicket(id),
timeline = Db.GetTimelineForTicket(id), timeline = Db.GetTimelineForTicket(id),
installationName = installation?.Name ?? (ticket.InstallationId.HasValue ? $"#{ticket.InstallationId}" : "No installation"), installationName = installation?.InstallationName ?? $"#{ticket.InstallationId}",
installationProduct = installation?.Product, creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}",
creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}", assigneeName = assignee?.Name
assigneeName = assignee?.Name
}; };
} }
@ -2139,37 +2137,18 @@ public class Controller : ControllerBase
var tickets = Db.GetAllTickets(); var tickets = Db.GetAllTickets();
var summaries = tickets.Select(t => var summaries = tickets.Select(t =>
{ {
var installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null; var installation = Db.GetInstallationById(t.InstallationId);
return new return new
{ {
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory, t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
t.InstallationId, t.CreatedAt, t.UpdatedAt, t.InstallationId, t.CreatedAt, t.UpdatedAt,
t.CustomSubCategory, t.CustomCategory, installationName = installation?.InstallationName ?? $"#{t.InstallationId}"
installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation")
}; };
}); });
return Ok(summaries); return Ok(summaries);
} }
[HttpGet(nameof(GetCustomSubCategories))]
public ActionResult<IEnumerable<String>> 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<IEnumerable<String>> 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))] [HttpGet(nameof(GetAdminUsers))]
public ActionResult<IEnumerable<Object>> GetAdminUsers(Token authToken) public ActionResult<IEnumerable<Object>> GetAdminUsers(Token authToken)
{ {

View File

@ -4,50 +4,25 @@ namespace InnovEnergy.App.Backend.DataTypes;
public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 } 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 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 public enum TicketSubCategory
{ {
General = 0, // legacy only — not offered for new tickets General = 0,
OtherLegacy = 99, // legacy catch-all — not offered for new tickets Other = 99,
// Hardware (1xx) // Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, PV = 105, Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
HardwareOther = 199,
// Software (2xx) // Software (2xx)
Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203, Backend = 200, Frontend = 201, Database = 202, Api = 203,
SoftwareOther = 299, // Network (3xx)
// Network (3xx) — legacy, not offered for new tickets
Connectivity = 300, VpnAccess = 301, S3Storage = 302, Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx) // UserAccess (4xx)
Login = 400, Permissions = 401, Permissions = 400, Login = 401,
UserAccessOther = 499,
// Firmware (5xx) // 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 public class Ticket
{ {
@ -59,10 +34,10 @@ public class Ticket
[Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open; [Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open;
public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium; public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium;
public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware; public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware;
public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.Battery; public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.General;
public Int32 Source { get; set; } = (Int32)TicketSource.Manual; 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; } public Int64? AssigneeId { get; set; }
[Indexed] public Int64 CreatedByUserId { get; set; } [Indexed] public Int64 CreatedByUserId { get; set; }
@ -74,7 +49,4 @@ public class Ticket
public String? RootCause { get; set; } public String? RootCause { get; set; }
public String? Solution { get; set; } public String? Solution { get; set; }
public Boolean PreFilledFromAi { get; set; } public Boolean PreFilledFromAi { get; set; }
public String? CustomSubCategory { get; set; }
public String? CustomCategory { get; set; }
} }

View File

@ -189,20 +189,4 @@ public static partial class Db
.Where(e => e.TicketId == ticketId) .Where(e => e.TicketId == ticketId)
.OrderBy(e => e.CreatedAt) .OrderBy(e => e.CreatedAt)
.ToList(); .ToList();
public static List<String> GetCustomSubCategoriesForCategory(Int32 category)
=> Tickets
.Where(t => t.Category == category && t.CustomSubCategory != null)
.Select(t => t.CustomSubCategory!)
.Distinct()
.OrderBy(s => s)
.ToList();
public static List<String> GetCustomCategories()
=> Tickets
.Where(t => t.CustomCategory != null)
.Select(t => t.CustomCategory!)
.Distinct()
.OrderBy(s => s)
.ToList();
} }

View File

@ -127,7 +127,7 @@
"PvAccessMethodErrorAlarm": "PV-Zugriffsfehler", "PvAccessMethodErrorAlarm": "PV-Zugriffsfehler",
"ReservedAlarms4": "Reservierter Alarm 4", "ReservedAlarms4": "Reservierter Alarm 4",
"ReservedAlarms5": "Reservierter Alarm 5", "ReservedAlarms5": "Reservierter Alarm 5",
"ReverseMeterConnection": "Zähleranschluss vertauscht", "ReverseMeterConnection": "Zähler falsch angeschlossen",
"InverterSealPulse": "Wechselrichter-Leistungsbegrenzung", "InverterSealPulse": "Wechselrichter-Leistungsbegrenzung",
"AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung", "AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung",
"AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz", "AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz",
@ -204,10 +204,10 @@
"BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen", "BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen",
"EpoFault": "EPO-Fehler (Notaus)", "EpoFault": "EPO-Fehler (Notaus)",
"MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler", "MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler",
"BmsCommunicationFailure": "BMS-Kommunikation ausgefallen", "BmsCommunicationFailure": "BMS-Kommunikationsfehler",
"BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler", "BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler",
"BatteryVoltageLow": "Batteriespannung niedrig", "BatteryVoltageLow": "Batteriespannung zu niedrig",
"BatteryVoltageHigh": "Batteriespannung hoch", "BatteryVoltageHigh": "Batteriespannung zu hoch",
"BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich", "BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich",
"BatteryReversed": "Batterie verkehrt herum", "BatteryReversed": "Batterie verkehrt herum",
"BatteryOpenCircuit": "Batteriekreis offen", "BatteryOpenCircuit": "Batteriekreis offen",

View File

@ -7,7 +7,7 @@
"alarm_AbnormalOutputVoltage": "Tension de sortie anormale", "alarm_AbnormalOutputVoltage": "Tension de sortie anormale",
"alarm_AbnormalOutputFrequency": "Fréquence de sortie anormale", "alarm_AbnormalOutputFrequency": "Fréquence de sortie anormale",
"alarm_AbnormalNullLine": "Ligne neutre anormale", "alarm_AbnormalNullLine": "Ligne neutre anormale",
"alarm_AbnormalOffGridOutputVoltage": "Tension de sortie backup anormale", "alarm_AbnormalOffGridOutputVoltage": "Tension de sortie hors réseau anormale",
"alarm_ExcessivelyHighAmbientTemperature": "Température ambiante trop élevée", "alarm_ExcessivelyHighAmbientTemperature": "Température ambiante trop élevée",
"alarm_ExcessiveRadiatorTemperature": "Température excessive du radiateur", "alarm_ExcessiveRadiatorTemperature": "Température excessive du radiateur",
"alarm_PcbOvertemperature": "Température excessive PCB", "alarm_PcbOvertemperature": "Température excessive PCB",
@ -183,7 +183,7 @@
"alarm_ExportLimitationFailSafe": "Sécurité limite d'exportation", "alarm_ExportLimitationFailSafe": "Sécurité limite d'exportation",
"alarm_DcBiasAbnormal": "Biais DC anormal", "alarm_DcBiasAbnormal": "Biais DC anormal",
"alarm_HighDcComponentOutputCurrent": "Composante DC élevée courant de sortie", "alarm_HighDcComponentOutputCurrent": "Composante DC élevée courant de sortie",
"alarm_BusVoltageSamplingAbnormal": "Échantillonnage tension bus anormal", "alarm_BusVoltageSamplingAbnormal": "Tension d'alimentation anormale",
"alarm_RelayFault": "Défaillance du relais", "alarm_RelayFault": "Défaillance du relais",
"alarm_BusVoltageAbnormal": "Tension d'alimentation anormale", "alarm_BusVoltageAbnormal": "Tension d'alimentation anormale",
"alarm_InternalCommunicationFailure": "Échec de communication interne", "alarm_InternalCommunicationFailure": "Échec de communication interne",

View File

@ -51,7 +51,7 @@
"alarm_LithiumBattery1Full": "Batteria Litio 1 Piena", "alarm_LithiumBattery1Full": "Batteria Litio 1 Piena",
"alarm_LithiumBattery1DischargeEnd": "Fine Scarica Batteria Litio 1", "alarm_LithiumBattery1DischargeEnd": "Fine Scarica Batteria Litio 1",
"alarm_LithiumBattery2Full": "Batteria Litio 2 Piena", "alarm_LithiumBattery2Full": "Batteria Litio 2 Piena",
"alarm_LithiumBattery2DischargeEnd": "Fine Scarica Batteria Litio 2", "alarm_LithiumBattery2DischargeEnd": "Fine Scarica Batteria 2",
"alarm_LeadBatteryTemperatureAbnormality": "Temperatura Batteria Anomala", "alarm_LeadBatteryTemperatureAbnormality": "Temperatura Batteria Anomala",
"alarm_BatteryAccessMethodError": "Errore Metodo Accesso Batteria", "alarm_BatteryAccessMethodError": "Errore Metodo Accesso Batteria",
"alarm_Pv1NotAccessed": "PV1 Non Rilevato", "alarm_Pv1NotAccessed": "PV1 Non Rilevato",
@ -131,7 +131,7 @@
"alarm_InverterSealPulse": "Impulso Sigillo Inverter", "alarm_InverterSealPulse": "Impulso Sigillo Inverter",
"alarm_AbnormalDieselGeneratorVoltage": "Tensione Generatore Diesel Anomala", "alarm_AbnormalDieselGeneratorVoltage": "Tensione Generatore Diesel Anomala",
"alarm_AbnormalDieselGeneratorFrequency": "Frequenza Generatore Diesel Anomala", "alarm_AbnormalDieselGeneratorFrequency": "Frequenza Generatore Diesel Anomala",
"alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase generatore invertita", "alarm_DieselGeneratorVoltageReverseSequence": "Sequenza di fase invertita",
"alarm_DieselGeneratorVoltageOutOfPhase": "Fase del generatore errata", "alarm_DieselGeneratorVoltageOutOfPhase": "Fase del generatore errata",
"alarm_GeneratorOverload": "Sovraccarico del generatore", "alarm_GeneratorOverload": "Sovraccarico del generatore",
"alarm_StringFault": "Guasto alla stringa", "alarm_StringFault": "Guasto alla stringa",
@ -208,7 +208,7 @@
"alarm_BmsChargeDischargeFailure": "Guasto Carica/Scarica BMS", "alarm_BmsChargeDischargeFailure": "Guasto Carica/Scarica BMS",
"alarm_BatteryVoltageLow": "Tensione Batteria Bassa", "alarm_BatteryVoltageLow": "Tensione Batteria Bassa",
"alarm_BatteryVoltageHigh": "Tensione Batteria Alta", "alarm_BatteryVoltageHigh": "Tensione Batteria Alta",
"alarm_BatteryTemperatureAbnormal": "Temperatura batteria fuori norma", "alarm_BatteryTemperatureAbnormal": "Temperatura batteria anomala",
"alarm_BatteryReversed": "Batteria invertita", "alarm_BatteryReversed": "Batteria invertita",
"alarm_BatteryOpenCircuit": "Circuiti aperti batteria", "alarm_BatteryOpenCircuit": "Circuiti aperti batteria",
"alarm_BatteryOverloadProtection": "Protezione sovraccarico batteria", "alarm_BatteryOverloadProtection": "Protezione sovraccarico batteria",

View File

@ -115,12 +115,12 @@ public static class ReportAggregationService
try try
{ {
var report = await WeeklyReportService.GenerateReportAsync( var report = await WeeklyReportService.GenerateReportAsync(
installation.Id, installation.Name, "en"); installation.Id, installation.InstallationName, "en");
SaveWeeklySummary(installation.Id, report, "en"); SaveWeeklySummary(installation.Id, report, "en");
generated++; generated++;
Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.Name})"); Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.InstallationName})");
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -361,7 +361,7 @@ public static class ReportAggregationService
// Get installation name for AI insight // Get installation name for AI insight
var installation = Db.GetInstallationById(installationId); var installation = Db.GetInstallationById(installationId);
var installationName = installation?.Name ?? $"Installation {installationId}"; var installationName = installation?.InstallationName ?? $"Installation {installationId}";
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy"); var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
var aiInsight = await GenerateMonthlyAiInsightAsync( var aiInsight = await GenerateMonthlyAiInsightAsync(
@ -477,7 +477,7 @@ public static class ReportAggregationService
var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1); var avgWeekendLoad = Math.Round(monthlies.Average(m => m.AvgWeekendDailyLoad), 1);
var installation = Db.GetInstallationById(installationId); var installation = Db.GetInstallationById(installationId);
var installationName = installation?.Name ?? $"Installation {installationId}"; var installationName = installation?.InstallationName ?? $"Installation {installationId}";
var aiInsight = await GenerateYearlyAiInsightAsync( var aiInsight = await GenerateYearlyAiInsightAsync(
installationName, year, monthlies.Count, installationName, year, monthlies.Count,
@ -558,7 +558,7 @@ public static class ReportAggregationService
public static Task<String> GetOrGenerateWeeklyInsightAsync( public static Task<String> GetOrGenerateWeeklyInsightAsync(
WeeklyReportSummary report, String language) WeeklyReportSummary report, String language)
{ {
var installationName = Db.GetInstallationById(report.InstallationId)?.Name var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
?? $"Installation {report.InstallationId}"; ?? $"Installation {report.InstallationId}";
return GetOrGenerateInsightAsync("weekly", report.Id, language, return GetOrGenerateInsightAsync("weekly", report.Id, language,
() => GenerateWeeklySummaryAiInsightAsync(report, installationName, language)); () => GenerateWeeklySummaryAiInsightAsync(report, installationName, language));
@ -588,7 +588,7 @@ public static class ReportAggregationService
MonthlyReportSummary report, String language) MonthlyReportSummary report, String language)
{ {
var installation = Db.GetInstallationById(report.InstallationId); var installation = Db.GetInstallationById(report.InstallationId);
var installationName = installation?.Name var installationName = installation?.InstallationName
?? $"Installation {report.InstallationId}"; ?? $"Installation {report.InstallationId}";
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy"); var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
return GetOrGenerateInsightAsync("monthly", report.Id, language, return GetOrGenerateInsightAsync("monthly", report.Id, language,
@ -606,7 +606,7 @@ public static class ReportAggregationService
public static Task<String> GetOrGenerateYearlyInsightAsync( public static Task<String> GetOrGenerateYearlyInsightAsync(
YearlyReportSummary report, String language) YearlyReportSummary report, String language)
{ {
var installationName = Db.GetInstallationById(report.InstallationId)?.Name var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
?? $"Installation {report.InstallationId}"; ?? $"Installation {report.InstallationId}";
return GetOrGenerateInsightAsync("yearly", report.Id, language, return GetOrGenerateInsightAsync("yearly", report.Id, language,
() => GenerateYearlyAiInsightAsync( () => GenerateYearlyAiInsightAsync(

View File

@ -31,14 +31,13 @@ public static class TicketDiagnosticService
/// Called fire-and-forget after ticket creation. /// Called fire-and-forget after ticket creation.
/// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed). /// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed).
/// </summary> /// </summary>
public static async Task DiagnoseTicketAsync(Int64 ticketId, String language = "en") public static async Task DiagnoseTicketAsync(Int64 ticketId)
{ {
var ticket = Db.GetTicketById(ticketId); var ticket = Db.GetTicketById(ticketId);
if (ticket is null) return; if (ticket is null) return;
var installation = ticket.InstallationId.HasValue var installation = Db.GetInstallationById(ticket.InstallationId);
? Db.GetInstallationById(ticket.InstallationId.Value) if (installation is null) return;
: null;
var diagnosis = new TicketAiDiagnosis var diagnosis = new TicketAiDiagnosis
{ {
@ -60,22 +59,18 @@ public static class TicketDiagnosticService
try try
{ {
var productName = installation != null var productName = ((ProductType)installation.Product).ToString();
? ((ProductType)installation.Product).ToString()
: "Unknown";
var recentErrors = ticket.InstallationId.HasValue var recentErrors = Db.Errors
? Db.Errors .Where(e => e.InstallationId == ticket.InstallationId)
.Where(e => e.InstallationId == ticket.InstallationId.Value) .OrderByDescending(e => e.Date)
.OrderByDescending(e => e.Date) .ToList()
.ToList() .Select(e => e.Description)
.Select(e => e.Description) .Distinct()
.Distinct() .Take(5)
.Take(5) .ToList();
.ToList()
: new List<string>();
var prompt = BuildPrompt(ticket, productName, recentErrors, language); var prompt = BuildPrompt(ticket, productName, recentErrors);
var result = await CallMistralAsync(prompt); var result = await CallMistralAsync(prompt);
if (result is null) if (result is null)
@ -111,16 +106,12 @@ public static class TicketDiagnosticService
}); });
} }
private static string BuildPrompt(Ticket ticket, string productName, List<string> recentErrors, string language = "en") private static string BuildPrompt(Ticket ticket, string productName, List<string> recentErrors)
{ {
var recentList = recentErrors.Count > 0 var recentList = recentErrors.Count > 0
? string.Join(", ", recentErrors) ? string.Join(", ", recentErrors)
: "none"; : "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. return $@"You are a senior field technician for {productName} battery energy storage systems.
A support ticket has been submitted with the following details: A support ticket has been submitted with the following details:
Subject: {ticket.Subject} Subject: {ticket.Subject}
@ -135,7 +126,7 @@ Analyze this ticket and respond in JSON only — no markdown, no explanation out
""confidence"": 0.85, ""confidence"": 0.85,
""recommendedActions"": [""Action 1"", ""Action 2"", ""Action 3""] ""recommendedActions"": [""Action 1"", ""Action 2"", ""Action 3""]
}} }}
Confidence must be a number between 0.0 and 1.0.{langInstruction}"; Confidence must be a number between 0.0 and 1.0.";
} }
private static async Task<TicketDiagnosisResult?> CallMistralAsync(string prompt) private static async Task<TicketDiagnosisResult?> CallMistralAsync(string prompt)

View File

@ -130,10 +130,10 @@ public static class RabbitMqManager
{ {
Console.WriteLine("Send replace battery email to the support team for installation "+installationId); Console.WriteLine("Send replace battery email to the support team for installation "+installationId);
string recipient = "support@innov.energy"; string recipient = "support@innov.energy";
string subject = $"Battery Alarm from {installation.Name}: 2 or more strings broken"; string subject = $"Battery Alarm from {installation.InstallationName}: 2 or more strings broken";
string text = $"Dear inesco Energy Support Team,\n" + string text = $"Dear inesco Energy Support Team,\n" +
$"\n"+ $"\n"+
$"Installation Name: {installation.Name}\n"+ $"Installation Name: {installation.InstallationName}\n"+
$"\n"+ $"\n"+
$"Installation Monitor Link: {monitorLink}\n"+ $"Installation Monitor Link: {monitorLink}\n"+
$"\n"+ $"\n"+

View File

@ -21,9 +21,7 @@ import {
TicketCategory, TicketCategory,
TicketSubCategory, TicketSubCategory,
subCategoryLabels, subCategoryLabels,
subCategoriesByCategory, subCategoriesByCategory
categoryLabels,
otherSubCategoryValues
} from 'src/interfaces/TicketTypes'; } from 'src/interfaces/TicketTypes';
type Installation = { type Installation = {
@ -51,6 +49,14 @@ const deviceOptionsByProduct: Record<number, { value: number; label: string }[]>
] ]
}; };
const categoryLabels: Record<number, string> = {
[TicketCategory.Hardware]: 'Hardware',
[TicketCategory.Software]: 'Software',
[TicketCategory.Network]: 'Network',
[TicketCategory.UserAccess]: 'User Access',
[TicketCategory.Firmware]: 'Firmware'
};
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@ -69,52 +75,15 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
const [priority, setPriority] = useState<number>(TicketPriority.Medium); const [priority, setPriority] = useState<number>(TicketPriority.Medium);
const [category, setCategory] = useState<number>(TicketCategory.Hardware); const [category, setCategory] = useState<number>(TicketCategory.Hardware);
const [subCategory, setSubCategory] = useState<number>( const [subCategory, setSubCategory] = useState<number>(
TicketSubCategory.Battery TicketSubCategory.General
); );
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// Custom "Other" fields
const [customSubCategory, setCustomSubCategory] = useState('');
const [customCategory, setCustomCategory] = useState('');
const [customSubSuggestions, setCustomSubSuggestions] = useState<string[]>([]);
const [customCatSuggestions, setCustomCatSuggestions] = useState<string[]>([]);
const hasDeviceOptions = const hasDeviceOptions =
selectedProduct !== '' && selectedProduct in deviceOptionsByProduct; 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(() => { useEffect(() => {
if (selectedProduct === '') { if (selectedProduct === '') {
setAllInstallations([]); setAllInstallations([]);
@ -168,16 +137,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
if (defaultInstallationId == null) setSelectedInstallation(null); if (defaultInstallationId == null) setSelectedInstallation(null);
}, [selectedDevice]); }, [selectedDevice]);
// Reset subcategory when category changes
useEffect(() => { useEffect(() => {
if (isOtherCategory) { setSubCategory(TicketSubCategory.General);
setSubCategory(0); // no subcategory for Other category
} else {
const subs = subCategoriesByCategory[category];
setSubCategory(subs?.[0] ?? 0);
}
setCustomSubCategory('');
setCustomCategory('');
}, [category]); }, [category]);
const filteredInstallations = useMemo(() => { const filteredInstallations = useMemo(() => {
@ -196,15 +157,13 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
setSelectedInstallation(null); setSelectedInstallation(null);
setPriority(TicketPriority.Medium); setPriority(TicketPriority.Medium);
setCategory(TicketCategory.Hardware); setCategory(TicketCategory.Hardware);
setSubCategory(TicketSubCategory.Battery); setSubCategory(TicketSubCategory.General);
setDescription(''); setDescription('');
setCustomSubCategory('');
setCustomCategory('');
setError(''); setError('');
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!subject.trim()) return; if (!subject.trim() || !selectedInstallation) return;
setSubmitting(true); setSubmitting(true);
setError(''); setError('');
@ -212,12 +171,10 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
.post('/CreateTicket', { .post('/CreateTicket', {
subject, subject,
description, description,
installationId: selectedInstallation?.id ?? null, installationId: selectedInstallation.id,
priority, priority,
category, category,
subCategory: isOtherCategory ? 0 : subCategory, subCategory
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
customCategory: isOtherCategory ? customCategory || null : null
}) })
.then(() => { .then(() => {
resetForm(); resetForm();
@ -228,7 +185,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
.finally(() => setSubmitting(false)); .finally(() => setSubmitting(false));
}; };
const availableSubCategories = subCategoriesByCategory[category] ?? []; const availableSubCategories = subCategoriesByCategory[category] ?? [0];
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
@ -358,77 +315,25 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
</Select> </Select>
</FormControl> </FormControl>
{/* Custom category label when "Other" category is selected */} <FormControl fullWidth margin="dense">
{isOtherCategory && ( <InputLabel>
<Autocomplete<string, false, false, true> <FormattedMessage
freeSolo id="subCategory"
options={customCatSuggestions} defaultMessage="Sub-Category"
value={customCategory} />
onInputChange={(_e, val) => setCustomCategory(val)} </InputLabel>
renderInput={(params) => ( <Select
<TextField value={subCategory}
{...params} label="Sub-Category"
label={ onChange={(e) => setSubCategory(Number(e.target.value))}
<FormattedMessage >
id="customCategoryLabel" {availableSubCategories.map((val) => (
defaultMessage="Category Label" <MenuItem key={val} value={val}>
/> {subCategoryLabels[val] ?? 'Unknown'}
} </MenuItem>
placeholder="Type or select from existing..." ))}
margin="dense" </Select>
/> </FormControl>
)}
/>
)}
{/* Subcategory dropdown — hidden when category is "Other" */}
{!isOtherCategory && availableSubCategories.length > 0 && (
<FormControl fullWidth margin="dense">
<InputLabel>
<FormattedMessage
id="subCategory"
defaultMessage="Sub-Category"
/>
</InputLabel>
<Select
value={subCategory}
label="Sub-Category"
onChange={(e) => {
setSubCategory(Number(e.target.value));
setCustomSubCategory('');
}}
>
{availableSubCategories.map((val) => (
<MenuItem key={val} value={val}>
{subCategoryLabels[val] ?? 'Unknown'}
</MenuItem>
))}
</Select>
</FormControl>
)}
{/* Custom subcategory free-text with autocomplete when "Other" sub is selected */}
{(isOtherSubCategory || isOtherCategory) && (
<Autocomplete<string, false, false, true>
freeSolo
options={customSubSuggestions}
value={customSubCategory}
onInputChange={(_e, val) => setCustomSubCategory(val)}
renderInput={(params) => (
<TextField
{...params}
label={
<FormattedMessage
id="customSubCategoryLabel"
defaultMessage="Custom Sub-Category"
/>
}
placeholder="Type or select from existing..."
margin="dense"
/>
)}
/>
)}
<TextField <TextField
label={ label={
@ -449,7 +354,9 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
<Button <Button
variant="contained" variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
disabled={submitting || !subject.trim()} disabled={
submitting || !subject.trim() || !selectedInstallation
}
> >
<FormattedMessage id="submit" defaultMessage="Submit" /> <FormattedMessage id="submit" defaultMessage="Submit" />
</Button> </Button>

View File

@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { import {
Alert, Alert,
Autocomplete,
Box, Box,
Button, Button,
Card, Card,
@ -29,21 +28,16 @@ import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
import routes from 'src/Resources/routes.json';
import { import {
TicketDetail as TicketDetailType, TicketDetail as TicketDetailType,
TicketStatus, TicketStatus,
TicketPriority, TicketPriority,
TicketCategory, TicketCategory,
TicketSubCategory,
AdminUser, AdminUser,
subCategoryLabels, subCategoryLabels,
subCategoryKeys, subCategoryKeys,
subCategoriesByCategory, subCategoriesByCategory
categoryLabels,
categoryKeys,
otherSubCategoryValues,
getCategoryDisplayLabel,
getSubCategoryDisplayLabel
} from 'src/interfaces/TicketTypes'; } from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer'; import Footer from 'src/components/Footer';
import StatusChip from './StatusChip'; import StatusChip from './StatusChip';
@ -58,6 +52,14 @@ const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' } [TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' }
}; };
const categoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[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 }[] = [ const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' }, { value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' }, { value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
@ -88,12 +90,6 @@ function TicketDetailPage() {
const [savingDescription, setSavingDescription] = useState(false); const [savingDescription, setSavingDescription] = useState(false);
const [descriptionSaved, setDescriptionSaved] = useState(false); const [descriptionSaved, setDescriptionSaved] = useState(false);
// Custom "Other" editing state
const [editCustomSub, setEditCustomSub] = useState('');
const [editCustomCat, setEditCustomCat] = useState('');
const [customSubSuggestions, setCustomSubSuggestions] = useState<string[]>([]);
const [customCatSuggestions, setCustomCatSuggestions] = useState<string[]>([]);
const fetchDetail = useCallback(() => { const fetchDetail = useCallback(() => {
if (!id) return; if (!id) return;
axiosConfig axiosConfig
@ -103,8 +99,6 @@ function TicketDetailPage() {
setRootCause(res.data.ticket.rootCause ?? ''); setRootCause(res.data.ticket.rootCause ?? '');
setSolution(res.data.ticket.solution ?? ''); setSolution(res.data.ticket.solution ?? '');
setDescription(res.data.ticket.description ?? ''); setDescription(res.data.ticket.description ?? '');
setEditCustomSub(res.data.ticket.customSubCategory ?? '');
setEditCustomCat(res.data.ticket.customCategory ?? '');
setError(''); setError('');
}) })
.catch(() => setError('Failed to load ticket details.')) .catch(() => setError('Failed to load ticket details.'))
@ -119,31 +113,6 @@ function TicketDetailPage() {
.catch(() => {}); .catch(() => {});
}, [fetchDetail]); }, [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) => { const handleStatusChange = (newStatus: number) => {
if (!detail) return; if (!detail) return;
if ( if (
@ -258,11 +227,6 @@ function TicketDetailPage() {
const { ticket, comments, diagnosis, timeline } = detail; 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 ( return (
<div style={{ userSelect: 'none' }}> <div style={{ userSelect: 'none' }}>
<Container maxWidth="xl" sx={{ mt: '20px' }}> <Container maxWidth="xl" sx={{ mt: '20px' }}>
@ -290,12 +254,9 @@ function TicketDetailPage() {
<StatusChip status={ticket.status} size="medium" /> <StatusChip status={ticket.status} size="medium" />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '} {intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })} ·{' '}
{catDisplay} {intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })}
{subDisplay !== 'Other' && subDisplay !== 'Unknown' && subDisplay !== 'General' {ticket.subCategory !== TicketSubCategory.General &&
? ` · ${subDisplay}` ` · ${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`}
: ticket.customSubCategory
? ` · ${ticket.customSubCategory}`
: ''}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@ -609,105 +570,38 @@ function TicketDetailPage() {
label="Category" label="Category"
onChange={(e) => { onChange={(e) => {
const newCat = Number(e.target.value); const newCat = Number(e.target.value);
const isNewOther = newCat === TicketCategory.Other;
const firstSub = subCategoriesByCategory[newCat]?.[0] ?? 0;
handleTicketFieldChange({ handleTicketFieldChange({
category: newCat, category: newCat,
subCategory: isNewOther ? 0 : firstSub, subCategory: TicketSubCategory.General
customSubCategory: null,
customCategory: null
}); });
}} }}
> >
{Object.entries(categoryLabels).map(([value, label]) => ( {Object.entries(categoryKeys).map(([value, msg]) => (
<MenuItem key={value} value={Number(value)}> <MenuItem key={value} value={Number(value)}>
{label} {intl.formatMessage(msg)}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
{/* Custom category label when "Other" category */} <FormControl fullWidth size="small">
{isOtherCategory && ( <InputLabel>
<Autocomplete<string, false, false, true> <FormattedMessage id="subCategory" defaultMessage="Sub-Category" />
freeSolo </InputLabel>
options={customCatSuggestions} <Select
value={editCustomCat} value={ticket.subCategory}
onInputChange={(_e, val) => setEditCustomCat(val)} label="Sub-Category"
onBlur={() => { onChange={(e) =>
if (editCustomCat !== (ticket.customCategory ?? '')) { handleTicketFieldChange({ subCategory: Number(e.target.value) })
handleTicketFieldChange({ customCategory: editCustomCat || null }); }
} >
}} {(subCategoriesByCategory[ticket.category] ?? [0]).map((sc) => (
size="small" <MenuItem key={sc} value={sc}>
renderInput={(params) => ( {subCategoryKeys[sc] ? intl.formatMessage(subCategoryKeys[sc]) : subCategoryLabels[sc] ?? 'Unknown'}
<TextField </MenuItem>
{...params} ))}
label={ </Select>
<FormattedMessage </FormControl>
id="customCategoryLabel"
defaultMessage="Category Label"
/>
}
placeholder="Type category name..."
/>
)}
/>
)}
{/* Subcategory dropdown — hidden when category is "Other" */}
{!isOtherCategory && (
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="subCategory" defaultMessage="Sub-Category" />
</InputLabel>
<Select
value={ticket.subCategory}
label="Sub-Category"
onChange={(e) => {
const newSub = Number(e.target.value);
handleTicketFieldChange({
subCategory: newSub,
customSubCategory: otherSubCategoryValues.has(newSub) ? ticket.customSubCategory : null
});
}}
>
{(subCategoriesByCategory[ticket.category] ?? []).map((sc) => (
<MenuItem key={sc} value={sc}>
{subCategoryKeys[sc] ? intl.formatMessage(subCategoryKeys[sc]) : subCategoryLabels[sc] ?? 'Unknown'}
</MenuItem>
))}
</Select>
</FormControl>
)}
{/* Custom subcategory when "Other" sub is selected */}
{(isOtherSubCategory || isOtherCategory) && (
<Autocomplete<string, false, false, true>
freeSolo
options={customSubSuggestions}
value={editCustomSub}
onInputChange={(_e, val) => setEditCustomSub(val)}
onBlur={() => {
if (editCustomSub !== (ticket.customSubCategory ?? '')) {
handleTicketFieldChange({ customSubCategory: editCustomSub || null });
}
}}
size="small"
renderInput={(params) => (
<TextField
{...params}
label={
<FormattedMessage
id="customSubCategoryLabel"
defaultMessage="Custom Sub-Category"
/>
}
placeholder="Type or select from existing..."
/>
)}
/>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -728,28 +622,7 @@ function TicketDetailPage() {
defaultMessage="Installation" defaultMessage="Installation"
/> />
</Typography> </Typography>
<Typography <Typography variant="body2">
variant="body2"
sx={detail.installationProduct != null && detail.ticket.installationId != null ? {
color: 'primary.main',
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' }
} : {}}
onClick={() => {
if (detail.installationProduct == null || detail.ticket.installationId == null) return;
const productRoutes: Record<number, string> = {
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} {detail.installationName}
</Typography> </Typography>
</Box> </Box>

View File

@ -25,10 +25,10 @@ import {
TicketSummary, TicketSummary,
TicketStatus, TicketStatus,
TicketPriority, TicketPriority,
categoryKeys, TicketCategory,
subCategoryKeys, TicketSubCategory,
getCategoryDisplayLabel, subCategoryLabels,
getSubCategoryDisplayLabel subCategoryKeys
} from 'src/interfaces/TicketTypes'; } from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer'; import Footer from 'src/components/Footer';
import CreateTicketModal from './CreateTicketModal'; import CreateTicketModal from './CreateTicketModal';
@ -49,6 +49,14 @@ const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' } [TicketPriority.Low]: { id: 'priorityLow', defaultMessage: 'Low' }
}; };
const categoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[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() { function TicketList() {
const navigate = useNavigate(); const navigate = useNavigate();
const intl = useIntl(); const intl = useIntl();
@ -196,43 +204,25 @@ function TicketList() {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{filtered.map((ticket) => { {filtered.map((ticket) => (
const catLabel = getCategoryDisplayLabel(ticket.category, ticket.customCategory); <TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/tickets/${ticket.id}`)}>
const catKey = categoryKeys[ticket.category]; <TableCell>{ticket.id}</TableCell>
const catDisplay = catKey <TableCell>{ticket.subject}</TableCell>
? intl.formatMessage(catKey) <TableCell>
: catLabel; <StatusChip status={ticket.status} />
const subLabel = getSubCategoryDisplayLabel(ticket.subCategory, ticket.customSubCategory); </TableCell>
const subKey = subCategoryKeys[ticket.subCategory]; <TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell>
const subDisplay = subKey <TableCell>
? intl.formatMessage(subKey) {intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })}
: subLabel; {ticket.subCategory !== TicketSubCategory.General &&
`${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`}
return ( </TableCell>
<TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/tickets/${ticket.id}`)}> <TableCell>{ticket.installationName}</TableCell>
<TableCell>{ticket.id}</TableCell> <TableCell>
<TableCell>{ticket.subject}</TableCell> {new Date(ticket.createdAt).toLocaleDateString()}
<TableCell> </TableCell>
<StatusChip status={ticket.status} /> </TableRow>
</TableCell> ))}
<TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell>
<TableCell>
{ticket.customCategory
? ticket.customCategory
: catDisplay}
{subLabel !== 'Other' && subLabel !== 'Unknown' && subLabel !== 'General'
? `${ticket.customSubCategory || subDisplay}`
: ticket.customSubCategory
? `${ticket.customSubCategory}`
: ''}
</TableCell>
<TableCell>{ticket.installationName}</TableCell>
<TableCell>
{new Date(ticket.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>

View File

@ -16,143 +16,75 @@ export enum TicketPriority {
export enum TicketCategory { export enum TicketCategory {
Hardware = 0, Hardware = 0,
Software = 1, Software = 1,
// Network = 2 removed — value reserved for legacy data Network = 2,
UserAccess = 3, UserAccess = 3,
Firmware = 4, Firmware = 4
Configuration = 5,
Other = 6
} }
export enum TicketSubCategory { export enum TicketSubCategory {
General = 0, // legacy only General = 0,
OtherLegacy = 99, // legacy catch-all Other = 99,
// Hardware (1xx) // Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, PV = 105, Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
HardwareOther = 199,
// Software (2xx) // Software (2xx)
Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203, Backend = 200, Frontend = 201, Database = 202, Api = 203,
SoftwareOther = 299, // Network (3xx)
// Network (3xx) — legacy
Connectivity = 300, VpnAccess = 301, S3Storage = 302, Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx) // UserAccess (4xx)
Login = 400, Permissions = 401, Permissions = 400, Login = 401,
UserAccessOther = 499,
// Firmware (5xx) // 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<number, string> = { export const subCategoryLabels: Record<number, string> = {
[TicketSubCategory.General]: 'General', [TicketSubCategory.General]: 'General',
[TicketSubCategory.OtherLegacy]: 'Other', [TicketSubCategory.Other]: 'Other',
[TicketSubCategory.Battery]: 'Battery', [TicketSubCategory.Battery]: 'Battery', [TicketSubCategory.Inverter]: 'Inverter',
[TicketSubCategory.Inverter]: 'Inverter', [TicketSubCategory.Cable]: 'Cable', [TicketSubCategory.Gateway]: 'Gateway',
[TicketSubCategory.Cable]: 'Cable', [TicketSubCategory.Metering]: 'Metering', [TicketSubCategory.Cooling]: 'Cooling',
[TicketSubCategory.Gateway]: 'Gateway', [TicketSubCategory.PvSolar]: 'PV / Solar', [TicketSubCategory.Safety]: 'Safety',
[TicketSubCategory.Metering]: 'Metering', [TicketSubCategory.Backend]: 'Backend', [TicketSubCategory.Frontend]: 'Frontend',
[TicketSubCategory.PV]: 'PV', [TicketSubCategory.Database]: 'Database', [TicketSubCategory.Api]: 'API',
[TicketSubCategory.HardwareOther]: 'Other', [TicketSubCategory.Connectivity]: 'Connectivity', [TicketSubCategory.VpnAccess]: 'VPN Access',
[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.S3Storage]: 'S3 Storage',
[TicketSubCategory.Login]: 'Login', [TicketSubCategory.Permissions]: 'Permissions', [TicketSubCategory.Login]: 'Login',
[TicketSubCategory.Permissions]: 'Permissions',
[TicketSubCategory.UserAccessOther]: 'Other',
[TicketSubCategory.BatteryFirmware]: 'Battery Firmware', [TicketSubCategory.BatteryFirmware]: 'Battery Firmware',
[TicketSubCategory.InverterFirmware]: 'Inverter 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<number, { id: string; defaultMessage: string }> = { export const subCategoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketSubCategory.General]: { id: 'subCatGeneral', defaultMessage: 'General' }, [TicketSubCategory.General]: { id: 'subCatGeneral', defaultMessage: 'General' },
[TicketSubCategory.OtherLegacy]: { id: 'subCatOtherLegacy', defaultMessage: 'Other' }, [TicketSubCategory.Other]: { id: 'subCatOther', defaultMessage: 'Other' },
[TicketSubCategory.Battery]: { id: 'subCatBattery', defaultMessage: 'Battery' }, [TicketSubCategory.Battery]: { id: 'subCatBattery', defaultMessage: 'Battery' },
[TicketSubCategory.Inverter]: { id: 'subCatInverter', defaultMessage: 'Inverter' }, [TicketSubCategory.Inverter]: { id: 'subCatInverter', defaultMessage: 'Inverter' },
[TicketSubCategory.Cable]: { id: 'subCatCable', defaultMessage: 'Cable' }, [TicketSubCategory.Cable]: { id: 'subCatCable', defaultMessage: 'Cable' },
[TicketSubCategory.Gateway]: { id: 'subCatGateway', defaultMessage: 'Gateway' }, [TicketSubCategory.Gateway]: { id: 'subCatGateway', defaultMessage: 'Gateway' },
[TicketSubCategory.Metering]: { id: 'subCatMetering', defaultMessage: 'Metering' }, [TicketSubCategory.Metering]: { id: 'subCatMetering', defaultMessage: 'Metering' },
[TicketSubCategory.PV]: { id: 'subCatPV', defaultMessage: 'PV' }, [TicketSubCategory.Cooling]: { id: 'subCatCooling', defaultMessage: 'Cooling' },
[TicketSubCategory.HardwareOther]: { id: 'subCatHardwareOther', defaultMessage: 'Other' }, [TicketSubCategory.PvSolar]: { id: 'subCatPvSolar', defaultMessage: 'PV / Solar' },
[TicketSubCategory.Monitor]: { id: 'subCatMonitor', defaultMessage: 'Monitor' }, [TicketSubCategory.Safety]: { id: 'subCatSafety', defaultMessage: 'Safety' },
[TicketSubCategory.ControllerService]: { id: 'subCatControllerService', defaultMessage: 'Controller Service' }, [TicketSubCategory.Backend]: { id: 'subCatBackend', defaultMessage: 'Backend' },
[TicketSubCategory.ModbusTcpService]: { id: 'subCatModbusTcpService', defaultMessage: 'Modbus TCP Service' }, [TicketSubCategory.Frontend]: { id: 'subCatFrontend', defaultMessage: 'Frontend' },
[TicketSubCategory.EMS]: { id: 'subCatEMS', defaultMessage: 'EMS' }, [TicketSubCategory.Database]: { id: 'subCatDatabase', defaultMessage: 'Database' },
[TicketSubCategory.SoftwareOther]: { id: 'subCatSoftwareOther', defaultMessage: 'Other' }, [TicketSubCategory.Api]: { id: 'subCatApi', defaultMessage: 'API' },
[TicketSubCategory.Connectivity]: { id: 'subCatConnectivity', defaultMessage: 'Connectivity' }, [TicketSubCategory.Connectivity]: { id: 'subCatConnectivity', defaultMessage: 'Connectivity' },
[TicketSubCategory.VpnAccess]: { id: 'subCatVpnAccess', defaultMessage: 'VPN Access' }, [TicketSubCategory.VpnAccess]: { id: 'subCatVpnAccess', defaultMessage: 'VPN Access' },
[TicketSubCategory.S3Storage]: { id: 'subCatS3Storage', defaultMessage: 'S3 Storage' }, [TicketSubCategory.S3Storage]: { id: 'subCatS3Storage', defaultMessage: 'S3 Storage' },
[TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' }, [TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' },
[TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' }, [TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' },
[TicketSubCategory.UserAccessOther]: { id: 'subCatUserAccessOther', defaultMessage: 'Other' }, [TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' },
[TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' }, [TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' },
[TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' }, [TicketSubCategory.ControllerFirmware]: { id: 'subCatControllerFirmware', defaultMessage: 'Controller 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<number, number[]> = { export const subCategoriesByCategory: Record<number, number[]> = {
[TicketCategory.Hardware]: [100, 101, 102, 103, 104, 105, 199], [TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99],
[TicketCategory.Software]: [200, 201, 202, 203, 299], [TicketCategory.Software]: [0, 200, 201, 202, 203, 99],
[TicketCategory.UserAccess]: [400, 401, 499], [TicketCategory.Network]: [0, 300, 301, 302, 99],
[TicketCategory.Firmware]: [500, 501, 502, 503, 599], [TicketCategory.UserAccess]: [0, 400, 401, 99],
[TicketCategory.Configuration]: [600, 601, 602, 699] [TicketCategory.Firmware]: [0, 500, 501, 502, 99]
// 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<number, string> = {
[TicketCategory.Hardware]: 'Hardware',
[TicketCategory.Software]: 'Software',
[TicketCategory.UserAccess]: 'User Access',
[TicketCategory.Firmware]: 'Firmware',
[TicketCategory.Configuration]: 'Configuration',
[TicketCategory.Other]: 'Other'
};
export const categoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[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<number, string> = {
2: 'Network (legacy)'
}; };
export enum TicketSource { export enum TicketSource {
@ -193,7 +125,7 @@ export type Ticket = {
category: number; category: number;
subCategory: number; subCategory: number;
source: number; source: number;
installationId: number | null; installationId: number;
assigneeId: number | null; assigneeId: number | null;
createdByUserId: number; createdByUserId: number;
tags: string; tags: string;
@ -203,8 +135,6 @@ export type Ticket = {
rootCause: string | null; rootCause: string | null;
solution: string | null; solution: string | null;
preFilledFromAi: boolean; preFilledFromAi: boolean;
customSubCategory: string | null;
customCategory: string | null;
}; };
export type TicketComment = { export type TicketComment = {
@ -246,7 +176,6 @@ export type TicketDetail = {
diagnosis: TicketAiDiagnosis | null; diagnosis: TicketAiDiagnosis | null;
timeline: TicketTimelineEvent[]; timeline: TicketTimelineEvent[];
installationName: string; installationName: string;
installationProduct: number | null;
creatorName: string; creatorName: string;
assigneeName: string | null; assigneeName: string | null;
}; };
@ -258,12 +187,10 @@ export type TicketSummary = {
priority: number; priority: number;
category: number; category: number;
subCategory: number; subCategory: number;
installationId: number | null; installationId: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
installationName: string; installationName: string;
customSubCategory: string | null;
customCategory: string | null;
}; };
export type AdminUser = { export type AdminUser = {
@ -276,25 +203,3 @@ export enum DiagnosisFeedback {
Rejected = 1, Rejected = 1,
Overridden = 2 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';
}