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
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning();
var lang = user.Language ?? "en";
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
return ticket;
}
@ -2112,7 +2113,7 @@ public class Controller : ControllerBase
var ticket = Db.GetTicketById(id);
if (ticket is null) return NotFound();
var installation = Db.GetInstallationById(ticket.InstallationId);
var installation = ticket.InstallationId.HasValue ? Db.GetInstallationById(ticket.InstallationId.Value) : null;
var creator = Db.GetUserById(ticket.CreatedByUserId);
var assignee = ticket.AssigneeId.HasValue ? Db.GetUserById(ticket.AssigneeId) : null;
@ -2122,9 +2123,10 @@ public class Controller : ControllerBase
comments = Db.GetCommentsForTicket(id),
diagnosis = Db.GetDiagnosisForTicket(id),
timeline = Db.GetTimelineForTicket(id),
installationName = installation?.InstallationName ?? $"#{ticket.InstallationId}",
creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}",
assigneeName = assignee?.Name
installationName = installation?.Name ?? (ticket.InstallationId.HasValue ? $"#{ticket.InstallationId}" : "No installation"),
installationProduct = installation?.Product,
creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}",
assigneeName = assignee?.Name
};
}
@ -2137,18 +2139,37 @@ public class Controller : ControllerBase
var tickets = Db.GetAllTickets();
var summaries = tickets.Select(t =>
{
var installation = Db.GetInstallationById(t.InstallationId);
var installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null;
return new
{
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
t.InstallationId, t.CreatedAt, t.UpdatedAt,
installationName = installation?.InstallationName ?? $"#{t.InstallationId}"
t.CustomSubCategory, t.CustomCategory,
installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation")
};
});
return Ok(summaries);
}
[HttpGet(nameof(GetCustomSubCategories))]
public ActionResult<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))]
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 TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 }
public enum TicketCategory { Hardware = 0, Software = 1, Network = 2, UserAccess = 3, Firmware = 4 }
public enum TicketCategory
{
Hardware = 0,
Software = 1,
// Network = 2 removed — value reserved for legacy data
UserAccess = 3,
Firmware = 4,
Configuration = 5,
Other = 6
}
public enum TicketSubCategory
{
General = 0,
Other = 99,
General = 0, // legacy only — not offered for new tickets
OtherLegacy = 99, // legacy catch-all — not offered for new tickets
// Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, PV = 105,
HardwareOther = 199,
// Software (2xx)
Backend = 200, Frontend = 201, Database = 202, Api = 203,
// Network (3xx)
Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203,
SoftwareOther = 299,
// Network (3xx) — legacy, not offered for new tickets
Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx)
Permissions = 400, Login = 401,
Login = 400, Permissions = 401,
UserAccessOther = 499,
// Firmware (5xx)
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502,
ExternalEmsFirmware = 503,
FirmwareOther = 599,
// Configuration (6xx)
BMS = 600, ConfigMonitor = 601, ExternalEMS = 602,
ConfigurationOther = 699
}
public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 }
public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 }
public class Ticket
{
@ -34,10 +59,10 @@ public class Ticket
[Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open;
public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium;
public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware;
public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.General;
public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.Battery;
public Int32 Source { get; set; } = (Int32)TicketSource.Manual;
[Indexed] public Int64 InstallationId { get; set; }
[Indexed] public Int64? InstallationId { get; set; }
public Int64? AssigneeId { get; set; }
[Indexed] public Int64 CreatedByUserId { get; set; }
@ -49,4 +74,7 @@ public class Ticket
public String? RootCause { get; set; }
public String? Solution { get; set; }
public Boolean PreFilledFromAi { get; set; }
public String? CustomSubCategory { get; set; }
public String? CustomCategory { get; set; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,75 +16,143 @@ export enum TicketPriority {
export enum TicketCategory {
Hardware = 0,
Software = 1,
Network = 2,
// Network = 2 removed — value reserved for legacy data
UserAccess = 3,
Firmware = 4
Firmware = 4,
Configuration = 5,
Other = 6
}
export enum TicketSubCategory {
General = 0,
Other = 99,
General = 0, // legacy only
OtherLegacy = 99, // legacy catch-all
// Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
Metering = 104, PV = 105,
HardwareOther = 199,
// Software (2xx)
Backend = 200, Frontend = 201, Database = 202, Api = 203,
// Network (3xx)
Monitor = 200, ControllerService = 201, ModbusTcpService = 202, EMS = 203,
SoftwareOther = 299,
// Network (3xx) — legacy
Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx)
Permissions = 400, Login = 401,
Login = 400, Permissions = 401,
UserAccessOther = 499,
// Firmware (5xx)
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502,
ExternalEmsFirmware = 503,
FirmwareOther = 599,
// Configuration (6xx)
BMS = 600, ConfigMonitor = 601, ExternalEMS = 602,
ConfigurationOther = 699
}
// Display labels for all subcategories (including legacy ones for backward compat)
export const subCategoryLabels: Record<number, string> = {
[TicketSubCategory.General]: 'General',
[TicketSubCategory.Other]: 'Other',
[TicketSubCategory.Battery]: 'Battery', [TicketSubCategory.Inverter]: 'Inverter',
[TicketSubCategory.Cable]: 'Cable', [TicketSubCategory.Gateway]: 'Gateway',
[TicketSubCategory.Metering]: 'Metering', [TicketSubCategory.Cooling]: 'Cooling',
[TicketSubCategory.PvSolar]: 'PV / Solar', [TicketSubCategory.Safety]: 'Safety',
[TicketSubCategory.Backend]: 'Backend', [TicketSubCategory.Frontend]: 'Frontend',
[TicketSubCategory.Database]: 'Database', [TicketSubCategory.Api]: 'API',
[TicketSubCategory.Connectivity]: 'Connectivity', [TicketSubCategory.VpnAccess]: 'VPN Access',
[TicketSubCategory.OtherLegacy]: 'Other',
[TicketSubCategory.Battery]: 'Battery',
[TicketSubCategory.Inverter]: 'Inverter',
[TicketSubCategory.Cable]: 'Cable',
[TicketSubCategory.Gateway]: 'Gateway',
[TicketSubCategory.Metering]: 'Metering',
[TicketSubCategory.PV]: 'PV',
[TicketSubCategory.HardwareOther]: 'Other',
[TicketSubCategory.Monitor]: 'Monitor',
[TicketSubCategory.ControllerService]: 'Controller Service',
[TicketSubCategory.ModbusTcpService]: 'Modbus TCP Service',
[TicketSubCategory.EMS]: 'EMS',
[TicketSubCategory.SoftwareOther]: 'Other',
[TicketSubCategory.Connectivity]: 'Connectivity',
[TicketSubCategory.VpnAccess]: 'VPN Access',
[TicketSubCategory.S3Storage]: 'S3 Storage',
[TicketSubCategory.Permissions]: 'Permissions', [TicketSubCategory.Login]: 'Login',
[TicketSubCategory.Login]: 'Login',
[TicketSubCategory.Permissions]: 'Permissions',
[TicketSubCategory.UserAccessOther]: 'Other',
[TicketSubCategory.BatteryFirmware]: 'Battery Firmware',
[TicketSubCategory.InverterFirmware]: 'Inverter Firmware',
[TicketSubCategory.ControllerFirmware]: 'Controller Firmware'
[TicketSubCategory.ControllerFirmware]: 'Controller Firmware',
[TicketSubCategory.ExternalEmsFirmware]: 'External EMS Firmware',
[TicketSubCategory.FirmwareOther]: 'Other',
[TicketSubCategory.BMS]: 'BMS',
[TicketSubCategory.ConfigMonitor]: 'Monitor',
[TicketSubCategory.ExternalEMS]: 'External EMS',
[TicketSubCategory.ConfigurationOther]: 'Other'
};
export const subCategoryKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketSubCategory.General]: { id: 'subCatGeneral', defaultMessage: 'General' },
[TicketSubCategory.Other]: { id: 'subCatOther', defaultMessage: 'Other' },
[TicketSubCategory.Battery]: { id: 'subCatBattery', defaultMessage: 'Battery' },
[TicketSubCategory.Inverter]: { id: 'subCatInverter', defaultMessage: 'Inverter' },
[TicketSubCategory.Cable]: { id: 'subCatCable', defaultMessage: 'Cable' },
[TicketSubCategory.Gateway]: { id: 'subCatGateway', defaultMessage: 'Gateway' },
[TicketSubCategory.Metering]: { id: 'subCatMetering', defaultMessage: 'Metering' },
[TicketSubCategory.Cooling]: { id: 'subCatCooling', defaultMessage: 'Cooling' },
[TicketSubCategory.PvSolar]: { id: 'subCatPvSolar', defaultMessage: 'PV / Solar' },
[TicketSubCategory.Safety]: { id: 'subCatSafety', defaultMessage: 'Safety' },
[TicketSubCategory.Backend]: { id: 'subCatBackend', defaultMessage: 'Backend' },
[TicketSubCategory.Frontend]: { id: 'subCatFrontend', defaultMessage: 'Frontend' },
[TicketSubCategory.Database]: { id: 'subCatDatabase', defaultMessage: 'Database' },
[TicketSubCategory.Api]: { id: 'subCatApi', defaultMessage: 'API' },
[TicketSubCategory.Connectivity]: { id: 'subCatConnectivity', defaultMessage: 'Connectivity' },
[TicketSubCategory.VpnAccess]: { id: 'subCatVpnAccess', defaultMessage: 'VPN Access' },
[TicketSubCategory.S3Storage]: { id: 'subCatS3Storage', defaultMessage: 'S3 Storage' },
[TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' },
[TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' },
[TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' },
[TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' },
[TicketSubCategory.ControllerFirmware]: { id: 'subCatControllerFirmware', defaultMessage: 'Controller Firmware' }
[TicketSubCategory.General]: { id: 'subCatGeneral', defaultMessage: 'General' },
[TicketSubCategory.OtherLegacy]: { id: 'subCatOtherLegacy', defaultMessage: 'Other' },
[TicketSubCategory.Battery]: { id: 'subCatBattery', defaultMessage: 'Battery' },
[TicketSubCategory.Inverter]: { id: 'subCatInverter', defaultMessage: 'Inverter' },
[TicketSubCategory.Cable]: { id: 'subCatCable', defaultMessage: 'Cable' },
[TicketSubCategory.Gateway]: { id: 'subCatGateway', defaultMessage: 'Gateway' },
[TicketSubCategory.Metering]: { id: 'subCatMetering', defaultMessage: 'Metering' },
[TicketSubCategory.PV]: { id: 'subCatPV', defaultMessage: 'PV' },
[TicketSubCategory.HardwareOther]: { id: 'subCatHardwareOther', defaultMessage: 'Other' },
[TicketSubCategory.Monitor]: { id: 'subCatMonitor', defaultMessage: 'Monitor' },
[TicketSubCategory.ControllerService]: { id: 'subCatControllerService', defaultMessage: 'Controller Service' },
[TicketSubCategory.ModbusTcpService]: { id: 'subCatModbusTcpService', defaultMessage: 'Modbus TCP Service' },
[TicketSubCategory.EMS]: { id: 'subCatEMS', defaultMessage: 'EMS' },
[TicketSubCategory.SoftwareOther]: { id: 'subCatSoftwareOther', defaultMessage: 'Other' },
[TicketSubCategory.Connectivity]: { id: 'subCatConnectivity', defaultMessage: 'Connectivity' },
[TicketSubCategory.VpnAccess]: { id: 'subCatVpnAccess', defaultMessage: 'VPN Access' },
[TicketSubCategory.S3Storage]: { id: 'subCatS3Storage', defaultMessage: 'S3 Storage' },
[TicketSubCategory.Login]: { id: 'subCatLogin', defaultMessage: 'Login' },
[TicketSubCategory.Permissions]: { id: 'subCatPermissions', defaultMessage: 'Permissions' },
[TicketSubCategory.UserAccessOther]: { id: 'subCatUserAccessOther', defaultMessage: 'Other' },
[TicketSubCategory.BatteryFirmware]: { id: 'subCatBatteryFirmware', defaultMessage: 'Battery Firmware' },
[TicketSubCategory.InverterFirmware]: { id: 'subCatInverterFirmware', defaultMessage: 'Inverter Firmware' },
[TicketSubCategory.ControllerFirmware]: { id: 'subCatControllerFirmware', defaultMessage: 'Controller Firmware' },
[TicketSubCategory.ExternalEmsFirmware]: { id: 'subCatExternalEmsFirmware', defaultMessage: 'External EMS Firmware' },
[TicketSubCategory.FirmwareOther]: { id: 'subCatFirmwareOther', defaultMessage: 'Other' },
[TicketSubCategory.BMS]: { id: 'subCatBMS', defaultMessage: 'BMS' },
[TicketSubCategory.ConfigMonitor]: { id: 'subCatConfigMonitor', defaultMessage: 'Monitor' },
[TicketSubCategory.ExternalEMS]: { id: 'subCatExternalEMS', defaultMessage: 'External EMS' },
[TicketSubCategory.ConfigurationOther]: { id: 'subCatConfigurationOther', defaultMessage: 'Other' }
};
// Active subcategories per category (for new ticket creation — no legacy entries)
export const subCategoriesByCategory: Record<number, number[]> = {
[TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99],
[TicketCategory.Software]: [0, 200, 201, 202, 203, 99],
[TicketCategory.Network]: [0, 300, 301, 302, 99],
[TicketCategory.UserAccess]: [0, 400, 401, 99],
[TicketCategory.Firmware]: [0, 500, 501, 502, 99]
[TicketCategory.Hardware]: [100, 101, 102, 103, 104, 105, 199],
[TicketCategory.Software]: [200, 201, 202, 203, 299],
[TicketCategory.UserAccess]: [400, 401, 499],
[TicketCategory.Firmware]: [500, 501, 502, 503, 599],
[TicketCategory.Configuration]: [600, 601, 602, 699]
// TicketCategory.Other (6) has no predefined subcategories — uses free-text only
};
// "Other" subcategory values per category — triggers free-text input
export const otherSubCategoryValues = new Set([199, 299, 499, 599, 699]);
// Category display labels (active categories only — used in dropdowns)
export const categoryLabels: Record<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 {
@ -125,7 +193,7 @@ export type Ticket = {
category: number;
subCategory: number;
source: number;
installationId: number;
installationId: number | null;
assigneeId: number | null;
createdByUserId: number;
tags: string;
@ -135,6 +203,8 @@ export type Ticket = {
rootCause: string | null;
solution: string | null;
preFilledFromAi: boolean;
customSubCategory: string | null;
customCategory: string | null;
};
export type TicketComment = {
@ -176,6 +246,7 @@ export type TicketDetail = {
diagnosis: TicketAiDiagnosis | null;
timeline: TicketTimelineEvent[];
installationName: string;
installationProduct: number | null;
creatorName: string;
assigneeName: string | null;
};
@ -187,10 +258,12 @@ export type TicketSummary = {
priority: number;
category: number;
subCategory: number;
installationId: number;
installationId: number | null;
createdAt: string;
updatedAt: string;
installationName: string;
customSubCategory: string | null;
customCategory: string | null;
};
export type AdminUser = {
@ -203,3 +276,25 @@ export enum DiagnosisFeedback {
Rejected = 1,
Overridden = 2
}
// Helper: get display label for a subcategory, preferring custom label when available
export function getSubCategoryDisplayLabel(
subCategory: number,
customSubCategory: string | null | undefined
): string {
if (otherSubCategoryValues.has(subCategory) && customSubCategory) {
return customSubCategory;
}
return subCategoryLabels[subCategory] ?? 'Unknown';
}
// Helper: get display label for a category, preferring custom label when available
export function getCategoryDisplayLabel(
category: number,
customCategory: string | null | undefined
): string {
if (category === TicketCategory.Other && customCategory) {
return customCategory;
}
return categoryLabels[category] ?? legacyCategoryLabels[category] ?? 'Unknown';
}