Compare commits

...

9 Commits

37 changed files with 8833 additions and 3580 deletions

2
.gitignore vendored
View File

@ -5,3 +5,5 @@
**/.idea/
**/.env
.claude/
**/__pycache__/
*.pyc

View File

@ -806,7 +806,7 @@ public class Controller : ControllerBase
/// <summary>
/// Returns an AI-generated diagnosis for a single error/alarm description.
/// Responses are cached in memory — repeated calls for the same error code
/// do not hit Mistral again.
/// do not hit the AI provider again.
/// </summary>
[HttpGet(nameof(DiagnoseError))]
public async Task<ActionResult<DiagnosticResponse>> DiagnoseError(Int64 installationId, string errorDescription, Token authToken)
@ -839,8 +839,11 @@ public class Controller : ControllerBase
/// Remove this endpoint in production if not needed.
/// </summary>
[HttpGet(nameof(TestAlarmKnowledgeBase))]
public ActionResult TestAlarmKnowledgeBase()
public ActionResult TestAlarmKnowledgeBase(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var testCases = new[]
{
// Sinexcel alarms (keys match SinexcelRecord.Api.cs property names)
@ -855,7 +858,7 @@ public class Controller : ControllerBase
"BmsFault",
"OverTemperature",
"AFCI Fault",
// Unknown alarm (should return null - would call Mistral)
// Unknown alarm (should return null - would call AI)
"Some unknown alarm XYZ123"
};
@ -867,7 +870,7 @@ public class Controller : ControllerBase
{
Alarm = alarm,
FoundInKnowledgeBase = diagnosis != null,
Explanation = diagnosis?.Explanation ?? "NOT FOUND - Would call Mistral API",
Explanation = diagnosis?.Explanation ?? "NOT FOUND - Would call AI API",
CausesCount = diagnosis?.Causes.Count ?? 0,
NextStepsCount = diagnosis?.NextSteps.Count ?? 0
});
@ -878,45 +881,47 @@ public class Controller : ControllerBase
TestTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
TotalTests = testCases.Length,
FoundInKnowledgeBase = results.Count(r => ((dynamic)r).FoundInKnowledgeBase),
WouldCallMistral = results.Count(r => !((dynamic)r).FoundInKnowledgeBase),
WouldCallAi = results.Count(r => !((dynamic)r).FoundInKnowledgeBase),
Results = results
});
}
/// <summary>
/// Test endpoint for the full AI diagnostic flow (knowledge base + Mistral API).
/// No auth required. Remove before production.
/// Usage: GET /api/TestDiagnoseError?errorDescription=SomeAlarm
/// Test endpoint for the full AI diagnostic flow (knowledge base + AI API).
/// Admin-only. Usage: GET /api/TestDiagnoseError?errorDescription=SomeAlarm
/// </summary>
[HttpGet(nameof(TestDiagnoseError))]
public async Task<ActionResult> TestDiagnoseError(string errorDescription = "AbnormalGridVoltage", string language = "en")
public async Task<ActionResult> TestDiagnoseError(Token authToken, string errorDescription = "AbnormalGridVoltage", string language = "en")
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
// 1. Try static lookup (KB for English, pre-generated translations for others)
var staticResult = DiagnosticService.TryGetTranslation(errorDescription, language);
if (staticResult is not null)
{
return Ok(new
{
Source = "KnowledgeBase",
Alarm = errorDescription,
MistralEnabled = DiagnosticService.IsEnabled,
Source = "KnowledgeBase",
Alarm = errorDescription,
AiEnabled = DiagnosticService.IsEnabled,
staticResult.Explanation,
staticResult.Causes,
staticResult.NextSteps
});
}
// 2. If not found, try Mistral with the correct language
// 2. If not found, try AI with the correct language
if (!DiagnosticService.IsEnabled)
return Ok(new { Source = "None", Alarm = errorDescription, Message = "Not in knowledge base and Mistral API key not configured." });
return Ok(new { Source = "None", Alarm = errorDescription, Message = "Not in knowledge base and AI API key not configured." });
var aiResult = await DiagnosticService.TestCallMistralAsync(errorDescription, language);
if (aiResult is null)
return Ok(new { Source = "MistralFailed", Alarm = errorDescription, Message = "Mistral API call failed or returned empty." });
return Ok(new { Source = "AiFailed", Alarm = errorDescription, Message = "AI API call failed or returned empty." });
return Ok(new
{
Source = "MistralAI",
Source = "Ai",
Alarm = errorDescription,
aiResult.Explanation,
aiResult.Causes,
@ -2012,50 +2017,71 @@ public class Controller : ControllerBase
// ── Alarm Review Campaign ────────────────────────────────────────────────
[HttpPost(nameof(SendTestAlarmReview))]
public async Task<ActionResult> SendTestAlarmReview()
public async Task<ActionResult> SendTestAlarmReview(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
await AlarmReviewService.SendTestBatchAsync();
return Ok(new { message = "Test review email sent to liu@inesco.energy. Check your inbox." });
}
[HttpPost(nameof(StartAlarmReviewCampaign))]
public ActionResult StartAlarmReviewCampaign()
public ActionResult StartAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.StartCampaign();
return Ok(new { message = "Alarm review campaign started." });
}
[HttpPost(nameof(StopAlarmReviewCampaign))]
public ActionResult StopAlarmReviewCampaign()
public ActionResult StopAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.StopCampaign();
return Ok(new { message = "Campaign paused — progress preserved. Use ResumeAlarmReviewCampaign to restart timers." });
}
[HttpPost(nameof(ResumeAlarmReviewCampaign))]
public ActionResult ResumeAlarmReviewCampaign()
public ActionResult ResumeAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.ResumeCampaign();
return Ok(new { message = "Campaign resumed — timers restarted from existing progress." });
}
[HttpPost(nameof(ResetAlarmReviewCampaign))]
public ActionResult ResetAlarmReviewCampaign()
public ActionResult ResetAlarmReviewCampaign(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
AlarmReviewService.ResetCampaign();
return Ok(new { message = "Campaign fully reset — all progress deleted. Use StartAlarmReviewCampaign to begin again." });
}
[HttpGet(nameof(CorrectAlarm))]
public ActionResult CorrectAlarm(int batch, string key)
public ActionResult CorrectAlarm(Token authToken, int batch, string key)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var html = AlarmReviewService.GetCorrectionPage(batch, key);
return Content(html, "text/html");
}
[HttpPost(nameof(ApplyAlarmCorrection))]
public ActionResult ApplyAlarmCorrection([FromBody] AlarmCorrectionRequest req)
public ActionResult ApplyAlarmCorrection(Token authToken, [FromBody] AlarmCorrectionRequest req)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
if (req == null) return BadRequest();
var correction = new DiagnosticResponse
{
@ -2068,16 +2094,22 @@ public class Controller : ControllerBase
}
[HttpGet(nameof(ReviewAlarms))]
public ActionResult ReviewAlarms(int batch, string reviewer)
public ActionResult ReviewAlarms(Token authToken, int batch, string reviewer)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var html = AlarmReviewService.GetReviewPage(batch, reviewer);
if (html is null) return NotFound("Batch not found or reviewer not recognised.");
return Content(html, "text/html");
}
[HttpPost(nameof(SubmitAlarmReview))]
public async Task<ActionResult> SubmitAlarmReview(int batch, string? reviewer, [FromBody] List<ReviewFeedback>? feedbacks)
public async Task<ActionResult> SubmitAlarmReview(Token authToken, int batch, string? reviewer, [FromBody] List<ReviewFeedback>? feedbacks)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
// Batch 0 = test mode — run dry-run synthesis and return preview HTML (nothing is saved)
if (batch == 0)
{
@ -2091,14 +2123,20 @@ public class Controller : ControllerBase
}
[HttpGet(nameof(GetAlarmReviewStatus))]
public ActionResult GetAlarmReviewStatus()
public ActionResult GetAlarmReviewStatus(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
return Ok(AlarmReviewService.GetStatus());
}
[HttpGet(nameof(DownloadCheckedKnowledgeBase))]
public ActionResult DownloadCheckedKnowledgeBase()
public ActionResult DownloadCheckedKnowledgeBase(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var content = AlarmReviewService.GetCheckedFileContent();
if (content is null) return NotFound("AlarmKnowledgeBaseChecked.cs has not been generated yet.");
@ -2598,6 +2636,8 @@ public class Controller : ControllerBase
[FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId,
[FromQuery] Int64? checklistItemId,
[FromQuery] String? subtaskKey,
[FromQuery] Token authToken)
{
var user = Db.GetSession(authToken)?.User;
@ -2676,6 +2716,8 @@ public class Controller : ControllerBase
TicketId = ticketId,
TicketCommentId = ticketCommentId,
InstallationId = installationId,
ChecklistItemId = checklistItemId,
SubtaskKey = subtaskKey,
Scope = scope,
S3Key = s3Key,
OriginalName = safeFileName,
@ -2725,6 +2767,8 @@ public class Controller : ControllerBase
[FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId,
[FromQuery] Int64? checklistItemId,
[FromQuery] String? subtaskKey,
[FromQuery] Token authToken)
{
var user = Db.GetSession(authToken)?.User;
@ -2736,6 +2780,12 @@ public class Controller : ControllerBase
if (ticketCommentId.HasValue)
return Ok(Db.GetDocumentsForComment(ticketCommentId.Value));
if (checklistItemId.HasValue)
{
if (user.UserType != 2) return Unauthorized();
return Ok(Db.GetDocumentsForChecklistItem(checklistItemId.Value, subtaskKey));
}
if (installationId.HasValue)
{
// Access control: admin can list all; others need installation access
@ -2747,7 +2797,7 @@ public class Controller : ControllerBase
return Ok(Db.GetDocumentsForInstallation(installationId.Value));
}
return BadRequest("Provide ticketId, ticketCommentId, or installationId.");
return BadRequest("Provide ticketId, ticketCommentId, installationId, or checklistItemId.");
}
[HttpDelete(nameof(DeleteDocument))]
@ -2774,4 +2824,114 @@ public class Controller : ControllerBase
return Ok();
}
// ── Checklist ───────────────────────────────────────────────────────
[HttpGet(nameof(GetChecklistForInstallation))]
public ActionResult<IEnumerable<ChecklistItem>> GetChecklistForInstallation(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null) return NotFound("Installation not found.");
if (!Db.ChecklistExistsForInstallation(installationId))
{
foreach (var def in ChecklistStepDefinitions.Steps)
{
Db.Create(new ChecklistItem
{
InstallationId = installationId,
StepNumber = def.Number,
StepTitle = def.Title,
Subtasks = def.SubtasksJson,
});
}
}
return Ok(Db.GetChecklistForInstallation(installationId));
}
[HttpPut(nameof(UpdateChecklistItem))]
public ActionResult<ChecklistItem> UpdateChecklistItem(
Int64 checklistItemId,
Int32? status,
String? comments,
Int64? assigneeId,
Boolean clearAssignee,
String? doneAt,
String? subtasks,
Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var item = Db.GetChecklistItemById(checklistItemId);
if (item is null) return NotFound("Checklist item not found.");
if (status.HasValue) item.Status = status.Value;
if (comments is not null) item.Comments = comments;
if (clearAssignee) item.AssigneeId = null;
else if (assigneeId.HasValue) item.AssigneeId = assigneeId.Value;
if (doneAt is not null) item.DoneAt = String.IsNullOrWhiteSpace(doneAt) ? null : doneAt;
if (subtasks is not null) item.Subtasks = subtasks;
// Auto-fill DoneAt when status transitions to Done and no date provided
if (item.Status == (Int32)ChecklistStatus.Done && String.IsNullOrWhiteSpace(item.DoneAt))
item.DoneAt = DateTime.UtcNow.ToString("yyyy-MM-dd");
item.UpdatedAt = DateTime.UtcNow;
return Db.Update(item) ? item : StatusCode(500, "Update failed.");
}
[HttpPost(nameof(NotifyChecklistAssignee))]
public async Task<ActionResult> NotifyChecklistAssignee(Int64 checklistItemId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var item = Db.GetChecklistItemById(checklistItemId);
if (item is null) return NotFound("Checklist item not found.");
if (item.AssigneeId is null) return BadRequest("No assignee set for this step.");
var assignee = Db.GetUserById(item.AssigneeId);
if (assignee is null) return BadRequest("Assignee user not found.");
var installation = Db.GetInstallationById(item.InstallationId);
if (installation is null) return NotFound("Installation not found.");
try
{
await assignee.SendChecklistAssignedEmail(item, installation, user.Name);
return Ok();
}
catch (Exception ex)
{
Console.WriteLine($"[Checklist] Failed to send assignee email: {ex}");
return StatusCode(500, "Failed to send notification email.");
}
}
[HttpGet(nameof(GetChecklistSummary))]
public ActionResult<IEnumerable<Object>> GetChecklistSummary(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) return Unauthorized();
var summaries = Db.ChecklistItems
.ToList()
.GroupBy(c => c.InstallationId)
.Select(g => new
{
installationId = g.Key,
done = g.Count(c => c.Status == (Int32)ChecklistStatus.Done),
total = g.Count()
})
.ToList();
return Ok(summaries);
}
}

