Compare commits
6 Commits
69148410f2
...
ac21c46c0e
| Author | SHA1 | Date |
|---|---|---|
|
|
ac21c46c0e | |
|
|
bf47a82b25 | |
|
|
88173303d9 | |
|
|
a40c168f1a | |
|
|
d54fc1c2ab | |
|
|
50bc85ff2a |
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git checkout feature/ticket-dashboard)",
|
||||||
|
"Bash(npx react-scripts build)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1309,20 +1309,14 @@ 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);
|
|
||||||
|
|
||||||
// Try local file first
|
if (Db.DailyRecordExists(installationId, isoDate))
|
||||||
var localPath = Path.Combine(jsonDir, fileName);
|
continue;
|
||||||
String? content = System.IO.File.Exists(localPath) ? System.IO.File.ReadAllText(localPath) : null;
|
|
||||||
|
|
||||||
// Try S3 if no local file
|
var content = AggregatedJsonParser.TryReadFromS3(installation, isoDate)
|
||||||
content ??= AggregatedJsonParser.TryReadFromS3(installation, isoDate)
|
|
||||||
.GetAwaiter().GetResult();
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
if (content is null) continue;
|
if (content is null) continue;
|
||||||
|
|
@ -2010,10 +2004,21 @@ 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)
|
||||||
|
|
@ -2030,6 +2035,19 @@ 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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2094,14 +2112,95 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,24 @@ 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, DataApi = 2, UserAccess = 3 }
|
public enum TicketCategory { Hardware = 0, Software = 1, Network = 2, UserAccess = 3, Firmware = 4 }
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -17,6 +34,7 @@ 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; }
|
||||||
|
|
@ -27,4 +45,8 @@ 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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ 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 }
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ 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 (Mon–Sun)
|
||||||
|
public List<string> MissingDates { get; set; } = new(); // ISO dates with no data
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WeeklySummary
|
public class WeeklySummary
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ public static class Program
|
||||||
TicketDiagnosticService.Initialize();
|
TicketDiagnosticService.Initialize();
|
||||||
NetworkProviderService.Initialize();
|
NetworkProviderService.Initialize();
|
||||||
AlarmReviewService.StartDailyScheduler();
|
AlarmReviewService.StartDailyScheduler();
|
||||||
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
DailyIngestionService.StartScheduler();
|
||||||
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
ReportAggregationService.StartScheduler();
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
RabbitMqManager.InitializeEnvironment();
|
RabbitMqManager.InitializeEnvironment();
|
||||||
|
|
|
||||||
|
|
@ -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 (local) → JSON (S3) → xlsx fallback.
|
/// Data source priority: 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,9 +14,6 @@ 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>
|
||||||
|
|
@ -53,7 +50,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)
|
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var installation in installations)
|
foreach (var installation in installations)
|
||||||
|
|
@ -72,7 +69,7 @@ public static class DailyIngestionService
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ingests data for one installation. Tries JSON (local + S3) and xlsx.
|
/// Ingests data for one installation. Tries JSON (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>
|
||||||
|
|
@ -82,31 +79,46 @@ public static class DailyIngestionService
|
||||||
IngestFromXlsx(installationId);
|
IngestFromXlsx(installationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
|
/// <summary>
|
||||||
|
/// 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());
|
|
||||||
|
|
||||||
// Collect JSON content from local files
|
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
|
||||||
var jsonFiles = Directory.Exists(jsonDir)
|
|
||||||
? Directory.GetFiles(jsonDir, "*.json")
|
|
||||||
: Array.Empty<String>();
|
|
||||||
|
|
||||||
foreach (var jsonPath in jsonFiles.OrderBy(f => f))
|
|
||||||
{
|
{
|
||||||
var content = File.ReadAllText(jsonPath);
|
var isoDate = date.ToString("yyyy-MM-dd");
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try S3 for recent days (yesterday + today) if no local files found
|
if (newDaily > 0 || newHourly > 0)
|
||||||
if (jsonFiles.Length == 0)
|
Console.WriteLine($"[DailyIngestion] Installation {installationId} (S3 date-range {fromDate:yyyy-MM-dd}–{toDate:yyyy-MM-dd}): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Boolean> TryIngestFromJson(Int64 installationId)
|
||||||
{
|
{
|
||||||
|
var newDaily = 0;
|
||||||
|
var newHourly = 0;
|
||||||
|
|
||||||
var installation = Db.GetInstallationById(installationId);
|
var installation = Db.GetInstallationById(installationId);
|
||||||
if (installation is not null)
|
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++)
|
for (var daysBack = 0; daysBack <= 1; daysBack++)
|
||||||
{
|
{
|
||||||
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
|
var date = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-daysBack));
|
||||||
|
|
@ -122,8 +134,6 @@ public static class DailyIngestionService
|
||||||
newDaily += d;
|
newDaily += d;
|
||||||
newHourly += h;
|
newHourly += h;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newDaily > 0 || newHourly > 0)
|
if (newDaily > 0 || newHourly > 0)
|
||||||
Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
Console.WriteLine($"[DailyIngestion] Installation {installationId} (JSON): {newDaily} day(s), {newHourly} hour(s) ingested.");
|
||||||
|
|
|
||||||
|
|
@ -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? _sundayReportTimer;
|
private static Timer? _weeklyReportTimer;
|
||||||
|
|
||||||
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,17 +18,42 @@ public static class ReportAggregationService
|
||||||
|
|
||||||
public static void StartScheduler()
|
public static void StartScheduler()
|
||||||
{
|
{
|
||||||
// ScheduleSundayWeeklyReport();
|
ScheduleWeeklyReportJob();
|
||||||
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 02:00, but only act on the 1st of the month
|
// Run daily at 04:00 UTC, but only act on the 1st of the month
|
||||||
var now = DateTime.Now;
|
var now = DateTime.UtcNow;
|
||||||
var next = now.Date.AddHours(2);
|
var next = now.Date.AddHours(4);
|
||||||
if (now >= next) next = next.AddDays(1);
|
if (now >= next) next = next.AddDays(1);
|
||||||
|
|
||||||
_monthEndTimer = new Timer(
|
_monthEndTimer = new Timer(
|
||||||
|
|
@ -36,7 +61,7 @@ public static class ReportAggregationService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (DateTime.Now.Day == 1)
|
if (DateTime.UtcNow.Day == 1)
|
||||||
RunMonthEndAggregation().GetAwaiter().GetResult();
|
RunMonthEndAggregation().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -46,14 +71,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 02:00, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm}");
|
Console.WriteLine($"[ReportAggregation] Month-end timer scheduled (daily 04:00 UTC, acts on 1st). Next check: {next:yyyy-MM-dd HH:mm} UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ScheduleYearEndJob()
|
private static void ScheduleYearEndJob()
|
||||||
{
|
{
|
||||||
// Run daily at 03:00, but only act on Jan 2nd
|
// Run daily at 05:00 UTC, but only act on Jan 2nd
|
||||||
var now = DateTime.Now;
|
var now = DateTime.UtcNow;
|
||||||
var next = now.Date.AddHours(3);
|
var next = now.Date.AddHours(5);
|
||||||
if (now >= next) next = next.AddDays(1);
|
if (now >= next) next = next.AddDays(1);
|
||||||
|
|
||||||
_yearEndTimer = new Timer(
|
_yearEndTimer = new Timer(
|
||||||
|
|
@ -61,7 +86,7 @@ public static class ReportAggregationService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (DateTime.Now.Month == 1 && DateTime.Now.Day == 2)
|
if (DateTime.UtcNow.Month == 1 && DateTime.UtcNow.Day == 2)
|
||||||
RunYearEndAggregation().GetAwaiter().GetResult();
|
RunYearEndAggregation().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -71,99 +96,40 @@ public static class ReportAggregationService
|
||||||
},
|
},
|
||||||
null, next - now, TimeSpan.FromDays(1));
|
null, next - now, TimeSpan.FromDays(1));
|
||||||
|
|
||||||
Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 03:00, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm}");
|
Console.WriteLine($"[ReportAggregation] Year-end timer scheduled (daily 05:00 UTC, acts on Jan 2). Next check: {next:yyyy-MM-dd HH:mm} UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sunday Weekly Report Automation ─────────────────────────────
|
// ── Weekly Report Auto-Generation ─────────────────────────────
|
||||||
// Generates weekly reports (Sunday–Saturday) 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 void ScheduleSundayWeeklyReport()
|
private static async Task RunWeeklyReportGeneration()
|
||||||
// {
|
{
|
||||||
// // Calculate delay until next Sunday 06:00
|
Console.WriteLine("[ReportAggregation] Running Monday weekly report generation...");
|
||||||
// var now = DateTime.Now;
|
|
||||||
// var daysUntil = ((int)DayOfWeek.Sunday - (int)now.DayOfWeek + 7) % 7;
|
var installations = Db.Installations
|
||||||
// var nextSunday = now.Date.AddDays(daysUntil == 0 && now.Hour >= 6 ? 7 : daysUntil).AddHours(6);
|
.Where(i => i.Product == (Int32)ProductType.SodioHome && i.Device != 3) // Skip Growatt (device=3)
|
||||||
//
|
.ToList();
|
||||||
// _sundayReportTimer = new Timer(
|
|
||||||
// _ =>
|
var generated = 0;
|
||||||
// {
|
foreach (var installation in installations)
|
||||||
// try
|
{
|
||||||
// {
|
try
|
||||||
// if (DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
|
{
|
||||||
// RunSundayWeeklyReports().GetAwaiter().GetResult();
|
var report = await WeeklyReportService.GenerateReportAsync(
|
||||||
// }
|
installation.Id, installation.InstallationName, "en");
|
||||||
// catch (Exception ex)
|
|
||||||
// {
|
SaveWeeklySummary(installation.Id, report, "en");
|
||||||
// Console.Error.WriteLine($"[ReportAggregation] Sunday report error: {ex.Message}");
|
generated++;
|
||||||
// }
|
|
||||||
// },
|
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] Running Sunday weekly report generation...");
|
Console.WriteLine($"[ReportAggregation] Weekly report generation complete. {generated}/{installations.Count} installations processed.");
|
||||||
//
|
}
|
||||||
// // 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 Sunday–Saturday)
|
|
||||||
// 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 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -335,7 +301,7 @@ public static class ReportAggregationService
|
||||||
|
|
||||||
private static async Task RunMonthEndAggregation()
|
private static async Task RunMonthEndAggregation()
|
||||||
{
|
{
|
||||||
var previousMonth = DateTime.Now.AddMonths(-1);
|
var previousMonth = DateTime.UtcNow.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);
|
||||||
|
|
@ -453,7 +419,7 @@ public static class ReportAggregationService
|
||||||
|
|
||||||
private static async Task RunYearEndAggregation()
|
private static async Task RunYearEndAggregation()
|
||||||
{
|
{
|
||||||
var previousYear = DateTime.Now.Year - 1;
|
var previousYear = DateTime.UtcNow.Year - 1;
|
||||||
|
|
||||||
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");
|
Console.WriteLine($"[ReportAggregation] Running year-end aggregation for {previousYear}...");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,8 @@ 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. xlsx file fallback (if DB not yet populated for the target week)
|
/// 2. S3 fallback — ingests missing dates into SQLite via IngestDateRangeAsync, then retries
|
||||||
|
/// 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>
|
||||||
|
|
@ -121,15 +122,25 @@ 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. Fallback: if DB empty, parse xlsx on the fly (pre-ingestion scenario)
|
// 2. S3 fallback: if DB empty, try ingesting from S3 into SQLite, then retry
|
||||||
|
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] DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
|
Console.WriteLine($"[WeeklyReportService] S3 empty, 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; })
|
||||||
|
|
@ -145,11 +156,12 @@ 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.");
|
||||||
|
|
||||||
// 3. Load hourly records from SQLite for behavioral analysis
|
// 4. 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();
|
||||||
|
|
||||||
// 3b. Fallback: if DB empty, parse hourly data from xlsx
|
// 4b. xlsx fallback for hourly data
|
||||||
if (currentHourlyData.Count == 0)
|
if (currentHourlyData.Count == 0)
|
||||||
{
|
{
|
||||||
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
|
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
|
||||||
|
|
@ -264,11 +276,24 @@ 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 = currentWeekDays.First().Date,
|
PeriodStart = weekStart?.ToString("yyyy-MM-dd") ?? currentWeekDays.First().Date,
|
||||||
PeriodEnd = currentWeekDays.Last().Date,
|
PeriodEnd = weekEnd?.ToString("yyyy-MM-dd") ?? currentWeekDays.Last().Date,
|
||||||
CurrentWeek = currentSummary,
|
CurrentWeek = currentSummary,
|
||||||
PreviousWeek = previousSummary,
|
PreviousWeek = previousSummary,
|
||||||
TotalEnergySaved = totalEnergySaved,
|
TotalEnergySaved = totalEnergySaved,
|
||||||
|
|
@ -284,6 +309,9 @@ public static class WeeklyReportService
|
||||||
DailyData = currentWeekDays,
|
DailyData = currentWeekDays,
|
||||||
Behavior = behavior,
|
Behavior = behavior,
|
||||||
AiInsight = aiInsight,
|
AiInsight = aiInsight,
|
||||||
|
DaysAvailable = currentWeekDays.Count,
|
||||||
|
DaysExpected = 7,
|
||||||
|
MissingDates = missingDates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ 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);
|
||||||
|
|
@ -236,6 +237,7 @@ function App() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route path={routes.tickets + '*'} element={<Tickets />} />
|
||||||
<Route path={routes.users + '*'} element={<Users />} />
|
<Route path={routes.users + '*'} element={<Users />} />
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,7 @@
|
||||||
"history": "history",
|
"history": "history",
|
||||||
"mainstats": "mainstats",
|
"mainstats": "mainstats",
|
||||||
"detailed_view": "detailed_view/",
|
"detailed_view": "detailed_view/",
|
||||||
"report": "report"
|
"report": "report",
|
||||||
|
"installationTickets": "installationTickets",
|
||||||
|
"tickets": "/tickets/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ 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;
|
||||||
|
|
@ -381,7 +382,8 @@ 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={{
|
||||||
|
|
@ -550,6 +552,17 @@ 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>}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ 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);
|
||||||
|
|
@ -165,6 +166,10 @@ 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
|
||||||
|
|
@ -294,6 +299,10 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ 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';
|
||||||
|
|
||||||
|
|
@ -324,7 +325,8 @@ 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={{
|
||||||
|
|
@ -428,6 +430,17 @@ 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>}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ 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);
|
||||||
|
|
@ -136,6 +137,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
defaultMessage="History Of Actions"
|
defaultMessage="History Of Actions"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
@ -217,6 +222,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
defaultMessage="History Of Actions"
|
defaultMessage="History Of Actions"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentTab != 'list' &&
|
: currentTab != 'list' &&
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,7 @@ 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';
|
||||||
|
|
@ -60,23 +57,16 @@ interface HourlyEnergyRecord {
|
||||||
// ── Date Helpers ─────────────────────────────────────────────
|
// ── Date Helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anchor date for the 7-day strip. Returns last completed Sunday.
|
* Returns the Monday of the current week.
|
||||||
* To switch to live-data mode later, change to: () => new Date()
|
|
||||||
*/
|
*/
|
||||||
function getDataAnchorDate(): Date {
|
function getCurrentMonday(): 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 lastSunday = new Date(today);
|
const offset = dow === 0 ? 6 : dow - 1; // Mon=0 offset
|
||||||
lastSunday.setDate(today.getDate() - (dow === 0 ? 7 : dow));
|
const monday = new Date(today);
|
||||||
lastSunday.setHours(0, 0, 0, 0);
|
monday.setDate(today.getDate() - offset);
|
||||||
return lastSunday;
|
return monday;
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|
@ -86,102 +76,101 @@ function formatDateISO(d: Date): string {
|
||||||
return `${y}-${m}-${day}`;
|
return `${y}-${m}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWeekDays(monday: Date): Date[] {
|
/**
|
||||||
return Array.from({ length: 7 }, (_, i) => {
|
* Returns current week Mon→yesterday. Today excluded because
|
||||||
const d = new Date(monday);
|
* S3 aggregated file is not available until end of day.
|
||||||
d.setDate(monday.getDate() + i);
|
*/
|
||||||
return d;
|
function getCurrentWeekDays(currentMonday: Date): Date[] {
|
||||||
});
|
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 anchor = useMemo(() => getDataAnchorDate(), []);
|
const currentMonday = useMemo(() => getCurrentMonday(), []);
|
||||||
const { monday, sunday } = useMemo(() => getWeekRange(anchor), [anchor]);
|
const yesterday = useMemo(() => {
|
||||||
const weekDays = useMemo(() => getWeekDays(monday), [monday]);
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [weekRecords, setWeekRecords] = useState<DailyEnergyData[]>([]);
|
const [allRecords, setAllRecords] = useState<DailyEnergyData[]>([]);
|
||||||
const [weekHourlyRecords, setWeekHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
const [allHourlyRecords, setAllHourlyRecords] = useState<HourlyEnergyRecord[]>([]);
|
||||||
const [selectedDate, setSelectedDate] = useState(formatDateISO(sunday));
|
const [selectedDate, setSelectedDate] = useState(formatDateISO(yesterday));
|
||||||
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);
|
||||||
|
|
||||||
// Fetch week data (daily + hourly) via combined endpoint with xlsx fallback + cache
|
// Current week Mon→yesterday only
|
||||||
|
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: {
|
params: { installationId, from, to }
|
||||||
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 ?? [];
|
||||||
setWeekRecords(Array.isArray(daily) ? daily : []);
|
setAllRecords(Array.isArray(daily) ? daily : []);
|
||||||
setWeekHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
setAllHourlyRecords(Array.isArray(hourly) ? hourly : []);
|
||||||
|
onHasData?.(Array.isArray(daily) && daily.length > 0);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setWeekRecords([]);
|
setAllRecords([]);
|
||||||
setWeekHourlyRecords([]);
|
setAllHourlyRecords([]);
|
||||||
|
onHasData?.(false);
|
||||||
})
|
})
|
||||||
.finally(() => setLoadingWeek(false));
|
.finally(() => setLoadingWeek(false));
|
||||||
}, [installationId, monday, sunday]);
|
}, [installationId, weekDays]);
|
||||||
|
|
||||||
// When selected date changes, extract data from week cache or fetch
|
// When selected date changes, extract data from cache
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNoData(false);
|
setNoData(false);
|
||||||
setSelectedDayRecord(null);
|
setSelectedDayRecord(null);
|
||||||
|
|
||||||
// Try week cache first
|
const cachedDay = allRecords.find((r) => r.date === selectedDate);
|
||||||
const cachedDay = weekRecords.find((r) => r.date === selectedDate);
|
const cachedHours = allHourlyRecords.filter((r) => r.date === selectedDate);
|
||||||
const cachedHours = weekHourlyRecords.filter((r) => r.date === selectedDate);
|
|
||||||
|
|
||||||
if (cachedDay) {
|
if (cachedDay) {
|
||||||
setSelectedDayRecord(cachedDay);
|
setSelectedDayRecord(cachedDay);
|
||||||
setHourlyRecords(cachedHours);
|
setHourlyRecords(cachedHours);
|
||||||
return;
|
} else if (!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);
|
setNoData(true);
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setHourlyRecords([]);
|
setHourlyRecords([]);
|
||||||
setNoData(true);
|
}
|
||||||
});
|
}, [installationId, selectedDate, allRecords, allHourlyRecords, loadingWeek]);
|
||||||
}, [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);
|
||||||
|
|
@ -197,53 +186,37 @@ export default function DailySection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Date Picker */}
|
{/* Day Strip — current week Mon→yesterday */}
|
||||||
<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 && (
|
||||||
<Container
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '20vh'
|
py: 6
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
|
<CircularProgress size={40} style={{ color: '#ffc04d' }} />
|
||||||
</Container>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No data state */}
|
{/* No data state */}
|
||||||
{!loadingWeek && noData && !record && (
|
{!loadingWeek && noData && !record && (
|
||||||
<Alert severity="info" sx={{ mb: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
|
<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 */}
|
||||||
|
|
@ -312,25 +285,14 @@ 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 }}>
|
||||||
|
|
@ -339,19 +301,12 @@ 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}
|
||||||
|
|
@ -375,25 +330,14 @@ 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="dataUpTo"
|
id="currentWeekHint"
|
||||||
defaultMessage="Data up to {date}"
|
defaultMessage="Current week (Mon–yesterday)"
|
||||||
values={{ date: sundayLabel }}
|
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ 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;
|
||||||
|
|
@ -473,7 +474,8 @@ 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={{
|
||||||
|
|
@ -607,6 +609,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{props.current_installation.device !== 3 && (
|
||||||
<Route
|
<Route
|
||||||
path={routes.report}
|
path={routes.report}
|
||||||
element={
|
element={
|
||||||
|
|
@ -615,6 +618,18 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentUser.userType == UserType.admin && (
|
||||||
|
<Route
|
||||||
|
path={routes.installationTickets}
|
||||||
|
element={
|
||||||
|
<InstallationTicketsTab
|
||||||
|
installationId={props.current_installation.id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
|
||||||
import { useIntl, FormattedMessage } from 'react-intl';
|
import { useIntl, FormattedMessage } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
|
|
@ -66,6 +66,9 @@ interface WeeklyReportResponse {
|
||||||
gridImportChangePercent: number;
|
gridImportChangePercent: number;
|
||||||
dailyData: DailyEnergyData[];
|
dailyData: DailyEnergyData[];
|
||||||
aiInsight: string;
|
aiInsight: string;
|
||||||
|
daysAvailable: number;
|
||||||
|
daysExpected: number;
|
||||||
|
missingDates: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReportSummary {
|
interface ReportSummary {
|
||||||
|
|
@ -228,6 +231,12 @@ 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;
|
||||||
|
|
@ -284,8 +293,17 @@ 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, maxWidth: 900, mx: 'auto' }} className="report-container">
|
<Box sx={{ p: 2, width: '100%', maxWidth: 900, mx: 'auto' }} className="report-container">
|
||||||
<style>{`
|
<style>{`
|
||||||
@media print {
|
@media print {
|
||||||
body * { visibility: hidden; }
|
body * { visibility: hidden; }
|
||||||
|
|
@ -302,6 +320,7 @@ 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 />}
|
||||||
|
|
@ -310,37 +329,72 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
>
|
>
|
||||||
<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' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'daily' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<DailySection installationId={installationId} />
|
<DailySection installationId={installationId} onHasData={setDailyHasData} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<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' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<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' }}>
|
<Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none', minHeight: '50vh' }}>
|
||||||
<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>
|
||||||
|
|
@ -349,7 +403,12 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
|
||||||
|
|
||||||
// ── Weekly Section (existing weekly report content) ────────────
|
// ── Weekly Section (existing weekly report content) ────────────
|
||||||
|
|
||||||
function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) {
|
interface WeeklySectionHandle {
|
||||||
|
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);
|
||||||
|
|
@ -367,17 +426,21 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
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 }
|
||||||
|
|
@ -386,28 +449,30 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Container
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '50vh'
|
py: 6
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</Container>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Container sx={{ py: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<Alert severity="warning"><FormattedMessage id="noReportData" defaultMessage="No report data found." /></Alert>
|
<Alert severity="warning">
|
||||||
</Container>
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -459,8 +524,6 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
borderRadius: 2
|
borderRadius: 2
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" fontWeight="bold">
|
<Typography variant="h5" fontWeight="bold">
|
||||||
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
|
<FormattedMessage id="reportTitle" defaultMessage="Weekly Performance Report" />
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -470,19 +533,23 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
<Typography variant="body2" sx={{ opacity: 0.7 }}>
|
||||||
{report.periodStart} — {report.periodEnd}
|
{report.periodStart} — {report.periodEnd}
|
||||||
</Typography>
|
</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' }}>
|
||||||
|
|
@ -618,7 +685,7 @@ function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installatio
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
// ── Weekly History (saved weekly reports for current month) ─────
|
// ── Weekly History (saved weekly reports for current month) ─────
|
||||||
|
|
||||||
|
|
@ -742,13 +809,17 @@ 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();
|
||||||
|
|
||||||
|
|
@ -798,12 +869,15 @@ 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 })}
|
||||||
onRegenerate={(r: MonthlyReport) => onGenerate(r.year, r.month)}
|
controlledIdx={selectedIdx}
|
||||||
|
onIdxChange={onSelectedIdxChange}
|
||||||
/>
|
/>
|
||||||
) : pendingMonths.length === 0 ? (
|
) : pendingMonths.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<FormattedMessage id="noMonthlyData" defaultMessage="No monthly reports available yet. Weekly reports will appear here for aggregation once generated." />
|
<Alert severity="warning">
|
||||||
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -816,13 +890,17 @@ 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();
|
||||||
|
|
||||||
|
|
@ -872,12 +950,15 @@ 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 })}
|
||||||
onRegenerate={(r: YearlyReport) => onGenerate(r.year)}
|
controlledIdx={selectedIdx}
|
||||||
|
onIdxChange={onSelectedIdxChange}
|
||||||
/>
|
/>
|
||||||
) : pendingYears.length === 0 ? (
|
) : pendingYears.length === 0 ? (
|
||||||
<Alert severity="info">
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<FormattedMessage id="noYearlyData" defaultMessage="No yearly reports available yet. Monthly reports will appear here for aggregation once generated." />
|
<Alert severity="warning">
|
||||||
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -893,7 +974,8 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
countFn,
|
countFn,
|
||||||
sendEndpoint,
|
sendEndpoint,
|
||||||
sendParamsFn,
|
sendParamsFn,
|
||||||
onRegenerate
|
controlledIdx,
|
||||||
|
onIdxChange
|
||||||
}: {
|
}: {
|
||||||
reports: T[];
|
reports: T[];
|
||||||
type: 'monthly' | 'yearly';
|
type: 'monthly' | 'yearly';
|
||||||
|
|
@ -902,20 +984,24 @@ 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;
|
||||||
onRegenerate?: (r: T) => void | Promise<void>;
|
controlledIdx?: number;
|
||||||
|
onIdxChange?: (idx: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
const [internalIdx, setInternalIdx] = useState(0);
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
const selectedIdx = controlledIdx ?? internalIdx;
|
||||||
|
const handleIdxChange = (idx: number) => {
|
||||||
|
setInternalIdx(idx);
|
||||||
|
onIdxChange?.(idx);
|
||||||
|
};
|
||||||
|
|
||||||
if (reports.length === 0) {
|
if (reports.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Alert severity="info">
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||||
<FormattedMessage
|
<Alert severity="warning">
|
||||||
id={type === 'monthly' ? 'noMonthlyData' : 'noYearlyData'}
|
<FormattedMessage id="noReportData" defaultMessage="No report data found." />
|
||||||
defaultMessage={type === 'monthly' ? 'No monthly reports available yet.' : 'No yearly reports available yet.'}
|
|
||||||
/>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -943,7 +1029,7 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
{reports.length > 1 && (
|
{reports.length > 1 && (
|
||||||
<Select
|
<Select
|
||||||
value={selectedIdx}
|
value={selectedIdx}
|
||||||
onChange={(e) => setSelectedIdx(Number(e.target.value))}
|
onChange={(e) => handleIdxChange(Number(e.target.value))}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ minWidth: 200 }}
|
sx={{ minWidth: 200 }}
|
||||||
>
|
>
|
||||||
|
|
@ -952,22 +1038,7 @@ function AggregatedSection<T extends ReportSummary>({
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ ml: 'auto' }}>
|
||||||
{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>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ 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);
|
||||||
|
|
@ -159,6 +160,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
defaultMessage="Report"
|
defaultMessage="Report"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'installationTickets',
|
||||||
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentUser.userType == UserType.partner
|
: currentUser.userType == UserType.partner
|
||||||
|
|
@ -231,6 +236,13 @@ 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
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -303,6 +315,10 @@ 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
|
||||||
|
|
@ -409,7 +425,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
textColor="primary"
|
textColor="primary"
|
||||||
indicatorColor="primary"
|
indicatorColor="primary"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs
|
||||||
|
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||||
|
.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
value={tab.value}
|
value={tab.value}
|
||||||
|
|
@ -476,7 +494,9 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
textColor="primary"
|
textColor="primary"
|
||||||
indicatorColor="primary"
|
indicatorColor="primary"
|
||||||
>
|
>
|
||||||
{singleInstallationTabs.map((tab) => (
|
{singleInstallationTabs
|
||||||
|
.filter((tab) => !(isGrowatt && tab.value === 'report'))
|
||||||
|
.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
value={tab.value}
|
value={tab.value}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,732 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,7 @@
|
||||||
"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",
|
||||||
|
|
@ -143,10 +144,9 @@
|
||||||
"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.",
|
||||||
"dataUpTo": "Daten bis {date}",
|
"currentWeekHint": "Aktuelle Woche (Mo–gestern)",
|
||||||
"intradayChart": "Tagesverlauf Energiefluss",
|
"intradayChart": "Tagesverlauf Energiefluss",
|
||||||
"batteryPower": "Batterieleistung",
|
"batteryPower": "Batterieleistung",
|
||||||
"batterySoCLabel": "Batterie SoC",
|
"batterySoCLabel": "Batterie SoC",
|
||||||
|
|
@ -515,5 +515,104 @@
|
||||||
"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}."
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +83,7 @@
|
||||||
"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",
|
||||||
|
|
@ -125,10 +126,9 @@
|
||||||
"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.",
|
||||||
"dataUpTo": "Data up to {date}",
|
"currentWeekHint": "Current week (Mon–yesterday)",
|
||||||
"intradayChart": "Intraday Power Flow",
|
"intradayChart": "Intraday Power Flow",
|
||||||
"batteryPower": "Battery Power",
|
"batteryPower": "Battery Power",
|
||||||
"batterySoCLabel": "Battery SoC",
|
"batterySoCLabel": "Battery SoC",
|
||||||
|
|
@ -263,5 +263,104 @@
|
||||||
"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}."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@
|
||||||
"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",
|
||||||
|
|
@ -137,10 +138,9 @@
|
||||||
"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.",
|
||||||
"dataUpTo": "Données jusqu'au {date}",
|
"currentWeekHint": "Semaine en cours (lun–hier)",
|
||||||
"intradayChart": "Flux d'énergie journalier",
|
"intradayChart": "Flux d'énergie journalier",
|
||||||
"batteryPower": "Puissance batterie",
|
"batteryPower": "Puissance batterie",
|
||||||
"batterySoCLabel": "SoC batterie",
|
"batterySoCLabel": "SoC batterie",
|
||||||
|
|
@ -515,5 +515,104 @@
|
||||||
"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}."
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +106,7 @@
|
||||||
"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",
|
||||||
|
|
@ -148,10 +149,9 @@
|
||||||
"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.",
|
||||||
"dataUpTo": "Dati fino al {date}",
|
"currentWeekHint": "Settimana corrente (lun–ieri)",
|
||||||
"intradayChart": "Flusso energetico giornaliero",
|
"intradayChart": "Flusso energetico giornaliero",
|
||||||
"batteryPower": "Potenza batteria",
|
"batteryPower": "Potenza batteria",
|
||||||
"batterySoCLabel": "SoC batteria",
|
"batterySoCLabel": "SoC batteria",
|
||||||
|
|
@ -515,5 +515,104 @@
|
||||||
"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}."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ 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';
|
||||||
|
|
@ -310,6 +311,19 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue