Compare commits

..

No commits in common. "ac21c46c0e513e6141622bd2176476d991663722" and "69148410f214c39c352214c7c383b7e89e5c5104" have entirely different histories.

34 changed files with 403 additions and 3349 deletions

View File

@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git checkout feature/ticket-dashboard)",
"Bash(npx react-scripts build)"
]
}
}

View File

@ -1309,14 +1309,20 @@ public class Controller : ControllerBase
Int64 installationId, Installation installation, Int64 installationId, Installation installation,
DateOnly fromDate, DateOnly toDate) DateOnly fromDate, DateOnly toDate)
{ {
var jsonDir = Path.Combine(
Environment.CurrentDirectory, "tmp_report", "aggregated", installationId.ToString());
for (var date = fromDate; date <= toDate; date = date.AddDays(1)) for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{ {
var isoDate = date.ToString("yyyy-MM-dd"); var isoDate = date.ToString("yyyy-MM-dd");
var fileName = AggregatedJsonParser.ToJsonFileName(date);
if (Db.DailyRecordExists(installationId, isoDate)) // Try local file first
continue; var localPath = Path.Combine(jsonDir, fileName);
String? content = System.IO.File.Exists(localPath) ? System.IO.File.ReadAllText(localPath) : null;
var content = AggregatedJsonParser.TryReadFromS3(installation, isoDate) // Try S3 if no local file
content ??= AggregatedJsonParser.TryReadFromS3(installation, isoDate)
.GetAwaiter().GetResult(); .GetAwaiter().GetResult();
if (content is null) continue; if (content is null) continue;
@ -2004,21 +2010,10 @@ public class Controller : ControllerBase
var existing = Db.GetTicketById(ticket.Id); var existing = Db.GetTicketById(ticket.Id);
if (existing is null) return NotFound(); if (existing is null) return NotFound();
// Enforce resolution when resolving
if (ticket.Status == (Int32)TicketStatus.Resolved
&& (String.IsNullOrWhiteSpace(ticket.RootCause) || String.IsNullOrWhiteSpace(ticket.Solution)))
{
return BadRequest("Root Cause and Solution are required to resolve a ticket.");
}
ticket.CreatedAt = existing.CreatedAt; ticket.CreatedAt = existing.CreatedAt;
ticket.CreatedByUserId = existing.CreatedByUserId; ticket.CreatedByUserId = existing.CreatedByUserId;
ticket.UpdatedAt = DateTime.UtcNow; ticket.UpdatedAt = DateTime.UtcNow;
// Track resolution added
var resolutionAdded = String.IsNullOrWhiteSpace(existing.RootCause)
&& !String.IsNullOrWhiteSpace(ticket.RootCause);
if (ticket.Status != existing.Status) if (ticket.Status != existing.Status)
{ {
if (ticket.Status == (Int32)TicketStatus.Resolved) if (ticket.Status == (Int32)TicketStatus.Resolved)
@ -2035,19 +2030,6 @@ public class Controller : ControllerBase
}); });
} }
if (resolutionAdded)
{
Db.Create(new TicketTimelineEvent
{
TicketId = ticket.Id,
EventType = (Int32)TimelineEventType.ResolutionAdded,
Description = $"Resolution added by {user.Name}.",
ActorType = (Int32)TimelineActorType.Human,
ActorId = user.Id,
CreatedAt = DateTime.UtcNow
});
}
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed."); return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
} }
@ -2112,95 +2094,14 @@ public class Controller : ControllerBase
var ticket = Db.GetTicketById(id); var ticket = Db.GetTicketById(id);
if (ticket is null) return NotFound(); if (ticket is null) return NotFound();
var installation = Db.GetInstallationById(ticket.InstallationId);
var creator = Db.GetUserById(ticket.CreatedByUserId);
var assignee = ticket.AssigneeId.HasValue ? Db.GetUserById(ticket.AssigneeId) : null;
return new return new
{ {
ticket, ticket,
comments = Db.GetCommentsForTicket(id), comments = Db.GetCommentsForTicket(id),
diagnosis = Db.GetDiagnosisForTicket(id), diagnosis = Db.GetDiagnosisForTicket(id),
timeline = Db.GetTimelineForTicket(id), timeline = Db.GetTimelineForTicket(id)
installationName = installation?.InstallationName ?? $"#{ticket.InstallationId}",
creatorName = creator?.Name ?? $"User #{ticket.CreatedByUserId}",
assigneeName = assignee?.Name
}; };
} }
[HttpGet(nameof(GetTicketSummaries))]
public ActionResult<Object> GetTicketSummaries(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var tickets = Db.GetAllTickets();
var summaries = tickets.Select(t =>
{
var installation = Db.GetInstallationById(t.InstallationId);
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}"
};
});
return Ok(summaries);
}
[HttpGet(nameof(GetAdminUsers))]
public ActionResult<IEnumerable<Object>> GetAdminUsers(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Ok(Db.Users
.Where(u => u.UserType == 2)
.Select(u => new { u.Id, u.Name })
.ToList());
}
[HttpPost(nameof(SubmitDiagnosisFeedback))]
public ActionResult SubmitDiagnosisFeedback(Int64 ticketId, Int32 feedback, String? overrideText, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var diagnosis = Db.GetDiagnosisForTicket(ticketId);
if (diagnosis is null) return NotFound();
diagnosis.Feedback = feedback;
diagnosis.OverrideText = overrideText;
if (!Db.Update(diagnosis)) return StatusCode(500, "Failed to save feedback.");
// On Accept: pre-fill ticket resolution from AI (only if not already filled)
if (feedback == (Int32)DiagnosisFeedback.Accepted)
{
var ticket = Db.GetTicketById(ticketId);
if (ticket is not null && String.IsNullOrWhiteSpace(ticket.RootCause))
{
ticket.RootCause = diagnosis.RootCause ?? "";
// RecommendedActions is stored as JSON array — parse to readable text
try
{
var actions = JsonConvert.DeserializeObject<string[]>(diagnosis.RecommendedActions ?? "[]");
ticket.Solution = actions is not null ? String.Join("\n", actions) : "";
}
catch
{
ticket.Solution = diagnosis.RecommendedActions ?? "";
}
ticket.PreFilledFromAi = true;
ticket.UpdatedAt = DateTime.UtcNow;
Db.Update(ticket);
}
}
return Ok();
}
} }

View File

@ -4,24 +4,7 @@ namespace InnovEnergy.App.Backend.DataTypes;
public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 } public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 }
public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 } public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 }
public enum TicketCategory { Hardware = 0, Software = 1, Network = 2, UserAccess = 3, Firmware = 4 } public enum TicketCategory { Hardware = 0, Software = 1, DataApi = 2, UserAccess = 3 }
public enum TicketSubCategory
{
General = 0,
Other = 99,
// Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
// Software (2xx)
Backend = 200, Frontend = 201, Database = 202, Api = 203,
// Network (3xx)
Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx)
Permissions = 400, Login = 401,
// Firmware (5xx)
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502
}
public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 } public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 }
public class Ticket public class Ticket
@ -34,7 +17,6 @@ public class Ticket
[Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open; [Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open;
public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium; public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium;
public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware; public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware;
public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.General;
public Int32 Source { get; set; } = (Int32)TicketSource.Manual; public Int32 Source { get; set; } = (Int32)TicketSource.Manual;
[Indexed] public Int64 InstallationId { get; set; } [Indexed] public Int64 InstallationId { get; set; }
@ -45,8 +27,4 @@ public class Ticket
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; } public DateTime? ResolvedAt { get; set; }
public String? RootCause { get; set; }
public String? Solution { get; set; }
public Boolean PreFilledFromAi { get; set; }
} }

View File

@ -5,8 +5,7 @@ namespace InnovEnergy.App.Backend.DataTypes;
public enum TimelineEventType public enum TimelineEventType
{ {
Created = 0, StatusChanged = 1, Assigned = 2, Created = 0, StatusChanged = 1, Assigned = 2,
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5, CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5
ResolutionAdded = 6
} }
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 } public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }

View File

@ -28,11 +28,6 @@ public class WeeklyReportResponse
public List<DailyEnergyData> DailyData { get; set; } = new(); public List<DailyEnergyData> DailyData { get; set; } = new();
public BehavioralPattern? Behavior { get; set; } public BehavioralPattern? Behavior { get; set; }
public string AiInsight { get; set; } = ""; public string AiInsight { get; set; } = "";
// Data availability — lets UI show which days are missing
public int DaysAvailable { get; set; } // how many of the 7 days have data
public int DaysExpected { get; set; } // 7 (MonSun)
public List<string> MissingDates { get; set; } = new(); // ISO dates with no data
} }
public class WeeklySummary public class WeeklySummary

View File

@ -30,8 +30,8 @@ public static class Program
TicketDiagnosticService.Initialize(); TicketDiagnosticService.Initialize();
NetworkProviderService.Initialize(); NetworkProviderService.Initialize();
AlarmReviewService.StartDailyScheduler(); AlarmReviewService.StartDailyScheduler();
DailyIngestionService.StartScheduler(); // DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
ReportAggregationService.StartScheduler(); // ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
RabbitMqManager.InitializeEnvironment(); RabbitMqManager.InitializeEnvironment();

View File

@ -5,7 +5,7 @@ namespace InnovEnergy.App.Backend.Services;
/// <summary> /// <summary>
/// Ingests daily energy totals into the DailyEnergyRecord SQLite table. /// Ingests daily energy totals into the DailyEnergyRecord SQLite table.
/// Data source priority: JSON (S3) → xlsx fallback. /// Data source priority: JSON (local) → JSON (S3) → xlsx fallback.
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the /// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
/// IngestDailyData API endpoint. /// IngestDailyData API endpoint.
/// </summary> /// </summary>
@ -14,6 +14,9 @@ public static class DailyIngestionService
private static readonly String TmpReportDir = private static readonly String TmpReportDir =
Environment.CurrentDirectory + "/tmp_report/"; Environment.CurrentDirectory + "/tmp_report/";
private static readonly String JsonAggregatedDir =
Environment.CurrentDirectory + "/tmp_report/aggregated/";
private static Timer? _dailyTimer; private static Timer? _dailyTimer;
/// <summary> /// <summary>
@ -50,7 +53,7 @@ public static class DailyIngestionService
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations..."); Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
var installations = Db.Installations var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3) .Where(i => i.Product == (Int32)ProductType.SodioHome)
.ToList(); .ToList();
foreach (var installation in installations) foreach (var installation in installations)
@ -69,7 +72,7 @@ public static class DailyIngestionService
} }
/// <summary> /// <summary>
/// Ingests data for one installation. Tries JSON (S3) and xlsx. /// Ingests data for one installation. Tries JSON (local + S3) and xlsx.
/// Both sources are tried — idempotency checks prevent duplicates. /// Both sources are tried — idempotency checks prevent duplicates.
/// JSON provides recent data; xlsx provides historical data. /// JSON provides recent data; xlsx provides historical data.
/// </summary> /// </summary>
@ -79,60 +82,47 @@ public static class DailyIngestionService
IngestFromXlsx(installationId); IngestFromXlsx(installationId);
} }
/// <summary> private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
/// Ingests S3 JSON data for a specific date range. Used by report services
/// as a fallback when SQLite has no records for the requested period.
/// Idempotent — skips dates already in DB.
/// </summary>
public static async Task IngestDateRangeAsync(Int64 installationId, DateOnly fromDate, DateOnly toDate)
{ {
var installation = Db.GetInstallationById(installationId);
if (installation is null) return;
var newDaily = 0; var newDaily = 0;
var newHourly = 0; var newHourly = 0;
var jsonDir = Path.Combine(JsonAggregatedDir, installationId.ToString());
for (var date = fromDate; date <= toDate; date = date.AddDays(1)) // Collect JSON content from local files
var jsonFiles = Directory.Exists(jsonDir)
? Directory.GetFiles(jsonDir, "*.json")
: Array.Empty<String>();
foreach (var jsonPath in jsonFiles.OrderBy(f => f))
{ {
var isoDate = date.ToString("yyyy-MM-dd"); var content = File.ReadAllText(jsonPath);
if (Db.DailyRecordExists(installationId, isoDate))
continue;
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
if (content is null) continue;
var (d, h) = IngestJsonContent(installationId, content); var (d, h) = IngestJsonContent(installationId, content);
newDaily += d; newDaily += d;
newHourly += h; newHourly += h;
} }
if (newDaily > 0 || newHourly > 0) // Also try S3 for recent days (yesterday + today) if no local files found
Console.WriteLine($"[DailyIngestion] Installation {installationId} (S3 date-range {fromDate:yyyy-MM-dd}{toDate:yyyy-MM-dd}): {newDaily} day(s), {newHourly} hour(s) ingested."); if (jsonFiles.Length == 0)
}
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
{
var newDaily = 0;
var newHourly = 0;
var installation = Db.GetInstallationById(installationId);
if (installation is null) return false;
// Try S3 for recent days (yesterday + today), skip if already in DB
for (var daysBack = 0; daysBack <= 1; daysBack++)
{ {
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack)); var installation = Db.GetInstallationById(installationId);
var isoDate = date.ToString("yyyy-MM-dd"); if (installation is not null)
{
for (var daysBack = 0; daysBack <= 1; daysBack++)
{
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
var isoDate = date.ToString("yyyy-MM-dd");
if (Db.DailyRecordExists(installationId, isoDate)) if (Db.DailyRecordExists(installationId, isoDate))
continue; continue;
var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate); var content = await AggregatedJsonParser.TryReadFromS3(installation, isoDate);
if (content is null) continue; if (content is null) continue;
var (d, h) = IngestJsonContent(installationId, content); var (d, h) = IngestJsonContent(installationId, content);
newDaily += d; newDaily += d;
newHourly += h; newHourly += h;
}
}
} }
if (newDaily > 0 || newHourly > 0) if (newDaily > 0 || newHourly > 0)

View File

@ -9,7 +9,7 @@ public static class ReportAggregationService
{ {
private static Timer? _monthEndTimer; private static Timer? _monthEndTimer;
private static Timer? _yearEndTimer; private static Timer? _yearEndTimer;
private static Timer? _weeklyReportTimer; // private static Timer? _sundayReportTimer;
private const Double ElectricityPriceCHF = 0.39; private const Double ElectricityPriceCHF = 0.39;
private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions"; private static readonly String MistralUrl = "https://api.mistral.ai/v1/chat/completions";
@ -18,42 +18,17 @@ public static class ReportAggregationService
public static void StartScheduler() public static void StartScheduler()
{ {
ScheduleWeeklyReportJob(); // ScheduleSundayWeeklyReport();
ScheduleMonthEndJob(); ScheduleMonthEndJob();
ScheduleYearEndJob(); ScheduleYearEndJob();
Console.WriteLine("[ReportAggregation] Scheduler started."); Console.WriteLine("[ReportAggregation] Scheduler started.");
} }
private static void ScheduleWeeklyReportJob()
{
// Run every Monday at 03:00 UTC — after DailyIngestionService (01:00 UTC)
var now = DateTime.UtcNow;
var daysUntil = ((Int32)DayOfWeek.Monday - (Int32)now.DayOfWeek + 7) % 7;
var nextMon = now.Date.AddDays(daysUntil == 0 && now.Hour >= 3 ? 7 : daysUntil).AddHours(3);
_weeklyReportTimer = new Timer(
_ =>
{
try
{
if (DateTime.UtcNow.DayOfWeek == DayOfWeek.Monday)
RunWeeklyReportGeneration().GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ReportAggregation] Weekly report error: {ex.Message}");
}
},
null, nextMon - now, TimeSpan.FromDays(7));
Console.WriteLine($"[ReportAggregation] Weekly report scheduled (Monday 03:00 UTC). Next run: {nextMon:yyyy-MM-dd HH:mm} UTC");
}
private static void ScheduleMonthEndJob() private static void ScheduleMonthEndJob()
{ {
// Run daily at 04:00 UTC, but only act on the 1st of the month // Run daily at 02:00, but only act on the 1st of the month
var now = DateTime.UtcNow; var now = DateTime.Now;
var next = now.Date.AddHours(4); var next = now.Date.AddHours(2);
if (now >= next) next = next.AddDays(1); if (now >= next) next = next.AddDays(1);
_monthEndTimer = new Timer( _monthEndTimer = new Timer(
@ -61,7 +36,7 @@ public static class ReportAggregationService
{ {
try try
{ {
if (DateTime.UtcNow.Day == 1) if (DateTime.Now.Day == 1)
RunMonthEndAggregation().GetAwaiter().GetResult(); RunMonthEndAggregation().GetAwaiter().GetResult();
} }
catch (Exception ex) catch (Exception ex)
@ -71,14 +46,14 @@ public static class ReportAggregationService
}, },
null, next - now, TimeSpan.FromDays(1)); null, next - now, TimeSpan.FromDays(1));
Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 04:00 UTC, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm} UTC"); Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 02:00, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm}");
} }
private static void ScheduleYearEndJob() private static void ScheduleYearEndJob()
{ {
// Run daily at 05:00 UTC, but only act on Jan 2nd // Run daily at 03:00, but only act on Jan 2nd
var now = DateTime.UtcNow; var now = DateTime.Now;
var next = now.Date.AddHours(5); var next = now.Date.AddHours(3);
if (now >= next) next = next.AddDays(1); if (now >= next) next = next.AddDays(1);
_yearEndTimer = new Timer( _yearEndTimer = new Timer(
@ -86,7 +61,7 @@ public static class ReportAggregationService
{ {
try try
{ {
if (DateTime.UtcNow.Month == 1 && DateTime.UtcNow.Day == 2) if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2)
RunYearEndAggregation().GetAwaiter().GetResult(); RunYearEndAggregation().GetAwaiter().GetResult();
} }
catch (Exception ex) catch (Exception ex)
@ -96,40 +71,99 @@ public static class ReportAggregationService
}, },
null, next - now, TimeSpan.FromDays(1)); null, next - now, TimeSpan.FromDays(1));
Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 05:00 UTC, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm} UTC"); Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 03:00, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm}");
} }
// ── Weekly Report Auto-Generation ───────────────────────────── // ── Sunday Weekly Report Automation ─────────────────────────────
// Generates weekly reports (SundaySaturday) for all SodiStoreHome
// installations every Sunday at 06:00, saves summary to DB, and
// emails the report to all users who have access to the installation.
//
// TODO: uncomment ScheduleSundayWeeklyReport() in StartScheduler() to enable.
private static async Task RunWeeklyReportGeneration() // private static void ScheduleSundayWeeklyReport()
{ // {
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation..."); // // Calculate delay until next Sunday 06:00
// var now = DateTime.Now;
var installations = Db.Installations // var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7;
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3) // var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6);
.ToList(); //
// _sundayReportTimer = new Timer(
var generated = 0; // _ =>
foreach (var installation in installations) // {
{ // try
try // {
{ // if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
var report = await WeeklyReportService.GenerateReportAsync( // RunSundayWeeklyReports().GetAwaiter().GetResult();
installation.Id, installation.InstallationName, "en"); // }
// catch (Exception ex)
SaveWeeklySummary(installation.Id, report, "en"); // {
generated++; // Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}");
// }
Console.WriteLine($"[ReportAggregation] Weekly report generated for installation {installation.Id} ({installation.InstallationName})"); // },
} // null, nextSunday - now, TimeSpan.FromDays(7));
catch (Exception ex) //
{ // Console.WriteLine($"[ReportAggregation] Sunday weekly report scheduled. Next run: {nextSunday:yyyy-MM-dd HH:mm}");
Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}"); // }
} //
} // private static async Task RunSundayWeeklyReports()
// {
Console.WriteLine($"[ReportAggregation] Weekly report generation complete. {generated}/{installations.Count} installations processed."); // Console.WriteLine("[ReportAggregation] Running Sunday weekly report generation...");
} //
// // Find all SodiStoreHome installations
// var installations = Db.Installations
// .Where(i => i.Product == (int)ProductType.SodioHome)
// .ToList();
//
// foreach (var installation in installations)
// {
// try
// {
// // Generate the weekly report (covers last SundaySaturday)
// var report = await WeeklyReportService.GenerateReportAsync(
// installation.Id, installation.InstallationName, "en");
//
// // Save summary to DB for future monthly aggregation
// SaveWeeklySummary(installation.Id, report);
//
// // Email the report to all users who have access to this installation
// var userIds = Db.InstallationAccess
// .Where(a => a.InstallationId == installation.Id)
// .Select(a => a.UserId)
// .ToList();
//
// foreach (var userId in userIds)
// {
// var user = Db.GetUserById(userId);
// if (user == null || String.IsNullOrWhiteSpace(user.Email))
// continue;
//
// try
// {
// var lang = user.Language ?? "en";
// // Regenerate with user's language if different from "en"
// var localizedReport = lang == "en"
// ? report
// : await WeeklyReportService.GenerateReportAsync(
// installation.Id, installation.InstallationName, lang);
//
// await ReportEmailService.SendReportEmailAsync(localizedReport, user.Email, lang);
// Console.WriteLine($"[ReportAggregation] Weekly report emailed to {user.Email} for installation {installation.Id}");
// }
// catch (Exception emailEx)
// {
// Console.Error.WriteLine($"[ReportAggregation] Failed to email {user.Email}: {emailEx.Message}");
// }
// }
// }
// catch (Exception ex)
// {
// Console.Error.WriteLine($"[ReportAggregation] Failed weekly report for installation {installation.Id}: {ex.Message}");
// }
// }
//
// Console.WriteLine("[ReportAggregation] Sunday weekly report generation complete.");
// }
// ── Save Weekly Summary ─────────────────────────────────────────── // ── Save Weekly Summary ───────────────────────────────────────────
@ -301,7 +335,7 @@ public static class ReportAggregationService
private static async Task RunMonthEndAggregation() private static async Task RunMonthEndAggregation()
{ {
var previousMonth = DateTime.UtcNow.AddMonths(-1); var previousMonth = DateTime.Now.AddMonths(-1);
var year = previousMonth.Year; var year = previousMonth.Year;
var month = previousMonth.Month; var month = previousMonth.Month;
var first = new DateOnly(year, month, 1); var first = new DateOnly(year, month, 1);
@ -419,7 +453,7 @@ public static class ReportAggregationService
private static async Task RunYearEndAggregation() private static async Task RunYearEndAggregation()
{ {
var previousYear = DateTime.UtcNow.Year - 1; var previousYear = DateTime.Now.Year - 1;
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}..."); Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");

View File

@ -91,8 +91,7 @@ public static class WeeklyReportService
/// Generates a full weekly report for the given installation. /// Generates a full weekly report for the given installation.
/// Data source priority: /// Data source priority:
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService) /// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
/// 2. S3 fallback — ingests missing dates into SQLite via IngestDateRangeAsync, then retries /// 2. xlsx file fallback (if DB not yet populated for the target week)
/// 3. xlsx file fallback (legacy, if both DB and S3 are empty)
/// Cache is keyed to the calendar week — invalidated when the week changes. /// Cache is keyed to the calendar week — invalidated when the week changes.
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache). /// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
/// </summary> /// </summary>
@ -122,25 +121,15 @@ public static class WeeklyReportService
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun) var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(ToDailyEnergyData).ToList(); .Select(ToDailyEnergyData).ToList();
// 2. S3 fallback: if DB empty, try ingesting from S3 into SQLite, then retry // 2. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
if (currentWeekDays.Count == 0)
{
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, trying S3 ingestion...");
await DailyIngestionService.IngestDateRangeAsync(installationId, prevMon, curSun);
currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
.Select(ToDailyEnergyData).ToList();
previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(ToDailyEnergyData).ToList();
}
// 3. xlsx fallback: if still empty after S3, parse xlsx on the fly (legacy)
if (currentWeekDays.Count == 0) if (currentWeekDays.Count == 0)
{ {
// Only parse xlsx files whose date range overlaps the needed weeks
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun); var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
if (relevantFiles.Count > 0) if (relevantFiles.Count > 0)
{ {
Console.WriteLine($"[WeeklyReportService] S3 empty, falling back to {relevantFiles.Count} xlsx file(s)."); Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList(); var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
currentWeekDays = allDaysParsed currentWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; }) .Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
@ -156,12 +145,11 @@ public static class WeeklyReportService
$"No energy data available for week {curMon:yyyy-MM-dd}{curSun:yyyy-MM-dd}. " + $"No energy data available for week {curMon:yyyy-MM-dd}{curSun:yyyy-MM-dd}. " +
"Upload an xlsx file or wait for daily ingestion."); "Upload an xlsx file or wait for daily ingestion.");
// 4. Load hourly records from SQLite for behavioral analysis // 3. Load hourly records from SQLite for behavioral analysis
// (S3 ingestion above already populated hourly records if available)
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun) var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
.Select(ToHourlyEnergyData).ToList(); .Select(ToHourlyEnergyData).ToList();
// 4b. xlsx fallback for hourly data // 3b. Fallback: if DB empty, parse hourly data from xlsx
if (currentHourlyData.Count == 0) if (currentHourlyData.Count == 0)
{ {
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun); var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
@ -276,24 +264,11 @@ public static class WeeklyReportService
selfSufficiency, totalEnergySaved, totalSavingsCHF, selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language, location, country, region); behavior, installationName, language, location, country, region);
// Compute data availability — which days of the week are missing
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
var missingDates = new List<String>();
if (weekStart.HasValue && weekEnd.HasValue)
{
for (var d = weekStart.Value; d <= weekEnd.Value; d = d.AddDays(1))
{
var iso = d.ToString("yyyy-MM-dd");
if (!availableDates.Contains(iso))
missingDates.Add(iso);
}
}
return new WeeklyReportResponse return new WeeklyReportResponse
{ {
InstallationName = installationName, InstallationName = installationName,
PeriodStart = weekStart?.ToString("yyyy-MM-dd") ?? currentWeekDays.First().Date, PeriodStart = currentWeekDays.First().Date,
PeriodEnd = weekEnd?.ToString("yyyy-MM-dd") ?? currentWeekDays.Last().Date, PeriodEnd = currentWeekDays.Last().Date,
CurrentWeek = currentSummary, CurrentWeek = currentSummary,
PreviousWeek = previousSummary, PreviousWeek = previousSummary,
TotalEnergySaved = totalEnergySaved, TotalEnergySaved = totalEnergySaved,
@ -309,9 +284,6 @@ public static class WeeklyReportService
DailyData = currentWeekDays, DailyData = currentWeekDays,
Behavior = behavior, Behavior = behavior,
AiInsight = aiInsight, AiInsight = aiInsight,
DaysAvailable = currentWeekDays.Count,
DaysExpected = 7,
MissingDates = missingDates,
}; };
} }

View File

@ -23,7 +23,6 @@ import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations
import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations'; import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations';
import { ProductIdContext } from './contexts/ProductIdContextProvider'; import { ProductIdContext } from './contexts/ProductIdContextProvider';
import { TourProvider } from './contexts/TourContext'; import { TourProvider } from './contexts/TourContext';
import Tickets from './content/dashboards/Tickets';
function App() { function App() {
const context = useContext(UserContext); const context = useContext(UserContext);
@ -237,7 +236,6 @@ function App() {
} }
/> />
<Route path={routes.tickets + '*'} element={<Tickets />} />
<Route path={routes.users + '*'} element={<Users />} /> <Route path={routes.users + '*'} element={<Users />} />
<Route <Route
path={'*'} path={'*'}

View File

@ -22,7 +22,5 @@
"history": "history", "history": "history",
"mainstats": "mainstats", "mainstats": "mainstats",
"detailed_view": "detailed_view/", "detailed_view": "detailed_view/",
"report": "report", "report": "report"
"installationTickets": "installationTickets",
"tickets": "/tickets/"
} }

View File

@ -28,7 +28,6 @@ import Topology from '../Topology/Topology';
import BatteryView from '../BatteryView/BatteryView'; import BatteryView from '../BatteryView/BatteryView';
import Configuration from '../Configuration/Configuration'; import Configuration from '../Configuration/Configuration';
import PvView from '../PvView/PvView'; import PvView from '../PvView/PvView';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
interface singleInstallationProps { interface singleInstallationProps {
current_installation?: I_Installation; current_installation?: I_Installation;
@ -382,8 +381,7 @@ function Installation(props: singleInstallationProps) {
currentTab != 'information' && currentTab != 'information' &&
currentTab != 'history' && currentTab != 'history' &&
currentTab != 'manage' && currentTab != 'manage' &&
currentTab != 'log' && currentTab != 'log' && (
currentTab != 'installationTickets' && (
<Container <Container
maxWidth="xl" maxWidth="xl"
sx={{ sx={{
@ -552,17 +550,6 @@ function Installation(props: singleInstallationProps) {
/> />
)} )}
{currentUser.userType == UserType.admin && (
<Route
path={routes.installationTickets}
element={
<InstallationTicketsTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={routes.live}></Navigate>} element={<Navigate to={routes.live}></Navigate>}

View File

@ -32,8 +32,7 @@ function InstallationTabs(props: InstallationTabsProps) {
'information', 'information',
'configuration', 'configuration',
'history', 'history',
'pvview', 'pvview'
'installationTickets'
]; ];
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -166,10 +165,6 @@ function InstallationTabs(props: InstallationTabsProps) {
{ {
value: 'pvview', value: 'pvview',
label: <FormattedMessage id="pvview" defaultMessage="Pv View" /> label: <FormattedMessage id="pvview" defaultMessage="Pv View" />
},
{
value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
} }
] ]
: currentUser.userType == UserType.partner : currentUser.userType == UserType.partner
@ -299,10 +294,6 @@ function InstallationTabs(props: InstallationTabsProps) {
defaultMessage="History Of Actions" defaultMessage="History Of Actions"
/> />
) )
},
{
value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
} }
] ]
: currentUser.userType == UserType.partner : currentUser.userType == UserType.partner

View File

@ -23,7 +23,6 @@ import SalidomoOverview from '../Overview/salidomoOverview';
import { UserType } from '../../../interfaces/UserTypes'; import { UserType } from '../../../interfaces/UserTypes';
import HistoryOfActions from '../History/History'; import HistoryOfActions from '../History/History';
import BuildIcon from '@mui/icons-material/Build'; import BuildIcon from '@mui/icons-material/Build';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import AccessContextProvider from '../../../contexts/AccessContextProvider'; import AccessContextProvider from '../../../contexts/AccessContextProvider';
import Access from '../ManageAccess/Access'; import Access from '../ManageAccess/Access';
@ -325,8 +324,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
currentTab != 'information' && currentTab != 'information' &&
currentTab != 'manage' && currentTab != 'manage' &&
currentTab != 'history' && currentTab != 'history' &&
currentTab != 'log' && currentTab != 'log' && (
currentTab != 'installationTickets' && (
<Container <Container
maxWidth="xl" maxWidth="xl"
sx={{ sx={{
@ -430,17 +428,6 @@ function SalidomoInstallation(props: singleInstallationProps) {
/> />
)} )}
{currentUser.userType == UserType.admin && (
<Route
path={routes.installationTickets}
element={
<InstallationTicketsTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route <Route
path={'*'} path={'*'}
element={<Navigate to={routes.batteryview}></Navigate>} element={<Navigate to={routes.batteryview}></Navigate>}

View File

@ -28,8 +28,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
'manage', 'manage',
'overview', 'overview',
'log', 'log',
'history', 'history'
'installationTickets'
]; ];
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -137,10 +136,6 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
defaultMessage="History Of Actions" defaultMessage="History Of Actions"
/> />
) )
},
{
value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
} }
] ]
: [ : [
@ -222,10 +217,6 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
defaultMessage="History Of Actions" defaultMessage="History Of Actions"
/> />
) )
},
{
value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
} }
] ]
: currentTab != 'list' && : currentTab != 'list' &&

View File