View File

@ -0,0 +1,23 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
public enum ChecklistStatus { NotStarted = 0, InProgress = 1, Done = 2 }
public class ChecklistItem
{
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
[Indexed] public Int64 InstallationId { get; set; }
public Int32 StepNumber { get; set; }
public String StepTitle { get; set; } = "";
public Int32 Status { get; set; } = (Int32)ChecklistStatus.NotStarted;
public String Comments { get; set; } = "";
public Int64? AssigneeId { get; set; }
public String? DoneAt { get; set; }
public String? Subtasks { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,70 @@
namespace InnovEnergy.App.Backend.DataTypes;
public record ChecklistStepDefinition(Int32 Number, String Title, String? SubtasksJson);
public static class ChecklistStepDefinitions
{
private const String NoSubtasks = null!;
public static readonly IReadOnlyList<ChecklistStepDefinition> Steps = new List<ChecklistStepDefinition>
{
new( 1, "Order created, customer and partner info recorded in CRM", NoSubtasks),
new( 2, "Hardware assembled at Vebo", NoSubtasks),
new( 3, "Installation created on Monitor under correct product and folder",
"""
[
{"text":"checklistStep3Sub1","checked":false}
]
"""),
new( 4, "Gateway SD card configured, VPN and gateway name registered", NoSubtasks),
new( 5, "Information tab filled out (customer, serials, VPN)",
"""
[
{"text":"checklistStep5Sub1","checked":false},
{"text":"checklistStep5Sub2","checked":false},
{"text":"checklistStep5Sub3","checked":false},
{"text":"checklistStep5Sub4","checked":false},
{"text":"checklistStep5Sub5","checked":false},
{"text":"checklistStep5Sub6","checked":false}
]
"""),
new( 6, "Installation configured and tested electrically / hardware-wise at Vebo",
"""
[
{"text":"checklistStep6Sub1","checked":false},
{"text":"checklistStep6Sub2","checked":false},
{"text":"checklistStep6Sub3","checked":false},
{"text":"checklistStep6Sub4","checked":false}
]
"""),
new( 7, "Installation tested software-wise at Vebo",
"""
[
{"text":"checklistStep7Sub1","checked":false},
{"text":"checklistStep7Sub2","checked":false},
{"text":"checklistStep7Sub3","checked":false},
{"text":"checklistStep7Sub4","checked":false}
]
"""),
new( 8, "Installation delivered to customer site",
"""
[
{"text":"checklistStep8Sub1","checked":false}
]
"""),
new( 9, "Installation connected to grid", NoSubtasks),
new(10, "Hardware verified on site",
"""
[
{"text":"checklistStep10Sub1","checked":false},
{"text":"checklistStep10Sub2","checked":false}
]
"""),
new(11, "Software verified on site", NoSubtasks),
new(12, "Installation online on Monitor", NoSubtasks),
new(13, "Customer informed about Monitor account and reports", NoSubtasks),
new(14, "User account created with correct folders and access", NoSubtasks),
new(15, "Customer follow-up completed, feedback collected", NoSubtasks),
new(16, "Further issues tracked via Ticket system", NoSubtasks),
};
}

View File

@ -15,6 +15,8 @@ public class Document
[Indexed] public Int64? TicketId { get; set; }
[Indexed] public Int64? TicketCommentId { get; set; }
[Indexed] public Int64? InstallationId { get; set; }
[Indexed] public Int64? ChecklistItemId { get; set; }
public String? SubtaskKey { get; set; }
public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment;
public String S3Key { get; set; } = "";

View File

@ -518,4 +518,67 @@ public static class UserMethods
return user.SendEmail(subject, body);
}
public static Task SendChecklistAssignedEmail(
this User user,
ChecklistItem item,
Installation installation,
String notifiedByName)
{
var productPath = installation.Product switch
{
4 => "sodistoregrid_installations",
5 => "sodistorepro_installations",
_ => "sodiohome_installations"
};
var checklistLink = $"https://monitor.inesco.energy/{productPath}/installation/{installation.Id}/checklist";
var installationName = String.IsNullOrEmpty(installation.Name) ? $"#{installation.Id}" : installation.Name;
var commentsBlock = String.IsNullOrWhiteSpace(item.Comments) ? "" : item.Comments;
var (subject, body) = (user.Language ?? "en") switch
{
"de" => (
"inesco energy Sie wurden einem Checklisten-Schritt zugewiesen",
$"Sehr geehrte/r {user.Name},\n\n" +
$"{notifiedByName} hat Sie einem Schritt der Installations-Checkliste zugewiesen:\n\n" +
$"Installation: {installationName}\n" +
$"Schritt {item.StepNumber}: {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Kommentare:\n{commentsBlock}\n\n" : "") +
$"Checkliste öffnen: {checklistLink}\n\n" +
"Mit freundlichen Grüssen\ninesco energy Monitor"
),
"fr" => (
"inesco energy Une étape de checklist vous a été attribuée",
$"Cher/Chère {user.Name},\n\n" +
$"{notifiedByName} vous a attribué une étape de la checklist d'installation :\n\n" +
$"Installation : {installationName}\n" +
$"Étape {item.StepNumber} : {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Commentaires :\n{commentsBlock}\n\n" : "") +
$"Ouvrir la checklist : {checklistLink}\n\n" +
"Cordialement,\ninesco energy Monitor"
),
"it" => (
"inesco energy Le è stato assegnato un passo della checklist",
$"Gentile {user.Name},\n\n" +
$"{notifiedByName} le ha assegnato un passo della checklist di installazione:\n\n" +
$"Installazione: {installationName}\n" +
$"Passo {item.StepNumber}: {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Commenti:\n{commentsBlock}\n\n" : "") +
$"Aprire la checklist: {checklistLink}\n\n" +
"Cordiali saluti,\ninesco energy Monitor"
),
_ => (
"inesco energy You have been assigned to a checklist step",
$"Dear {user.Name},\n\n" +
$"{notifiedByName} has assigned you to an installation checklist step:\n\n" +
$"Installation: {installationName}\n" +
$"Step {item.StepNumber}: {item.StepTitle}\n\n" +
(commentsBlock.Length > 0 ? $"Comments:\n{commentsBlock}\n\n" : "") +
$"Open the checklist: {checklistLink}\n\n" +
"Best regards,\ninesco energy Monitor"
)
};
return user.SendEmail(subject, body);
}
}

View File

@ -92,6 +92,9 @@ public static partial class Db
// Document storage
public static Boolean Create(Document document) => Insert(document);
// Checklist
public static Boolean Create(ChecklistItem item) => Insert(item);
public static void HandleAction(UserAction newAction)
{
//Find the total number of actions for this installation

View File

@ -42,6 +42,9 @@ public static partial class Db
// Document storage
public static TableQuery<Document> Documents => Connection.Table<Document>();
// Checklist
public static TableQuery<ChecklistItem> ChecklistItems => Connection.Table<ChecklistItem>();
public static void Init()
{
@ -83,6 +86,9 @@ public static partial class Db
// Document storage
Connection.CreateTable<Document>();
// Checklist
Connection.CreateTable<ChecklistItem>();
});
// One-time migration: normalize legacy long-form language values to ISO codes
@ -103,6 +109,51 @@ public static partial class Db
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'");
Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'inesco Energy Master Admin'");
// One-time migration: rewrite early-seeded English subtask text to translation keys so the
// frontend can localize them. Idempotent: rows already containing keys match nothing.
var subtaskTextToKey = new (String Old, String Key)[]
{
("Customer information (email, address)", "checklistStep5Sub1"),
("Installation information (external EMS, grid provider, data collection)", "checklistStep5Sub2"),
("Installation information (external EMS, network provider, data collection)", "checklistStep5Sub2"),
("Battery serial number", "checklistStep5Sub3"),
("Inverter serial number", "checklistStep5Sub4"),
("Data logger serial number", "checklistStep5Sub5"),
("VPN details", "checklistStep5Sub6"),
("Inverter firmware and configuration verified", "checklistStep6Sub1"),
("Battery firmware and configuration verified", "checklistStep6Sub2"),
("Internet for gateway configured", "checklistStep6Sub3"),
("Communication cable between gateway and inverter correct", "checklistStep6Sub4"),
("S3 bucket number and key credentials copied from Information tab into config.json","checklistStep7Sub1"),
("Product ID configured in config.json", "checklistStep7Sub2"),
("USB ID configured in config.json", "checklistStep7Sub3"),
("Inverter data reading from inverter tested", "checklistStep7Sub4")
};
foreach (var (oldText, key) in subtaskTextToKey)
{
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = REPLACE(Subtasks, ?, ?) WHERE Subtasks LIKE ?",
$"\"{oldText}\"", $"\"{key}\"", $"%\"{oldText}\"%");
}
// One-time backfill: step 3 originally had no subtasks; add the installation serial subtask
// to existing rows.
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 3 AND (Subtasks IS NULL OR Subtasks = '')",
"[{\"text\":\"checklistStep3Sub1\",\"checked\":false}]");
// One-time backfill: step 8 originally had no subtasks; add the delivery-receipt subtask
// to existing rows so already-seeded installations pick up the new subtask after deploy.
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 8 AND (Subtasks IS NULL OR Subtasks = '')",
"[{\"text\":\"checklistStep8Sub1\",\"checked\":false}]");
// One-time backfill: step 10 originally had no subtasks; add the two upload subtasks
// (installation protocol + time & material report) to existing rows.
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 10 AND (Subtasks IS NULL OR Subtasks = '')",
"[{\"text\":\"checklistStep10Sub1\",\"checked\":false},{\"text\":\"checklistStep10Sub2\",\"checked\":false}]");
//UpdateKeys();
CleanupSessions().SupressAwaitWarning();
DeleteSnapshots().SupressAwaitWarning();
@ -150,6 +201,9 @@ public static partial class Db
// Document storage
fileConnection.CreateTable<Document>();
// Checklist
fileConnection.CreateTable<ChecklistItem>();
// Migrate new columns: set defaults for existing rows where NULL or empty
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");

