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:
parent
3b6a16f2f8
commit
876a82bf82
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"+
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue