allow create ticket for instalation not on monitor and link installation live page from ticket and allow type Other to create new category

This commit is contained in:
Yinyin Liu 2026-03-18 15:45:09 +01:00
parent 3b6a16f2f8
commit 876a82bf82
10 changed files with 594 additions and 195 deletions

View File

@ -1990,7 +1990,8 @@ public class Controller : ControllerBase
}); });
// Fire-and-forget AI diagnosis // Fire-and-forget AI diagnosis
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning(); var lang = user.Language ?? "en";
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
return ticket; return ticket;
} }
@ -2112,7 +2113,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 = Db.GetInstallationById(ticket.InstallationId); var installation = ticket.InstallationId.HasValue ? Db.GetInstallationById(ticket.InstallationId.Value) : null;
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;
@ -2122,9 +2123,10 @@ 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?.InstallationName ?? $"#{ticket.InstallationId}", installationName = installation?.Name ?? (ticket.InstallationId.HasValue ? $"#{ticket.InstallationId}" : "No installation"),
creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}", installationProduct = installation?.Product,
assigneeName = assignee?.Name creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}",
assigneeName = assignee?.Name
}; };
} }
@ -2137,18 +2139,37 @@ public class Controller : ControllerBase
var tickets = Db.GetAllTickets(); var tickets = Db.GetAllTickets();
var summaries = tickets.Select(t => var summaries = tickets.Select(t =>
{ {
var installation = Db.GetInstallationById(t.InstallationId); var installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null;
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,
installationName = installation?.InstallationName ?? $"#{t.InstallationId}" t.CustomSubCategory, t.CustomCategory,
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,25 +4,50 @@ 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, General = 0, // legacy only — not offered for new tickets
Other = 99, OtherLegacy = 99, // legacy catch-all — not offered for new tickets
// Hardware (1xx) // Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107, Metering = 104, PV = 105,
HardwareOther = 199,
// Software (2xx) // Software (2xx)
Backend = 200, Frontend = 201, Database = 202, Api = 203, Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203,
// Network (3xx) SoftwareOther = 299,
// Network (3xx) — legacy, not offered for new tickets
Connectivity = 300, VpnAccess = 301, S3Storage = 302, Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx) // UserAccess (4xx)
Permissions = 400, Login = 401, Login = 400, Permissions = 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
{ {
@ -34,10 +59,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.General; public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.Battery;
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; }
@ -49,4 +74,7 @@ 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,4 +189,20 @@ 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

@ -115,12 +115,12 @@ public static class ReportAggregationService
try try
{ {
var report = await WeeklyReportService.GenerateReportAsync( var report = await WeeklyReportService.GenerateReportAsync(
installation.Id, installation.InstallationName, "en"); installation.Id, installation.Name, "en");
SaveWeeklySummary(installation.Id, report, "en"); SaveWeeklySummary(installation.Id, report, "en");
generated++; 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) 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?.InstallationName ?? $"Installation {installationId}"; var installationName = installation?.Name ?? $"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?.InstallationName ?? $"Installation {installationId}"; var installationName = installation?.Name ?? $"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)?.InstallationName var installationName = Db.GetInstallationById(report.InstallationId)?.Name
?? $"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?.InstallationName var installationName = installation?.Name
?? $"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)?.InstallationName var installationName = Db.GetInstallationById(report.InstallationId)?.Name
?? $"Installation {report.InstallationId}"; ?? $"Installation {report.InstallationId}";
return GetOrGenerateInsightAsync("yearly", report.Id, language, return GetOrGenerateInsightAsync("yearly", report.Id, language,
() => GenerateYearlyAiInsightAsync( () => GenerateYearlyAiInsightAsync(

View File

@ -31,13 +31,14 @@ 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) public static async Task DiagnoseTicketAsync(Int64 ticketId, String language = "en")
{ {
var ticket = Db.GetTicketById(ticketId); var ticket = Db.GetTicketById(ticketId);
if (ticket is null) return; if (ticket is null) return;
var installation = Db.GetInstallationById(ticket.InstallationId); var installation = ticket.InstallationId.HasValue
if (installation is null) return; ? Db.GetInstallationById(ticket.InstallationId.Value)
: null;
var diagnosis = new TicketAiDiagnosis var diagnosis = new TicketAiDiagnosis
{ {
@ -59,18 +60,22 @@ public static class TicketDiagnosticService
try try
{ {
var productName = ((ProductType)installation.Product).ToString(); var productName = installation != null
? ((ProductType)installation.Product).ToString()
: "Unknown";
var recentErrors = Db.Errors var recentErrors = ticket.InstallationId.HasValue
.Where(e => e.InstallationId == ticket.InstallationId) ? Db.Errors
.OrderByDescending(e => e.Date) .Where(e => e.InstallationId == ticket.InstallationId.Value)
.ToList() .OrderByDescending(e => e.Date)
.Select(e => e.Description) .ToList()
.Distinct() .Select(e => e.Description)
.Take(5) .Distinct()
.ToList(); .Take(5)
.ToList()
: new List<string>();
var prompt = BuildPrompt(ticket, productName, recentErrors); var prompt = BuildPrompt(ticket, productName, recentErrors, language);
var result = await CallMistralAsync(prompt); var result = await CallMistralAsync(prompt);
if (result is null) if (result is null)
@ -106,12 +111,16 @@ public static class TicketDiagnosticService
}); });
} }
private static string BuildPrompt(Ticket ticket, string productName, List<string> recentErrors) private static string BuildPrompt(Ticket ticket, string productName, List<string> recentErrors, string language = "en")
{ {
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}
@ -126,7 +135,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."; Confidence must be a number between 0.0 and 1.0.{langInstruction}";
} }
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.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" + string text = $"Dear inesco Energy Support Team,\n" +
$"\n"+ $"\n"+
$"Installation Name: {installation.InstallationName}\n"+ $"Installation Name: {installation.Name}\n"+
$"\n"+ $"\n"+
$"Installation Monitor Link: {monitorLink}\n"+ $"Installation Monitor Link: {monitorLink}\n"+
$"\n"+ $"\n"+

View File

@ -21,7 +21,9 @@ import {
TicketCategory, TicketCategory,
TicketSubCategory, TicketSubCategory,
subCategoryLabels, subCategoryLabels,
subCategoriesByCategory subCategoriesByCategory,
categoryLabels,
otherSubCategoryValues
} from 'src/interfaces/TicketTypes'; } from 'src/interfaces/TicketTypes';
type Installation = { type Installation = {
@ -49,14 +51,6 @@ 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;
@ -75,15 +69,52 @@ 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.General TicketSubCategory.Battery
); );
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([]);
@ -137,8 +168,16 @@ 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(() => {
setSubCategory(TicketSubCategory.General); if (isOtherCategory) {
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(() => {
@ -157,13 +196,15 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
setSelectedInstallation(null); setSelectedInstallation(null);
setPriority(TicketPriority.Medium); setPriority(TicketPriority.Medium);
setCategory(TicketCategory.Hardware); setCategory(TicketCategory.Hardware);
setSubCategory(TicketSubCategory.General); setSubCategory(TicketSubCategory.Battery);
setDescription(''); setDescription('');
setCustomSubCategory('');
setCustomCategory('');
setError(''); setError('');
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!subject.trim() || !selectedInstallation) return; if (!subject.trim()) return;
setSubmitting(true); setSubmitting(true);
setError(''); setError('');
@ -171,10 +212,12 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
.post('/CreateTicket', { .post('/CreateTicket', {
subject, subject,
description, description,
installationId: selectedInstallation.id, installationId: selectedInstallation?.id ?? null,
priority, priority,
category, category,
subCategory subCategory: isOtherCategory ? 0 : subCategory,
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
customCategory: isOtherCategory ? customCategory || null : null
}) })
.then(() => { .then(() => {
resetForm(); resetForm();
@ -185,7 +228,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
.finally(() => setSubmitting(false)); .finally(() => setSubmitting(false));
}; };
const availableSubCategories = subCategoriesByCategory[category] ?? [0]; const availableSubCategories = subCategoriesByCategory[category] ?? [];
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
@ -315,25 +358,77 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth margin="dense"> {/* Custom category label when "Other" category is selected */}
<InputLabel> {isOtherCategory && (
<FormattedMessage <Autocomplete<string, false, false, true>
id="subCategory" freeSolo
defaultMessage="Sub-Category" options={customCatSuggestions}
/> value={customCategory}
</InputLabel> onInputChange={(_e, val) => setCustomCategory(val)}
<Select renderInput={(params) => (
value={subCategory} <TextField
label="Sub-Category" {...params}
onChange={(e) => setSubCategory(Number(e.target.value))} label={
> <FormattedMessage
{availableSubCategories.map((val) => ( id="customCategoryLabel"
<MenuItem key={val} value={val}> defaultMessage="Category Label"
{subCategoryLabels[val] ?? 'Unknown'} />
</MenuItem> }
))} placeholder="Type or select from existing..."
</Select> margin="dense"
</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={
@ -354,9 +449,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
<Button <Button
variant="contained" variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
disabled={ disabled={submitting || !subject.trim()}
submitting || !subject.trim() || !selectedInstallation
}
> >
<FormattedMessage id="submit" defaultMessage="Submit" /> <FormattedMessage id="submit" defaultMessage="Submit" />
</Button> </Button>

View File

@ -2,6 +2,7 @@ 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,
@ -28,16 +29,21 @@ 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';
@ -52,14 +58,6 @@ 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' },
@ -90,6 +88,12 @@ 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
@ -99,6 +103,8 @@ 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.'))
@ -113,6 +119,31 @@ 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 (
@ -227,6 +258,11 @@ 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' }}>
@ -254,9 +290,12 @@ 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: '-' })} ·{' '}
{intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })} {catDisplay}
{ticket.subCategory !== TicketSubCategory.General && {subDisplay !== 'Other' && subDisplay !== 'Unknown' && subDisplay !== 'General'
` · ${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`} ? ` · ${subDisplay}`
: ticket.customSubCategory
? ` · ${ticket.customSubCategory}`
: ''}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@ -570,38 +609,105 @@ 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: TicketSubCategory.General subCategory: isNewOther ? 0 : firstSub,
customSubCategory: null,
customCategory: null
}); });
}} }}
> >
{Object.entries(categoryKeys).map(([value, msg]) => ( {Object.entries(categoryLabels).map(([value, label]) => (
<MenuItem key={value} value={Number(value)}> <MenuItem key={value} value={Number(value)}>
{intl.formatMessage(msg)} {label}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth size="small"> {/* Custom category label when "Other" category */}
<InputLabel> {isOtherCategory && (
<FormattedMessage id="subCategory" defaultMessage="Sub-Category" /> <Autocomplete<string, false, false, true>
</InputLabel> freeSolo
<Select options={customCatSuggestions}
value={ticket.subCategory} value={editCustomCat}
label="Sub-Category" onInputChange={(_e, val) => setEditCustomCat(val)}
onChange={(e) => onBlur={() => {
handleTicketFieldChange({ subCategory: Number(e.target.value) }) if (editCustomCat !== (ticket.customCategory ?? '')) {
} handleTicketFieldChange({ customCategory: editCustomCat || null });
> }
{(subCategoriesByCategory[ticket.category] ?? [0]).map((sc) => ( }}
<MenuItem key={sc} value={sc}> size="small"
{subCategoryKeys[sc] ? intl.formatMessage(subCategoryKeys[sc]) : subCategoryLabels[sc] ?? 'Unknown'} renderInput={(params) => (
</MenuItem> <TextField
))} {...params}
</Select> label={
</FormControl> <FormattedMessage
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>
@ -622,7 +728,28 @@ function TicketDetailPage() {
defaultMessage="Installation" defaultMessage="Installation"
/> />
</Typography> </Typography>
<Typography variant="body2"> <Typography
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,
TicketCategory, categoryKeys,
TicketSubCategory, subCategoryKeys,
subCategoryLabels, getCategoryDisplayLabel,
subCategoryKeys getSubCategoryDisplayLabel
} 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,14 +49,6 @@ 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();
@ -204,25 +196,43 @@ function TicketList() {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{filtered.map((ticket) => ( {filtered.map((ticket) => {
<TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/tickets/${ticket.id}`)}> const catLabel = getCategoryDisplayLabel(ticket.category, ticket.customCategory);
<TableCell>{ticket.id}</TableCell> const catKey = categoryKeys[ticket.category];
<TableCell>{ticket.subject}</TableCell> const catDisplay = catKey
<TableCell> ? intl.formatMessage(catKey)
<StatusChip status={ticket.status} /> : catLabel;
</TableCell> const subLabel = getSubCategoryDisplayLabel(ticket.subCategory, ticket.customSubCategory);
<TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell> const subKey = subCategoryKeys[ticket.subCategory];
<TableCell> const subDisplay = subKey
{intl.formatMessage(categoryKeys[ticket.category] ?? { id: 'unknown', defaultMessage: '-' })} ? intl.formatMessage(subKey)
{ticket.subCategory !== TicketSubCategory.General && : subLabel;
`${subCategoryKeys[ticket.subCategory] ? intl.formatMessage(subCategoryKeys[ticket.subCategory]) : subCategoryLabels[ticket.subCategory] ?? ''}`}
</TableCell> return (
<TableCell>{ticket.installationName}</TableCell> <TableRow key={ticket.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/tickets/${ticket.id}`)}>
<TableCell> <TableCell>{ticket.id}</TableCell>
{new Date(ticket.createdAt).toLocaleDateString()} <TableCell>{ticket.subject}</TableCell>
</TableCell> <TableCell>
</TableRow> <StatusChip status={ticket.status} />
))} </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,75 +16,143 @@ export enum TicketPriority {
export enum TicketCategory { export enum TicketCategory {
Hardware = 0, Hardware = 0,
Software = 1, Software = 1,
Network = 2, // Network = 2 removed — value reserved for legacy data
UserAccess = 3, UserAccess = 3,
Firmware = 4 Firmware = 4,
Configuration = 5,
Other = 6
} }
export enum TicketSubCategory { export enum TicketSubCategory {
General = 0, General = 0, // legacy only
Other = 99, OtherLegacy = 99, // legacy catch-all
// Hardware (1xx) // Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107, Metering = 104, PV = 105,
HardwareOther = 199,
// Software (2xx) // Software (2xx)
Backend = 200, Frontend = 201, Database = 202, Api = 203, Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203,
// Network (3xx) SoftwareOther = 299,
// Network (3xx) — legacy
Connectivity = 300, VpnAccess = 301, S3Storage = 302, Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx) // UserAccess (4xx)
Permissions = 400, Login = 401, Login = 400, Permissions = 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.Other]: 'Other', [TicketSubCategory.OtherLegacy]: 'Other',
[TicketSubCategory.Battery]: 'Battery', [TicketSubCategory.Inverter]: 'Inverter', [TicketSubCategory.Battery]: 'Battery',
[TicketSubCategory.Cable]: 'Cable', [TicketSubCategory.Gateway]: 'Gateway', [TicketSubCategory.Inverter]: 'Inverter',
[TicketSubCategory.Metering]: 'Metering', [TicketSubCategory.Cooling]: 'Cooling', [TicketSubCategory.Cable]: 'Cable',
[TicketSubCategory.PvSolar]: 'PV / Solar', [TicketSubCategory.Safety]: 'Safety', [TicketSubCategory.Gateway]: 'Gateway',
[TicketSubCategory.Backend]: 'Backend', [TicketSubCategory.Frontend]: 'Frontend', [TicketSubCategory.Metering]: 'Metering',
[TicketSubCategory.Database]: 'Database', [TicketSubCategory.Api]: 'API', [TicketSubCategory.PV]: 'PV',
[TicketSubCategory.Connectivity]: 'Connectivity', [TicketSubCategory.VpnAccess]: 'VPN Access', [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.S3Storage]: 'S3 Storage',
[TicketSubCategory.Permissions]: 'Permissions', [TicketSubCategory.Login]: 'Login', [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.Other]: { id: 'subCatOther', defaultMessage: 'Other' }, [TicketSubCategory.OtherLegacy]: { id: 'subCatOtherLegacy', 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.Cooling]: { id: 'subCatCooling', defaultMessage: 'Cooling' }, [TicketSubCategory.PV]: { id: 'subCatPV', defaultMessage: 'PV' },
[TicketSubCategory.PvSolar]: { id: 'subCatPvSolar', defaultMessage: 'PV / Solar' }, [TicketSubCategory.HardwareOther]: { id: 'subCatHardwareOther', defaultMessage: 'Other' },
[TicketSubCategory.Safety]: { id: 'subCatSafety', defaultMessage: 'Safety' }, [TicketSubCategory.Monitor]: { id: 'subCatMonitor', defaultMessage: 'Monitor' },
[TicketSubCategory.Backend]: { id: 'subCatBackend', defaultMessage: 'Backend' }, [TicketSubCategory.ControllerService]: { id: 'subCatControllerService', defaultMessage: 'Controller Service' },
[TicketSubCategory.Frontend]: { id: 'subCatFrontend', defaultMessage: 'Frontend' }, [TicketSubCategory.ModbusTcpService]: { id: 'subCatModbusTcpService', defaultMessage: 'Modbus TCP Service' },
[TicketSubCategory.Database]: { id: 'subCatDatabase', defaultMessage: 'Database' }, [TicketSubCategory.EMS]: { id: 'subCatEMS', defaultMessage: 'EMS' },
[TicketSubCategory.Api]: { id: 'subCatApi', defaultMessage: 'API' }, [TicketSubCategory.SoftwareOther]: { id: 'subCatSoftwareOther', defaultMessage: 'Other' },
[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.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' }, [TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' },
[TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' }, [TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' },
[TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' }, [TicketSubCategory.UserAccessOther]: { id: 'subCatUserAccessOther', defaultMessage: 'Other' },
[TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' }, [TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' },
[TicketSubCategory.ControllerFirmware]: { id: 'subCatControllerFirmware', defaultMessage: 'Controller 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<number, number[]> = { export const subCategoriesByCategory: Record<number, number[]> = {
[TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99], [TicketCategory.Hardware]: [100, 101, 102, 103, 104, 105, 199],
[TicketCategory.Software]: [0, 200, 201, 202, 203, 99], [TicketCategory.Software]: [200, 201, 202, 203, 299],
[TicketCategory.Network]: [0, 300, 301, 302, 99], [TicketCategory.UserAccess]: [400, 401, 499],
[TicketCategory.UserAccess]: [0, 400, 401, 99], [TicketCategory.Firmware]: [500, 501, 502, 503, 599],
[TicketCategory.Firmware]: [0, 500, 501, 502, 99] [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<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 {
@ -125,7 +193,7 @@ export type Ticket = {
category: number; category: number;
subCategory: number; subCategory: number;
source: number; source: number;
installationId: number; installationId: number | null;
assigneeId: number | null; assigneeId: number | null;
createdByUserId: number; createdByUserId: number;
tags: string; tags: string;
@ -135,6 +203,8 @@ 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 = {
@ -176,6 +246,7 @@ 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;
}; };
@ -187,10 +258,12 @@ export type TicketSummary = {
priority: number; priority: number;
category: number; category: number;
subCategory: number; subCategory: number;
installationId: number; installationId: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
installationName: string; installationName: string;
customSubCategory: string | null;
customCategory: string | null;
}; };
export type AdminUser = { export type AdminUser = {
@ -203,3 +276,25 @@ 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';
}