View File

@ -145,6 +145,9 @@ public static partial class Db
// Clean up installation-level documents
Documents.Delete(d => d.InstallationId == installation.Id);
// Clean up checklist items for this installation
ChecklistItems.Delete(c => c.InstallationId == installation.Id);
return Installations.Delete(i => i.Id == installation.Id) > 0;
}
}

View File

@ -233,4 +233,26 @@ public static partial class Db
.Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument)
.OrderBy(d => d.CreatedAt)
.ToList();
public static List<Document> GetDocumentsForChecklistItem(Int64 checklistItemId, String? subtaskKey = null)
=> Documents
.Where(d => d.ChecklistItemId == checklistItemId
&& d.Scope == (Int32)DocumentScope.InstallationDocument
&& (subtaskKey == null || d.SubtaskKey == subtaskKey))
.OrderBy(d => d.CreatedAt)
.ToList();
// ── Checklist Queries ───────────────────────────────────────────────
public static List<ChecklistItem> GetChecklistForInstallation(Int64 installationId)
=> ChecklistItems
.Where(c => c.InstallationId == installationId)
.OrderBy(c => c.StepNumber)
.ToList();
public static Boolean ChecklistExistsForInstallation(Int64 installationId)
=> ChecklistItems.Any(c => c.InstallationId == installationId);
public static ChecklistItem? GetChecklistItemById(Int64 id)
=> ChecklistItems.FirstOrDefault(c => c.Id == id);
}

View File

@ -73,4 +73,7 @@ public static partial class Db
public static Boolean Update(Ticket ticket) => Update(obj: ticket);
public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis);
public static Boolean Update(TicketComment comment) => Update(obj: comment);
// Checklist
public static Boolean Update(ChecklistItem item) => Update(obj: item);
}

View File