@ -4,7 +4,10 @@ import {
Alert, Alert,
Box, Box,
CircularProgress, CircularProgress,
Container,
Grid,
Paper, Paper,
TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
@ -57,16 +60,23 @@ interface HourlyEnergyRecord {
// ── Date Helpers ───────────────────────────────────────────── // ── Date Helpers ─────────────────────────────────────────────
/** /**
* Returns the Monday of the current week. * Anchor date for the 7-day strip. Returns last completed Sunday.
* To switch to live-data mode later, change to: () => new Date()
*/ */
function getCurrentMonday(): Date { function getDataAnchorDate(): Date {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0);
const dow = today.getDay(); // 0=Sun const dow = today.getDay(); // 0=Sun
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset const lastSunday = new Date(today);
const monday = new Date(today); lastSunday.setDate(today.getDate() - (dow === 0 ? 7 : dow));
monday.setDate(today.getDate() - offset); lastSunday.setHours(0, 0, 0, 0);
return monday; return lastSunday;
}
function getWeekRange(anchor: Date): { monday: Date; sunday: Date } {
const sunday = new Date(anchor);
const monday = new Date(sunday);
monday.setDate(sunday.getDate() - 6);
return { monday, sunday };
} }
function formatDateISO(d: Date): string { function formatDateISO(d: Date): string {
@ -76,101 +86,102 @@ function formatDateISO(d: Date): string {
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
} }
/** function getWeekDays(monday: Date): Date[] {
* Returns current week Monyesterday. Today excluded because return Array.from({ length: 7 }, (_, i) => {
* S3 aggregated file is not available until end of day. const d = new Date(monday);
*/ d.setDate(monday.getDate() + i);
function getCurrentWeekDays(currentMonday: Date): Date[] { return d;
const yesterday = new Date(); });
yesterday.setHours(0, 0, 0, 0);
yesterday.setDate(yesterday.getDate() - 1);
const days: Date[] = [];
for (let d = new Date(currentMonday); d <= yesterday; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days;
} }
// ── Main Component ─────────────────────────────────────────── // ── Main Component ───────────────────────────────────────────
export default function DailySection({ export default function DailySection({
installationId, installationId
onHasData
}: { }: {
installationId: number; installationId: number;
onHasData?: (hasData: boolean) => void;
}) { }) {
const intl = useIntl(); const intl = useIntl();
const currentMonday = useMemo(() => getCurrentMonday(), []); const anchor = useMemo(() => getDataAnchorDate(), []);
const yesterday = useMemo(() => { const { monday, sunday } = useMemo(() => getWeekRange(anchor), [anchor]);
const d = new Date(); const weekDays = useMemo(() => getWeekDays(monday), [monday]);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - 1);
return d;
}, []);
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]); const [weekRecords, setWeekRecords] = useState<DailyEnergyData[]>([]);
const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]); const [weekHourlyRecords, setWeekHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [selectedDate, setSelectedDate] = useState(formatDateISO(yesterday)); const [selectedDate, setSelectedDate] = useState(formatDateISO(sunday));
const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null); const [selectedDayRecord, setSelectedDayRecord] = useState<DailyEnergyData | null>(null);
const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]); const [hourlyRecords, setHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
const [loadingWeek, setLoadingWeek] = useState(false); const [loadingWeek, setLoadingWeek] = useState(false);
const [noData, setNoData] = useState(false); const [noData, setNoData] = useState(false);
// Current week Mon→yesterday only // Fetch week data (daily + hourly) via combined endpoint with xlsx fallback + cache
const weekDays = useMemo(
() => getCurrentWeekDays(currentMonday),
[currentMonday]
);
// Fetch data for current week days
useEffect(() => { useEffect(() => {
if (weekDays.length === 0) return;
const from = formatDateISO(weekDays[0]);
const to = formatDateISO(weekDays[weekDays.length - 1]);
setLoadingWeek(true); setLoadingWeek(true);
axiosConfig axiosConfig
.get('/GetDailyDetailRecords', { .get('/GetDailyDetailRecords', {
params: { installationId, from, to } params: {
installationId,
from: formatDateISO(monday),
to: formatDateISO(sunday)
}
}) })
.then((res) => { .then((res) => {
const daily = res.data?.dailyRecords?.records ?? []; const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? []; const hourly = res.data?.hourlyRecords?.records ?? [];
setAllRecords(Array.isArray(daily) ? daily : []); setWeekRecords(Array.isArray(daily) ? daily : []);
setAllHourlyRecords(Array.isArray(hourly) ? hourly : []); setWeekHourlyRecords(Array.isArray(hourly) ? hourly : []);
onHasData?.(Array.isArray(daily) && daily.length > 0);
}) })
.catch(() => { .catch(() => {
setAllRecords([]); setWeekRecords([]);
setAllHourlyRecords([]); setWeekHourlyRecords([]);
onHasData?.(false);
}) })
.finally(() => setLoadingWeek(false)); .finally(() => setLoadingWeek(false));
}, [installationId, weekDays]); }, [installationId, monday, sunday]);
// When selected date changes, extract data from cache // When selected date changes, extract data from week cache or fetch
useEffect(() => { useEffect(() => {
setNoData(false); setNoData(false);
setSelectedDayRecord(null); setSelectedDayRecord(null);
const cachedDay = allRecords.find((r) => r.date === selectedDate); // Try week cache first
const cachedHours = allHourlyRecords.filter((r) => r.date === selectedDate); const cachedDay = weekRecords.find((r) => r.date === selectedDate);
const cachedHours = weekHourlyRecords.filter((r) => r.date === selectedDate);
if (cachedDay) { if (cachedDay) {
setSelectedDayRecord(cachedDay); setSelectedDayRecord(cachedDay);
setHourlyRecords(cachedHours); setHourlyRecords(cachedHours);
} else if (!loadingWeek) { return;
setNoData(true);
setHourlyRecords([]);
} }
}, [installationId, selectedDate, allRecords, allHourlyRecords, loadingWeek]);
// Not in cache (date picker outside strip) — fetch via combined endpoint
axiosConfig
.get('/GetDailyDetailRecords', {
params: { installationId, from: selectedDate, to: selectedDate }
})
.then((res) => {
const daily = res.data?.dailyRecords?.records ?? [];
const hourly = res.data?.hourlyRecords?.records ?? [];
setHourlyRecords(Array.isArray(hourly) ? hourly : []);
if (Array.isArray(daily) && daily.length > 0) {
setSelectedDayRecord(daily[0]);
} else {
setNoData(true);
}
})
.catch(() => {
setHourlyRecords([]);
setNoData(true);
});
}, [installationId, selectedDate, weekRecords, weekHourlyRecords]);
const record = selectedDayRecord; const record = selectedDayRecord;
const kpis = useMemo(() => computeKPIs(record), [record]); const kpis = useMemo(() => computeKPIs(record), [record]);
const handleDatePicker = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
};
const handleStripSelect = (date: string) => { const handleStripSelect = (date: string) => {
setSelectedDate(date); setSelectedDate(date);
setNoData(false); setNoData(false);
@ -186,37 +197,53 @@ export default function DailySection({
return ( return (
<> <>
{/* Day Strip — current week Mon→yesterday */} {/* Date Picker */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="body1" fontWeight="bold">
<FormattedMessage id="selectDate" defaultMessage="Select Date" />
</Typography>
<TextField
type="date"
size="small"
value={selectedDate}
onChange={handleDatePicker}
inputProps={{ max: formatDateISO(new Date()) }}
sx={{ width: 200 }}
/>
</Box>
{/* 7-Day Strip */}
<DayStrip <DayStrip
weekDays={weekDays} weekDays={weekDays}
weekRecords={weekRecords}
selectedDate={selectedDate} selectedDate={selectedDate}
onSelect={handleStripSelect} onSelect={handleStripSelect}
sunday={sunday}
loading={loadingWeek}
/> />
{/* Loading state */} {/* Loading state */}
{loadingWeek && !record && ( {loadingWeek && !record && (
<Box <Container
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
py: 6 height: '20vh'
}} }}
> >
<CircularProgress size={40} style={{ color: '#ffc04d' }} /> <CircularProgress size={40} style={{ color: '#ffc04d' }} />
</Box> </Container>
)} )}
{/* No data state */} {/* No data state */}
{!loadingWeek && noData && !record && ( {!loadingWeek && noData && !record && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}> <Alert severity="info" sx={{ mb: 3 }}>
<Alert severity="warning"> <FormattedMessage
<FormattedMessage id="noDataForDate"
id="noReportData" defaultMessage="No data available for the selected date."
defaultMessage="No report data found." />
/> </Alert>
</Alert>
</Box>
)} )}
{/* Day detail */} {/* Day detail */}
@ -285,14 +312,25 @@ function computeKPIs(record: DailyEnergyData | null) {
function DayStrip({ function DayStrip({
weekDays, weekDays,
weekRecords,
selectedDate, selectedDate,
onSelect, onSelect,
sunday,
loading
}: { }: {
weekDays: Date[]; weekDays: Date[];
weekRecords: DailyEnergyData[];
selectedDate: string; selectedDate: string;
onSelect: (date: string) => void; onSelect: (date: string) => void;
sunday: Date;
loading: boolean;
}) { }) {
const intl = useIntl(); const intl = useIntl();
const sundayLabel = sunday.toLocaleDateString(intl.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
return ( return (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
@ -301,12 +339,19 @@ function DayStrip({
display: 'flex', display: 'flex',
gap: 1, gap: 1,
overflowX: 'auto', overflowX: 'auto',
pb: 1 pb: 1,
mb: 1
}} }}
> >
{weekDays.map((day) => { {weekDays.map((day) => {
const dateStr = formatDateISO(day); const dateStr = formatDateISO(day);
const isSelected = dateStr === selectedDate; const isSelected = dateStr === selectedDate;
const record = weekRecords.find((r) => r.date === dateStr);
const selfSuff =
record && record.loadConsumption > 0
? Math.min(100, (1 - record.gridImport / record.loadConsumption) * 100)
: null;
return ( return (
<Paper <Paper
key={dateStr} key={dateStr}
@ -330,14 +375,25 @@ function DayStrip({
<Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}> <Typography variant="h6" fontWeight="bold" sx={{ lineHeight: 1.2 }}>
{day.getDate()} {day.getDate()}
</Typography> </Typography>
<Typography
variant="caption"
sx={{ color: selfSuff != null ? '#8e44ad' : '#ccc' }}
>
{loading
? '...'
: selfSuff != null
? `${selfSuff.toFixed(0)}%`
: '—'}
</Typography>
</Paper> </Paper>
); );
})} })}
</Box> </Box>
<Typography variant="caption" sx={{ color: '#888' }}> <Typography variant="caption" sx={{ color: '#888' }}>
<FormattedMessage <FormattedMessage
id="currentWeekHint" id="dataUpTo"
defaultMessage="Current week (Monyesterday)" defaultMessage="Data up to {date}"
values={{ date: sundayLabel }}
/> />
</Typography> </Typography>
</Box> </Box>

View File

@ -28,7 +28,6 @@ import SodistoreHomeConfiguration from './SodistoreHomeConfiguration';
import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
import Overview from '../Overview/overview'; import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport'; import WeeklyReport from './WeeklyReport';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
interface singleInstallationProps { interface singleInstallationProps {
current_installation?: I_Installation; current_installation?: I_Installation;
@ -474,8 +473,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
currentTab != 'manage' && currentTab != 'manage' &&
currentTab != 'history' && currentTab != 'history' &&
currentTab != 'log' && currentTab != 'log' &&
currentTab != 'report' && currentTab != 'report' && (
currentTab != 'installationTickets' && (
<Container <Container
maxWidth="xl" maxWidth="xl"
sx={{ sx={{
@ -609,27 +607,14 @@ function SodioHomeInstallation(props: singleInstallationProps) {
} }
/> />
{props.current_installation.device !== 3 && ( <Route
<Route path={routes.report}
path={routes.report} element={
element={ <WeeklyReport
<WeeklyReport installationId={props.current_installation.id}
installationId={props.current_installation.id} />
/> }
} />
/>
)}
{currentUser.userType == UserType.admin && (
<Route
path={routes.installationTickets}
element={
<InstallationTicketsTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route <Route
path={'*'} path={'*'}

View File

@ -1,4 +1,4 @@
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react'; import { useEffect, useState } from 'react';
import { useIntl, FormattedMessage } from 'react-intl'; import { useIntl, FormattedMessage } from 'react-intl';
import { import {
Accordion, Accordion,
@ -66,9 +66,6 @@ interface WeeklyReportResponse {
gridImportChangePercent: number; gridImportChangePercent: number;
dailyData: DailyEnergyData[]; dailyData: DailyEnergyData[];
aiInsight: string; aiInsight: string;
daysAvailable: number;
daysExpected: number;
missingDates: string[];
} }
interface ReportSummary { interface ReportSummary {
@ -231,12 +228,6 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const [pendingMonths, setPendingMonths] = useState<PendingMonth[]>([]); const [pendingMonths, setPendingMonths] = useState<PendingMonth[]>([]);
const [pendingYears, setPendingYears] = useState<PendingYear[]>([]); const [pendingYears, setPendingYears] = useState<PendingYear[]>([]);
const [generating, setGenerating] = useState<string | null>(null); const [generating, setGenerating] = useState<string | null>(null);
const [selectedMonthlyIdx, setSelectedMonthlyIdx] = useState(0);
const [selectedYearlyIdx, setSelectedYearlyIdx] = useState(0);
const [regenerating, setRegenerating] = useState(false);
const [dailyHasData, setDailyHasData] = useState(false);
const [weeklyHasData, setWeeklyHasData] = useState(false);
const weeklyRef = useRef<WeeklySectionHandle>(null);
const fetchReportData = () => { const fetchReportData = () => {
const lang = intl.locale; const lang = intl.locale;
@ -293,17 +284,8 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const safeTab = Math.min(activeTab, tabs.length - 1); const safeTab = Math.min(activeTab, tabs.length - 1);
const activeTabHasData = (() => {
const key = tabs[safeTab]?.key;
if (key === 'daily') return dailyHasData;
if (key === 'weekly') return weeklyHasData;
if (key === 'monthly') return monthlyReports.length > 0;
if (key === 'yearly') return yearlyReports.length > 0;
return false;
})();
return ( return (
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container"> <Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }} className="report-container">
<style>{` <style>{`
@media print { @media print {
body * { visibility: hidden; } body * { visibility: hidden; }
@ -320,81 +302,45 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
> >
{tabs.map(t => <Tab key={t.key} label={t.label} />)} {tabs.map(t => <Tab key={t.key} label={t.label} />)}
</Tabs> </Tabs>
{activeTabHasData && ( <Button
<Button variant="outlined"
variant="outlined" startIcon={<DownloadIcon />}
startIcon={<DownloadIcon />} onClick={() => window.print()}
onClick={() => window.print()} sx={{ ml: 2, whiteSpace: 'nowrap' }}
sx={{ ml: 2, whiteSpace: 'nowrap' }} >
> <FormattedMessage id="downloadPdf" defaultMessage="Download PDF" />
<FormattedMessage id="downloadPdf" defaultMessage="Download PDF" /> </Button>
</Button>
)}
{tabs[safeTab]?.key !== 'daily' && activeTabHasData && (
<Button
variant="outlined"
disabled={regenerating || generating !== null}
startIcon={(regenerating || generating !== null) ? <CircularProgress size={16} /> : <RefreshIcon />}
onClick={async () => {
const key = tabs[safeTab]?.key;
if (key === 'weekly') {
weeklyRef.current?.regenerate();
} else if (key === 'monthly') {
const r = monthlyReports[selectedMonthlyIdx];
if (r) {
setRegenerating(true);
try { await handleGenerateMonthly(r.year, r.month); } finally { setRegenerating(false); }
}
} else if (key === 'yearly') {
const r = yearlyReports[selectedYearlyIdx];
if (r) {
setRegenerating(true);
try { await handleGenerateYearly(r.year); } finally { setRegenerating(false); }
}
}
}}
sx={{ ml: 1, whiteSpace: 'nowrap' }}
>
<FormattedMessage id="regenerateReport" defaultMessage="Regenerate" />
</Button>
)}
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}> <Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none' }}>
<DailySection installationId={installationId} onHasData={setDailyHasData} /> <DailySection installationId={installationId} />
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}> <Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
<WeeklySection <WeeklySection
ref={weeklyRef}
installationId={installationId} installationId={installationId}
latestMonthlyPeriodEnd={ latestMonthlyPeriodEnd={
monthlyReports.length > 0 monthlyReports.length > 0
? monthlyReports.reduce((a, b) => a.periodEnd > b.periodEnd ? a : b).periodEnd ? monthlyReports.reduce((a, b) => a.periodEnd > b.periodEnd ? a : b).periodEnd
: null : null
} }
onHasData={setWeeklyHasData}
/> />
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}> <Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none' }}>
<MonthlySection <MonthlySection
installationId={installationId} installationId={installationId}
reports={monthlyReports} reports={monthlyReports}
pendingMonths={pendingMonths} pendingMonths={pendingMonths}
generating={generating} generating={generating}
onGenerate={handleGenerateMonthly} onGenerate={handleGenerateMonthly}
selectedIdx={selectedMonthlyIdx}
onSelectedIdxChange={setSelectedMonthlyIdx}
/> />
</Box> </Box>
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}> <Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none' }}>
<YearlySection <YearlySection
installationId={installationId} installationId={installationId}
reports={yearlyReports} reports={yearlyReports}
pendingYears={pendingYears} pendingYears={pendingYears}
generating={generating} generating={generating}
onGenerate={handleGenerateYearly} onGenerate={handleGenerateYearly}
selectedIdx={selectedYearlyIdx}
onSelectedIdxChange={setSelectedYearlyIdx}
/> />
</Box> </Box>
</Box> </Box>
@ -403,12 +349,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
// ── Weekly Section (existing weekly report content) ──────────── // ── Weekly Section (existing weekly report content) ────────────
interface WeeklySectionHandle { function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) {
regenerate: () => void;
}
const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number; latestMonthlyPeriodEnd: string | null; onHasData?: (hasData: boolean) => void }>(
({ installationId, latestMonthlyPeriodEnd, onHasData }, ref) => {
const intl = useIntl(); const intl = useIntl();
const [report, setReport] = useState<WeeklyReportResponse | null>(null); const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -426,21 +367,17 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
params: { installationId, language: intl.locale, forceRegenerate } params: { installationId, language: intl.locale, forceRegenerate }
}); });
setReport(res.data); setReport(res.data);
onHasData?.(true);
} catch (err: any) { } catch (err: any) {
const msg = const msg =
err.response?.data || err.response?.data ||
err.message || err.message ||
intl.formatMessage({ id: 'failedToLoadReport' }); intl.formatMessage({ id: 'failedToLoadReport' });
setError(typeof msg === 'string' ? msg : JSON.stringify(msg)); setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
onHasData?.(false);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useImperativeHandle(ref, () => ({ regenerate: () => fetchReport(true) }));
const handleSendEmail = async (emailAddress: string) => { const handleSendEmail = async (emailAddress: string) => {
await axiosConfig.post('/SendWeeklyReportEmail', null, { await axiosConfig.post('/SendWeeklyReportEmail', null, {
params: { installationId, emailAddress } params: { installationId, emailAddress }
@ -449,30 +386,28 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
if (loading) { if (loading) {
return ( return (
<Box <Container
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
py: 6 height: '50vh'
}} }}
> >
<CircularProgress size={50} style={{ color: '#ffc04d' }} /> <CircularProgress size={50} style={{ color: '#ffc04d' }} />
<Typography variant="body2" mt={2}> <Typography variant="body2" mt={2}>
<FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." /> <FormattedMessage id="generatingReport" defaultMessage="Generating weekly report..." />
</Typography> </Typography>
</Box> </Container>
); );
} }
if (error) { if (error) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}> <Container sx={{ py: 4 }}>
<Alert severity="warning"> <Alert severity="warning"><FormattedMessage id="noReportData" defaultMessage="No report data found." /></Alert>
<FormattedMessage id="noReportData" defaultMessage="No report data found." /> </Container>
</Alert>
</Box>
); );
} }
@ -524,32 +459,30 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
borderRadius: 2 borderRadius: 2
}} }}
> >
<Typography variant="h5" fontWeight="bold"> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" /> <Box>
</Typography> <Typography variant="h5" fontWeight="bold">
<Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}> <FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
{report.installationName} </Typography>
</Typography> <Typography variant="body1" sx={{ opacity: 0.9, mt: 0.5 }}>
<Typography variant="body2" sx={{ opacity: 0.7 }}> {report.installationName}
{report.periodStart} {report.periodEnd} </Typography>
</Typography> <Typography variant="body2" sx={{ opacity: 0.7 }}>
{report.periodStart} {report.periodEnd}
</Typography>
</Box>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
onClick={() => fetchReport(true)}
sx={{ color: '#fff', borderColor: 'rgba(255,255,255,0.5)', '&:hover': { borderColor: '#fff' } }}
>
<FormattedMessage id="regenerateReport" defaultMessage="Regenerate" />
</Button>
</Box>
</Paper> </Paper>
{/* Missing days warning */}
{report.missingDates && report.missingDates.length > 0 && (
<Alert severity="warning" sx={{ mb: 3 }}>
<FormattedMessage
id="missingDaysWarning"
defaultMessage="Data available for {available}/{expected} days. Missing: {dates}"
values={{
available: report.daysAvailable,
expected: report.daysExpected,
dates: report.missingDates.join(', ')
}}
/>
</Alert>
)}
{/* Weekly Insights */} {/* Weekly Insights */}
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}> <Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
@ -685,7 +618,7 @@ const WeeklySection = forwardRef<WeeklySectionHandle, { installationId: number;
/> />
</> </>
); );
}); }
// ── Weekly History (saved weekly reports for current month) ───── // ── Weekly History (saved weekly reports for current month) ─────
@ -809,17 +742,13 @@ function MonthlySection({
reports, reports,
pendingMonths, pendingMonths,
generating, generating,
onGenerate, onGenerate
selectedIdx,
onSelectedIdxChange
}: { }: {
installationId: number; installationId: number;
reports: MonthlyReport[]; reports: MonthlyReport[];
pendingMonths: PendingMonth[]; pendingMonths: PendingMonth[];
generating: string | null; generating: string | null;
onGenerate: (year: number, month: number) => void; onGenerate: (year: number, month: number) => void;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
}) { }) {
const intl = useIntl(); const intl = useIntl();
@ -869,15 +798,12 @@ function MonthlySection({
countFn={(r: MonthlyReport) => r.weekCount} countFn={(r: MonthlyReport) => r.weekCount}
sendEndpoint="/SendMonthlyReportEmail" sendEndpoint="/SendMonthlyReportEmail"
sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })} sendParamsFn={(r: MonthlyReport) => ({ installationId, year: r.year, month: r.month })}
controlledIdx={selectedIdx} onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
onIdxChange={onSelectedIdxChange}
/> />
) : pendingMonths.length === 0 ? ( ) : pendingMonths.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}> <Alert severity="info">
<Alert severity="warning"> <FormattedMessage id="noMonthlyData" defaultMessage="No monthly reports available yet. Weekly reports will appear here for aggregation once generated." />
<FormattedMessage id="noReportData" defaultMessage="No report data found." /> </Alert>
</Alert>
</Box>
) : null} ) : null}
</> </>
); );
@ -890,17 +816,13 @@ function YearlySection({
reports, reports,
pendingYears, pendingYears,
generating, generating,
onGenerate, onGenerate
selectedIdx,
onSelectedIdxChange
}: { }: {
installationId: number; installationId: number;
reports: YearlyReport[]; reports: YearlyReport[];
pendingYears: PendingYear[]; pendingYears: PendingYear[];
generating: string | null; generating: string | null;
onGenerate: (year: number) => void; onGenerate: (year: number) => void;
selectedIdx: number;
onSelectedIdxChange: (idx: number) => void;
}) { }) {
const intl = useIntl(); const intl = useIntl();
@ -950,15 +872,12 @@ function YearlySection({
countFn={(r: YearlyReport) => r.monthCount} countFn={(r: YearlyReport) => r.monthCount}
sendEndpoint="/SendYearlyReportEmail" sendEndpoint="/SendYearlyReportEmail"
sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })} sendParamsFn={(r: YearlyReport) => ({ installationId, year: r.year })}
controlledIdx={selectedIdx} onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
onIdxChange={onSelectedIdxChange}
/> />
) : pendingYears.length === 0 ? ( ) : pendingYears.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}> <Alert severity="info">
<Alert severity="warning"> <FormattedMessage id="noYearlyData" defaultMessage="No yearly reports available yet. Monthly reports will appear here for aggregation once generated." />
<FormattedMessage id="noReportData" defaultMessage="No report data found." /> </Alert>
</Alert>
</Box>
) : null} ) : null}
</> </>
); );
@ -974,8 +893,7 @@ function AggregatedSection<T extends ReportSummary>({
countFn, countFn,
sendEndpoint, sendEndpoint,
sendParamsFn, sendParamsFn,
controlledIdx, onRegenerate
onIdxChange
}: { }: {
reports: T[]; reports: T[];
type: 'monthly' | 'yearly'; type: 'monthly' | 'yearly';
@ -984,24 +902,20 @@ function AggregatedSection<T extends ReportSummary>({
countFn: (r: T) => number; countFn: (r: T) => number;
sendEndpoint: string; sendEndpoint: string;
sendParamsFn: (r: T) => object; sendParamsFn: (r: T) => object;
controlledIdx?: number; onRegenerate?: (r: T) => void | Promise<void>;
onIdxChange?: (idx: number) => void;
}) { }) {
const intl = useIntl(); const intl = useIntl();
const [internalIdx, setInternalIdx] = useState(0); const [selectedIdx, setSelectedIdx] = useState(0);
const selectedIdx = controlledIdx ?? internalIdx; const [regenerating, setRegenerating] = useState(false);
const handleIdxChange = (idx: number) => {
setInternalIdx(idx);
onIdxChange?.(idx);
};
if (reports.length === 0) { if (reports.length === 0) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}> <Alert severity="info">
<Alert severity="warning"> <FormattedMessage
<FormattedMessage id="noReportData" defaultMessage="No report data found." /> id={type === 'monthly' ? 'noMonthlyData' : 'noYearlyData'}
</Alert> defaultMessage={type === 'monthly' ? 'No monthly reports available yet.' : 'No yearly reports available yet.'}
</Box> />
</Alert>
); );
} }
@ -1029,7 +943,7 @@ function AggregatedSection<T extends ReportSummary>({
{reports.length > 1 && ( {reports.length > 1 && (
<Select <Select
value={selectedIdx} value={selectedIdx}
onChange={(e) => handleIdxChange(Number(e.target.value))} onChange={(e) => setSelectedIdx(Number(e.target.value))}
size="small" size="small"
sx={{ minWidth: 200 }} sx={{ minWidth: 200 }}
> >
@ -1038,7 +952,22 @@ function AggregatedSection<T extends ReportSummary>({
))} ))}
</Select> </Select>
)} )}
<Box sx={{ ml: 'auto' }}> <Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
{onRegenerate && (
<Button
variant="outlined"
size="small"
disabled={regenerating}
startIcon={regenerating ? <CircularProgress size={14} /> : <RefreshIcon />}
onClick={async () => {
setRegenerating(true);
try { await onRegenerate(r); } finally { setRegenerating(false); }
}}
sx={{ textTransform: 'none' }}
>
<FormattedMessage id="regenerateReport" defaultMessage="Regenerate" />
</Button>
)}
<EmailBar onSend={handleSendEmail} /> <EmailBar onSend={handleSendEmail} />
</Box> </Box>
</Box> </Box>

