Phase 12K: Warning UI Refinement.

This commit is contained in:
Nick Beckley 2026-06-15 10:08:29 +01:00
parent ecc8d3c76a
commit 1783ab88ee
3 changed files with 220 additions and 189 deletions

View File

@ -4052,6 +4052,7 @@ public sealed class ContinuityValidationService(
ShowAcknowledgedWarnings = showAcknowledgedWarnings, ShowAcknowledgedWarnings = showAcknowledgedWarnings,
Warnings = rows, Warnings = rows,
ProjectWarnings = dashboardWarnings, ProjectWarnings = dashboardWarnings,
WarningGroups = BuildWarningSeverityGroups(dashboardWarnings),
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(), BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
ChapterOptions = projectChapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber:g}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == chapterId)).ToList(), ChapterOptions = projectChapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber:g}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == chapterId)).ToList(),
WarningTypeOptions = lookupData.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == warningTypeId)).ToList(), WarningTypeOptions = lookupData.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == warningTypeId)).ToList(),
@ -4297,6 +4298,33 @@ public sealed class ContinuityValidationService(
warning.InvestigationSceneId = warning.CurrentSceneId ?? warning.ReferencedSceneId ?? warning.SceneID; warning.InvestigationSceneId = warning.CurrentSceneId ?? warning.ReferencedSceneId ?? warning.SceneID;
} }
private static IReadOnlyList<WarningSeverityGroupViewModel> BuildWarningSeverityGroups(IReadOnlyList<ProjectWarningViewModel> warnings)
{
var groups = warnings
.GroupBy(x => WarningSeverityGroupLabel(x.Severity))
.ToDictionary(x => x.Key, x => x.ToList(), StringComparer.OrdinalIgnoreCase);
return new[] { "Critical", "Warning", "Information" }
.Where(groups.ContainsKey)
.Select(severity => new WarningSeverityGroupViewModel
{
Severity = severity,
IsExpanded = severity is "Critical" or "Warning",
Warnings = groups[severity]
})
.ToList();
}
private static string WarningSeverityGroupLabel(string severity)
{
return severity switch
{
"Critical" or "Error" => "Critical",
"Warning" => "Warning",
_ => "Information"
};
}
private static string WarningCategoryForPersisted(ContinuityWarning warning) private static string WarningCategoryForPersisted(ContinuityWarning warning)
{ {
return warning.EntityType switch return warning.EntityType switch

View File

@ -1671,6 +1671,7 @@ public sealed class WarningDashboardViewModel
public bool ShowAcknowledgedWarnings { get; set; } public bool ShowAcknowledgedWarnings { get; set; }
public IReadOnlyList<ContinuityWarning> Warnings { get; set; } = []; public IReadOnlyList<ContinuityWarning> Warnings { get; set; } = [];
public IReadOnlyList<ProjectWarningViewModel> ProjectWarnings { get; set; } = []; public IReadOnlyList<ProjectWarningViewModel> ProjectWarnings { get; set; } = [];
public IReadOnlyList<WarningSeverityGroupViewModel> WarningGroups { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = []; public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = []; public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> WarningTypeOptions { get; set; } = []; public IReadOnlyList<SelectListItem> WarningTypeOptions { get; set; } = [];
@ -1683,6 +1684,13 @@ public sealed class WarningDashboardViewModel
public bool HasAnyWarnings { get; set; } public bool HasAnyWarnings { get; set; }
} }
public sealed class WarningSeverityGroupViewModel
{
public string Severity { get; set; } = string.Empty;
public bool IsExpanded { get; set; }
public IReadOnlyList<ProjectWarningViewModel> Warnings { get; set; } = [];
}
public sealed class ProjectWarningViewModel public sealed class ProjectWarningViewModel
{ {
public string Source { get; set; } = "Persisted"; public string Source { get; set; } = "Persisted";

View File

@ -7,7 +7,7 @@
@functions { @functions {
private static string SeverityBadgeClass(string severity) => severity switch private static string SeverityBadgeClass(string severity) => severity switch
{ {
"Critical" => "bg-danger", "Critical" or "Error" => "bg-danger",
"Warning" => "bg-warning text-dark", "Warning" => "bg-warning text-dark",
_ => "bg-info text-dark" _ => "bg-info text-dark"
}; };
@ -143,194 +143,189 @@
} }
else else
{ {
<div class="table-responsive"> <div class="accordion" id="warning-severity-groups">
<table class="table table-sm align-middle"> @foreach (var group in Model.WarningGroups)
<thead> {
<tr> var groupId = $"warning-group-{group.Severity.ToLowerInvariant().Replace(" ", "-")}";
<th>Severity</th> <div class="accordion-item">
<th>Category</th> <h3 class="accordion-header" id="@($"{groupId}-heading")">
<th>Warning Type</th> <button class="accordion-button @(group.IsExpanded ? "" : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#@groupId" aria-expanded="@group.IsExpanded.ToString().ToLowerInvariant()" aria-controls="@groupId">
<th>Message</th> @group.Severity (@group.Warnings.Count)
<th>Book</th> </button>
<th>Chapter</th> </h3>
<th>Scene</th> <div id="@groupId" class="accordion-collapse collapse @(group.IsExpanded ? "show" : "")" aria-labelledby="@($"{groupId}-heading")">
<th>Status</th> <div class="accordion-body p-0">
<th>Actions</th> <div class="table-responsive">
</tr> <table class="table table-sm align-middle mb-0">
</thead> <thead>
<tbody> <tr>
@foreach (var warning in Model.ProjectWarnings) <th>Severity</th>
{ <th>Category</th>
<tr> <th>Warning Type</th>
<td><span class="badge @SeverityBadgeClass(warning.Severity)">@warning.Severity</span></td> <th>Message</th>
<td>@warning.Category</td> <th>Scene</th>
<td>@warning.WarningTypeDisplayName</td> <th>Status</th>
<td> <th>Actions</th>
<div>@warning.Message</div> </tr>
@if (!string.IsNullOrWhiteSpace(warning.Details)) </thead>
{ <tbody>
<small class="text-muted d-block">@warning.Details</small> @foreach (var warning in group.Warnings)
} {
@if (!string.IsNullOrWhiteSpace(warning.KnowledgeTitle)) <tr>
{ <td><span class="badge @SeverityBadgeClass(warning.Severity)">@warning.Severity</span></td>
<small class="text-muted d-block">Knowledge: @warning.KnowledgeTitle</small> <td>@warning.Category</td>
} <td>@warning.WarningTypeDisplayName</td>
@if (!string.IsNullOrWhiteSpace(warning.PreviousKnowledgeState) && !string.IsNullOrWhiteSpace(warning.CurrentKnowledgeState)) <td>
{ <div>@warning.Message</div>
<small class="text-muted d-block">State: @warning.PreviousKnowledgeState &rarr; @warning.CurrentKnowledgeState</small> @if (!string.IsNullOrWhiteSpace(warning.Details))
} {
@if (warning.PreviousSceneId.HasValue) <small class="text-muted d-block">@warning.Details</small>
{ }
<small class="text-muted d-block"> @if (!string.IsNullOrWhiteSpace(warning.PreviousKnowledgeState) && !string.IsNullOrWhiteSpace(warning.CurrentKnowledgeState))
Previous: {
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.PreviousSceneId">@warning.PreviousContextSceneDisplayName</a> <small class="text-muted d-block">@warning.PreviousKnowledgeState &rarr; @warning.CurrentKnowledgeState</small>
</small> }
} @if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes))
@if (warning.CurrentSceneId.HasValue) {
{ <small class="text-muted d-block">Note: @warning.AcknowledgementNotes</small>
<small class="text-muted d-block"> }
Current: </td>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.CurrentSceneId">@warning.CurrentContextSceneDisplayName</a> <td>
</small> @if (warning.PreviousSceneId.HasValue || warning.CurrentSceneId.HasValue)
} {
@if (warning.EstablishedSceneId.HasValue) @if (warning.PreviousSceneId.HasValue)
{ {
<small class="text-muted d-block"> <small class="text-muted d-block">Previous</small>
Established: <a class="d-block mb-2" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.PreviousSceneId">@warning.PreviousContextSceneDisplayName</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.EstablishedSceneId">@(string.IsNullOrWhiteSpace(warning.EstablishedContextSceneDisplayName) ? warning.EstablishedSceneDisplayName : warning.EstablishedContextSceneDisplayName)</a> }
</small> @if (warning.CurrentSceneId.HasValue)
} {
@if (warning.ReferencedSceneId.HasValue) <small class="text-muted d-block">Current</small>
{ <a class="d-block" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.CurrentSceneId">@warning.CurrentContextSceneDisplayName</a>
<small class="text-muted d-block"> }
Earlier reference: }
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.ReferencedSceneId">@(string.IsNullOrWhiteSpace(warning.ReferencedContextSceneDisplayName) ? warning.ReferencedSceneDisplayName : warning.ReferencedContextSceneDisplayName)</a> else if (warning.SceneID.HasValue)
</small> {
} <a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.ContextSceneDisplayName</a>
@if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes)) }
{ else
<small class="text-muted d-block">Note: @warning.AcknowledgementNotes</small> {
} <span class="muted">Project-wide</span>
</td> }
<td>@(string.IsNullOrWhiteSpace(warning.BookDisplayName) ? "Project" : warning.BookDisplayName)</td> </td>
<td>@(string.IsNullOrWhiteSpace(warning.ChapterDisplayName) ? "-" : warning.ChapterDisplayName)</td> <td>
<td> @if (warning.IsDismissed)
@if (warning.SceneID.HasValue) {
{ <span class="badge bg-secondary">Dismissed</span>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.ContextSceneDisplayName</a> }
} else if (warning.IsIntentional || warning.IsAcknowledged)
else {
{ <span class="badge bg-secondary">Acknowledged</span>
<span class="muted">Project-wide</span> }
} else
</td> {
<td> <span class="badge bg-warning text-dark">Active</span>
@if (warning.IsDismissed) }
{ </td>
<span class="badge bg-secondary">Dismissed</span> <td>
} <div class="d-grid gap-2" style="min-width: 150px;">
else if (warning.IsIntentional || warning.IsAcknowledged) @if (warning.CanInvestigate)
{ {
<span class="badge bg-secondary">Acknowledged</span> <a class="btn btn-outline-primary btn-sm w-100"
} asp-controller="ContinuityExplorer"
else asp-action="Index"
{ asp-route-projectId="@Model.Project.ProjectID"
<span class="badge bg-warning text-dark">Active</span> asp-route-mode="@warning.InvestigationDisplayMode"
} asp-route-characterId="@warning.CharacterID"
</td> asp-route-assetId="@warning.AssetID"
<td> asp-route-sceneId="@warning.InvestigationSceneId">Investigate</a>
@if (warning.CanInvestigate) }
{ @if (warning.Source == "Persisted" && warning.ContinuityWarningID.HasValue)
<a class="btn btn-outline-primary btn-sm" {
asp-controller="ContinuityExplorer" @if (warning.IsDismissed || warning.IsIntentional)
asp-action="Index" {
asp-route-projectId="@Model.Project.ProjectID" <form asp-action="Restore" method="post" class="d-grid">
asp-route-mode="@warning.InvestigationDisplayMode" <input type="hidden" name="id" value="@warning.ContinuityWarningID" />
asp-route-characterId="@warning.CharacterID" <input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
asp-route-assetId="@warning.AssetID" <input type="hidden" name="bookId" value="@Model.BookID" />
asp-route-sceneId="@warning.InvestigationSceneId">Investigate</a> <input type="hidden" name="sceneId" value="@Model.SceneID" />
} <button class="btn btn-outline-secondary btn-sm w-100" type="submit">Restore</button>
@if (warning.Source == "Persisted" && warning.ContinuityWarningID.HasValue) </form>
{ }
@if (warning.IsDismissed || warning.IsIntentional) else
{ {
<form asp-action="Restore" method="post" class="d-inline"> <form asp-action="Dismiss" method="post" class="d-grid">
<input type="hidden" name="id" value="@warning.ContinuityWarningID" /> <input type="hidden" name="id" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" /> <input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.BookID" /> <input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" /> <input type="hidden" name="sceneId" value="@Model.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Restore</button> <button class="btn btn-outline-secondary btn-sm w-100" type="submit">Dismiss</button>
</form> </form>
} <form asp-action="MarkIntentional" method="post" class="d-grid">
else <input type="hidden" name="id" value="@warning.ContinuityWarningID" />
{ <input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<form asp-action="Dismiss" method="post" class="d-inline"> <input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="id" value="@warning.ContinuityWarningID" /> <input type="hidden" name="sceneId" value="@Model.SceneID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" /> <button class="btn btn-outline-secondary btn-sm w-100" type="submit">Intentional</button>
<input type="hidden" name="bookId" value="@Model.BookID" /> </form>
<input type="hidden" name="sceneId" value="@Model.SceneID" /> }
<button class="btn btn-outline-secondary btn-sm" type="submit">Dismiss</button> }
</form> else if (warning.IsAcknowledged)
<form asp-action="MarkIntentional" method="post" class="d-inline"> {
<input type="hidden" name="id" value="@warning.ContinuityWarningID" /> <span class="badge bg-secondary justify-self-stretch">Acknowledged</span>
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" /> <form asp-action="RestoreGeneratedWarning" method="post" class="d-grid">
<input type="hidden" name="bookId" value="@Model.BookID" /> <input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="sceneId" value="@Model.SceneID" /> <input type="hidden" name="SceneID" value="@warning.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Intentional</button> <input type="hidden" name="CharacterID" value="@warning.CharacterID" />
</form> <input type="hidden" name="AssetID" value="@warning.AssetID" />
} <input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Project.ProjectID" />
} <input type="hidden" name="ReturnFilter.BookID" value="@Model.BookID" />
else <input type="hidden" name="ReturnFilter.ChapterID" value="@Model.ChapterID" />
{ <input type="hidden" name="ReturnFilter.SceneID" value="@Model.SceneID" />
@if (warning.IsAcknowledged) <input type="hidden" name="ReturnFilter.WarningTypeID" value="@Model.WarningTypeID" />
{ <input type="hidden" name="ReturnFilter.WarningSeverityID" value="@Model.WarningSeverityID" />
<form asp-action="RestoreGeneratedWarning" method="post" class="d-inline"> <input type="hidden" name="ReturnFilter.EntityType" value="@Model.EntityType" />
<input type="hidden" name="WarningType" value="@warning.WarningType" /> <input type="hidden" name="ReturnFilter.Category" value="@Model.Category" />
<input type="hidden" name="SceneID" value="@warning.SceneID" /> <input type="hidden" name="ReturnFilter.IncludeDismissed" value="@Model.IncludeDismissed" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" /> <input type="hidden" name="ReturnFilter.IncludeIntentional" value="@Model.IncludeIntentional" />
<input type="hidden" name="AssetID" value="@warning.AssetID" /> <input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.ShowAcknowledgedWarnings" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Project.ProjectID" /> <button class="btn btn-outline-secondary btn-sm w-100" type="submit">Restore</button>
<input type="hidden" name="ReturnFilter.BookID" value="@Model.BookID" /> </form>
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.ChapterID" /> }
<input type="hidden" name="ReturnFilter.SceneID" value="@Model.SceneID" /> else
<input type="hidden" name="ReturnFilter.WarningTypeID" value="@Model.WarningTypeID" /> {
<input type="hidden" name="ReturnFilter.WarningSeverityID" value="@Model.WarningSeverityID" /> <form asp-action="AcknowledgeGeneratedWarning" method="post" class="d-grid gap-2">
<input type="hidden" name="ReturnFilter.EntityType" value="@Model.EntityType" /> <input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="ReturnFilter.Category" value="@Model.Category" /> <input type="hidden" name="SceneID" value="@warning.SceneID" />
<input type="hidden" name="ReturnFilter.IncludeDismissed" value="@Model.IncludeDismissed" /> <input type="hidden" name="CharacterID" value="@warning.CharacterID" />
<input type="hidden" name="ReturnFilter.IncludeIntentional" value="@Model.IncludeIntentional" /> <input type="hidden" name="AssetID" value="@warning.AssetID" />
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.ShowAcknowledgedWarnings" /> <input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Restore</button> <input type="hidden" name="ReturnFilter.BookID" value="@Model.BookID" />
</form> <input type="hidden" name="ReturnFilter.ChapterID" value="@Model.ChapterID" />
} <input type="hidden" name="ReturnFilter.SceneID" value="@Model.SceneID" />
else <input type="hidden" name="ReturnFilter.WarningTypeID" value="@Model.WarningTypeID" />
{ <input type="hidden" name="ReturnFilter.WarningSeverityID" value="@Model.WarningSeverityID" />
<form asp-action="AcknowledgeGeneratedWarning" method="post" class="d-flex gap-1 align-items-center"> <input type="hidden" name="ReturnFilter.EntityType" value="@Model.EntityType" />
<input type="hidden" name="WarningType" value="@warning.WarningType" /> <input type="hidden" name="ReturnFilter.Category" value="@Model.Category" />
<input type="hidden" name="SceneID" value="@warning.SceneID" /> <input type="hidden" name="ReturnFilter.IncludeDismissed" value="@Model.IncludeDismissed" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" /> <input type="hidden" name="ReturnFilter.IncludeIntentional" value="@Model.IncludeIntentional" />
<input type="hidden" name="AssetID" value="@warning.AssetID" /> <input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.ShowAcknowledgedWarnings" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Project.ProjectID" /> <label class="form-label mb-0 small">Reason (optional)</label>
<input type="hidden" name="ReturnFilter.BookID" value="@Model.BookID" /> <input class="form-control form-control-sm" type="text" name="Notes" maxlength="500" />
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.ChapterID" /> <button class="btn btn-outline-success btn-sm w-100" type="submit">Acknowledge</button>
<input type="hidden" name="ReturnFilter.SceneID" value="@Model.SceneID" /> </form>
<input type="hidden" name="ReturnFilter.WarningTypeID" value="@Model.WarningTypeID" /> }
<input type="hidden" name="ReturnFilter.WarningSeverityID" value="@Model.WarningSeverityID" /> </div>
<input type="hidden" name="ReturnFilter.EntityType" value="@Model.EntityType" /> </td>
<input type="hidden" name="ReturnFilter.Category" value="@Model.Category" /> </tr>
<input type="hidden" name="ReturnFilter.IncludeDismissed" value="@Model.IncludeDismissed" /> }
<input type="hidden" name="ReturnFilter.IncludeIntentional" value="@Model.IncludeIntentional" /> </tbody>
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.ShowAcknowledgedWarnings" /> </table>
<input class="form-control form-control-sm" type="text" name="Notes" maxlength="500" placeholder="Optional note" /> </div>
<button class="btn btn-outline-primary btn-sm" type="submit">Acknowledge</button> </div>
</form> </div>
} </div>
} }
</td>
</tr>
}
</tbody>
</table>
</div> </div>
} }
</section> </section>