@ -1,4 +1,4 @@
{
{
"AbnormalGridVoltage": {
"Explanation": "Der Wechselrichter hat festgestellt, dass die Netzspannung außerhalb des zulässigen Bereichs liegt. Das System benötigt manuellen Eingriff zur Wiederherstellung.",
"Causes": [
@ -28,15 +28,15 @@
]
},
"InvertedSequenceOfGridVoltage": {
"Explanation": "Die Phasenreihenfolge der dreiphasigen Netzspannung ist vertauscht. Dies ist ein Verdrahtungsproblem, das einen sicheren Betrieb verhindert.",
"Explanation": "Die Reihenfolge der drei Stromphasen vom Netz ist falsch angeschlossen. Das System kann nicht sicher arbeiten.",
"Causes": [
"Falsche Verdrahtung der Netzphasen während der Installation (L1, L2, L3 vertauscht)",
"Nachträgliche Verdrahtungsarbeiten ohne Überprüfung der Phasenfolge"
"Falsche Verdrahtung der Netzphasen (L1, L2, L3) bei der Installation",
"Nachträgliche Änderungen an der Verdrahtung ohne Prüfung der Phasenfolge"
],
"NextSteps": [
"Schalten Sie das gesamte System sicher aus, bevor Sie die Verdrahtung berühren",
"Vertauschen Sie zwei der drei Phasenleitungen an der Netzverbindung, um die Reihenfolge zu korrigieren",
"Schalten Sie das System wieder ein und überprüfen Sie, ob die Warnung behoben ist"
"Tauschen Sie zwei der drei Phasenleitungen an der Netzverbindung, um die Reihenfolge zu korrigieren",
"Schalten Sie das System wieder ein und prüfen Sie, ob die Warnung verschwunden ist"
]
},
"GridVoltagePhaseLoss": {
@ -135,7 +135,7 @@
]
},
"ExcessiveRadiatorTemperature": {
"Explanation": "Die Temperatur des Kühlkörpers (Radiator) des Wechselrichters ist zu hoch. Der Kühlkörper dient dazu, Wärme während des Betriebs abzuführen.",
"Explanation": "Der Kühlkörper des Wechselrichters ist zu heiß, weil die Wärme nicht richtig abgeführt wird.",
"Causes": [
"Verstopfte oder blockierte Lüftungsschlitze verhindern die Wärmeabfuhr",
"Ausfall des Kühlgebläses reduziert die Luftzirkulation",
@ -145,8 +145,7 @@
"NextSteps": [
"Reinigen Sie die Lüftungsschlitze und Staubfilter Staubansammlungen sind eine häufige Ursache",
"Überprüfen Sie, ob das Kühlgebläse läuft (Hören Sie auf Gebläsegeräusche während des Betriebs)",
"Reduzieren Sie die Last vorübergehend, um die Wärmeentwicklung zu verringern",
"Reparieren oder ersetzen Sie das Gebläse, falls es defekt ist, und starten Sie den Wechselrichter neu"
"Reduzieren Sie die Last vorübergehend, um die Wärmeentwicklung zu verringern"
]
},
"PcbOvertemperature": {
@ -541,13 +540,14 @@
"Battery2OverloadTimeout": {
"Explanation": "Batterie 2 läuft seit zu langer Zeit unter Überlast.",
"Causes": [
"Dauerhafte hohe Last, die die Entladerating von Batterie 2 überschreitet",
"Dauerhafte hohe Last, die die Entladeleistung von Batterie 2 überschreitet",
"Batterie 2 ist degradiert und kann weniger Leistung bereitstellen"
],
"NextSteps": [
"Den Gesamtstromverbrauch reduzieren",
"Prüfen, ob Batterie 2 für die Lastanforderungen richtig dimensioniert ist",
"Nach Lastreduzierung den Wechselrichter neu starten"
"Nach Lastreduzierung den Wechselrichter neu starten",
"Bitte überprüfen Sie die Einstellungen zur Batterie (z. B. Lade-/Entladestrom)"
]
},
"Battery2SoftStartFailure": {
@ -967,9 +967,9 @@
]
},
"Pv3ReverseConnection": {
"Explanation": "PV-String 3 ist mit vertauschter Polarität angeschlossen. Dies ist ein Verdrahtungsfehler, der vor dem Betrieb behoben werden muss.",
"Explanation": "PV-String 3 ist mit vertauschter Polarität angeschlossen. Dieser Verdrahtungsfehler muss vor dem Betrieb behoben werden.",
"Causes": [
"Positive und negative Kabel von PV-String 3 wurden während der Installation vertauscht",
"Positive und negative Kabel von PV-String 3 wurden womöglich während der Installation vertauscht",
"Falsche Kabelverbindung am DC-Eingang des Wechselrichters"
],
"NextSteps": [
@ -1247,12 +1247,13 @@
]
},
"InverterOverloadTimeout": {
"Explanation": "Der Wechselrichter war zu lange überlastet und hat sich abgeschaltet.",
"Explanation": "Der Wechselrichter war zu lange überlastet und hat sich automatisch abgeschaltet.",
"Causes": [
"Dauerhafte Überlastung, die die Kurzzeit-Überlastfähigkeit des Wechselrichters überschreitet",
"Der Wechselrichter ist für die tatsächliche Last zu klein dimensioniert"
],
"NextSteps": [
"Die sinnvolle Aufteilung der Lasten zwischen Notstrom- und normalem Hausnetz überprüfen.",
"Die angeschlossene Last dauerhaft reduzieren",
"Falls die Last notwendig ist, auf einen größeren Wechselrichter umsteigen",
"Die Ursache beheben und den Wechselrichter neu starten"
@ -1265,6 +1266,7 @@
"Ein neues leistungsstarkes Gerät wurde hinzugefügt, das die Systemleistung übersteigt"
],
"NextSteps": [
"Die sinnvolle Aufteilung der Lasten zwischen Notstrom- und normalem Hausnetz überprüfen.",
"Last reduzieren, indem nicht essentielle Geräte ausgeschaltet werden",
"Nutzung leistungsstarker Geräte staffeln und den Wechselrichter neu starten"
]
@ -1295,16 +1297,15 @@
]
},
"Dsp1ParameterSettingFault": {
"Explanation": "DSP 1 (digitaler Signalprozessor) hat eine falsche Parameterkonfiguration erkannt.",
"Explanation": "Der Wechselrichter hat eine falsche Einstellung in seinen internen Parametern erkannt.",
"Causes": [
"Ein oder mehrere Wechselrichterparameter sind außerhalb des zulässigen Bereichs eingestellt",
"Firmware-Korruption beeinflusst die Parameterspeicherung",
"Konfigurationsinkonsistenz nach einem Firmware-Update"
"Ein oder mehrere Parameter des Wechselrichters liegen außerhalb des erlaubten Bereichs.",
"Die Firmware ist beschädigt und beeinflusst die Speicherung der Einstellungen.",
"Nach einem Firmware-Update stimmen die Einstellungen nicht mehr überein."
],
"NextSteps": [
"Alle Wechselrichter-Parameter überprüfen und eventuell ungültige Werte korrigieren",
"Parameter auf Werkseinstellungen zurücksetzen, falls unsicher über die richtigen Werte",
"Die Ursache beheben und den Wechselrichter neu starten"
"Überprüfen Sie alle Parameter des Wechselrichters und korrigieren Sie ungültige Werte.",
"Setzen Sie die Parameter auf Werkseinstellungen zurück, falls Sie unsicher sind."
]
},
"Dsp2ParameterSettingFault": {
@ -1568,14 +1569,15 @@
]
},
"ReverseMeterConnection": {
"Explanation": "Der Stromzähler ist falsch installiert oder verdrahtet. Die Zählerstände (Import/Export) sind bis zur Korrektur ungenau.",
"Explanation": "Der Stromzähler ist falsch angeschlossen, sodass die Messwerte (Strombezug/Einspeisung) nicht stimmen.",
"Causes": [
"Der Stromwandler (CT) ist in die falsche Richtung installiert",
"Die L- und N-Leitungen des Zählers sind bei der Installation vertauscht"
"Der Stromwandler (CT) ist in die falsche Richtung eingebaut",
"Die L- und N-Leitungen des Zählers wurden vertauscht",
"Andere Messwandler in der Nähe stören mindestens 30 cm Abstand halten"
],
"NextSteps": [
"Verlassen Sie sich nicht auf die Zählerstände, bis die Korrektur erfolgt ist",
"Kontaktieren Sie Ihren Installateur oder einen qualifizierten Elektriker, um den Stromwandler oder die Zählerverkabelung zu korrigieren"
"Kontaktieren Sie Ihren Installateur oder einen qualifizierten Elektriker, um den Stromwandler oder die Zählerverkabelung zu prüfen"
]
},
"InverterSealPulse": {
@ -2121,7 +2123,7 @@
]
},
"LithiumBatteryOverload": {
"Explanation": "Der Überlastschutz der Lithiumbatterie wurde aktiviert die Last entnimmt mehr Strom, als die Batterie abgeben kann.",
"Explanation": "Der Überlastschutz der Batterie wurde aktiviert die Last entnimmt mehr Strom, als die Batterie abgeben kann.",
"Causes": [
"Die Gesamtlastleistung überschreitet die maximale Entladeleistung der Batterie",
"Hochstrom beim Einschalten großer Motoren oder Kompressoren übersteigt vorübergehend die Batteriegrenzen"
@ -2220,6 +2222,19 @@
"Reparieren oder entfernen Sie den überlastenden Verbraucher, bevor Sie den Wechselrichter neu starten"
]
},
"OffGridBusVoltageTooLow": {
"Explanation": "Die Gleichspannung im Inselbetrieb ist zu stark abgesunken, um einen stabilen Betrieb aufrechtzuerhalten.",
"Causes": [
"Batterieladestand zu niedrig",
"Zu hohe Last am Inselausgang",
"Defekt oder lockere Verbindung in der DC-Bus-Verdrahtung"
],
"NextSteps": [
"Last am Inselausgang reduzieren",
"Batterieladestand prüfen und ggf. aufladen",
"DC-Bus-Verdrahtung auf lockere Verbindungen oder Schäden überprüfen"
]
},
"OffGridOutputOverload": {
"Explanation": "Der netzunabhängige (EPS/Backup)-Ausgang ist überlastet — es wird mehr Strom angefordert, als der Wechselrichter im Backup-Modus liefern kann.",
"Causes": [
@ -2805,18 +2820,5 @@
"Messen Sie die tatsächliche DC-Spannung, bevor Sie wieder anschließen",
"Überprüfen Sie das String-Design und reduzieren Sie gegebenenfalls die Anzahl der Module in Reihe, um die Wechselrichter-Spannungsgrenzen einzuhalten"
]
},
"OffGridBusVoltageTooLow": {
"Explanation": "Die Gleichspannung im Inselbetrieb ist zu stark abgesunken, um einen stabilen Betrieb aufrechtzuerhalten.",
"Causes": [
"Batterieladestand zu niedrig",
"Zu hohe Last am Inselausgang",
"Defekt oder lockere Verbindung in der DC-Bus-Verdrahtung"
],
"NextSteps": [
"Last am Inselausgang reduzieren",
"Batterieladestand prüfen und ggf. aufladen",
"DC-Bus-Verdrahtung auf lockere Verbindungen oder Schäden überprüfen"
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -279,6 +279,10 @@ public static class AlarmReviewService
if (current.Synthesized)
{
// Campaign is fully reviewed → nothing left to do. Without this guard the
// recovery branch below re-fires the admin completion email every workday.
if (progress.Batches.Count * BatchSize >= AllAlarmKeys.Length) return;
// Next batch is sent immediately after synthesis — only act here as a safety net
// in case the server restarted before SendNextBatchAsync could run.
var nextAlreadySent = progress.Batches.Count > current.BatchNumber;

View File

@ -4,18 +4,29 @@ generate_alarm_translations.py
Post-campaign script: reads AlarmTranslationsChecked.de.json (the reviewed and
AI-synthesized German content), translates into English, French, and Italian,
and writes:
and writes preview files for review BEFORE replacing the live translations:
Resources/AlarmTranslations.de.json replace with reviewed German
Resources/AlarmTranslations.en.json back-translated from German
Resources/AlarmTranslations.fr.json translated from German
Resources/AlarmTranslations.it.json translated from German
Services/AlarmKnowledgeBase.cs updated English source (keeps same structure)
Resources/AlarmTranslationsChecked.en.json NEW (back-translated from German)
Resources/AlarmTranslationsChecked.fr.json NEW (translated from German)
Resources/AlarmTranslationsChecked.it.json NEW (translated from German)
Services/AlarmKnowledgeBase.cs updated in-place (review via `git diff`)
Resources/AlarmTranslationsChecked.de.json is the INPUT and is not modified.
Live files Resources/AlarmTranslations.{de,fr,it}.json are NOT overwritten
review the *Checked* files, then manually copy them onto the live names when ready:
cp Resources/AlarmTranslationsChecked.de.json Resources/AlarmTranslations.de.json
cp Resources/AlarmTranslationsChecked.en.json Resources/AlarmTranslations.en.json
cp Resources/AlarmTranslationsChecked.fr.json Resources/AlarmTranslations.fr.json
cp Resources/AlarmTranslationsChecked.it.json Resources/AlarmTranslations.it.json
Run this AFTER the review campaign is complete:
export MISTRAL_API_KEY=your_key_here
cd csharp/App/Backend
python3 generate_alarm_translations.py
The script reads MISTRAL_API_KEY from the environment, falling back to the same
.env file the C# backend uses (csharp/App/Backend/.env). No `export` needed if
the .env file is in place.
"""
import json
@ -23,7 +34,6 @@ import os
import re
import sys
import time
import shutil
from typing import Optional
import requests
@ -245,18 +255,17 @@ def main():
print(f"ERROR: {CHECKED_FILE} not found. Run the review campaign first.")
sys.exit(1)
with open(CHECKED_FILE, "r", encoding="utf-8") as f:
# utf-8-sig strips the BOM that the C# AlarmReviewService writes via Encoding.UTF8
with open(CHECKED_FILE, "r", encoding="utf-8-sig") as f:
german_source = json.load(f)
alarm_keys = list(german_source.keys())
print(f"Loaded {len(alarm_keys)} alarms from {CHECKED_FILE}.")
# Step 1: copy reviewed German as the new de.json
de_out = os.path.join(RESOURCES_DIR, "AlarmTranslations.de.json")
shutil.copy(CHECKED_FILE, de_out)
print(f"\n✓ Copied reviewed German → {de_out}")
# The reviewed German JSON is already at AlarmTranslationsChecked.de.json — no copy needed.
# User will manually replace AlarmTranslations.de.json after reviewing all four Checked files.
# Step 2: translate to en, fr, it
# Translate to en, fr, it → write to AlarmTranslationsChecked.{lang}.json (preview names)
all_translations = {} # lang_code → {key → entry}
for lang_code, lang_name in TARGET_LANGUAGES.items():
print(f"\n── Translating to {lang_name} ({lang_code}) ──")
@ -296,7 +305,7 @@ def main():
time.sleep(1)
all_translations[lang_code] = translations
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslationsChecked.{lang_code}.json")
with open(out_file, "w", encoding="utf-8") as f:
json.dump(translations, f, ensure_ascii=False, indent=2)
print(f" ✓ Wrote {len(translations)} entries → {out_file}")
@ -312,8 +321,17 @@ def main():
else:
print(" Skipped — en.json not generated or AlarmKnowledgeBase.cs not found.")
print("\n✓ Done. Review the output files before deploying.")
print(" Next: cd csharp/App/Backend && dotnet build && ./deploy.sh")
print("\n✓ Done. Review these preview files before replacing the live ones:")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.de.json (reviewed German — input, unchanged)")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.en.json (new)")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.fr.json (new)")
print(f" - {RESOURCES_DIR}/AlarmTranslationsChecked.it.json (new)")
print(f" - {KNOWLEDGE_BASE} (overwritten — review with `git diff`)")
print("\nWhen satisfied:")
print(" for lang in de en fr it; do")
print(f" cp {RESOURCES_DIR}/AlarmTranslationsChecked.$lang.json {RESOURCES_DIR}/AlarmTranslations.$lang.json")
print(" done")
print(" dotnet build && ./deploy.sh")
if __name__ == "__main__":

View File

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
patch_missing_alarms.py
Re-translates specific keys that failed during a previous run of
generate_alarm_translations.py (e.g. due to LLM JSON-format glitches),
then regenerates AlarmKnowledgeBase.cs with the now-complete English set.
Translates one key per API call to dodge the multi-key JSON formatting
issue that caused the original failures.
Edit MISSING below to set which keys to retry per language, then run:
cd csharp/App/Backend
python3 patch_missing_alarms.py
"""
import json
import os
import sys
from generate_alarm_translations import (
translate_batch,
parse_kb_key_sections,
write_knowledge_base_cs,
load_env_file,
CHECKED_FILE,
KNOWLEDGE_BASE,
RESOURCES_DIR,
TARGET_LANGUAGES,
)
# Keys that failed during the 2026-04-28 run.
# Update this dict if a new run produces different failures.
MISSING = {
"en": [
"DcBusOvervoltage",
"DcBusUndervoltage",
"DcBusVoltageUnbalance",
"BusSlowOvervoltage",
"HardwareBusOvervoltage",
],
"it": [
"NtcTemperatureSensorBroken",
"SyncSignalAbnormal",
"GridStartupConditionsNotMet",
"BatteryCommunicationFailure",
"BatteryDisconnected",
],
}
def main():
api_key = os.environ.get("MISTRAL_API_KEY", "").strip()
if not api_key:
script_dir = os.path.dirname(os.path.abspath(__file__))
api_key = load_env_file(os.path.join(script_dir, ".env")).get("MISTRAL_API_KEY", "").strip()
if not api_key:
print("ERROR: MISTRAL_API_KEY not found in environment or .env file.")
sys.exit(1)
print("MISTRAL_API_KEY loaded.")
with open(CHECKED_FILE, encoding="utf-8-sig") as f:
de = json.load(f)
en_translations = None
for lang_code, missing_keys in MISSING.items():
lang_name = TARGET_LANGUAGES[lang_code]
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslationsChecked.{lang_code}.json")
with open(out_file, encoding="utf-8") as f:
existing = json.load(f)
# Idempotent: only translate keys that are still genuinely missing from the JSON.
actually_missing = [
k for k in missing_keys
if k in de and (k not in existing or not existing[k].get("Explanation"))
]
if not actually_missing:
print(f"\n── {lang_name} ({lang_code}) already complete ({len(existing)} entries) — skipping translation ──")
else:
print(f"\n── Patching {lang_name} ({lang_code}) — {len(actually_missing)} keys ──")
translated = {}
for key in actually_missing:
print(f" {key}")
result = translate_batch(api_key, {key: de[key]}, lang_name)
if result and key in result:
r = result[key]
translated[key] = {
"Explanation": r.get("Explanation", ""),
"Causes": r.get("Causes", []),
"NextSteps": r.get("NextSteps", []),
}
snippet = r.get("Explanation", "")[:80]
print(f" OK: {snippet}{'...' if len(r.get('Explanation','')) > 80 else ''}")
else:
print(f" FAILED: {key}")
existing.update(translated)
with open(out_file, "w", encoding="utf-8") as f:
json.dump(existing, f, ensure_ascii=False, indent=2)
print(f" ✓ Wrote {len(existing)} total entries → {out_file}")
if lang_code == "en":
en_translations = existing
if en_translations is not None and os.path.exists(KNOWLEDGE_BASE):
print("\n── Regenerating AlarmKnowledgeBase.cs ──")
key_sections = parse_kb_key_sections(KNOWLEDGE_BASE)
write_knowledge_base_cs(KNOWLEDGE_BASE, en_translations, key_sections)
print("\n✓ Patch done.")
if __name__ == "__main__":
main()

View File

@ -26,5 +26,6 @@
"report": "report",
"installationTickets": "installationTickets",
"documents": "documents",
"checklist": "checklist",
"tickets": "/tickets/"
}

View File

@ -32,6 +32,8 @@ interface FileUploadButtonProps {
ticketId?: number;
ticketCommentId?: number;
installationId?: number;
checklistItemId?: number;
subtaskKey?: string;
onUploaded?: (doc: UploadedDocument) => void;
disabled?: boolean;
}
@ -41,6 +43,8 @@ function FileUploadButton({
ticketId,
ticketCommentId,
installationId,
checklistItemId,
subtaskKey,
onUploaded,
disabled = false
}: FileUploadButtonProps) {
@ -87,7 +91,7 @@ function FileUploadButton({
try {
const res = await axiosConfig.post('/UploadDocument', formData, {
params: { scope, ticketId, ticketCommentId, installationId },
params: { scope, ticketId, ticketCommentId, installationId, checklistItemId, subtaskKey },
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setProgress(Math.round((e.loaded * 100) / e.total));

View File

@ -0,0 +1,321 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Checkbox,
Chip,
FormControl,
FormControlLabel,
IconButton,
MenuItem,
Select,
Stack,
TableCell,
TableRow,
TextField,
Tooltip,
Typography
} from '@mui/material';
import EmailIcon from '@mui/icons-material/Email';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { FormattedMessage, useIntl } from 'react-intl';
import {
ChecklistItem,
ChecklistStatus,
ChecklistSubtask,
parseSubtasks,
serializeSubtasks,
UPLOADABLE_SUBTASK_KEYS
} from 'src/interfaces/ChecklistTypes';
import { AdminUser } from 'src/interfaces/TicketTypes';
import SubtaskDocumentUpload from './SubtaskDocumentUpload';
type EmailIconState = 'idle' | 'loading' | 'success' | 'error';
interface Props {
item: ChecklistItem;
installationId: number;
adminUsers: AdminUser[];
onUpdate: (
id: number,
patch: Partial<{
status: number;
comments: string;
assigneeId: number | null;
doneAt: string | null;
subtasks: string;
}>
) => Promise<boolean>;
onNotify: (id: number) => Promise<boolean>;
}
const statusColors: Record<number, string> = {
[ChecklistStatus.NotStarted]: '#9e9e9e',
[ChecklistStatus.InProgress]: '#ed6c02',
[ChecklistStatus.Done]: '#2e7d32'
};
function ChecklistStepRow({ item, installationId, adminUsers, onUpdate, onNotify }: Props) {
const intl = useIntl();
const [comments, setComments] = useState(item.comments ?? '');
const [emailState, setEmailState] = useState<EmailIconState>('idle');
const [subtasksOpen, setSubtasksOpen] = useState(false);
const [subtasks, setSubtasks] = useState<ChecklistSubtask[]>(() =>
parseSubtasks(item.subtasks)
);
useEffect(() => {
setComments(item.comments ?? '');
}, [item.comments]);
useEffect(() => {
setSubtasks(parseSubtasks(item.subtasks));
}, [item.subtasks]);
const subtaskSummary = useMemo(() => {
if (subtasks.length === 0) return null;
const done = subtasks.filter((s) => s.checked).length;
return `${done}/${subtasks.length}`;
}, [subtasks]);
const handleStatusChange = async (value: number) => {
await onUpdate(item.id, { status: value });
};
const handleAssigneeChange = async (rawValue: string) => {
if (rawValue === '') {
await onUpdate(item.id, { assigneeId: null });
} else {
await onUpdate(item.id, { assigneeId: Number(rawValue) });
}
};
const handleDoneAtChange = async (value: string) => {
await onUpdate(item.id, { doneAt: value || null });
};
const handleCommentsBlur = async () => {
if (comments !== (item.comments ?? '')) {
await onUpdate(item.id, { comments });
}
};
const handleSubtaskToggle = async (index: number) => {
const updated = subtasks.map((s, i) =>
i === index ? { ...s, checked: !s.checked } : s
);
setSubtasks(updated);
await onUpdate(item.id, { subtasks: serializeSubtasks(updated) });
};
const handleNotifyClick = async () => {
if (!item.assigneeId || emailState === 'loading') return;
setEmailState('loading');
const ok = await onNotify(item.id);
setEmailState(ok ? 'success' : 'error');
setTimeout(() => setEmailState('idle'), 2500);
};
const renderEmailIcon = () => {
if (emailState === 'success') return <CheckIcon sx={{ color: '#2e7d32' }} />;
if (emailState === 'error') return <CloseIcon sx={{ color: '#d32f2f' }} />;
return <EmailIcon />;
};
const filteredAdmins = useMemo(
() =>
adminUsers.filter((u) => {
const name = (u.name ?? '').toLowerCase();
return (
!name.includes('inesco energy master admin') &&
!name.includes('paal myhre')
);
}),
[adminUsers]
);
return (
<>
<TableRow hover>
<TableCell sx={{ width: 48, verticalAlign: 'top' }}>
<Typography variant="body2" fontWeight={600}>
{item.stepNumber}
</Typography>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', minWidth: 240 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">
{intl.formatMessage({
id: `checklistStep${item.stepNumber}`,
defaultMessage: item.stepTitle
})}
</Typography>
{subtasks.length > 0 && (
<>
<Chip
size="small"
label={subtaskSummary}
color={
subtasks.every((s) => s.checked) ? 'success' : 'default'
}
/>
<IconButton
size="small"
onClick={() => setSubtasksOpen((o) => !o)}
aria-label={intl.formatMessage({
id: 'checklistToggleSubtasks',
defaultMessage: 'Toggle subtasks'
})}
>
{subtasksOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</>
)}
</Stack>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 160 }}>
<FormControl size="small" fullWidth>
<Select
value={item.status}
onChange={(e) => handleStatusChange(Number(e.target.value))}
sx={{
'& .MuiSelect-select': {
color: statusColors[item.status] ?? 'inherit',
fontWeight: 600
}
}}
>
<MenuItem value={ChecklistStatus.NotStarted}>
<FormattedMessage id="checklistNotStarted" defaultMessage="Not Started" />
</MenuItem>
<MenuItem value={ChecklistStatus.InProgress}>
<FormattedMessage id="checklistInProgress" defaultMessage="In Progress" />
</MenuItem>
<MenuItem value={ChecklistStatus.Done}>
<FormattedMessage id="checklistDone" defaultMessage="Done" />
</MenuItem>
</Select>
</FormControl>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 260 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<FormControl size="small" sx={{ flex: 1 }}>
<Select
value={item.assigneeId ?? ''}
displayEmpty
onChange={(e) => handleAssigneeChange(String(e.target.value))}
>
<MenuItem value="">
<em>
<FormattedMessage
id="checklistNoAssignee"
defaultMessage="Unassigned"
/>
</em>
</MenuItem>
{filteredAdmins.map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.name}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip
title={
!item.assigneeId
? intl.formatMessage({
id: 'checklistNotifyDisabledTooltip',
defaultMessage: 'Assign someone first to send a notification'
})
: intl.formatMessage({
id: 'checklistNotifyTooltip',
defaultMessage: 'Send email notification to assignee'
})
}
>
<span>
<IconButton
size="small"
disabled={!item.assigneeId || emailState === 'loading'}
onClick={handleNotifyClick}
>
{renderEmailIcon()}
</IconButton>
</span>
</Tooltip>
</Stack>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 160 }}>
<TextField
size="small"
type="date"
value={item.doneAt ?? ''}
onChange={(e) => handleDoneAtChange(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', minWidth: 280 }}>
<TextField
size="small"
multiline
minRows={1}
maxRows={6}
value={comments}
onChange={(e) => setComments(e.target.value)}
onBlur={handleCommentsBlur}
fullWidth
placeholder={intl.formatMessage({
id: 'checklistCommentsPlaceholder',
defaultMessage: 'Notes, contact info, observations…'
})}
/>
</TableCell>
</TableRow>
{subtasks.length > 0 && subtasksOpen && (
<TableRow>
<TableCell colSpan={6} sx={{ backgroundColor: '#fafafa', py: 1 }}>
<Box pl={6} display="flex" flexDirection="column">
{subtasks.map((s, i) => (
<Box key={i}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={s.checked}
onChange={() => handleSubtaskToggle(i)}
/>
}
label={
<Typography variant="body2" component="span">
{intl.formatMessage({ id: s.text, defaultMessage: s.text })}
</Typography>
}
sx={{ ml: 0 }}
/>
{UPLOADABLE_SUBTASK_KEYS.has(s.text) && (
<SubtaskDocumentUpload
installationId={installationId}
checklistItemId={item.id}
subtaskKey={s.text}
/>
)}
</Box>
))}
</Box>
</TableCell>
</TableRow>
)}
</>
);
}
export default ChecklistStepRow;

View File

@ -0,0 +1,265 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
LinearProgress,
Snackbar,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Paper
} from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import {
ChecklistItem,
ChecklistStatus
} from 'src/interfaces/ChecklistTypes';
import { AdminUser } from 'src/interfaces/TicketTypes';
import ChecklistStepRow from './ChecklistStepRow';
interface Props {
installationId: number;
}
type ToastState = {
open: boolean;
severity: 'success' | 'error';
message: string;
};
function InstallationChecklistTab({ installationId }: Props) {
const intl = useIntl();
const [items, setItems] = useState<ChecklistItem[]>([]);
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [toast, setToast] = useState<ToastState>({
open: false,
severity: 'success',
message: ''
});
const fetchItems = useCallback(() => {
setLoading(true);
axiosConfig
.get('/GetChecklistForInstallation', { params: { installationId } })
.then((res) => {
setItems(Array.isArray(res.data) ? res.data : []);
setError('');
})
.catch(() => setError('Failed to load checklist.'))
.finally(() => setLoading(false));
}, [installationId]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
useEffect(() => {
axiosConfig
.get('/GetAdminUsers')
.then((res) => {
if (Array.isArray(res.data)) setAdminUsers(res.data);
})
.catch(() => setAdminUsers([]));
}, []);
const progress = useMemo(() => {
const total = items.length;
const done = items.filter(
(i) => i.status === ChecklistStatus.Done
).length;
const percent = total === 0 ? 0 : Math.round((done / total) * 100);
return { total, done, percent };
}, [items]);
const handleUpdate = useCallback(
async (
id: number,
patch: Partial<{
status: number;
comments: string;
assigneeId: number | null;
doneAt: string | null;
subtasks: string;
}>
) => {
const params: Record<string, unknown> = { checklistItemId: id };
if (patch.status !== undefined) params.status = patch.status;
if (patch.comments !== undefined) params.comments = patch.comments;
if (patch.subtasks !== undefined) params.subtasks = patch.subtasks;
if ('assigneeId' in patch) {
if (patch.assigneeId === null) {
params.clearAssignee = true;
} else if (typeof patch.assigneeId === 'number') {
params.assigneeId = patch.assigneeId;
}
}
if ('doneAt' in patch) {
params.doneAt = patch.doneAt ?? '';
}
try {
const res = await axiosConfig.put('/UpdateChecklistItem', null, {
params
});
const updated = res.data as ChecklistItem;
setItems((prev) =>
prev.map((it) => (it.id === id ? updated : it))
);
return true;
} catch {
setToast({
open: true,
severity: 'error',
message: intl.formatMessage({
id: 'checklistSaveFailed',
defaultMessage: 'Failed to save change'
})
});
return false;
}
},
[intl]
);
const handleNotify = useCallback(
async (id: number) => {
const item = items.find((i) => i.id === id);
if (!item || !item.assigneeId) return false;
const assignee = adminUsers.find((u) => u.id === item.assigneeId);
const assigneeName = assignee?.name ?? '';
try {
await axiosConfig.post('/NotifyChecklistAssignee', null, {
params: { checklistItemId: id }
});
setToast({
open: true,
severity: 'success',
message: intl.formatMessage(
{
id: 'checklistEmailSent',
defaultMessage: 'Email sent to {name}'
},
{ name: assigneeName }
)
});
return true;
} catch {
setToast({
open: true,
severity: 'error',
message: intl.formatMessage({
id: 'checklistEmailFailed',
defaultMessage: 'Failed to send email — try again'
})
});
return false;
}
},
[items, adminUsers, intl]
);
if (loading) {
return (
<Box p={3}>
<LinearProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
return (
<Box p={2}>
<Box mb={2}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="h4">
<FormattedMessage
id="checklistTitle"
defaultMessage="Steps to Bring Installation to Monitor"
/>
</Typography>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="checklistProgress"
defaultMessage="Progress: {done}/{total} ({percent}%)"
values={progress}
/>
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>
<FormattedMessage id="checklistStep" defaultMessage="Step" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistStatus" defaultMessage="Status" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistAssignee" defaultMessage="Assignee" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistDateDone" defaultMessage="Date Done" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistComments" defaultMessage="Comments" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<ChecklistStepRow
key={item.id}
item={item}
installationId={installationId}
adminUsers={adminUsers}
onUpdate={handleUpdate}
onNotify={handleNotify}
/>
))}
</TableBody>
</Table>
</TableContainer>
<Snackbar
open={toast.open}
autoHideDuration={4000}
onClose={() => setToast((t) => ({ ...t, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={toast.severity}
onClose={() => setToast((t) => ({ ...t, open: false }))}
>
{toast.message}
</Alert>
</Snackbar>
</Box>
);
}
export default InstallationChecklistTab;

View File

@ -0,0 +1,56 @@
import React from 'react';
import { Box, LinearProgress, Tooltip, Typography } from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
interface Props {
done: number | null;
total: number | null;
}
function phaseId(done: number, total: number): string {
if (total === 0) return 'checklistPhaseEmpty';
if (done >= total) return 'checklistPhaseComplete';
if (done <= 5) return 'checklistPhasePreparation';
if (done <= 12) return 'checklistPhaseOnSite';
return 'checklistPhaseHandover';
}
function SetupProgress({ done, total }: Props) {
const intl = useIntl();
if (done === null || total === null || total === 0) {
return (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: 'small' }}>
--
</Typography>
);
}
const percent = Math.round((done / total) * 100);
const color = percent >= 100 ? 'success' : percent > 0 ? 'warning' : 'inherit';
const tooltip = intl.formatMessage({
id: phaseId(done, total),
defaultMessage: 'Progress'
});
return (
<Tooltip title={tooltip}>
<Box display="flex" alignItems="center" sx={{ minWidth: 120 }}>
<Box sx={{ width: 70, mr: 1 }}>
<LinearProgress
variant="determinate"
value={percent}
color={color === 'inherit' ? 'primary' : (color as any)}
sx={{ height: 6, borderRadius: 3 }}
/>
</Box>
<Typography variant="body2" sx={{ fontSize: 'small', whiteSpace: 'nowrap' }}>
{done}/{total}
</Typography>
</Box>
</Tooltip>
);
}
export default SetupProgress;

View File

@ -0,0 +1,124 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, IconButton, Stack, Typography } from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import FileUploadButton, {
UploadedDocument
} from 'src/components/FileUploadButton';
interface DocumentItem {
id: number;
originalName: string;
contentType: string;
sizeBytes: number;
createdAt: string;
}
interface Props {
installationId: number;
checklistItemId: number;
subtaskKey: string;
}
function fileIcon(contentType: string) {
if (contentType === 'application/pdf')
return <PictureAsPdfIcon fontSize="small" color="error" />;
return <InsertDriveFileIcon fontSize="small" />;
}
function SubtaskDocumentUpload({ installationId, checklistItemId, subtaskKey }: Props) {
const [docs, setDocs] = useState<DocumentItem[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const fetchDocs = useCallback(() => {
axiosConfig
.get('/GetDocuments', { params: { checklistItemId, subtaskKey } })
.then((res) => {
if (Array.isArray(res.data)) setDocs(res.data);
})
.catch(() => setDocs([]));
}, [checklistItemId, subtaskKey]);
useEffect(() => {
fetchDocs();
}, [fetchDocs, refreshKey]);
const handleDownload = (doc: DocumentItem) => {
axiosConfig
.get('/DownloadDocument', {
params: { id: doc.id },
responseType: 'blob'
})
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', doc.originalName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
})
.catch(() => {});
};
const handleUploaded = (_doc: UploadedDocument) => {
setRefreshKey((k) => k + 1);
};
return (
<Box mt={0.5} mb={1}>
{docs.length > 0 && (
<Stack spacing={0.25} mb={0.75}>
{docs.map((d) => (
<Stack
key={d.id}
direction="row"
alignItems="center"
spacing={1}
sx={{ pl: 4 }}
>
{fileIcon(d.contentType)}
<Typography variant="body2" sx={{ flex: 1 }} noWrap>
{d.originalName}
</Typography>
<IconButton
size="small"
onClick={() => handleDownload(d)}
aria-label="download"
>
<DownloadIcon fontSize="small" />
</IconButton>
</Stack>
))}
</Stack>
)}
<Box sx={{ pl: 4 }}>
<FileUploadButton
scope={1}
installationId={installationId}
checklistItemId={checklistItemId}
subtaskKey={subtaskKey}
onUploaded={handleUploaded}
/>
</Box>
{docs.length === 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ pl: 4, display: 'block', mt: 0.5 }}
>
<FormattedMessage
id="checklistNoAttachments"
defaultMessage="No file attached yet."
/>
</Typography>
)}
</Box>
);
}
export default SubtaskDocumentUpload;

View File

@ -27,6 +27,14 @@ import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import axiosConfig from 'src/Resources/axiosConfig';
import {
CHECKLIST_ENABLED_PRODUCTS,
ChecklistSummary
} from 'src/interfaces/ChecklistTypes';
import SetupProgress from '../Checklist/SetupProgress';
interface FlatInstallationViewProps {
installations: I_Installation[];
@ -46,6 +54,28 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortByStatus, setSortByStatus] = useState('All Installations');
const [sortByAction, setSortByAction] = useState('All Installations');
const { currentUser } = useContext(UserContext);
const showChecklistColumn =
currentUser?.userType === UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(product ?? -1);
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
new Map()
);
useEffect(() => {
if (!showChecklistColumn) return;
axiosConfig
.get('/GetChecklistSummary')
.then((res) => {
if (!Array.isArray(res.data)) return;
const map = new Map<number, ChecklistSummary>();
res.data.forEach((s: ChecklistSummary) => {
map.set(s.installationId, s);
});
setProgressMap(map);
})
.catch(() => setProgressMap(new Map()));
}, [showChecklistColumn, currentLocation.pathname]);
const HoverableTableRow = styled(TableRow)(({ theme }) => ({
cursor: 'pointer',
@ -315,6 +345,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
{showChecklistColumn && (
<TableCell>
<FormattedMessage
id="setupProgress"
defaultMessage="Setup Progress"
/>
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
@ -461,6 +499,19 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
)}
</div>
</TableCell>
{showChecklistColumn && (
<TableCell>
{(() => {
const summary = progressMap.get(installation.id);
return (
<SetupProgress
done={summary?.done ?? 0}
total={summary?.total ?? 16}
/>
);
})()}
</TableCell>
)}
</TableRow>
);
})}

View File

@ -31,6 +31,8 @@ import Configuration from '../Configuration/Configuration';
import PvView from '../PvView/PvView';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
import InstallationChecklistTab from '../Checklist/InstallationChecklistTab';
import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -386,7 +388,8 @@ function Installation(props: singleInstallationProps) {
currentTab != 'history' &&
// currentTab != 'manage' &&
currentTab != 'log' &&
currentTab != 'installationTickets' && (
currentTab != 'installationTickets' &&
currentTab != 'checklist' && (
<Container
maxWidth="xl"
sx={{
@ -569,6 +572,18 @@ function Installation(props: singleInstallationProps) {
/>
)}
{currentUser.userType == UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && (
<Route
path={routes.checklist}
element={
<InstallationChecklistTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}
element={<Navigate to={routes.live}></Navigate>}

View File

@ -34,7 +34,8 @@ function InstallationTabs(props: InstallationTabsProps) {
'history',
'pvview',
'installationTickets',
'documents'
'documents',
'checklist'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -105,6 +106,10 @@ function InstallationTabs(props: InstallationTabsProps) {
// TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready
const hidePvView = props.product === 4;
// Checklist is only shown for Sodistore Grid (product=4) in the Installations view.
// Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows.
const showChecklist = props.product === 4;
const singleInstallationTabs = (
currentUser.userType == UserType.admin
? [
@ -175,6 +180,10 @@ function InstallationTabs(props: InstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -229,7 +238,9 @@ function InstallationTabs(props: InstallationTabsProps) {
)
}
]
).filter((tab) => !(hidePvView && tab.value === 'pvview'));
)
.filter((tab) => !(hidePvView && tab.value === 'pvview'))
.filter((tab) => !(!showChecklist && tab.value === 'checklist'));
const tabs =
currentTab != 'list' &&
@ -316,6 +327,10 @@ function InstallationTabs(props: InstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -410,9 +425,10 @@ function InstallationTabs(props: InstallationTabsProps) {
];
// Filter out PV View for SodistoreGrid
const filteredTabs = hidePvView
const filteredTabs = (hidePvView
? tabs.filter((tab) => tab.value !== 'pvview')
: tabs;
: tabs
).filter((tab) => !(!showChecklist && tab.value === 'checklist'));
return installations.length > 1 ? (
<>

View File

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import {
Card,
CircularProgress,
@ -20,6 +20,14 @@ import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build';
import { getDeviceTypeName } from '../Information/installationSetupUtils';
import { UserContext } from 'src/contexts/userContext';
import { UserType } from 'src/interfaces/UserTypes';
import axiosConfig from 'src/Resources/axiosConfig';
import {
CHECKLIST_ENABLED_PRODUCTS,
ChecklistSummary
} from 'src/interfaces/ChecklistTypes';
import SetupProgress from '../Checklist/SetupProgress';
interface FlatInstallationViewProps {
installations: I_Installation[];
@ -32,6 +40,32 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const currentLocation = useLocation();
const baseRoute = props.product === 5 ? routes.sodistorepro_installations : routes.sodiohome_installations;
const { currentUser } = useContext(UserContext);
const showChecklistColumn =
currentUser?.userType === UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1);
const isListViewPath =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
new Map()
);
useEffect(() => {
if (!showChecklistColumn || !isListViewPath) return;
axiosConfig
.get('/GetChecklistSummary')
.then((res) => {
if (!Array.isArray(res.data)) return;
const map = new Map<number, ChecklistSummary>();
res.data.forEach((s: ChecklistSummary) => {
map.set(s.installationId, s);
});
setProgressMap(map);
})
.catch(() => setProgressMap(new Map()));
}, [showChecklistColumn, isListViewPath]);
const sortedInstallations = useMemo(() => {
return [...props.installations].sort((a, b) => {
// Data-collection-disabled installations sink below everything (even offline).
@ -83,9 +117,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
}
}));
const isListView =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
const isListView = isListViewPath;
return (
<Grid container spacing={1} sx={{ marginTop: 0.1 }}>
@ -118,6 +150,14 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
{showChecklistColumn && (
<TableCell>
<FormattedMessage
id="setupProgress"
defaultMessage="Setup Progress"
/>
</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
@ -282,6 +322,19 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
)}
</div>
</TableCell>
{showChecklistColumn && (
<TableCell>
{(() => {
const summary = progressMap.get(installation.id);
return (
<SetupProgress
done={summary?.done ?? 0}
total={summary?.total ?? 16}
/>
);
})()}
</TableCell>
)}
</HoverableTableRow>
);
})}

View File

@ -30,6 +30,8 @@ import Overview from '../Overview/overview';
import WeeklyReport from './WeeklyReport';
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
import DocumentsTab from '../Documents/DocumentsTab';
import InstallationChecklistTab from '../Checklist/InstallationChecklistTab';
import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes';
interface singleInstallationProps {
current_installation?: I_Installation;
@ -496,7 +498,8 @@ function SodioHomeInstallation(props: singleInstallationProps) {
currentTab != 'log' &&
currentTab != 'report' &&
currentTab != 'installationTickets' &&
currentTab != 'documents' && (
currentTab != 'documents' &&
currentTab != 'checklist' && (
<Container
maxWidth="xl"
sx={{
@ -670,6 +673,18 @@ function SodioHomeInstallation(props: singleInstallationProps) {
/>
)}
{currentUser.userType == UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && (
<Route
path={routes.checklist}
element={
<InstallationChecklistTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}
element={<Navigate to={dataCollectionDisabled ? routes.information : routes.live}></Navigate>}

View File

@ -53,7 +53,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
'configuration',
'report',
'installationTickets',
'documents'
'documents',
'checklist'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -192,6 +193,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -279,7 +284,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
const dataCollectionDisabled =
currentInstallation?.dataCollectionEnabled === false
|| (installations.length === 1 && installations[0].dataCollectionEnabled === false);
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents'];
const allowedWhenDisabled = ['list', 'tree', 'information', 'history', 'installationTickets', 'documents', 'checklist'];
const tabs = inInstallationView && currentUser.userType == UserType.admin
? [
@ -361,6 +366,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: inInstallationView && currentUser.userType == UserType.partner

View File

@ -0,0 +1,55 @@
export enum ChecklistStatus {
NotStarted = 0,
InProgress = 1,
Done = 2
}
export type ChecklistSubtask = {
text: string;
checked: boolean;
};
export type ChecklistItem = {
id: number;
installationId: number;
stepNumber: number;
stepTitle: string;
status: number;
comments: string;
assigneeId: number | null;
doneAt: string | null;
subtasks: string | null;
createdAt: string;
updatedAt: string;
};
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]);
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
'checklistStep8Sub1',
'checklistStep10Sub1',
'checklistStep10Sub2'
]);
export type ChecklistSummary = {
installationId: number;
done: number;
total: number;
};
export function parseSubtasks(raw: string | null | undefined): ChecklistSubtask[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((x) => x && typeof x.text === 'string')
.map((x) => ({ text: String(x.text), checked: Boolean(x.checked) }));
} catch {
return [];
}
}
export function serializeSubtasks(subtasks: ChecklistSubtask[]): string {
return JSON.stringify(subtasks);
}

View File

@ -693,5 +693,65 @@
"fileTooLarge": "Datei überschreitet die maximale Grösse von 25 MB.",
"invalidFileType": "Ungültiger Dateityp.",
"uploadFailed": "Hochladen fehlgeschlagen.",
"uploadSuccess": "Hochladen erfolgreich."
"uploadSuccess": "Hochladen erfolgreich.",
"checklist": "Checkliste",
"checklistTitle": "Schritte zur Anbindung der Installation an Monitor",
"checklistProgress": "Fortschritt: {done}/{total} ({percent}%)",
"checklistStep": "Schritt",
"checklistStatus": "Status",
"checklistAssignee": "Zuständig",
"checklistDateDone": "Erledigungsdatum",
"checklistComments": "Kommentare",
"checklistNotStarted": "Nicht gestartet",
"checklistInProgress": "In Bearbeitung",
"checklistDone": "Erledigt",
"checklistNoAssignee": "Nicht zugewiesen",
"checklistNotifyTooltip": "E-Mail-Benachrichtigung an Zuständige senden",
"checklistNotifyDisabledTooltip": "Weisen Sie zuerst jemanden zu, um eine Benachrichtigung zu senden",
"checklistToggleSubtasks": "Unteraufgaben umschalten",
"checklistCommentsPlaceholder": "Notizen, Kontaktinformationen, Beobachtungen…",
"checklistEmailSent": "E-Mail an {name} gesendet",
"checklistEmailFailed": "E-Mail-Versand fehlgeschlagen — bitte erneut versuchen",
"checklistSaveFailed": "Änderung konnte nicht gespeichert werden",
"checklistStep1": "Auftrag erstellt, Kunden- und Partnerinformationen im CRM erfasst",
"checklistStep2": "Hardware bei Vebo zusammengebaut",
"checklistStep3": "Installation auf Monitor unter korrektem Produkt und Ordner erstellt",
"checklistStep4": "Gateway-SD-Karte konfiguriert, VPN und Gateway-Name registriert",
"checklistStep5": "Informations-Tab ausgefüllt (Kunde, Seriennummern, VPN)",
"checklistStep6": "Installation bei Vebo elektrisch/hardwareseitig konfiguriert und getestet",
"checklistStep7": "Installation bei Vebo softwareseitig getestet",
"checklistStep8": "Installation am Kundenstandort ausgeliefert",
"checklistStep9": "Installation ans Netz angeschlossen",
"checklistStep10": "Hardware vor Ort verifiziert",
"checklistStep11": "Software vor Ort verifiziert",
"checklistStep12": "Installation online auf Monitor",
"checklistStep13": "Kunde über Monitor-Konto und Reports informiert",
"checklistStep14": "Benutzerkonto mit richtigen Ordnern und Zugriffen erstellt",
"checklistStep15": "Kundennachverfolgung abgeschlossen, Feedback eingeholt",
"checklistStep16": "Weitere Anliegen werden über das Ticket-System verfolgt",
"checklistStep3Sub1": "Installations-Seriennummer",
"checklistStep5Sub1": "Kundeninformationen (E-Mail, Adresse)",
"checklistStep5Sub2": "Installationsinformationen (externes EMS, Stromanbieter, Datenerfassung)",
"checklistStep5Sub3": "Batterie-Seriennummer",
"checklistStep5Sub4": "Wechselrichter-Seriennummer",
"checklistStep5Sub5": "Datenlogger-Seriennummer",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Wechselrichter-Firmware und Konfiguration geprüft",
"checklistStep6Sub2": "Batterie-Firmware und Konfiguration geprüft",
"checklistStep6Sub3": "Internet für Gateway konfiguriert",
"checklistStep6Sub4": "Kommunikationskabel zwischen Gateway und Wechselrichter korrekt",
"checklistStep7Sub1": "S3-Bucket-Nummer und Schlüssel-Credentials aus Informations-Tab in config.json übernommen",
"checklistStep7Sub2": "Produkt-ID in config.json konfiguriert",
"checklistStep7Sub3": "USB-ID in config.json konfiguriert",
"checklistStep7Sub4": "Datenlesung vom Wechselrichter getestet",
"checklistStep8Sub1": "Lieferschein mit Kundenunterschrift erhalten und hochgeladen",
"checklistStep10Sub1": "Installationsprotokoll hochgeladen",
"checklistStep10Sub2": "Zeit- und Materialbericht in Monitoring hochgeladen",
"checklistNoAttachments": "Noch keine Datei angehängt.",
"setupProgress": "Setup-Fortschritt",
"checklistPhaseEmpty": "Nicht gestartet",
"checklistPhasePreparation": "Vorbereitung",
"checklistPhaseOnSite": "Vor Ort",
"checklistPhaseHandover": "Kundenübergabe",
"checklistPhaseComplete": "Abgeschlossen"
}

View File

@ -441,5 +441,65 @@
"fileTooLarge": "File exceeds maximum size of 25 MB.",
"invalidFileType": "Invalid file type.",
"uploadFailed": "Upload failed.",
"uploadSuccess": "Upload successful."
"uploadSuccess": "Upload successful.",
"checklist": "Checklist",
"checklistTitle": "Steps to Bring Installation to Monitor",
"checklistProgress": "Progress: {done}/{total} ({percent}%)",
"checklistStep": "Step",
"checklistStatus": "Status",
"checklistAssignee": "Assignee",
"checklistDateDone": "Date Done",
"checklistComments": "Comments",
"checklistNotStarted": "Not Started",
"checklistInProgress": "In Progress",
"checklistDone": "Done",
"checklistNoAssignee": "Unassigned",
"checklistNotifyTooltip": "Send email notification to assignee",
"checklistNotifyDisabledTooltip": "Assign someone first to send a notification",
"checklistToggleSubtasks": "Toggle subtasks",
"checklistCommentsPlaceholder": "Notes, contact info, observations…",
"checklistEmailSent": "Email sent to {name}",
"checklistEmailFailed": "Failed to send email — try again",
"checklistSaveFailed": "Failed to save change",
"checklistStep1": "Order created, customer and partner info recorded in CRM",
"checklistStep2": "Hardware assembled at Vebo",
"checklistStep3": "Installation created on Monitor under correct product and folder",
"checklistStep4": "Gateway SD card configured, VPN and gateway name registered",
"checklistStep5": "Information tab filled out (customer, serials, VPN)",
"checklistStep6": "Installation configured and tested electrically / hardware-wise at Vebo",
"checklistStep7": "Installation tested software-wise at Vebo",
"checklistStep8": "Installation delivered to customer site",
"checklistStep9": "Installation connected to grid",
"checklistStep10": "Hardware verified on site",
"checklistStep11": "Software verified on site",
"checklistStep12": "Installation online on Monitor",
"checklistStep13": "Customer informed about Monitor account and reports",
"checklistStep14": "User account created with correct folders and access",
"checklistStep15": "Customer follow-up completed, feedback collected",
"checklistStep16": "Further issues tracked via Ticket system",
"checklistStep3Sub1": "Installation serial number",
"checklistStep5Sub1": "Customer information (email, address)",
"checklistStep5Sub2": "Installation information (external EMS, grid provider, data collection)",
"checklistStep5Sub3": "Battery serial number",
"checklistStep5Sub4": "Inverter serial number",
"checklistStep5Sub5": "Data logger serial number",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Inverter firmware and configuration verified",
"checklistStep6Sub2": "Battery firmware and configuration verified",
"checklistStep6Sub3": "Internet for gateway configured",
"checklistStep6Sub4": "Communication cable between gateway and inverter correct",
"checklistStep7Sub1": "S3 bucket number and key credentials copied from Information tab into config.json",
"checklistStep7Sub2": "Product ID configured in config.json",
"checklistStep7Sub3": "USB ID configured in config.json",
"checklistStep7Sub4": "Inverter data reading from inverter tested",
"checklistStep8Sub1": "Delivery receipt with customer signature received and uploaded",
"checklistStep10Sub1": "Installation protocol uploaded",
"checklistStep10Sub2": "Time and material report uploaded to Monitoring",
"checklistNoAttachments": "No file attached yet.",
"setupProgress": "Setup Progress",
"checklistPhaseEmpty": "Not started",
"checklistPhasePreparation": "Preparation",
"checklistPhaseOnSite": "On-site",
"checklistPhaseHandover": "Customer handover",
"checklistPhaseComplete": "Complete"
}

View File

@ -693,5 +693,65 @@
"fileTooLarge": "Le fichier dépasse la taille maximale de 25 Mo.",
"invalidFileType": "Type de fichier non valide.",
"uploadFailed": "Échec du téléchargement.",
"uploadSuccess": "Téléchargement réussi."
"uploadSuccess": "Téléchargement réussi.",
"checklist": "Checklist",
"checklistTitle": "Étapes pour connecter l'installation à Monitor",
"checklistProgress": "Progression : {done}/{total} ({percent}%)",
"checklistStep": "Étape",
"checklistStatus": "Statut",
"checklistAssignee": "Responsable",
"checklistDateDone": "Date de réalisation",
"checklistComments": "Commentaires",
"checklistNotStarted": "Non commencé",
"checklistInProgress": "En cours",
"checklistDone": "Terminé",
"checklistNoAssignee": "Non attribué",
"checklistNotifyTooltip": "Envoyer une notification par e-mail au responsable",
"checklistNotifyDisabledTooltip": "Attribuez d'abord un responsable pour envoyer une notification",
"checklistToggleSubtasks": "Afficher/masquer les sous-tâches",
"checklistCommentsPlaceholder": "Notes, coordonnées, observations…",
"checklistEmailSent": "E-mail envoyé à {name}",
"checklistEmailFailed": "Échec de l'envoi — veuillez réessayer",
"checklistSaveFailed": "Échec de l'enregistrement",
"checklistStep1": "Commande créée, informations client et partenaire enregistrées dans le CRM",
"checklistStep2": "Matériel assemblé chez Vebo",
"checklistStep3": "Installation créée sur Monitor sous le bon produit et dossier",
"checklistStep4": "Carte SD du gateway configurée, VPN et nom du gateway enregistrés",
"checklistStep5": "Onglet Informations rempli (client, numéros de série, VPN)",
"checklistStep6": "Installation configurée et testée électriquement/matériel chez Vebo",
"checklistStep7": "Installation testée côté logiciel chez Vebo",
"checklistStep8": "Installation livrée sur le site du client",
"checklistStep9": "Installation raccordée au réseau",
"checklistStep10": "Matériel vérifié sur site",
"checklistStep11": "Logiciel vérifié sur site",
"checklistStep12": "Installation en ligne sur Monitor",
"checklistStep13": "Client informé du compte Monitor et des rapports",
"checklistStep14": "Compte utilisateur créé avec les dossiers et accès corrects",
"checklistStep15": "Suivi client effectué, retour recueilli",
"checklistStep16": "Problèmes ultérieurs suivis via le système de tickets",
"checklistStep3Sub1": "Numéro de série de l'installation",
"checklistStep5Sub1": "Informations client (e-mail, adresse)",
"checklistStep5Sub2": "Informations d'installation (EMS externe, fournisseur réseau, collecte de données)",
"checklistStep5Sub3": "Numéro de série de la batterie",
"checklistStep5Sub4": "Numéro de série de l'onduleur",
"checklistStep5Sub5": "Numéro de série de l'enregistreur de données",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Firmware et configuration de l'onduleur vérifiés",
"checklistStep6Sub2": "Firmware et configuration de la batterie vérifiés",
"checklistStep6Sub3": "Internet pour le gateway configuré",
"checklistStep6Sub4": "Câble de communication entre gateway et onduleur correct",
"checklistStep7Sub1": "Numéro de bucket S3 et identifiants copiés depuis l'onglet Informations dans config.json",
"checklistStep7Sub2": "ID produit configuré dans config.json",
"checklistStep7Sub3": "ID USB configuré dans config.json",
"checklistStep7Sub4": "Lecture des données de l'onduleur testée",
"checklistStep8Sub1": "Bon de livraison signé par le client reçu et téléversé",
"checklistStep10Sub1": "Procès-verbal d'installation téléversé",
"checklistStep10Sub2": "Rapport de temps et matériaux téléversé dans Monitoring",
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
"setupProgress": "Progression installation",
"checklistPhaseEmpty": "Non commencé",
"checklistPhasePreparation": "Préparation",
"checklistPhaseOnSite": "Sur site",
"checklistPhaseHandover": "Transfert client",
"checklistPhaseComplete": "Terminé"
}

View File

@ -693,5 +693,65 @@
"fileTooLarge": "Il file supera la dimensione massima di 25 MB.",
"invalidFileType": "Tipo di file non valido.",
"uploadFailed": "Caricamento fallito.",
"uploadSuccess": "Caricamento riuscito."
"uploadSuccess": "Caricamento riuscito.",
"checklist": "Checklist",
"checklistTitle": "Passi per collegare l'installazione a Monitor",
"checklistProgress": "Avanzamento: {done}/{total} ({percent}%)",
"checklistStep": "Passo",
"checklistStatus": "Stato",
"checklistAssignee": "Assegnatario",
"checklistDateDone": "Data completamento",
"checklistComments": "Commenti",
"checklistNotStarted": "Non avviato",
"checklistInProgress": "In corso",
"checklistDone": "Completato",
"checklistNoAssignee": "Non assegnato",
"checklistNotifyTooltip": "Invia notifica e-mail all'assegnatario",
"checklistNotifyDisabledTooltip": "Assegnare prima qualcuno per inviare una notifica",
"checklistToggleSubtasks": "Mostra/nascondi sottoattività",
"checklistCommentsPlaceholder": "Note, contatti, osservazioni…",
"checklistEmailSent": "E-mail inviata a {name}",
"checklistEmailFailed": "Invio e-mail non riuscito — riprovare",
"checklistSaveFailed": "Salvataggio non riuscito",
"checklistStep1": "Ordine creato, informazioni cliente e partner registrate nel CRM",
"checklistStep2": "Hardware assemblato presso Vebo",
"checklistStep3": "Installazione creata su Monitor sotto il prodotto e la cartella corretti",
"checklistStep4": "Scheda SD del gateway configurata, VPN e nome gateway registrati",
"checklistStep5": "Scheda Informazioni compilata (cliente, seriali, VPN)",
"checklistStep6": "Installazione configurata e testata elettricamente/hardware presso Vebo",
"checklistStep7": "Installazione testata a livello software presso Vebo",
"checklistStep8": "Installazione consegnata presso il sito del cliente",
"checklistStep9": "Installazione collegata alla rete",
"checklistStep10": "Hardware verificato in sito",
"checklistStep11": "Software verificato in sito",
"checklistStep12": "Installazione online su Monitor",
"checklistStep13": "Cliente informato su account Monitor e report",
"checklistStep14": "Account utente creato con cartelle e accessi corretti",
"checklistStep15": "Follow-up cliente completato, feedback raccolto",
"checklistStep16": "Ulteriori problemi tracciati tramite il sistema di ticket",
"checklistStep3Sub1": "Numero di serie dell'installazione",
"checklistStep5Sub1": "Informazioni cliente (e-mail, indirizzo)",
"checklistStep5Sub2": "Informazioni installazione (EMS esterno, fornitore di rete, raccolta dati)",
"checklistStep5Sub3": "Numero di serie batteria",
"checklistStep5Sub4": "Numero di serie inverter",
"checklistStep5Sub5": "Numero di serie data logger",
"checklistStep5Sub6": "VPN",
"checklistStep6Sub1": "Firmware e configurazione inverter verificati",
"checklistStep6Sub2": "Firmware e configurazione batteria verificati",
"checklistStep6Sub3": "Internet per gateway configurato",
"checklistStep6Sub4": "Cavo di comunicazione tra gateway e inverter corretto",
"checklistStep7Sub1": "Numero bucket S3 e credenziali copiati dalla scheda Informazioni in config.json",
"checklistStep7Sub2": "ID prodotto configurato in config.json",
"checklistStep7Sub3": "ID USB configurato in config.json",
"checklistStep7Sub4": "Lettura dati inverter testata",
"checklistStep8Sub1": "Bolla di consegna con firma del cliente ricevuta e caricata",
"checklistStep10Sub1": "Verbale di installazione caricato",
"checklistStep10Sub2": "Rapporto tempi e materiali caricato su Monitoring",
"checklistNoAttachments": "Nessun file allegato.",
"setupProgress": "Avanzamento installazione",
"checklistPhaseEmpty": "Non avviato",
"checklistPhasePreparation": "Preparazione",
"checklistPhaseOnSite": "In sito",
"checklistPhaseHandover": "Consegna cliente",
"checklistPhaseComplete": "Completato"
}