View File

@ -31,8 +31,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
'log', 'log',
'history', 'history',
'configuration', 'configuration',
'report', 'report'
'installationTickets'
]; ];
const [currentTab, setCurrentTab] = useState<string>(undefined); const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -160,10 +159,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="Report" defaultMessage="Report"
/> />
) )
},
{
value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
} }
] ]
: currentUser.userType == UserType.partner : currentUser.userType == UserType.partner
@ -236,13 +231,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
currentTab != 'tree' && currentTab != 'tree' &&
!location.pathname.includes('folder'); !location.pathname.includes('folder');
// Determine if current installation is Growatt (device=3) to hide report tab
const currentInstallation = sodiohomeInstallations.find((i) =>
location.pathname.includes(`/${i.id}/`)
);
const isGrowatt = currentInstallation?.device === 3
|| (sodiohomeInstallations.length === 1 && sodiohomeInstallations[0].device === 3);
const tabs = inInstallationView && currentUser.userType == UserType.admin const tabs = inInstallationView && currentUser.userType == UserType.admin
? [ ? [
{ {
@ -315,10 +303,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
defaultMessage="Report" defaultMessage="Report"
/> />
) )
},
{
value: 'installationTickets',
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
} }
] ]
: inInstallationView && currentUser.userType == UserType.partner : inInstallationView && currentUser.userType == UserType.partner
@ -425,9 +409,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
textColor="primary" textColor="primary"
indicatorColor="primary" indicatorColor="primary"
> >
{tabs {tabs.map((tab) => (
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.map((tab) => (
<Tab <Tab
key={tab.value} key={tab.value}
value={tab.value} value={tab.value}
@ -494,9 +476,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
textColor="primary" textColor="primary"
indicatorColor="primary" indicatorColor="primary"
> >
{singleInstallationTabs {singleInstallationTabs.map((tab) => (
.filter((tab) => !(isGrowatt && tab.value === 'report'))
.map((tab) => (
<Tab <Tab
key={tab.value} key={tab.value}
value={tab.value} value={tab.value}

View File

@ -1,276 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Box,
Button,
Card,
CardContent,
CardHeader,
Chip,
CircularProgress,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Typography
} from '@mui/material';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
DiagnosisStatus,
DiagnosisFeedback,
TicketAiDiagnosis
} from 'src/interfaces/TicketTypes';
interface AiDiagnosisPanelProps {
diagnosis: TicketAiDiagnosis | null;
onRefresh: () => void;
}
function getConfidenceColor(
confidence: number
): 'success' | 'warning' | 'error' {
if (confidence >= 0.7) return 'success';
if (confidence >= 0.4) return 'warning';
return 'error';
}
function parseActions(actionsJson: string | null): string[] {
if (!actionsJson) return [];
try {
const parsed = JSON.parse(actionsJson);
return Array.isArray(parsed) ? parsed : [];
} catch {
return actionsJson.split('\n').filter((s) => s.trim());
}
}
const feedbackLabels: Record<number, string> = {
[DiagnosisFeedback.Accepted]: 'Accepted',
[DiagnosisFeedback.Rejected]: 'Rejected',
[DiagnosisFeedback.Overridden]: 'Overridden'
};
function AiDiagnosisPanel({ diagnosis, onRefresh }: AiDiagnosisPanelProps) {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [submittingFeedback, setSubmittingFeedback] = useState(false);
const isPending =
diagnosis &&
(diagnosis.status === DiagnosisStatus.Pending ||
diagnosis.status === DiagnosisStatus.Analyzing);
useEffect(() => {
if (isPending) {
intervalRef.current = setInterval(onRefresh, 5000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPending, onRefresh]);
const handleFeedback = (feedback: DiagnosisFeedback) => {
if (!diagnosis) return;
setSubmittingFeedback(true);
axiosConfig
.post('/SubmitDiagnosisFeedback', null, {
params: {
ticketId: diagnosis.ticketId,
feedback
}
})
.then(() => onRefresh())
.finally(() => setSubmittingFeedback(false));
};
if (!diagnosis) {
return (
<Card>
<CardHeader
title={
<FormattedMessage
id="aiDiagnosis"
defaultMessage="AI Diagnosis"
/>
}
/>
<Divider />
<CardContent>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noDiagnosis"
defaultMessage="No AI diagnosis available."
/>
</Typography>
</CardContent>
</Card>
);
}
if (isPending) {
return (
<Card>
<CardHeader
title={
<FormattedMessage
id="aiDiagnosis"
defaultMessage="AI Diagnosis"
/>
}
/>
<Divider />
<CardContent>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}
>
<CircularProgress size={24} />
<Typography variant="body2">
<FormattedMessage
id="diagnosisAnalyzing"
defaultMessage="AI is analyzing this ticket..."
/>
</Typography>
</Box>
</CardContent>
</Card>
);
}
if (diagnosis.status === DiagnosisStatus.Failed) {
return (
<Card>
<CardHeader
title={
<FormattedMessage
id="aiDiagnosis"
defaultMessage="AI Diagnosis"
/>
}
/>
<Divider />
<CardContent>
<Alert severity="error">
<FormattedMessage
id="diagnosisFailed"
defaultMessage="AI diagnosis failed. Please try again later."
/>
</Alert>
</CardContent>
</Card>
);
}
const actions = parseActions(diagnosis.recommendedActions);
const hasFeedback = diagnosis.feedback != null;
return (
<Card>
<CardHeader
title={
<FormattedMessage id="aiDiagnosis" defaultMessage="AI Diagnosis" />
}
/>
<Divider />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage id="rootCause" defaultMessage="Root Cause" />
</Typography>
<Typography variant="body2">
{diagnosis.rootCause ?? '-'}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage id="confidence" defaultMessage="Confidence" />
</Typography>
{diagnosis.confidence != null ? (
<Chip
label={`${Math.round(diagnosis.confidence * 100)}%`}
color={getConfidenceColor(diagnosis.confidence)}
size="small"
/>
) : (
<Typography variant="body2">-</Typography>
)}
</Box>
{actions.length > 0 && (
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage
id="recommendedActions"
defaultMessage="Recommended Actions"
/>
</Typography>
<List dense disablePadding>
{actions.map((action, i) => (
<ListItem key={i} disableGutters>
<ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircleOutlineIcon
fontSize="small"
color="primary"
/>
</ListItemIcon>
<ListItemText primary={action} />
</ListItem>
))}
</List>
</Box>
)}
<Divider />
{hasFeedback ? (
<Box>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="feedbackSubmitted"
defaultMessage="Feedback: {feedback}"
values={{
feedback: feedbackLabels[diagnosis.feedback!] ?? 'Unknown'
}}
/>
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2">
<FormattedMessage
id="diagnosisFeedbackLabel"
defaultMessage="Was this helpful?"
/>
</Typography>
<Button
size="small"
variant="outlined"
color="success"
startIcon={<ThumbUpIcon />}
disabled={submittingFeedback}
onClick={() => handleFeedback(DiagnosisFeedback.Accepted)}
>
<FormattedMessage id="accept" defaultMessage="Accept" />
</Button>
<Button
size="small"
variant="outlined"
color="error"
startIcon={<ThumbDownIcon />}
disabled={submittingFeedback}
onClick={() => handleFeedback(DiagnosisFeedback.Rejected)}
>
<FormattedMessage id="reject" defaultMessage="Reject" />
</Button>
</Box>
)}
</CardContent>
</Card>
);
}
export default AiDiagnosisPanel;

View File

@ -1,135 +0,0 @@
import React, { useState } from 'react';
import {
Avatar,
Box,
Button,
Card,
CardContent,
CardHeader,
Divider,
TextField,
Typography
} from '@mui/material';
import PersonIcon from '@mui/icons-material/Person';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
interface CommentThreadProps {
ticketId: number;
comments: TicketComment[];
onCommentAdded: () => void;
adminUsers?: AdminUser[];
}
function CommentThread({
ticketId,
comments,
onCommentAdded,
adminUsers = []
}: CommentThreadProps) {
const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const sorted = [...comments].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const handleSubmit = () => {
if (!body.trim()) return;
setSubmitting(true);
axiosConfig
.post('/AddTicketComment', { ticketId, body })
.then(() => {
setBody('');
onCommentAdded();
})
.finally(() => setSubmitting(false));
};
return (
<Card>
<CardHeader
title={
<FormattedMessage id="comments" defaultMessage="Comments" />
}
/>
<Divider />
<CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sorted.length === 0 && (
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noComments"
defaultMessage="No comments yet."
/>
</Typography>
)}
{sorted.map((comment) => {
const isAi = comment.authorType === CommentAuthorType.AiAgent;
return (
<Box
key={comment.id}
sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}
>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: isAi ? 'primary.main' : 'grey.500'
}}
>
{isAi ? (
<SmartToyIcon fontSize="small" />
) : (
<PersonIcon fontSize="small" />
)}
</Avatar>
<Box sx={{ flex: 1 }}>
<Box
sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}
>
<Typography variant="subtitle2">
{isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)}
</Typography>
<Typography variant="caption" color="text.disabled">
{new Date(comment.createdAt).toLocaleString()}
</Typography>
</Box>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{comment.body}
</Typography>
</Box>
</Box>
);
})}
<Divider />
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
multiline
minRows={2}
maxRows={4}
placeholder="Add a comment..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
variant="contained"
onClick={handleSubmit}
disabled={submitting || !body.trim()}
sx={{ alignSelf: 'flex-end' }}
>
<FormattedMessage id="addComment" defaultMessage="Add" />
</Button>
</Box>
</CardContent>
</Card>
);
}
export default CommentThread;

View File

@ -1,368 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
Autocomplete,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
TextField
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
TicketPriority,
TicketCategory,
TicketSubCategory,
subCategoryLabels,
subCategoriesByCategory
} from 'src/interfaces/TicketTypes';
type Installation = {
id: number;
name: string;
device: number;
};
const productOptions = [
{ value: 0, label: 'Salimax' },
{ value: 1, label: 'Salidomo' },
{ value: 2, label: 'Sodistore Home' },
{ value: 3, label: 'Sodistore Max' },
{ value: 4, label: 'Sodistore Grid' }
];
const deviceOptionsByProduct: Record<number, { value: number; label: string }[]> = {
1: [
{ value: 1, label: 'Cerbo' },
{ value: 2, label: 'Venus' }
],
2: [
{ value: 3, label: 'Growatt' },
{ value: 4, label: 'Sinexcel' }
]
};
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;
onCreated: () => void;
defaultInstallationId?: number;
}
function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: Props) {
const [subject, setSubject] = useState('');
const [selectedProduct, setSelectedProduct] = useState<number | ''>('');
const [selectedDevice, setSelectedDevice] = useState<number | ''>('');
const [allInstallations, setAllInstallations] = useState<Installation[]>([]);
const [selectedInstallation, setSelectedInstallation] =
useState<Installation | null>(null);
const [loadingInstallations, setLoadingInstallations] = useState(false);
const [priority, setPriority] = useState<number>(TicketPriority.Medium);
const [category, setCategory] = useState<number>(TicketCategory.Hardware);
const [subCategory, setSubCategory] = useState<number>(
TicketSubCategory.General
);
const [description, setDescription] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const hasDeviceOptions =
selectedProduct !== '' && selectedProduct in deviceOptionsByProduct;
useEffect(() => {
if (selectedProduct === '') {
setAllInstallations([]);
setSelectedInstallation(null);
setSelectedDevice('');
return;
}
setLoadingInstallations(true);
setSelectedInstallation(null);
setSelectedDevice('');
axiosConfig
.get('/GetAllInstallationsFromProduct', {
params: { product: selectedProduct }
})
.then((res) => {
const data = res.data;
if (Array.isArray(data)) {
const mapped = data.map((item: any) => ({
id: item.id,
name: item.name,
device: item.device
}));
setAllInstallations(mapped);
if (defaultInstallationId != null) {
const match = mapped.find((inst: Installation) => inst.id === defaultInstallationId);
if (match) {
setSelectedInstallation(match);
setSelectedDevice(match.device ?? '');
}
}
}
})
.catch(() => setAllInstallations([]))
.finally(() => setLoadingInstallations(false));
}, [selectedProduct]);
useEffect(() => {
if (defaultInstallationId == null || !open) return;
axiosConfig
.get('/GetInstallationById', { params: { id: defaultInstallationId } })
.then((res) => {
const inst = res.data;
if (inst) {
setSelectedProduct(inst.product);
}
})
.catch(() => {});
}, [defaultInstallationId, open]);
useEffect(() => {
if (defaultInstallationId == null) setSelectedInstallation(null);
}, [selectedDevice]);
useEffect(() => {
setSubCategory(TicketSubCategory.General);
}, [category]);
const filteredInstallations = useMemo(() => {
if (!hasDeviceOptions || selectedDevice === '') return allInstallations;
return allInstallations.filter((inst) => inst.device === selectedDevice);
}, [allInstallations, selectedDevice, hasDeviceOptions]);
const installationReady =
selectedProduct !== '' && (!hasDeviceOptions || selectedDevice !== '');
const resetForm = () => {
setSubject('');
setSelectedProduct('');
setSelectedDevice('');
setAllInstallations([]);
setSelectedInstallation(null);
setPriority(TicketPriority.Medium);
setCategory(TicketCategory.Hardware);
setSubCategory(TicketSubCategory.General);
setDescription('');
setError('');
};
const handleSubmit = () => {
if (!subject.trim() || !selectedInstallation) return;
setSubmitting(true);
setError('');
axiosConfig
.post('/CreateTicket', {
subject,
description,
installationId: selectedInstallation.id,
priority,
category,
subCategory
})
.then(() => {
resetForm();
onCreated();
onClose();
})
.catch(() => setError('Failed to create ticket.'))
.finally(() => setSubmitting(false));
};
const availableSubCategories = subCategoriesByCategory[category] ?? [0];
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<FormattedMessage id="createTicket" defaultMessage="Create Ticket" />
</DialogTitle>
<DialogContent
sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}
>
{error && <Alert severity="error">{error}</Alert>}
<TextField
label={<FormattedMessage id="subject" defaultMessage="Subject" />}
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
fullWidth
margin="dense"
/>
<FormControl fullWidth margin="dense">
<InputLabel>
<FormattedMessage id="product" defaultMessage="Product" />
</InputLabel>
<Select
value={selectedProduct}
label="Product"
onChange={(e) =>
setSelectedProduct(
e.target.value === '' ? '' : Number(e.target.value)
)
}
>
{productOptions.map((p) => (
<MenuItem key={p.value} value={p.value}>
{p.label}
</MenuItem>
))}
</Select>
</FormControl>
{hasDeviceOptions && (
<FormControl fullWidth margin="dense">
<InputLabel>
<FormattedMessage id="device" defaultMessage="Device" />
</InputLabel>
<Select
value={selectedDevice}
label="Device"
onChange={(e) =>
setSelectedDevice(
e.target.value === '' ? '' : Number(e.target.value)
)
}
>
{deviceOptionsByProduct[selectedProduct as number].map((d) => (
<MenuItem key={d.value} value={d.value}>
{d.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
<Autocomplete<Installation, false, false, false>
options={filteredInstallations}
getOptionLabel={(opt) => opt.name}
value={selectedInstallation}
onChange={(_e, val) => setSelectedInstallation(val)}
disabled={!installationReady}
loading={loadingInstallations}
renderInput={(params) => (
<TextField
{...params}
label={
<FormattedMessage
id="installation"
defaultMessage="Installation"
/>
}
margin="dense"
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loadingInstallations ? (
<CircularProgress size={20} />
) : null}
{params.InputProps.endAdornment}
</>
)
}}
/>
)}
/>
<FormControl fullWidth margin="dense">
<InputLabel>
<FormattedMessage id="priority" defaultMessage="Priority" />
</InputLabel>
<Select
value={priority}
label="Priority"
onChange={(e) => setPriority(Number(e.target.value))}
>
<MenuItem value={TicketPriority.Critical}>Critical</MenuItem>
<MenuItem value={TicketPriority.High}>High</MenuItem>
<MenuItem value={TicketPriority.Medium}>Medium</MenuItem>
<MenuItem value={TicketPriority.Low}>Low</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth margin="dense">
<InputLabel>
<FormattedMessage id="category" defaultMessage="Category" />
</InputLabel>
<Select
value={category}
label="Category"
onChange={(e) => setCategory(Number(e.target.value))}
>
{Object.entries(categoryLabels).map(([val, label]) => (
<MenuItem key={val} value={Number(val)}>
{label}
</MenuItem>
))}
</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>
<TextField
label={
<FormattedMessage id="description" defaultMessage="Description" />
}
value={description}
onChange={(e) => setDescription(e.target.value)}
multiline
rows={4}
fullWidth
margin="dense"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={
submitting || !subject.trim() || !selectedInstallation
}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
</DialogActions>
</Dialog>
);
}
export default CreateTicketModal;

View File

@ -1,181 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import { Ticket, TicketStatus } from 'src/interfaces/TicketTypes';
import StatusChip from './StatusChip';
import CreateTicketModal from './CreateTicketModal';
interface Props {
installationId: number;
}
const statusCountKeys: {
status: number;
id: string;
defaultMessage: string;
color: string;
}[] = [
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
{ status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
];
function InstallationTicketsTab({ installationId }: Props) {
const navigate = useNavigate();
const intl = useIntl();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const fetchTickets = () => {
axiosConfig
.get('/GetTicketsForInstallation', { params: { installationId } })
.then((res) => {
setTickets(res.data);
setError('');
})
.catch(() => setError('Failed to load tickets.'))
.finally(() => setLoading(false));
};
useEffect(() => {
fetchTickets();
}, [installationId]);
const statusCounts = statusCountKeys.map((s) => ({
...s,
count: tickets.filter((t) => t.status === s.status).length
}));
if (loading) return null;
return (
<Box sx={{ p: 2 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h4">
<FormattedMessage id="tickets" defaultMessage="Tickets" />
</Typography>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
>
<FormattedMessage id="createTicket" defaultMessage="Create Ticket" />
</Button>
</Box>
<Box display="flex" gap={1.5} mb={2} flexWrap="wrap">
{statusCounts.map((s) => (
<Chip
key={s.status}
label={`${intl.formatMessage({ id: s.id, defaultMessage: s.defaultMessage })}: ${s.count}`}
size="small"
sx={{
backgroundColor: s.count > 0 ? s.color : '#e0e0e0',
color: s.count > 0 ? '#fff' : '#757575',
fontWeight: 600
}}
/>
))}
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{tickets.length === 0 && !error ? (
<Typography variant="body2" color="text.secondary">
<FormattedMessage id="noTickets" defaultMessage="No tickets found." />
</Typography>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>
<FormattedMessage id="subject" defaultMessage="Subject" />
</TableCell>
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
<TableCell>
<FormattedMessage id="priority" defaultMessage="Priority" />
</TableCell>
<TableCell>
<FormattedMessage id="createdAt" defaultMessage="Created" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tickets
.sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime()
)
.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({
id: `priority${['Critical', 'High', 'Medium', 'Low'][ticket.priority]}`,
defaultMessage: ['Critical', 'High', 'Medium', 'Low'][
ticket.priority
]
})}
</TableCell>
<TableCell>
{new Date(ticket.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<CreateTicketModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => {
setCreateOpen(false);
fetchTickets();
}}
defaultInstallationId={installationId}
/>
</Box>
);
}
export default InstallationTicketsTab;

View File

@ -1,39 +0,0 @@
import React from 'react';
import { Chip } from '@mui/material';
import { TicketStatus } from 'src/interfaces/TicketTypes';
const statusLabels: Record<number, string> = {
[TicketStatus.Open]: 'Open',
[TicketStatus.InProgress]: 'In Progress',
[TicketStatus.Escalated]: 'Escalated',
[TicketStatus.Resolved]: 'Resolved',
[TicketStatus.Closed]: 'Closed'
};
const statusColors: Record<
number,
'error' | 'warning' | 'info' | 'success' | 'default'
> = {
[TicketStatus.Open]: 'error',
[TicketStatus.InProgress]: 'warning',
[TicketStatus.Escalated]: 'error',
[TicketStatus.Resolved]: 'success',
[TicketStatus.Closed]: 'default'
};
interface StatusChipProps {
status: number;
size?: 'small' | 'medium';
}
function StatusChip({ status, size = 'small' }: StatusChipProps) {
return (
<Chip
label={statusLabels[status] ?? 'Unknown'}
color={statusColors[status] ?? 'default'}
size={size}
/>
);
}
export default StatusChip;

View File

@ -1,732 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CardContent,
CardHeader,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
TextField,
Typography
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
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 {
TicketDetail as TicketDetailType,
TicketStatus,
TicketPriority,
TicketCategory,
TicketSubCategory,
AdminUser,
subCategoryLabels,
subCategoryKeys,
subCategoriesByCategory
} from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer';
import StatusChip from './StatusChip';
import AiDiagnosisPanel from './AiDiagnosisPanel';
import CommentThread from './CommentThread';
import TimelinePanel from './TimelinePanel';
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
[TicketPriority.High]: { id: 'priorityHigh', defaultMessage: 'High' },
[TicketPriority.Medium]: { id: 'priorityMedium', defaultMessage: 'Medium' },
[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' },
{ value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
];
function TicketDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const intl = useIntl();
const [detail, setDetail] = useState<TicketDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [updatingStatus, setUpdatingStatus] = useState(false);
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [updatingAssignee, setUpdatingAssignee] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [rootCause, setRootCause] = useState('');
const [solution, setSolution] = useState('');
const [savingResolution, setSavingResolution] = useState(false);
const [resolutionError, setResolutionError] = useState('');
const [resolutionSaved, setResolutionSaved] = useState(false);
const [description, setDescription] = useState('');
const [editingDescription, setEditingDescription] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const [descriptionSaved, setDescriptionSaved] = useState(false);
const fetchDetail = useCallback(() => {
if (!id) return;
axiosConfig
.get('/GetTicketDetail', { params: { id: Number(id) } })
.then((res) => {
setDetail(res.data);
setRootCause(res.data.ticket.rootCause ?? '');
setSolution(res.data.ticket.solution ?? '');
setDescription(res.data.ticket.description ?? '');
setError('');
})
.catch(() => setError('Failed to load ticket details.'))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchDetail();
axiosConfig
.get('/GetAdminUsers')
.then((res) => setAdminUsers(res.data))
.catch(() => {});
}, [fetchDetail]);
const handleStatusChange = (newStatus: number) => {
if (!detail) return;
if (
newStatus === TicketStatus.Resolved &&
(!rootCause.trim() || !solution.trim())
) {
setResolutionError(
'Root Cause and Solution are required to resolve a ticket.'
);
return;
}
setResolutionError('');
setUpdatingStatus(true);
axiosConfig
.put('/UpdateTicket', {
...detail.ticket,
status: newStatus,
rootCause,
solution
})
.then(() => fetchDetail())
.catch(() => setError('Failed to update status.'))
.finally(() => setUpdatingStatus(false));
};
const handleAssigneeChange = (assigneeId: number | '') => {
if (!detail) return;
setUpdatingAssignee(true);
axiosConfig
.put('/UpdateTicket', {
...detail.ticket,
assigneeId: assigneeId === '' ? null : assigneeId
})
.then(() => fetchDetail())
.catch(() => setError('Failed to update assignee.'))
.finally(() => setUpdatingAssignee(false));
};
const handleTicketFieldChange = (fields: Partial<typeof detail.ticket>) => {
if (!detail) return;
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, ...fields })
.then(() => fetchDetail())
.catch(() => setError('Failed to update ticket.'));
};
const handleDelete = () => {
if (!id) return;
setDeleting(true);
axiosConfig
.delete('/DeleteTicket', { params: { id: Number(id) } })
.then(() => navigate('/tickets'))
.catch(() => {
setError('Failed to delete ticket.');
setDeleting(false);
setDeleteOpen(false);
});
};
const handleSaveDescription = () => {
if (!detail) return;
setSavingDescription(true);
setDescriptionSaved(false);
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, description })
.then(() => {
fetchDetail();
setDescriptionSaved(true);
setEditingDescription(false);
})
.catch(() => setError('Failed to save description.'))
.finally(() => setSavingDescription(false));
};
const handleSaveResolution = () => {
if (!detail) return;
setSavingResolution(true);
setResolutionError('');
setResolutionSaved(false);
axiosConfig
.put('/UpdateTicket', { ...detail.ticket, rootCause, solution })
.then(() => {
fetchDetail();
setResolutionSaved(true);
})
.catch(() => setResolutionError('Failed to save resolution.'))
.finally(() => setSavingResolution(false));
};
if (loading) {
return (
<Container maxWidth="xl" sx={{ mt: 4, textAlign: 'center' }}>
<CircularProgress />
</Container>
);
}
if (error || !detail) {
return (
<Container maxWidth="xl" sx={{ mt: 4 }}>
<Alert severity="error">{error || 'Ticket not found.'}</Alert>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/tickets')}
sx={{ mt: 2 }}
>
<FormattedMessage id="backToTickets" defaultMessage="Back to Tickets" />
</Button>
</Container>
);
}
const { ticket, comments, diagnosis, timeline } = detail;
return (
<div style={{ userSelect: 'none' }}>
<Container maxWidth="xl" sx={{ mt: '20px' }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/tickets')}
>
<FormattedMessage id="backToTickets" defaultMessage="Back to Tickets" />
</Button>
<Button
color="error"
startIcon={<DeleteIcon />}
onClick={() => setDeleteOpen(true)}
>
<FormattedMessage id="deleteTicket" defaultMessage="Delete" />
</Button>
</Box>
<Box mb={3}>
<Typography variant="h3" gutterBottom>
#{ticket.id} {ticket.subject}
</Typography>
<Box display="flex" gap={1} alignItems="center">
<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] ?? ''}`}
</Typography>
</Box>
</Box>
<Grid container spacing={3}>
{/* Left column: Description, AI Diagnosis, Comments */}
<Grid item xs={12} md={8}>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage
id="description"
defaultMessage="Description"
/>
}
action={
!editingDescription && (
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => setEditingDescription(true)}
>
<FormattedMessage id="edit" defaultMessage="Edit" />
</Button>
)
}
/>
<Divider />
<CardContent>
{editingDescription ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
multiline
minRows={3}
fullWidth
value={description}
onChange={(e) => {
setDescription(e.target.value);
setDescriptionSaved(false);
}}
/>
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
{descriptionSaved && (
<Typography variant="body2" color="success.main">
<FormattedMessage id="descriptionSaved" defaultMessage="Description saved." />
</Typography>
)}
<Button
size="small"
onClick={() => {
setEditingDescription(false);
setDescription(ticket.description ?? '');
}}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
variant="contained"
size="small"
onClick={handleSaveDescription}
disabled={savingDescription}
>
<FormattedMessage id="save" defaultMessage="Save" />
</Button>
</Box>
</Box>
) : (
<Typography
variant="body1"
sx={{ whiteSpace: 'pre-wrap' }}
>
{ticket.description || (
<Typography
component="span"
variant="body2"
color="text.secondary"
>
<FormattedMessage
id="noDescription"
defaultMessage="No description provided."
/>
</Typography>
)}
</Typography>
)}
</CardContent>
</Card>
<Box sx={{ mb: 3 }}>
<AiDiagnosisPanel
diagnosis={diagnosis}
onRefresh={fetchDetail}
/>
</Box>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage
id="resolution"
defaultMessage="Resolution"
/>
}
subheader={
detail.ticket.preFilledFromAi ? (
<Typography variant="caption" color="info.main">
<FormattedMessage
id="preFilledFromAi"
defaultMessage="Pre-filled from AI diagnosis"
/>
</Typography>
) : undefined
}
/>
<Divider />
<CardContent
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{ticket.status === TicketStatus.Resolved ||
ticket.status === TicketStatus.Closed ? (
<>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage
id="rootCauseLabel"
defaultMessage="Root Cause"
/>
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{ticket.rootCause || '-'}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" gutterBottom>
<FormattedMessage
id="solutionLabel"
defaultMessage="Solution"
/>
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{ticket.solution || '-'}
</Typography>
</Box>
</>
) : (
<>
<TextField
label={
<FormattedMessage
id="rootCauseLabel"
defaultMessage="Root Cause"
/>
}
multiline
minRows={2}
fullWidth
value={rootCause}
onChange={(e) => setRootCause(e.target.value)}
error={
!!resolutionError && !rootCause.trim()
}
/>
<TextField
label={
<FormattedMessage
id="solutionLabel"
defaultMessage="Solution"
/>
}
multiline
minRows={2}
fullWidth
value={solution}
onChange={(e) => setSolution(e.target.value)}
error={
!!resolutionError && !solution.trim()
}
/>
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
{resolutionSaved && (
<Typography variant="body2" color="success.main">
<FormattedMessage
id="resolutionSaved"
defaultMessage="Resolution saved successfully."
/>
</Typography>
)}
{resolutionError && (
<Typography variant="body2" color="error.main">
{resolutionError}
</Typography>
)}
<Button
variant="contained"
size="small"
onClick={handleSaveResolution}
disabled={
savingResolution ||
(!rootCause.trim() && !solution.trim())
}
>
<FormattedMessage
id="saveResolution"
defaultMessage="Save Resolution"
/>
</Button>
</Box>
</>
)}
</CardContent>
</Card>
<CommentThread
ticketId={ticket.id}
comments={comments}
onCommentAdded={fetchDetail}
adminUsers={adminUsers}
/>
</Grid>
{/* Right column: Status, Assignee, Details, Timeline */}
<Grid item xs={12} md={4}>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage
id="updateStatus"
defaultMessage="Update Status"
/>
}
/>
<Divider />
<CardContent
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="status" defaultMessage="Status" />
</InputLabel>
<Select
value={ticket.status}
label="Status"
onChange={(e) =>
handleStatusChange(Number(e.target.value))
}
disabled={updatingStatus}
>
{statusKeys.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{intl.formatMessage({ id: opt.id, defaultMessage: opt.defaultMessage })}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="assignee" defaultMessage="Assignee" />
</InputLabel>
<Select
value={ticket.assigneeId ?? ''}
label="Assignee"
onChange={(e) =>
handleAssigneeChange(
e.target.value === '' ? '' : Number(e.target.value)
)
}
disabled={updatingAssignee}
>
<MenuItem value="">
<em>
<FormattedMessage
id="unassigned"
defaultMessage="Unassigned"
/>
</em>
</MenuItem>
{adminUsers.map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="priority" defaultMessage="Priority" />
</InputLabel>
<Select
value={ticket.priority}
label="Priority"
onChange={(e) =>
handleTicketFieldChange({ priority: Number(e.target.value) })
}
>
{Object.entries(priorityKeys).map(([value, msg]) => (
<MenuItem key={value} value={Number(value)}>
{intl.formatMessage(msg)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>
<FormattedMessage id="category" defaultMessage="Category" />
</InputLabel>
<Select
value={ticket.category}
label="Category"
onChange={(e) => {
const newCat = Number(e.target.value);
handleTicketFieldChange({
category: newCat,
subCategory: TicketSubCategory.General
});
}}
>
{Object.entries(categoryKeys).map(([value, msg]) => (
<MenuItem key={value} value={Number(value)}>
{intl.formatMessage(msg)}
</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>
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardHeader
title={
<FormattedMessage id="details" defaultMessage="Details" />
}
/>
<Divider />
<CardContent
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="installation"
defaultMessage="Installation"
/>
</Typography>
<Typography variant="body2">
{detail.installationName}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="createdBy"
defaultMessage="Created By"
/>
</Typography>
<Typography variant="body2">
{detail.creatorName}
</Typography>
</Box>
{detail.assigneeName && (
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="assignee"
defaultMessage="Assignee"
/>
</Typography>
<Typography variant="body2">
{detail.assigneeName}
</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="createdAt"
defaultMessage="Created"
/>
</Typography>
<Typography variant="body2">
{new Date(ticket.createdAt).toLocaleString()}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="updatedAt"
defaultMessage="Updated"
/>
</Typography>
<Typography variant="body2">
{new Date(ticket.updatedAt).toLocaleString()}
</Typography>
</Box>
{ticket.resolvedAt && (
<Box>
<Typography variant="caption" color="text.secondary">
<FormattedMessage
id="resolvedAt"
defaultMessage="Resolved"
/>
</Typography>
<Typography variant="body2">
{new Date(ticket.resolvedAt).toLocaleString()}
</Typography>
</Box>
)}
</CardContent>
</Card>
<TimelinePanel events={timeline} />
</Grid>
</Grid>
{/* Delete confirmation dialog */}
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
<DialogTitle>
<FormattedMessage
id="confirmDeleteTicket"
defaultMessage="Delete Ticket?"
/>
</DialogTitle>
<DialogContent>
<DialogContentText>
<FormattedMessage
id="confirmDeleteTicketMessage"
defaultMessage="This will permanently delete this ticket, its comments, AI diagnosis, and timeline. This action cannot be undone."
/>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteOpen(false)} disabled={deleting}>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
<Button
color="error"
variant="contained"
onClick={handleDelete}
disabled={deleting}
>
<FormattedMessage id="deleteTicket" defaultMessage="Delete" />
</Button>
</DialogActions>
</Dialog>
</Container>
<Footer />
</div>
);
}
export default TicketDetailPage;

View File

@ -1,242 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Container,
FormControl,
InputLabel,
MenuItem,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
TicketSummary,
TicketStatus,
TicketPriority,
TicketCategory,
TicketSubCategory,
subCategoryLabels,
subCategoryKeys
} from 'src/interfaces/TicketTypes';
import Footer from 'src/components/Footer';
import CreateTicketModal from './CreateTicketModal';
import StatusChip from './StatusChip';
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
[TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
};
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
[TicketPriority.High]: { id: 'priorityHigh', defaultMessage: 'High' },
[TicketPriority.Medium]: { id: 'priorityMedium', defaultMessage: 'Medium' },
[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();
const [tickets, setTickets] = useState<TicketSummary[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<number | ''>('');
const [createOpen, setCreateOpen] = useState(false);
const [error, setError] = useState('');
const fetchTickets = () => {
axiosConfig
.get('/GetTicketSummaries')
.then((res) => setTickets(res.data))
.catch(() => setError('Failed to load tickets'));
};
useEffect(() => {
fetchTickets();
}, []);
const filtered = tickets
.filter((t) => {
const matchesSearch =
search === '' ||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
t.installationName.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === '' || t.status === statusFilter;
return matchesSearch && matchesStatus;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return (
<div style={{ userSelect: 'none' }}>
<Container maxWidth="xl" sx={{ marginTop: '20px' }}>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb={2}
>
<Typography variant="h3">
<FormattedMessage id="tickets" defaultMessage="Tickets" />
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
>
<FormattedMessage
id="createTicket"
defaultMessage="Create Ticket"
/>
</Button>
</Box>
<Box display="flex" gap={2} mb={2}>
<TextField
size="small"
label={<FormattedMessage id="search" defaultMessage="Search" />}
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 250 }}
/>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>
<FormattedMessage id="status" defaultMessage="Status" />
</InputLabel>
<Select
value={statusFilter}
label="Status"
onChange={(e) =>
setStatusFilter(e.target.value === '' ? '' : Number(e.target.value))
}
>
<MenuItem value="">
<FormattedMessage
id="allStatuses"
defaultMessage="All Statuses"
/>
</MenuItem>
{Object.entries(statusKeys).map(([val, msg]) => (
<MenuItem key={val} value={Number(val)}>
{intl.formatMessage(msg)}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{filtered.length === 0 && !error ? (
<Alert severity="info">
<FormattedMessage
id="noTickets"
defaultMessage="No tickets found."
/>
</Alert>
) : (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>
<FormattedMessage
id="subject"
defaultMessage="Subject"
/>
</TableCell>
<TableCell>
<FormattedMessage
id="status"
defaultMessage="Status"
/>
</TableCell>
<TableCell>
<FormattedMessage
id="priority"
defaultMessage="Priority"
/>
</TableCell>
<TableCell>
<FormattedMessage
id="category"
defaultMessage="Category"
/>
</TableCell>
<TableCell>
<FormattedMessage
id="installation"
defaultMessage="Installation"
/>
</TableCell>
<TableCell>
<FormattedMessage
id="createdAt"
defaultMessage="Created"
/>
</TableCell>
</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>
))}
</TableBody>
</Table>
</TableContainer>
)}
<CreateTicketModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={fetchTickets}
/>
</Container>
<Footer />
</div>
);
}
export default TicketList;

View File

@ -1,106 +0,0 @@
import React from 'react';
import {
Box,
Card,
CardContent,
CardHeader,
Divider,
Typography
} from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import {
TicketTimelineEvent,
TimelineEventType
} from 'src/interfaces/TicketTypes';
const eventTypeKeys: Record<number, { id: string; defaultMessage: string }> = {
[TimelineEventType.Created]: { id: 'timelineCreated', defaultMessage: 'Created' },
[TimelineEventType.StatusChanged]: { id: 'timelineStatusChanged', defaultMessage: 'Status Changed' },
[TimelineEventType.Assigned]: { id: 'timelineAssigned', defaultMessage: 'Assigned' },
[TimelineEventType.CommentAdded]: { id: 'timelineCommentAdded', defaultMessage: 'Comment Added' },
[TimelineEventType.AiDiagnosisAttached]: { id: 'timelineAiDiagnosis', defaultMessage: 'AI Diagnosis' },
[TimelineEventType.Escalated]: { id: 'timelineEscalated', defaultMessage: 'Escalated' },
[TimelineEventType.ResolutionAdded]: { id: 'timelineResolutionAdded', defaultMessage: 'Resolution Added' }
};
const eventTypeColors: Record<number, string> = {
[TimelineEventType.Created]: '#1976d2',
[TimelineEventType.StatusChanged]: '#ed6c02',
[TimelineEventType.Assigned]: '#9c27b0',
[TimelineEventType.CommentAdded]: '#2e7d32',
[TimelineEventType.AiDiagnosisAttached]: '#0288d1',
[TimelineEventType.Escalated]: '#d32f2f',
[TimelineEventType.ResolutionAdded]: '#4caf50'
};
interface TimelinePanelProps {
events: TicketTimelineEvent[];
}
function TimelinePanel({ events }: TimelinePanelProps) {
const intl = useIntl();
const sorted = [...events].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return (
<Card>
<CardHeader
title={
<FormattedMessage id="timeline" defaultMessage="Timeline" />
}
/>
<Divider />
<CardContent>
{sorted.length === 0 ? (
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="noTimelineEvents"
defaultMessage="No events yet."
/>
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{sorted.map((event) => (
<Box
key={event.id}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor:
eventTypeColors[event.eventType] ?? '#757575',
mt: 0.8,
flexShrink: 0
}}
/>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{eventTypeKeys[event.eventType]
? intl.formatMessage(eventTypeKeys[event.eventType])
: 'Event'}
</Typography>
<Typography variant="body2" color="text.secondary">
{event.description}
</Typography>
<Typography variant="caption" color="text.disabled">
{new Date(event.createdAt).toLocaleString()}
</Typography>
</Box>
</Box>
))}
</Box>
)}
</CardContent>
</Card>
);
}
export default TimelinePanel;

View File

@ -1,15 +0,0 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import TicketList from './TicketList';
import TicketDetailPage from './TicketDetail';
function Tickets() {
return (
<Routes>
<Route index element={<TicketList />} />
<Route path=":id" element={<TicketDetailPage />} />
</Routes>
);
}
export default Tickets;

View File

@ -1,205 +0,0 @@
export enum TicketStatus {
Open = 0,
InProgress = 1,
Escalated = 2,
Resolved = 3,
Closed = 4
}
export enum TicketPriority {
Critical = 0,
High = 1,
Medium = 2,
Low = 3
}
export enum TicketCategory {
Hardware = 0,
Software = 1,
Network = 2,
UserAccess = 3,
Firmware = 4
}
export enum TicketSubCategory {
General = 0,
Other = 99,
// Hardware (1xx)
Battery = 100, Inverter = 101, Cable = 102, Gateway = 103,
Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107,
// Software (2xx)
Backend = 200, Frontend = 201, Database = 202, Api = 203,
// Network (3xx)
Connectivity = 300, VpnAccess = 301, S3Storage = 302,
// UserAccess (4xx)
Permissions = 400, Login = 401,
// Firmware (5xx)
BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502
}
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.S3Storage]: 'S3 Storage',
[TicketSubCategory.Permissions]: 'Permissions', [TicketSubCategory.Login]: 'Login',
[TicketSubCategory.BatteryFirmware]: 'Battery Firmware',
[TicketSubCategory.InverterFirmware]: 'Inverter Firmware',
[TicketSubCategory.ControllerFirmware]: 'Controller Firmware'
};
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' }
};
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]
};
export enum TicketSource {
Manual = 0,
AutoAlert = 1,
Email = 2,
Api = 3
}
export enum CommentAuthorType {
Human = 0,
AiAgent = 1
}
export enum DiagnosisStatus {
Pending = 0,
Analyzing = 1,
Completed = 2,
Failed = 3
}
export enum TimelineEventType {
Created = 0,
StatusChanged = 1,
Assigned = 2,
CommentAdded = 3,
AiDiagnosisAttached = 4,
Escalated = 5,
ResolutionAdded = 6
}
export type Ticket = {
id: number;
subject: string;
description: string;
status: number;
priority: number;
category: number;
subCategory: number;
source: number;
installationId: number;
assigneeId: number | null;
createdByUserId: number;
tags: string;
createdAt: string;
updatedAt: string;
resolvedAt: string | null;
rootCause: string | null;
solution: string | null;
preFilledFromAi: boolean;
};
export type TicketComment = {
id: number;
ticketId: number;
authorType: number;
authorId: number | null;
body: string;
createdAt: string;
};
export type TicketAiDiagnosis = {
id: number;
ticketId: number;
status: number;
rootCause: string | null;
confidence: number | null;
recommendedActions: string | null;
similarTicketIds: string | null;
feedback: number | null;
overrideText: string | null;
createdAt: string;
completedAt: string | null;
};
export type TicketTimelineEvent = {
id: number;
ticketId: number;
eventType: number;
description: string;
actorType: number;
actorId: number | null;
createdAt: string;
};
export type TicketDetail = {
ticket: Ticket;
comments: TicketComment[];
diagnosis: TicketAiDiagnosis | null;
timeline: TicketTimelineEvent[];
installationName: string;
creatorName: string;
assigneeName: string | null;
};
export type TicketSummary = {
id: number;
subject: string;
status: number;
priority: number;
category: number;
subCategory: number;
installationId: number;
createdAt: string;
updatedAt: string;
installationName: string;
};
export type AdminUser = {
id: number;
name: string;
};
export enum DiagnosisFeedback {
Accepted = 0,
Rejected = 1,
Overridden = 2
}

View File

@ -101,7 +101,6 @@
"lastSeen": "Zuletzt gesehen", "lastSeen": "Zuletzt gesehen",
"reportTitle": "Wöchentlicher Leistungsbericht", "reportTitle": "Wöchentlicher Leistungsbericht",
"weeklyInsights": "Wöchentliche Einblicke", "weeklyInsights": "Wöchentliche Einblicke",
"missingDaysWarning": "Daten verfügbar für {available}/{expected} Tage. Fehlend: {dates}",
"weeklySavings": "Ihre Einsparungen diese Woche", "weeklySavings": "Ihre Einsparungen diese Woche",
"solarEnergyUsed": "Energie gespart", "solarEnergyUsed": "Energie gespart",
"solarStayedHome": "Solar + Batterie, nicht vom Netz", "solarStayedHome": "Solar + Batterie, nicht vom Netz",
@ -144,9 +143,10 @@
"dailyTab": "Täglich", "dailyTab": "Täglich",
"dailyReportTitle": "Tägliche Energieübersicht", "dailyReportTitle": "Tägliche Energieübersicht",
"dailySummary": "Tagesübersicht", "dailySummary": "Tagesübersicht",
"selectDate": "Datum wählen",
"noDataForDate": "Keine Daten für das gewählte Datum verfügbar.", "noDataForDate": "Keine Daten für das gewählte Datum verfügbar.",
"noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.", "noHourlyData": "Stündliche Daten für diesen Tag nicht verfügbar.",
"currentWeekHint": "Aktuelle Woche (Mogestern)", "dataUpTo": "Daten bis {date}",
"intradayChart": "Tagesverlauf Energiefluss", "intradayChart": "Tagesverlauf Energiefluss",
"batteryPower": "Batterieleistung", "batteryPower": "Batterieleistung",
"batterySoCLabel": "Batterie SoC", "batterySoCLabel": "Batterie SoC",
@ -515,104 +515,5 @@
"tourConfigurationTitle": "Konfiguration", "tourConfigurationTitle": "Konfiguration",
"tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.", "tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.",
"tourHistoryTitle": "Verlauf", "tourHistoryTitle": "Verlauf",
"tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert.", "tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert."
"tickets": "Tickets",
"createTicket": "Ticket erstellen",
"subject": "Betreff",
"description": "Beschreibung",
"priority": "Priorität",
"category": "Kategorie",
"allStatuses": "Alle Status",
"createdAt": "Erstellt",
"noTickets": "Keine Tickets gefunden.",
"backToTickets": "Zurück zu Tickets",
"aiDiagnosis": "KI-Diagnose",
"rootCause": "Ursache",
"confidence": "Zuverlässigkeit",
"recommendedActions": "Empfohlene Massnahmen",
"diagnosisAnalyzing": "KI analysiert dieses Ticket...",
"diagnosisFailed": "KI-Diagnose fehlgeschlagen. Bitte versuchen Sie es später erneut.",
"noDiagnosis": "Keine KI-Diagnose verfügbar.",
"comments": "Kommentare",
"noComments": "Noch keine Kommentare.",
"addComment": "Hinzufügen",
"timeline": "Zeitverlauf",
"noTimelineEvents": "Noch keine Ereignisse.",
"updateStatus": "Status aktualisieren",
"details": "Details",
"createdBy": "Erstellt von",
"updatedAt": "Aktualisiert",
"resolvedAt": "Gelöst",
"noDescription": "Keine Beschreibung vorhanden.",
"assignee": "Zuständig",
"unassigned": "Nicht zugewiesen",
"deleteTicket": "Löschen",
"confirmDeleteTicket": "Ticket löschen?",
"confirmDeleteTicketMessage": "Dieses Ticket wird mit allen Kommentaren, KI-Diagnosen und dem Zeitverlauf dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"diagnosisFeedbackLabel": "War das hilfreich?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Akzeptieren",
"reject": "Ablehnen",
"resolution": "Lösung",
"rootCauseLabel": "Ursache",
"solutionLabel": "Lösung",
"saveResolution": "Lösung speichern",
"preFilledFromAi": "Vorausgefüllt durch KI-Diagnose",
"resolutionRequired": "Ursache und Lösung sind erforderlich, um ein Ticket zu lösen.",
"resolutionSaved": "Lösung erfolgreich gespeichert.",
"statusOpen": "Offen",
"statusInProgress": "In Bearbeitung",
"statusEscalated": "Eskaliert",
"statusResolved": "Gelöst",
"statusClosed": "Geschlossen",
"priorityCritical": "Kritisch",
"priorityHigh": "Hoch",
"priorityMedium": "Mittel",
"priorityLow": "Niedrig",
"categoryHardware": "Hardware",
"categorySoftware": "Software",
"categoryNetwork": "Netzwerk",
"categoryUserAccess": "Benutzerzugang",
"categoryFirmware": "Firmware",
"subCategory": "Unterkategorie",
"edit": "Bearbeiten",
"save": "Speichern",
"descriptionSaved": "Beschreibung gespeichert.",
"subCatGeneral": "Allgemein",
"subCatOther": "Sonstiges",
"subCatBattery": "Batterie",
"subCatInverter": "Wechselrichter",
"subCatCable": "Kabel",
"subCatGateway": "Gateway",
"subCatMetering": "Messung",
"subCatCooling": "Kühlung",
"subCatPvSolar": "PV / Solar",
"subCatSafety": "Sicherheit",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Datenbank",
"subCatApi": "API",
"subCatConnectivity": "Konnektivität",
"subCatVpnAccess": "VPN-Zugang",
"subCatS3Storage": "S3-Speicher",
"subCatPermissions": "Berechtigungen",
"subCatLogin": "Anmeldung",
"subCatBatteryFirmware": "Batterie-Firmware",
"subCatInverterFirmware": "Wechselrichter-Firmware",
"subCatControllerFirmware": "Controller-Firmware",
"timelineCreated": "Erstellt",
"timelineStatusChanged": "Status geändert",
"timelineAssigned": "Zugewiesen",
"timelineCommentAdded": "Kommentar hinzugefügt",
"timelineAiDiagnosis": "KI-Diagnose",
"timelineEscalated": "Eskaliert",
"timelineResolutionAdded": "Lösung hinzugefügt",
"timelineCreatedDesc": "Ticket erstellt von {name}.",
"timelineStatusChangedDesc": "Status geändert auf {status}.",
"timelineAssignedDesc": "Zugewiesen an {name}.",
"timelineCommentAddedDesc": "Kommentar hinzugefügt von {name}.",
"timelineAiDiagnosisCompletedDesc": "KI-Diagnose abgeschlossen.",
"timelineAiDiagnosisFailedDesc": "KI-Diagnose fehlgeschlagen.",
"timelineEscalatedDesc": "Ticket eskaliert.",
"timelineResolutionAddedDesc": "Lösung hinzugefügt von {name}."
} }

View File

@ -83,7 +83,6 @@
"lastSeen": "Last seen", "lastSeen": "Last seen",
"reportTitle": "Weekly Performance Report", "reportTitle": "Weekly Performance Report",
"weeklyInsights": "Weekly Insights", "weeklyInsights": "Weekly Insights",
"missingDaysWarning": "Data available for {available}/{expected} days. Missing: {dates}",
"weeklySavings": "Your Savings This Week", "weeklySavings": "Your Savings This Week",
"solarEnergyUsed": "Energy Saved", "solarEnergyUsed": "Energy Saved",
"solarStayedHome": "solar + battery, not bought from grid", "solarStayedHome": "solar + battery, not bought from grid",
@ -126,9 +125,10 @@
"dailyTab": "Daily", "dailyTab": "Daily",
"dailyReportTitle": "Daily Energy Summary", "dailyReportTitle": "Daily Energy Summary",
"dailySummary": "Daily Summary", "dailySummary": "Daily Summary",
"selectDate": "Select Date",
"noDataForDate": "No data available for the selected date.", "noDataForDate": "No data available for the selected date.",
"noHourlyData": "Hourly data not available for this day.", "noHourlyData": "Hourly data not available for this day.",
"currentWeekHint": "Current week (Monyesterday)", "dataUpTo": "Data up to {date}",
"intradayChart": "Intraday Power Flow", "intradayChart": "Intraday Power Flow",
"batteryPower": "Battery Power", "batteryPower": "Battery Power",
"batterySoCLabel": "Battery SoC", "batterySoCLabel": "Battery SoC",
@ -263,104 +263,5 @@
"tourConfigurationTitle": "Configuration", "tourConfigurationTitle": "Configuration",
"tourConfigurationContent": "View and modify device settings for this installation.", "tourConfigurationContent": "View and modify device settings for this installation.",
"tourHistoryTitle": "History", "tourHistoryTitle": "History",
"tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when.", "tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when."
"tickets": "Tickets",
"createTicket": "Create Ticket",
"subject": "Subject",
"description": "Description",
"priority": "Priority",
"category": "Category",
"allStatuses": "All Statuses",
"createdAt": "Created",
"noTickets": "No tickets found.",
"backToTickets": "Back to Tickets",
"aiDiagnosis": "AI Diagnosis",
"rootCause": "Root Cause",
"confidence": "Confidence",
"recommendedActions": "Recommended Actions",
"diagnosisAnalyzing": "AI is analyzing this ticket...",
"diagnosisFailed": "AI diagnosis failed. Please try again later.",
"noDiagnosis": "No AI diagnosis available.",
"comments": "Comments",
"noComments": "No comments yet.",
"addComment": "Add",
"timeline": "Timeline",
"noTimelineEvents": "No events yet.",
"updateStatus": "Update Status",
"details": "Details",
"createdBy": "Created By",
"updatedAt": "Updated",
"resolvedAt": "Resolved",
"noDescription": "No description provided.",
"assignee": "Assignee",
"unassigned": "Unassigned",
"deleteTicket": "Delete",
"confirmDeleteTicket": "Delete Ticket?",
"confirmDeleteTicketMessage": "This will permanently delete this ticket, its comments, AI diagnosis, and timeline. This action cannot be undone.",
"diagnosisFeedbackLabel": "Was this helpful?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accept",
"reject": "Reject",
"resolution": "Resolution",
"rootCauseLabel": "Root Cause",
"solutionLabel": "Solution",
"saveResolution": "Save Resolution",
"preFilledFromAi": "Pre-filled from AI diagnosis",
"resolutionRequired": "Root Cause and Solution are required to resolve a ticket.",
"resolutionSaved": "Resolution saved successfully.",
"statusOpen": "Open",
"statusInProgress": "In Progress",
"statusEscalated": "Escalated",
"statusResolved": "Resolved",
"statusClosed": "Closed",
"priorityCritical": "Critical",
"priorityHigh": "High",
"priorityMedium": "Medium",
"priorityLow": "Low",
"categoryHardware": "Hardware",
"categorySoftware": "Software",
"categoryNetwork": "Network",
"categoryUserAccess": "User Access",
"categoryFirmware": "Firmware",
"subCategory": "Sub-Category",
"edit": "Edit",
"save": "Save",
"descriptionSaved": "Description saved.",
"subCatGeneral": "General",
"subCatOther": "Other",
"subCatBattery": "Battery",
"subCatInverter": "Inverter",
"subCatCable": "Cable",
"subCatGateway": "Gateway",
"subCatMetering": "Metering",
"subCatCooling": "Cooling",
"subCatPvSolar": "PV / Solar",
"subCatSafety": "Safety",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Database",
"subCatApi": "API",
"subCatConnectivity": "Connectivity",
"subCatVpnAccess": "VPN Access",
"subCatS3Storage": "S3 Storage",
"subCatPermissions": "Permissions",
"subCatLogin": "Login",
"subCatBatteryFirmware": "Battery Firmware",
"subCatInverterFirmware": "Inverter Firmware",
"subCatControllerFirmware": "Controller Firmware",
"timelineCreated": "Created",
"timelineStatusChanged": "Status Changed",
"timelineAssigned": "Assigned",
"timelineCommentAdded": "Comment Added",
"timelineAiDiagnosis": "AI Diagnosis",
"timelineEscalated": "Escalated",
"timelineResolutionAdded": "Resolution Added",
"timelineCreatedDesc": "Ticket created by {name}.",
"timelineStatusChangedDesc": "Status changed to {status}.",
"timelineAssignedDesc": "Assigned to {name}.",
"timelineCommentAddedDesc": "Comment added by {name}.",
"timelineAiDiagnosisCompletedDesc": "AI diagnosis completed.",
"timelineAiDiagnosisFailedDesc": "AI diagnosis failed.",
"timelineEscalatedDesc": "Ticket escalated.",
"timelineResolutionAddedDesc": "Resolution added by {name}."
} }

View File

@ -95,7 +95,6 @@
"lastSeen": "Dernière connexion", "lastSeen": "Dernière connexion",
"reportTitle": "Rapport de performance hebdomadaire", "reportTitle": "Rapport de performance hebdomadaire",
"weeklyInsights": "Aperçus hebdomadaires", "weeklyInsights": "Aperçus hebdomadaires",
"missingDaysWarning": "Données disponibles pour {available}/{expected} jours. Manquants : {dates}",
"weeklySavings": "Vos économies cette semaine", "weeklySavings": "Vos économies cette semaine",
"solarEnergyUsed": "Énergie économisée", "solarEnergyUsed": "Énergie économisée",
"solarStayedHome": "solaire + batterie, non achetée au réseau", "solarStayedHome": "solaire + batterie, non achetée au réseau",
@ -138,9 +137,10 @@
"dailyTab": "Quotidien", "dailyTab": "Quotidien",
"dailyReportTitle": "Résumé énergétique quotidien", "dailyReportTitle": "Résumé énergétique quotidien",
"dailySummary": "Résumé du jour", "dailySummary": "Résumé du jour",
"selectDate": "Sélectionner la date",
"noDataForDate": "Aucune donnée disponible pour la date sélectionnée.", "noDataForDate": "Aucune donnée disponible pour la date sélectionnée.",
"noHourlyData": "Données horaires non disponibles pour ce jour.", "noHourlyData": "Données horaires non disponibles pour ce jour.",
"currentWeekHint": "Semaine en cours (lunhier)", "dataUpTo": "Données jusqu'au {date}",
"intradayChart": "Flux d'énergie journalier", "intradayChart": "Flux d'énergie journalier",
"batteryPower": "Puissance batterie", "batteryPower": "Puissance batterie",
"batterySoCLabel": "SoC batterie", "batterySoCLabel": "SoC batterie",
@ -515,104 +515,5 @@
"tourConfigurationTitle": "Configuration", "tourConfigurationTitle": "Configuration",
"tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.", "tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.",
"tourHistoryTitle": "Historique", "tourHistoryTitle": "Historique",
"tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand.", "tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand."
"tickets": "Tickets",
"createTicket": "Créer un ticket",
"subject": "Objet",
"description": "Description",
"priority": "Priorité",
"category": "Catégorie",
"allStatuses": "Tous les statuts",
"createdAt": "Créé",
"noTickets": "Aucun ticket trouvé.",
"backToTickets": "Retour aux tickets",
"aiDiagnosis": "Diagnostic IA",
"rootCause": "Cause principale",
"confidence": "Confiance",
"recommendedActions": "Actions recommandées",
"diagnosisAnalyzing": "L'IA analyse ce ticket...",
"diagnosisFailed": "Le diagnostic IA a échoué. Veuillez réessayer plus tard.",
"noDiagnosis": "Aucun diagnostic IA disponible.",
"comments": "Commentaires",
"noComments": "Aucun commentaire pour le moment.",
"addComment": "Ajouter",
"timeline": "Chronologie",
"noTimelineEvents": "Aucun événement pour le moment.",
"updateStatus": "Mettre à jour le statut",
"details": "Détails",
"createdBy": "Créé par",
"updatedAt": "Mis à jour",
"resolvedAt": "Résolu",
"noDescription": "Aucune description fournie.",
"assignee": "Responsable",
"unassigned": "Non assigné",
"deleteTicket": "Supprimer",
"confirmDeleteTicket": "Supprimer le ticket ?",
"confirmDeleteTicketMessage": "Ce ticket sera définitivement supprimé avec tous ses commentaires, diagnostics IA et sa chronologie. Cette action est irréversible.",
"diagnosisFeedbackLabel": "Était-ce utile ?",
"feedbackSubmitted": "Retour : {feedback}",
"accept": "Accepter",
"reject": "Rejeter",
"resolution": "Résolution",
"rootCauseLabel": "Cause principale",
"solutionLabel": "Solution",
"saveResolution": "Enregistrer la résolution",
"preFilledFromAi": "Pré-rempli par le diagnostic IA",
"resolutionRequired": "La cause principale et la solution sont requises pour résoudre un ticket.",
"resolutionSaved": "Résolution enregistrée avec succès.",
"statusOpen": "Ouvert",
"statusInProgress": "En cours",
"statusEscalated": "Escaladé",
"statusResolved": "Résolu",
"statusClosed": "Fermé",
"priorityCritical": "Critique",
"priorityHigh": "Élevée",
"priorityMedium": "Moyenne",
"priorityLow": "Faible",
"categoryHardware": "Matériel",
"categorySoftware": "Logiciel",
"categoryNetwork": "Réseau",
"categoryUserAccess": "Accès utilisateur",
"categoryFirmware": "Firmware",
"subCategory": "Sous-catégorie",
"edit": "Modifier",
"save": "Enregistrer",
"descriptionSaved": "Description enregistrée.",
"subCatGeneral": "Général",
"subCatOther": "Autre",
"subCatBattery": "Batterie",
"subCatInverter": "Onduleur",
"subCatCable": "Câble",
"subCatGateway": "Passerelle",
"subCatMetering": "Comptage",
"subCatCooling": "Refroidissement",
"subCatPvSolar": "PV / Solaire",
"subCatSafety": "Sécurité",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Base de données",
"subCatApi": "API",
"subCatConnectivity": "Connectivité",
"subCatVpnAccess": "Accès VPN",
"subCatS3Storage": "Stockage S3",
"subCatPermissions": "Autorisations",
"subCatLogin": "Connexion",
"subCatBatteryFirmware": "Firmware batterie",
"subCatInverterFirmware": "Firmware onduleur",
"subCatControllerFirmware": "Firmware contrôleur",
"timelineCreated": "Créé",
"timelineStatusChanged": "Statut modifié",
"timelineAssigned": "Assigné",
"timelineCommentAdded": "Commentaire ajouté",
"timelineAiDiagnosis": "Diagnostic IA",
"timelineEscalated": "Escaladé",
"timelineResolutionAdded": "Résolution ajoutée",
"timelineCreatedDesc": "Ticket créé par {name}.",
"timelineStatusChangedDesc": "Statut modifié en {status}.",
"timelineAssignedDesc": "Assigné à {name}.",
"timelineCommentAddedDesc": "Commentaire ajouté par {name}.",
"timelineAiDiagnosisCompletedDesc": "Diagnostic IA terminé.",
"timelineAiDiagnosisFailedDesc": "Diagnostic IA échoué.",
"timelineEscalatedDesc": "Ticket escaladé.",
"timelineResolutionAddedDesc": "Résolution ajoutée par {name}."
} }

View File

@ -106,7 +106,6 @@
"lastSeen": "Ultima visualizzazione", "lastSeen": "Ultima visualizzazione",
"reportTitle": "Rapporto settimanale sulle prestazioni", "reportTitle": "Rapporto settimanale sulle prestazioni",
"weeklyInsights": "Approfondimenti settimanali", "weeklyInsights": "Approfondimenti settimanali",
"missingDaysWarning": "Dati disponibili per {available}/{expected} giorni. Mancanti: {dates}",
"weeklySavings": "I tuoi risparmi questa settimana", "weeklySavings": "I tuoi risparmi questa settimana",
"solarEnergyUsed": "Energia risparmiata", "solarEnergyUsed": "Energia risparmiata",
"solarStayedHome": "solare + batteria, non acquistata dalla rete", "solarStayedHome": "solare + batteria, non acquistata dalla rete",
@ -149,9 +148,10 @@
"dailyTab": "Giornaliero", "dailyTab": "Giornaliero",
"dailyReportTitle": "Riepilogo energetico giornaliero", "dailyReportTitle": "Riepilogo energetico giornaliero",
"dailySummary": "Riepilogo del giorno", "dailySummary": "Riepilogo del giorno",
"selectDate": "Seleziona data",
"noDataForDate": "Nessun dato disponibile per la data selezionata.", "noDataForDate": "Nessun dato disponibile per la data selezionata.",
"noHourlyData": "Dati orari non disponibili per questo giorno.", "noHourlyData": "Dati orari non disponibili per questo giorno.",
"currentWeekHint": "Settimana corrente (lunieri)", "dataUpTo": "Dati fino al {date}",
"intradayChart": "Flusso energetico giornaliero", "intradayChart": "Flusso energetico giornaliero",
"batteryPower": "Potenza batteria", "batteryPower": "Potenza batteria",
"batterySoCLabel": "SoC batteria", "batterySoCLabel": "SoC batteria",
@ -515,104 +515,5 @@
"tourConfigurationTitle": "Configurazione", "tourConfigurationTitle": "Configurazione",
"tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.", "tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.",
"tourHistoryTitle": "Cronologia", "tourHistoryTitle": "Cronologia",
"tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando.", "tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando."
"tickets": "Ticket",
"createTicket": "Crea ticket",
"subject": "Oggetto",
"description": "Descrizione",
"priority": "Priorità",
"category": "Categoria",
"allStatuses": "Tutti gli stati",
"createdAt": "Creato",
"noTickets": "Nessun ticket trovato.",
"backToTickets": "Torna ai ticket",
"aiDiagnosis": "Diagnosi IA",
"rootCause": "Causa principale",
"confidence": "Affidabilità",
"recommendedActions": "Azioni consigliate",
"diagnosisAnalyzing": "L'IA sta analizzando questo ticket...",
"diagnosisFailed": "Diagnosi IA fallita. Riprovare più tardi.",
"noDiagnosis": "Nessuna diagnosi IA disponibile.",
"comments": "Commenti",
"noComments": "Nessun commento ancora.",
"addComment": "Aggiungi",
"timeline": "Cronologia",
"noTimelineEvents": "Nessun evento ancora.",
"updateStatus": "Aggiorna stato",
"details": "Dettagli",
"createdBy": "Creato da",
"updatedAt": "Aggiornato",
"resolvedAt": "Risolto",
"noDescription": "Nessuna descrizione fornita.",
"assignee": "Assegnatario",
"unassigned": "Non assegnato",
"deleteTicket": "Elimina",
"confirmDeleteTicket": "Eliminare il ticket?",
"confirmDeleteTicketMessage": "Questo ticket verrà eliminato definitivamente con tutti i commenti, la diagnosi IA e la cronologia. Questa azione non può essere annullata.",
"diagnosisFeedbackLabel": "È stato utile?",
"feedbackSubmitted": "Feedback: {feedback}",
"accept": "Accetta",
"reject": "Rifiuta",
"resolution": "Risoluzione",
"rootCauseLabel": "Causa principale",
"solutionLabel": "Soluzione",
"saveResolution": "Salva risoluzione",
"preFilledFromAi": "Precompilato dalla diagnosi IA",
"resolutionRequired": "Causa principale e soluzione sono necessarie per risolvere un ticket.",
"resolutionSaved": "Risoluzione salvata con successo.",
"statusOpen": "Aperto",
"statusInProgress": "In corso",
"statusEscalated": "Escalato",
"statusResolved": "Risolto",
"statusClosed": "Chiuso",
"priorityCritical": "Critica",
"priorityHigh": "Alta",
"priorityMedium": "Media",
"priorityLow": "Bassa",
"categoryHardware": "Hardware",
"categorySoftware": "Software",
"categoryNetwork": "Rete",
"categoryUserAccess": "Accesso utente",
"categoryFirmware": "Firmware",
"subCategory": "Sottocategoria",
"edit": "Modifica",
"save": "Salva",
"descriptionSaved": "Descrizione salvata.",
"subCatGeneral": "Generale",
"subCatOther": "Altro",
"subCatBattery": "Batteria",
"subCatInverter": "Inverter",
"subCatCable": "Cavo",
"subCatGateway": "Gateway",
"subCatMetering": "Misurazione",
"subCatCooling": "Raffreddamento",
"subCatPvSolar": "PV / Solare",
"subCatSafety": "Sicurezza",
"subCatBackend": "Backend",
"subCatFrontend": "Frontend",
"subCatDatabase": "Database",
"subCatApi": "API",
"subCatConnectivity": "Connettività",
"subCatVpnAccess": "Accesso VPN",
"subCatS3Storage": "Storage S3",
"subCatPermissions": "Permessi",
"subCatLogin": "Accesso",
"subCatBatteryFirmware": "Firmware batteria",
"subCatInverterFirmware": "Firmware inverter",
"subCatControllerFirmware": "Firmware controller",
"timelineCreated": "Creato",
"timelineStatusChanged": "Stato modificato",
"timelineAssigned": "Assegnato",
"timelineCommentAdded": "Commento aggiunto",
"timelineAiDiagnosis": "Diagnosi IA",
"timelineEscalated": "Escalato",
"timelineResolutionAdded": "Risoluzione aggiunta",
"timelineCreatedDesc": "Ticket creato da {name}.",
"timelineStatusChangedDesc": "Stato modificato in {status}.",
"timelineAssignedDesc": "Assegnato a {name}.",
"timelineCommentAddedDesc": "Commento aggiunto da {name}.",
"timelineAiDiagnosisCompletedDesc": "Diagnosi IA completata.",
"timelineAiDiagnosisFailedDesc": "Diagnosi IA fallita.",
"timelineEscalatedDesc": "Ticket escalato.",
"timelineResolutionAddedDesc": "Risoluzione aggiunta da {name}."
} }

View File

@ -13,7 +13,6 @@ import { NavLink as RouterLink } from 'react-router-dom';
import { SidebarContext } from 'src/contexts/SidebarContext'; import { SidebarContext } from 'src/contexts/SidebarContext';
import BrightnessLowTwoToneIcon from '@mui/icons-material/BrightnessLowTwoTone'; import BrightnessLowTwoToneIcon from '@mui/icons-material/BrightnessLowTwoTone';
import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone'; import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone';
import ConfirmationNumberTwoToneIcon from '@mui/icons-material/ConfirmationNumberTwoTone';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { UserContext } from '../../../../contexts/userContext'; import { UserContext } from '../../../../contexts/userContext';
import { UserType } from '../../../../interfaces/UserTypes'; import { UserType } from '../../../../interfaces/UserTypes';
@ -311,19 +310,6 @@ function SidebarMenu() {
</Button> </Button>
</ListItem> </ListItem>
</List> </List>
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/tickets"
startIcon={<ConfirmationNumberTwoToneIcon />}
>
<FormattedMessage id="tickets" defaultMessage="Tickets" />
</Button>
</ListItem>
</List>
</SubMenuWrapper> </SubMenuWrapper>
</List> </List>
)} )}