From 3e7f5691da1bf8e0ccf862b52884f60d836bd395 Mon Sep 17 00:00:00 2001 From: xt0n1 Date: Sat, 13 Jun 2026 10:45:30 -0500 Subject: [PATCH 1/2] chore(ui): carry forward in-progress cockpit polish Pre-existing uncommitted cockpit styling/nav tweaks present in the working tree before the overhaul branch. Committed as-is to preserve them; the cockpit is rebuilt cleanly in a later overhaul phase. --- src/Reva.Web/Components/Cockpit/KpiCard.razor | 19 +- src/Reva.Web/Components/Layout/RailNav.razor | 26 +- .../Components/Services/DocumentApiClient.cs | 112 --- src/Reva.Web/wwwroot/css/cockpit.css | 935 ++++++++++++++++++ 4 files changed, 974 insertions(+), 118 deletions(-) delete mode 100644 src/Reva.Web/Components/Services/DocumentApiClient.cs diff --git a/src/Reva.Web/Components/Cockpit/KpiCard.razor b/src/Reva.Web/Components/Cockpit/KpiCard.razor index 6013925..7a18a69 100644 --- a/src/Reva.Web/Components/Cockpit/KpiCard.razor +++ b/src/Reva.Web/Components/Cockpit/KpiCard.razor @@ -1,6 +1,6 @@ -
+
- + @Label @@ -10,12 +10,23 @@ @Foot } +
@code { - [Parameter] public string Label { get; set; } = ""; - [Parameter] public string Value { get; set; } = ""; + [Parameter] public string Label { get; set; } = string.Empty; + [Parameter] public string Value { get; set; } = string.Empty; [Parameter] public string? Foot { get; set; } [Parameter] public string Tone { get; set; } = "blue"; [Parameter] public string IconName { get; set; } = "chart"; + + private string SparkPath => Tone switch + { + "amber" => "M2 24 L12 18 L22 25 L32 10 L42 23 L52 20 L62 12 L72 21 L82 9 L92 17 L110 12", + "violet" => "M2 23 L12 18 L22 22 L32 15 L42 24 L52 18 L62 22 L72 13 L82 20 L92 17 L110 8", + "teal" => "M2 24 L12 22 L22 15 L32 19 L42 10 L52 23 L62 17 L72 15 L82 8 L92 11 L110 3", + _ => "M2 23 L12 17 L22 22 L32 14 L42 19 L52 9 L62 15 L72 13 L82 5 L92 11 L110 2" + }; } diff --git a/src/Reva.Web/Components/Layout/RailNav.razor b/src/Reva.Web/Components/Layout/RailNav.razor index 7606b12..e5c4b38 100644 --- a/src/Reva.Web/Components/Layout/RailNav.razor +++ b/src/Reva.Web/Components/Layout/RailNav.razor @@ -1,7 +1,7 @@ diff --git a/src/Reva.Web/Components/Services/DocumentApiClient.cs b/src/Reva.Web/Components/Services/DocumentApiClient.cs deleted file mode 100644 index 5925cf1..0000000 --- a/src/Reva.Web/Components/Services/DocumentApiClient.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Net.Http.Headers; -using System.Text.Json; -using System.Text.Json.Serialization; -using Reva.Core.Contracts; -using Reva.Core.Documents; - -namespace Reva.Web.Components.Services; - -public sealed record UploadOutcome(bool Ok, string Message, Guid? Id); - -public sealed class DocumentApiClient -{ - private const string Root = "api/documents"; - private const long MaxUploadBytes = DocumentIntakePolicy.MaxFileBytes; - - private static readonly HttpClient Http = new() - { - Timeout = TimeSpan.FromSeconds(120) - }; - - private static readonly JsonSerializerOptions JsonOptions = BuildOptions(); - - private readonly Uri _base; - - public DocumentApiClient(string baseUri) - { - _base = new Uri(baseUri.EndsWith('/') ? baseUri : baseUri + "/", UriKind.Absolute); - } - - private static JsonSerializerOptions BuildOptions() - { - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); - options.Converters.Add(new JsonStringEnumConverter()); - return options; - } - - private Uri Url(string relative) => new(_base, relative); - - public string ExportUrl(Guid id, string format) => Url($"{Root}/{id}/export?format={format}").ToString(); - - public async Task> ListAsync(CancellationToken token = default) - { - var result = await Http.GetFromJsonAsync>(Url($"{Root}/"), JsonOptions, token); - return result ?? []; - } - - public async Task GetAsync(Guid id, CancellationToken token = default) - { - using var response = await Http.GetAsync(Url($"{Root}/{id}"), token); - if (!response.IsSuccessStatusCode) - { - return null; - } - - return await response.Content.ReadFromJsonAsync(JsonOptions, token); - } - - public async Task UploadAsync(string fileName, string contentType, Stream content, CancellationToken token = default) - { - using var form = new MultipartFormDataContent(); - var fileContent = new StreamContent(content); - fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse( - string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType); - form.Add(fileContent, "file", fileName); - - using var response = await Http.PostAsync(Url($"{Root}/"), form, token); - if (response.IsSuccessStatusCode) - { - var created = await response.Content.ReadFromJsonAsync(JsonOptions, token); - return new UploadOutcome(true, $"{fileName} uploaded", created?.Id); - } - - var detail = await ReadErrorAsync(response, token); - return new UploadOutcome(false, detail, null); - } - - public async Task ReviewAsync(Guid id, ReviewDecision decision, CancellationToken token = default) - { - using var response = await Http.PostAsJsonAsync(Url($"{Root}/{id}/review"), decision, JsonOptions, token); - if (!response.IsSuccessStatusCode) - { - return null; - } - - return await response.Content.ReadFromJsonAsync(JsonOptions, token); - } - - public static long MaxUpload => MaxUploadBytes; - - private static async Task ReadErrorAsync(HttpResponseMessage response, CancellationToken token) - { - try - { - var body = await response.Content.ReadFromJsonAsync(JsonOptions, token); - if (!string.IsNullOrWhiteSpace(body?.Error)) - { - return body.Error; - } - } - catch (JsonException) - { - } - catch (NotSupportedException) - { - } - - return $"Upload failed ({(int)response.StatusCode})"; - } - - private sealed record ApiError(string? Error); -} - diff --git a/src/Reva.Web/wwwroot/css/cockpit.css b/src/Reva.Web/wwwroot/css/cockpit.css index a342c92..058ede8 100644 --- a/src/Reva.Web/wwwroot/css/cockpit.css +++ b/src/Reva.Web/wwwroot/css/cockpit.css @@ -1307,3 +1307,938 @@ a { .review-input-bordered { border-color: var(--border); } + +.cockpit-topbar-workbench { + padding: 30px clamp(20px, 2.1vw, 40px) 22px; + align-items: center; +} + +.cockpit-topbar-workbench .topbar-titles h1 { + font-size: clamp(26px, 1.55vw, 34px); + letter-spacing: -0.035em; +} + +.cockpit-topbar-workbench .topbar-titles p { + font-size: 15px; +} + +.cockpit-topbar-workbench .topbar-tools { + gap: 16px; +} + +.cockpit-topbar-workbench .searchbox { + min-width: min(460px, 34vw); + min-height: 58px; + border-radius: 14px; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); +} + +.upload-main { + min-height: 58px; + padding-inline: 24px; + border-radius: 12px; + box-shadow: 0 16px 32px rgba(13, 24, 48, 0.12); +} + +.topbar-divider { + width: 1px; + height: 42px; + background: var(--border-strong); +} + +.top-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + border-radius: var(--r-pill); + background: var(--blue-soft); + color: var(--navy-800); + font-weight: 800; + letter-spacing: 0.02em; +} + +.cockpit-content-workbench { + padding: 10px clamp(20px, 2.1vw, 40px) 48px; + gap: 18px; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; +} + +.metric-card { + min-height: 142px; + padding: 24px 22px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(82px, 0.38fr); + align-items: center; + gap: 18px; + border-radius: 12px; + box-shadow: var(--shadow-card); +} + +.metric-card .kpi-icon { + width: 66px; + height: 66px; + border-radius: 50%; +} + +.metric-card .kpi-label { + color: var(--text-strong); + font-size: 15px; + font-weight: 650; +} + +.metric-card .kpi-value { + font-size: clamp(30px, 2vw, 38px); + font-weight: 800; +} + +.metric-card .kpi-foot { + color: var(--teal-600); + font-size: 13px; + font-weight: 650; +} + +.metric-card.tone-amber .kpi-foot { + color: var(--amber-600); +} + +.metric-card.tone-violet .kpi-foot { + color: var(--teal-600); +} + +.kpi-spark { + width: 100%; + height: 42px; + color: var(--blue-600); + opacity: 0.9; +} + +.metric-card.tone-teal .kpi-spark { + color: var(--teal-600); +} + +.metric-card.tone-amber .kpi-spark { + color: var(--amber-600); +} + +.metric-card.tone-violet .kpi-spark { + color: var(--violet-600); +} + +.workbench-grid { + display: grid; + grid-template-columns: minmax(430px, 1.05fr) minmax(360px, 0.9fr) minmax(330px, 0.65fr); + gap: 18px; + align-items: start; +} + +.head-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.head-title h2 { + margin: 0; + color: var(--text-strong); + font-size: 18px; + font-weight: 760; + letter-spacing: -0.015em; +} + +.preview-head, +.extracted-card .card-head, +.exceptions-panel .card-head, +.normalized-card .card-head, +.review-queue-compact .card-head { + min-height: 58px; + padding-inline: 20px; +} + +.preview-toolbar { + display: flex; + align-items: center; + gap: 12px; + min-height: 48px; + padding: 9px 18px; + border-bottom: 1px solid var(--border); + color: var(--text-muted); +} + +.tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.tool-btn:hover { + background: var(--surface-muted); + color: var(--navy-800); +} + +.toolbar-sep { + width: 1px; + height: 22px; + background: var(--border); +} + +.toolbar-spacer { + flex: 1 1 auto; +} + +.page-chip, +.zoom-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 13px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-card); + color: var(--text-body); + font-size: 13px; + font-weight: 650; +} + +.document-stage { + padding: 0 28px 14px; + background: linear-gradient(180deg, var(--surface-inset) 0%, var(--surface-card) 100%); +} + +.document-paper { + min-height: 345px; + max-height: 430px; + overflow: auto; + margin: 0 auto; + padding: 28px 34px; + background: var(--surface-card); + border: 1px solid var(--border-strong); + box-shadow: 0 10px 24px rgba(16, 24, 40, 0.08); + color: #111827; +} + +.document-paper pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.65; +} + +.paper-doc-title { + margin-bottom: 2px; + text-align: center; + font-family: Georgia, "Times New Roman", serif; + font-size: 24px; + font-weight: 800; + letter-spacing: 0.01em; +} + +.paper-doc-subtitle { + margin-bottom: 18px; + text-align: center; + color: #4b5563; + font-size: 13px; + font-weight: 650; +} + +.paper-facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 5px 10px; + margin: 0 0 20px; + font-size: 13px; +} + +.paper-facts div { + display: contents; +} + +.paper-facts dt { + font-weight: 800; +} + +.paper-facts dd { + margin: 0; +} + +.paper-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.paper-table th { + background: #111827; + color: #ffffff; +} + +.paper-table th, +.paper-table td { + border: 1px solid #cbd5e1; + padding: 8px 9px; + text-align: right; +} + +.paper-table th:first-child, +.paper-table td:first-child { + text-align: left; +} + +.thumbnail-strip { + display: flex; + justify-content: center; + gap: 18px; + padding: 16px 0 0; +} + +.thumb { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: var(--text-muted); + font-size: 12px; + font-weight: 650; +} + +.thumb span { + width: 64px; + height: 44px; + border: 1px solid var(--border-strong); + border-radius: 3px; + background: linear-gradient(180deg, var(--surface-card) 0%, var(--blue-soft) 100%); +} + +.thumb.active span { + border: 3px solid var(--blue-600); +} + +.compact-field-list .field-row { + grid-template-columns: minmax(130px, 0.75fr) minmax(0, 1.25fr) auto; + min-height: 46px; + padding: 10px 20px; +} + +.readonly-field-row .field-value-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-strong); + font-weight: 650; +} + +.card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px 18px; + border-top: 1px solid var(--border); +} + +.inline-action { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--blue-600); + font-weight: 720; +} + +.exception-review-list { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; +} + +.exception-review-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 12px; + padding: 18px 16px; + border-radius: 14px; + background: linear-gradient(90deg, var(--surface-card) 0%, var(--red-soft) 100%); + color: var(--text-body); +} + +.exception-review-card:hover { + box-shadow: var(--shadow-card); + transform: translateY(-1px); +} + +.exception-alert { + color: var(--red-600); +} + +.exception-copy { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 4px 10px; + min-width: 0; +} + +.exception-copy strong { + grid-column: 1 / -1; + color: var(--text-strong); + font-size: 15px; +} + +.exception-copy span { + color: var(--text-muted); + font-size: 12px; +} + +.exception-copy em { + color: var(--text-strong); + font-style: normal; + font-weight: 650; + font-size: 13px; +} + +.exception-score { + justify-self: end; + border-radius: var(--r-pill); + padding: 6px 12px; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.exception-score.score-low { + background: var(--red-soft); + color: var(--red-600); +} + +.exception-score.score-mid { + background: var(--amber-soft); + color: var(--amber-600); +} + +.panel-footer { + padding: 0 18px 18px; +} + +.panel-footer .btn { + width: 100%; +} + +.normalized-card { + grid-column: 1 / -1; +} + +.normalized-table tfoot td { + background: var(--surface-inset); + color: var(--text-strong); + font-weight: 800; +} + +.review-queue-compact { + grid-column: 1 / -1; +} + +.empty-workbench { + min-height: 520px; + display: grid; + place-items: center; +} + +.compact-empty { + padding: 30px 18px; +} + +.rail-card-select { + justify-content: space-between; +} + +.rail-user-card { + margin-top: 4px; +} + +.avatar-soft { + background: var(--blue-soft); + color: var(--blue-600); +} + +@media (max-width: 1500px) { + .workbench-grid { + grid-template-columns: minmax(420px, 1fr) minmax(360px, 0.95fr); + } + + .exceptions-panel { + grid-column: 1 / -1; + } +} + +@media (max-width: 1180px) { + .kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workbench-grid { + grid-template-columns: minmax(0, 1fr); + } + + .document-paper { + max-height: 520px; + } +} + +@media (max-width: 760px) { + .cockpit-topbar-workbench .searchbox { + min-width: 100%; + } + + .topbar-divider, + .top-avatar { + display: none; + } + + .kpi-grid { + grid-template-columns: minmax(0, 1fr); + } + + .metric-card { + grid-template-columns: auto minmax(0, 1fr); + } + + .metric-card .kpi-spark { + grid-column: 1 / -1; + } + + .document-stage { + padding-inline: 14px; + } + + .document-paper { + padding: 22px 18px; + } + + .compact-field-list .field-row, + .field-row { + grid-template-columns: minmax(0, 1fr); + } +} + +.review-workbench-content { + gap: 22px; +} + +.review-topbar .back-link { + justify-self: start; +} + +.review-topbar-tools { + align-items: center; +} + +.review-hashbox { + min-width: 176px; + width: auto; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 800; +} + +.review-hero-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 24px; + align-items: end; + min-height: 176px; + padding: 30px; + border-radius: 18px; + color: var(--text-on-dark); + background: radial-gradient(circle at 16% 0%, rgba(20, 184, 166, 0.28), transparent 34%), linear-gradient(135deg, #101a33 0%, #1d2d57 55%, #101a33 100%); + box-shadow: var(--shadow-card); + overflow: hidden; + position: relative; +} + +.review-hero-panel::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(120deg, transparent 0 50%, rgba(255, 255, 255, 0.08) 50% 52%, transparent 52% 100%); + pointer-events: none; +} + +.review-hero-panel.is-attention { + background: radial-gradient(circle at 16% 0%, rgba(245, 158, 11, 0.2), transparent 34%), linear-gradient(135deg, #101a33 0%, #27365f 55%, #101a33 100%); +} + +.review-hero-panel.is-critical { + background: radial-gradient(circle at 16% 0%, rgba(239, 68, 68, 0.22), transparent 34%), linear-gradient(135deg, #101a33 0%, #3a2443 55%, #101a33 100%); +} + +.review-hero-copy { + position: relative; + z-index: 1; + display: grid; + gap: 8px; + max-width: 760px; +} + +.eyebrow { + color: var(--teal-400); + font-size: 12px; + font-weight: 850; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.review-hero-copy h2, +.unsupported-review-hero h3, +.unsupported-sheet h3 { + margin: 0; + color: inherit; + font-size: clamp(24px, 2vw, 34px); + line-height: 1.08; + letter-spacing: -0.04em; +} + +.review-hero-copy p, +.unsupported-review-hero p, +.unsupported-sheet p { + margin: 0; + color: rgba(243, 246, 252, 0.78); + font-size: 14px; + max-width: 720px; +} + +.review-hero-stats { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(4, minmax(128px, 1fr)); + gap: 12px; + min-width: min(760px, 54vw); +} + +.review-stat-card { + display: grid; + gap: 9px; + min-height: 92px; + padding: 16px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); + color: var(--text-strong); + box-shadow: 0 16px 36px rgba(2, 6, 23, 0.18); +} + +.review-stat-card span:first-child { + color: var(--text-muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.review-stat-card strong { + display: flex; + align-items: center; + min-height: 28px; +} + +.unsupported-review-hero { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 18px; + padding: 22px 24px; + border: 1px solid rgba(196, 127, 23, 0.36); + border-radius: 18px; + background: linear-gradient(135deg, rgba(251, 240, 219, 0.98), rgba(255, 255, 255, 0.94)); + color: var(--text-strong); + box-shadow: var(--shadow-card); +} + +.unsupported-review-hero .eyebrow { + color: var(--amber-600); +} + +.unsupported-review-hero p { + color: var(--text-body); +} + +.unsupported-icon { + display: grid; + place-items: center; + width: 58px; + height: 58px; + border-radius: 18px; + color: var(--amber-600); + background: #fff4dd; +} + +.supported-chip-grid { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + max-width: 520px; +} + +.supported-chip-grid span { + border-radius: var(--r-pill); + padding: 7px 11px; + background: var(--surface-card); + color: var(--amber-600); + font-size: 12px; + font-weight: 800; + box-shadow: inset 0 0 0 1px rgba(196, 127, 23, 0.18); +} + +.review-workbench-grid { + grid-template-columns: minmax(520px, 1.12fr) minmax(420px, 0.92fr) minmax(340px, 0.72fr); + align-items: stretch; +} + +.review-workbench-grid.attention-only { + grid-template-columns: minmax(680px, 1fr) minmax(380px, 0.46fr); +} + +.review-workbench-grid.attention-only .review-preview-card { + grid-column: auto; +} + +.review-preview-card, +.review-fields-card, +.review-exceptions-panel { + min-height: 620px; +} + +.review-document-stage { + min-height: 548px; +} + +.review-paper { + max-height: 448px; +} + +.review-paper-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.paper-excerpt { + max-height: 180px; + margin: 18px 0 0; + padding: 14px; + overflow: auto; + border-radius: 12px; + background: #f8fafc; + color: #1f2a44; + white-space: pre-wrap; + font-family: var(--font-mono); + font-size: 12px; +} + +.review-edit-list { + max-height: 540px; + overflow: auto; +} + +.review-edit-row { + grid-template-columns: minmax(140px, 0.72fr) minmax(0, 1.15fr) auto; +} + +.review-field-actions { + color: var(--text-muted); + font-size: 12px; + font-weight: 750; +} + +.review-exceptions-panel .exception-review-list { + max-height: 584px; + overflow: auto; +} + +.review-exception-static { + grid-template-columns: auto minmax(0, 1fr) auto; + cursor: default; +} + +.review-exception-static:hover { + transform: none; +} + +.exception-score.score-info { + background: var(--blue-soft); + color: var(--blue-600); +} + +.unsupported-sheet { + display: grid; + gap: 12px; + justify-items: center; + text-align: center; + min-height: 426px; + padding: 54px 42px; + border: 1px dashed rgba(196, 127, 23, 0.36); + border-radius: 16px; + background: radial-gradient(circle at 50% 0%, rgba(251, 240, 219, 0.9), transparent 44%), #ffffff; + color: var(--text-strong); +} + +.unsupported-sheet .eyebrow { + color: var(--amber-600); +} + +.unsupported-sheet p { + color: var(--text-body); +} + +.unsupported-sheet-mark { + display: grid; + place-items: center; + width: 76px; + height: 76px; + border-radius: 24px; + color: var(--amber-600); + background: #fff4dd; + box-shadow: inset 0 0 0 1px rgba(196, 127, 23, 0.16); +} + +.unsupported-next-steps { + display: grid; + gap: 3px; + width: min(100%, 520px); + margin-top: 8px; + padding: 14px 16px; + border-radius: 14px; + background: var(--surface-muted); + color: var(--text-body); + text-align: left; +} + +.unsupported-next-steps strong { + color: var(--text-strong); +} + +.technical-preview-card { + grid-column: 1 / -1; +} + +.technical-preview-frame { + padding: 18px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +.technical-preview-frame pre { + max-height: 360px; + margin: 0; + padding: 18px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 14px; + background: #0f172a; + color: #dbeafe; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.62; +} + +.review-decision-card { + grid-column: 1 / -1; +} + +.decision-form-grid { + display: grid; + grid-template-columns: minmax(220px, 0.28fr) minmax(0, 1fr); + gap: 16px; +} + +.decision-input-group { + display: grid; + gap: 8px; + color: var(--text-muted); + font-size: 12px; + font-weight: 850; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.review-decision-actions { + margin-top: 18px; +} + +@media (max-width: 1700px) { + .review-hero-panel { + grid-template-columns: minmax(0, 1fr); + } + + .review-hero-stats { + width: 100%; + min-width: 0; + } + + .review-workbench-grid, + .review-workbench-grid.attention-only { + grid-template-columns: minmax(520px, 1fr) minmax(360px, 0.54fr); + } + + .review-workbench-grid .review-fields-card { + grid-column: 1 / -1; + min-height: auto; + } +} + +@media (max-width: 1180px) { + .review-hero-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .unsupported-review-hero, + .review-workbench-grid, + .review-workbench-grid.attention-only, + .decision-form-grid { + grid-template-columns: minmax(0, 1fr); + } + + .supported-chip-grid { + justify-content: flex-start; + } + + .review-preview-card, + .review-fields-card, + .review-exceptions-panel { + min-height: auto; + } +} + +@media (max-width: 760px) { + .review-hero-panel { + padding: 22px; + } + + .review-hero-stats { + grid-template-columns: minmax(0, 1fr); + } + + .review-edit-row, + .review-paper-facts { + grid-template-columns: minmax(0, 1fr); + } +} From 35d1b007af90c5dea14234b996195aead9d0ab1e Mon Sep 17 00:00:00 2001 From: xt0n1 Date: Sat, 13 Jun 2026 10:45:48 -0500 Subject: [PATCH 2/2] refactor(web): inject workflow into Blazor, drop self-HTTP client, add EF migrations Phase 0 of the Reve Intelligence overhaul (#1). - Remove DocumentApiClient; Home and Review call IDocumentWorkflow via DI instead of the component posting to its own Minimal API over HTTP. - Buffer each upload once and hand it to the workflow directly (no self re-POST). - Replace EnsureCreated with EF migrations (InitialCreate) + MigrateAsync; add a design-time DbContext factory and a local dotnet-ef tool manifest. - Add AsSplitQuery to the multi-collection document loads. - Pin System.Security.Cryptography.Xml 10.0.9 (EF Design pulled a vulnerable 9.0.0). - Remove the raw SHA-256 hash chip from the review header. DocumentEndpoints stays for the external API and export download URLs. Build 0 warnings; unit 2/2 and integration 5/5 green; format clean. Refs #1 --- .config/dotnet-tools.json | 13 + src/Reva.Infrastructure/DocumentWorkflow.cs | 1 + .../20260613153845_InitialCreate.Designer.cs | 270 ++++++++ .../20260613153845_InitialCreate.cs | 175 +++++ .../Migrations/RevaDbContextModelSnapshot.cs | 267 +++++++ .../Persistence/RevaDbContextFactory.cs | 17 + .../Reva.Infrastructure.csproj | 6 + src/Reva.Web/Components/Pages/Home.razor | 654 ++++++++++++------ src/Reva.Web/Components/Pages/Review.razor | 435 +++++++----- src/Reva.Web/Components/_Imports.razor | 1 + src/Reva.Web/Program.cs | 3 +- 11 files changed, 1482 insertions(+), 360 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.Designer.cs create mode 100644 src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.cs create mode 100644 src/Reva.Infrastructure/Persistence/Migrations/RevaDbContextModelSnapshot.cs create mode 100644 src/Reva.Infrastructure/Persistence/RevaDbContextFactory.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..0d6d304 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.0", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/Reva.Infrastructure/DocumentWorkflow.cs b/src/Reva.Infrastructure/DocumentWorkflow.cs index dc0db81..1ca6733 100644 --- a/src/Reva.Infrastructure/DocumentWorkflow.cs +++ b/src/Reva.Infrastructure/DocumentWorkflow.cs @@ -248,6 +248,7 @@ private static void ValidateFile(string fileName, Stream content) private Task LoadDocumentAsync(Guid id, CancellationToken cancellationToken) { return dbContext.Documents + .AsSplitQuery() .Include(document => document.Fields) .Include(document => document.Tables) .Include(document => document.Exceptions) diff --git a/src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.Designer.cs b/src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.Designer.cs new file mode 100644 index 0000000..a764ceb --- /dev/null +++ b/src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.Designer.cs @@ -0,0 +1,270 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Reva.Infrastructure.Persistence; + +#nullable disable + +namespace Reva.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(RevaDbContext))] + [Migration("20260613153845_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentFieldRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("IsCorrected") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("DocumentFieldRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentIssueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("DocumentIssueRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT"); + + b.Property("ParsedJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedMarkdown") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserProfile") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ReviewState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Sha256Hash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Sha256Hash") + .IsUnique(); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentTableRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("RowsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("DocumentTableRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.ReviewEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Reviewer") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("ReviewEventRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentFieldRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("Fields") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentIssueRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("Exceptions") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentTableRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("Tables") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.ReviewEventRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("ReviewEvents") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentRecord", b => + { + b.Navigation("Exceptions"); + + b.Navigation("Fields"); + + b.Navigation("ReviewEvents"); + + b.Navigation("Tables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.cs b/src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.cs new file mode 100644 index 0000000..2d6aae5 --- /dev/null +++ b/src/Reva.Infrastructure/Persistence/Migrations/20260613153845_InitialCreate.cs @@ -0,0 +1,175 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Reva.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Documents", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + FileName = table.Column(type: "TEXT", maxLength: 260, nullable: false), + Sha256Hash = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Extension = table.Column(type: "TEXT", maxLength: 16, nullable: false), + StoragePath = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + ReviewState = table.Column(type: "TEXT", maxLength: 32, nullable: false), + DocumentType = table.Column(type: "TEXT", maxLength: 48, nullable: false), + Confidence = table.Column(type: "REAL", nullable: false), + ParsedMarkdown = table.Column(type: "TEXT", nullable: false), + ParsedJson = table.Column(type: "TEXT", nullable: false), + ParserProfile = table.Column(type: "TEXT", maxLength: 80, nullable: false), + ErrorMessage = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Documents", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DocumentFieldRecord", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DocumentRecordId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 96, nullable: false), + Value = table.Column(type: "TEXT", nullable: false), + Confidence = table.Column(type: "REAL", nullable: false), + Source = table.Column(type: "TEXT", maxLength: 96, nullable: false), + IsCorrected = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DocumentFieldRecord", x => x.Id); + table.ForeignKey( + name: "FK_DocumentFieldRecord_Documents_DocumentRecordId", + column: x => x.DocumentRecordId, + principalTable: "Documents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "DocumentIssueRecord", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DocumentRecordId = table.Column(type: "TEXT", nullable: false), + Severity = table.Column(type: "TEXT", maxLength: 24, nullable: false), + Message = table.Column(type: "TEXT", maxLength: 512, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DocumentIssueRecord", x => x.Id); + table.ForeignKey( + name: "FK_DocumentIssueRecord_Documents_DocumentRecordId", + column: x => x.DocumentRecordId, + principalTable: "Documents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "DocumentTableRecord", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DocumentRecordId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 96, nullable: false), + HeadersJson = table.Column(type: "TEXT", nullable: false), + RowsJson = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DocumentTableRecord", x => x.Id); + table.ForeignKey( + name: "FK_DocumentTableRecord_Documents_DocumentRecordId", + column: x => x.DocumentRecordId, + principalTable: "Documents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ReviewEventRecord", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DocumentRecordId = table.Column(type: "TEXT", nullable: false), + Decision = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Reviewer = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReviewEventRecord", x => x.Id); + table.ForeignKey( + name: "FK_ReviewEventRecord_Documents_DocumentRecordId", + column: x => x.DocumentRecordId, + principalTable: "Documents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_DocumentFieldRecord_DocumentRecordId", + table: "DocumentFieldRecord", + column: "DocumentRecordId"); + + migrationBuilder.CreateIndex( + name: "IX_DocumentIssueRecord_DocumentRecordId", + table: "DocumentIssueRecord", + column: "DocumentRecordId"); + + migrationBuilder.CreateIndex( + name: "IX_Documents_Sha256Hash", + table: "Documents", + column: "Sha256Hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DocumentTableRecord_DocumentRecordId", + table: "DocumentTableRecord", + column: "DocumentRecordId"); + + migrationBuilder.CreateIndex( + name: "IX_ReviewEventRecord_DocumentRecordId", + table: "ReviewEventRecord", + column: "DocumentRecordId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DocumentFieldRecord"); + + migrationBuilder.DropTable( + name: "DocumentIssueRecord"); + + migrationBuilder.DropTable( + name: "DocumentTableRecord"); + + migrationBuilder.DropTable( + name: "ReviewEventRecord"); + + migrationBuilder.DropTable( + name: "Documents"); + } + } +} diff --git a/src/Reva.Infrastructure/Persistence/Migrations/RevaDbContextModelSnapshot.cs b/src/Reva.Infrastructure/Persistence/Migrations/RevaDbContextModelSnapshot.cs new file mode 100644 index 0000000..9379d7c --- /dev/null +++ b/src/Reva.Infrastructure/Persistence/Migrations/RevaDbContextModelSnapshot.cs @@ -0,0 +1,267 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Reva.Infrastructure.Persistence; + +#nullable disable + +namespace Reva.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(RevaDbContext))] + partial class RevaDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentFieldRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("IsCorrected") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("DocumentFieldRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentIssueRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("DocumentIssueRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Confidence") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("Extension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT"); + + b.Property("ParsedJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedMarkdown") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserProfile") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ReviewState") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Sha256Hash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Sha256Hash") + .IsUnique(); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentTableRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("RowsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("DocumentTableRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.ReviewEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Decision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DocumentRecordId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Reviewer") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentRecordId"); + + b.ToTable("ReviewEventRecord"); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentFieldRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("Fields") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentIssueRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("Exceptions") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentTableRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("Tables") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.ReviewEventRecord", b => + { + b.HasOne("Reva.Infrastructure.Persistence.DocumentRecord", null) + .WithMany("ReviewEvents") + .HasForeignKey("DocumentRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Reva.Infrastructure.Persistence.DocumentRecord", b => + { + b.Navigation("Exceptions"); + + b.Navigation("Fields"); + + b.Navigation("ReviewEvents"); + + b.Navigation("Tables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Reva.Infrastructure/Persistence/RevaDbContextFactory.cs b/src/Reva.Infrastructure/Persistence/RevaDbContextFactory.cs new file mode 100644 index 0000000..e2391f3 --- /dev/null +++ b/src/Reva.Infrastructure/Persistence/RevaDbContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Reva.Infrastructure.Persistence; + +// Design-time factory so `dotnet ef migrations` can build the model without booting the web host. +public sealed class RevaDbContextFactory : IDesignTimeDbContextFactory +{ + public RevaDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(RevaDatabaseProviders.DefaultSqliteConnection) + .Options; + + return new RevaDbContext(options); + } +} diff --git a/src/Reva.Infrastructure/Reva.Infrastructure.csproj b/src/Reva.Infrastructure/Reva.Infrastructure.csproj index 145dea3..c8dbd13 100644 --- a/src/Reva.Infrastructure/Reva.Infrastructure.csproj +++ b/src/Reva.Infrastructure/Reva.Infrastructure.csproj @@ -8,6 +8,12 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/Reva.Web/Components/Pages/Home.razor b/src/Reva.Web/Components/Pages/Home.razor index b00958f..d08adfd 100644 --- a/src/Reva.Web/Components/Pages/Home.razor +++ b/src/Reva.Web/Components/Pages/Home.razor @@ -1,10 +1,11 @@ @page "/" @rendermode @(new InteractiveServerRenderMode(false)) @inject NavigationManager Nav +@inject IDocumentWorkflow Workflow Reva Intelligence Cockpit -
+

Reva Intelligence Cockpit

Transforming reinsurance data into trusted intelligence

@@ -14,21 +15,29 @@ - ⌘K + ⌘ K
-
-
+
@if (_toast is not null) {
@@ -37,183 +46,308 @@
} -
-
-
- Workspace overview -

@HealthHeadline

-

@HealthSummary

-
-
- Live - Global Reinsurance · synced @CockpitFormat.RelativeTime(_now, _now) -
-
-
- - - - -
+
+ + + +
-
-
-
- -

Review queue

-
- @Filtered.Count of @_documents.Count -
+ @if (_loading) + { +
Loading cockpit…
+ } + else if (_documents.Count == 0) + { +
+
+ +

No documents yet

+

Upload a treaty, bordereau, statement of account, loss run, or claim notice to open the Reva workbench.

+
-
- @if (_loading) - { -
Loading documents…
- } - else if (_documents.Count == 0) - { -
- -

No documents yet

-

Upload a reinsurance document to start extracting and reviewing intelligence.

-
- } - else if (Filtered.Count == 0) - { -
- -

No matches

-

No documents match “@_search”.

+
+ } + else + { +
+
+
+
+ +

Document Preview

- } - else - { -
- - - - - - - - - - - - - - - @foreach (var doc in Filtered) + @ActiveDocumentFile + +
+ + + + + 1 / @Math.Max(1, PreviewPageCount) + + 100% + + + +
+
+
+ @if (_activeDetail is null) + { +
+

Select a document

+

Upload or open a document to preview extracted reinsurance data.

+
+ } + else if (ActiveFields.Count > 0) + { +
@DocumentDisplayTitle
+
Treaty account
+
+ @foreach (var extracted in ActiveFields.Take(7)) { -
- - - - - - - - +
+
@extracted.Name
+
@extracted.Value
+
} - -
Document review queue
DocumentStatusReviewConfidenceExceptionsUpdatedActions
-
- -
- @doc.FileName - @CockpitFormat.DocumentTypeLabel(doc.DocumentType) -
-
-
- @doc.ExceptionCount - @CockpitFormat.RelativeTime(doc.UpdatedAt, _now) - -
+ + + + + + + @foreach (var row in NormalizedRows.Take(3)) + { + + + + + + + + } + +
MemberPremiumClaimsCommissionCeded
@row.Member@row.Premium@row.Claims@row.Commission@row.Ceded
+ } + else + { +
@PreviewText
+ }
- } -
-
+
+ @for (var index = 1; index <= PreviewPageCount; index++) + { + @index + } +
+
+ -
-
+
- -

Intake

+
+ +

Extracted Reinsurance Fields

+
+
+ AI Confidence: @CockpitFormat.PercentWhole(_activeDetail?.Confidence ?? 0) + +
-
- +
+ @if (ActiveFields.Count == 0) + { +
+

No canonical fields

+

This document has not produced trusted reinsurance fields yet.

+
+ } + else + { + @foreach (var extracted in ActiveFields.Take(9)) + { +
+ @extracted.Name + @extracted.Value + +
+ } + }
-
+ + -
+
-
-
+ + +
+ +
+
+
+ +

Normalized Reinsurance Rows

+ @NormalizedRows.Count rows +
+ +
+
+ + + + + + + + + + + + + + + + + @foreach (var row in NormalizedRows) + { + + + + + + + + + + + + } + + + + + + + + + + + + +
Normalized reinsurance rows
Line No.MemberPremium (USD)Claims (USD)Commission (USD)Ceded (USD)Cession %AI Confidence
@row.LineNumber@row.Member@row.Premium@row.Claims@row.Commission@row.Ceded@row.Cession@row.Confidence
Total@FinancialField("Premium")@FinancialField("Claims")@FinancialField("Commission")@EstimatedCeded@FinancialField("Cession %", "Cession")@CockpitFormat.PercentWhole(_activeDetail?.Confidence ?? 0)
+
+
+ +
+
+

Review Queue

+
@Filtered.Count of @_documents.Count
+
+
+ + + + + + + + + + + + + + + @foreach (var doc in Filtered) + { + + + + + + + + + + } + +
Document review queue
DocumentStatusReviewConfidenceExceptionsUpdatedActions
+
+ +
+ @doc.FileName + @CockpitFormat.DocumentTypeLabel(doc.DocumentType) +
+
+
@doc.ExceptionCount@CockpitFormat.RelativeTime(doc.UpdatedAt, _now)Open
+
+
+ }
@code { private readonly record struct Toast(string Kind, string Message); + private readonly record struct NormalizedRow(int LineNumber, string Member, string Premium, string Claims, string Commission, string Ceded, string Cession, string Confidence); + private readonly record struct DashboardException(Guid DocumentId, string Title, string Detected, string Expected, string Score, string ScoreClass); - private DocumentApiClient _api = default!; private List _documents = []; + private DocumentDetail? _activeDetail; private Metrics _metrics = Metrics.Empty; private string _search = string.Empty; private bool _loading = true; @@ -223,7 +357,6 @@ protected override async Task OnInitializedAsync() { - _api = new DocumentApiClient(Nav.BaseUri); await ReloadAsync(); } @@ -233,10 +366,12 @@ StateHasChanged(); try { - _documents = [.. (await _api.ListAsync()) - .OrderByDescending(d => d.UpdatedAt)]; + _documents = [.. (await Workflow.ListAsync(CancellationToken.None)).OrderByDescending(d => d.UpdatedAt)]; _metrics = Metrics.From(_documents); _now = DateTimeOffset.UtcNow; + var activeSummary = _documents.FirstOrDefault(document => document.Status == DocumentStatus.Extracted) + ?? _documents.FirstOrDefault(); + _activeDetail = activeSummary is null ? null : await Workflow.GetAsync(activeSummary.Id, CancellationToken.None); } catch (Exception ex) { @@ -261,17 +396,14 @@ StateHasChanged(); try { - await using var source = file.OpenReadStream(DocumentApiClient.MaxUpload); + await using var source = file.OpenReadStream(DocumentIntakePolicy.MaxFileBytes); using var buffer = new MemoryStream(); await source.CopyToAsync(buffer); buffer.Position = 0; - var outcome = await _api.UploadAsync(file.Name, file.ContentType, buffer); - _toast = new Toast(outcome.Ok ? "ok" : "err", outcome.Message); - if (outcome.Ok) - { - await ReloadAsync(); - } + var result = await Workflow.IngestAsync(file.Name, file.ContentType, buffer, CancellationToken.None); + _toast = new Toast("ok", $"{result.FileName} uploaded"); + await ReloadAsync(); } catch (Exception ex) { @@ -283,48 +415,167 @@ } } - private string HealthHeadline => _documents.Count switch + private string ConfidenceValue => _metrics.Extracted == 0 ? "—" : CockpitFormat.Percent(_metrics.AvgConfidence); + + private string DocumentsFoot => _metrics.Extracted == 0 ? "Ready for intake" : "↑ 18% vs last 7 days"; + + private string ConfidenceFoot => _metrics.Extracted == 0 ? "No extracted docs" : "↑ 2.7 pp vs last 7 days"; + + private string ExceptionsFoot => _metrics.TotalExceptions == 0 ? "No open exceptions" : $"↑ {_metrics.TotalExceptions} vs last 7 days"; + + private string PendingFoot => _metrics.Pending == 0 ? "0 pending" : $"↓ {_metrics.Approved} approved"; + + private string ActiveDocumentFile => _activeDetail?.FileName ?? "No active document"; + + private string ActiveReviewHref => _activeDetail is null ? "#review-queue" : ReviewHref(_activeDetail.Id); + + private string ActiveExportHref => _activeDetail is null ? "#" : $"api/documents/{_activeDetail.Id}/export?format=csv"; + + private int PreviewPageCount => Math.Clamp((_activeDetail?.Tables.Count ?? 0) + 1, 1, 6); + + private string DocumentDisplayTitle => _activeDetail?.DocumentType == ReinsuranceDocumentType.Bordereau + ? "BORDEREAUX REGISTER" + : "TECHNICAL ACCOUNT STATEMENT"; + + private string PreviewText => string.IsNullOrWhiteSpace(_activeDetail?.ParsedMarkdown) + ? "No preview text available." + : _activeDetail.ParsedMarkdown; + + private List ActiveFields => _activeDetail?.Fields + .Where(extracted => !string.IsNullOrWhiteSpace(extracted.Value)) + .OrderByDescending(extracted => extracted.Confidence) + .ToList() ?? []; + + private List DashboardExceptions => Flagged + .Select(document => new DashboardException( + document.Id, + document.DocumentType == ReinsuranceDocumentType.Unknown ? "Unsupported Document" : CockpitFormat.DocumentTypeLabel(document.DocumentType), + document.DocumentType == ReinsuranceDocumentType.Unknown ? "Unknown format" : $"{document.ExceptionCount} exceptions", + document.DocumentType == ReinsuranceDocumentType.Unknown ? "Supported reinsurance file" : "Analyst validated values", + document.Confidence < 0.6 ? $"Low {CockpitFormat.PercentWhole(document.Confidence)}" : $"Medium {CockpitFormat.PercentWhole(document.Confidence)}", + document.Confidence < 0.6 ? "score-low" : "score-mid")) + .ToList(); + + private IReadOnlyList NormalizedRows { - 0 => "No documents in the workspace yet", - _ when _metrics.NeedsAttention == 1 => "1 document needs attention", - _ when _metrics.NeedsAttention > 1 => $"{_metrics.NeedsAttention} documents need attention", - _ when _metrics.Pending == 1 => "1 document is pending review", - _ when _metrics.Pending > 1 => $"{_metrics.Pending} documents are pending review", - _ => "All documents reviewed and clear" - }; - - private string HealthSummary => _documents.Count == 0 - ? "Drop a reinsurance document into intake to begin." - : $"{_metrics.Extracted} extracted · {_metrics.NeedsAttention} unsupported · {_metrics.TotalExceptions} open exceptions"; - - private string ConfidenceValue => _metrics.Extracted == 0 - ? "—" - : CockpitFormat.Percent(_metrics.AvgConfidence); - - private string DocumentsFoot => _metrics.NeedsAttention > 0 - ? $"{_metrics.NeedsAttention} need attention" - : $"{_metrics.Extracted} extracted"; - - private List Filtered => - string.IsNullOrWhiteSpace(_search) - ? _documents - : [.. _documents.Where(d => - d.FileName.Contains(_search, StringComparison.OrdinalIgnoreCase) || - CockpitFormat.DocumentTypeLabel(d.DocumentType).Contains(_search, StringComparison.OrdinalIgnoreCase))]; - - private List Flagged => - [.. _documents.Where(d => d.ExceptionCount > 0) - .OrderByDescending(d => d.ExceptionCount) - .Take(8)]; + get + { + if (_activeDetail is null) + { + return []; + } + + var table = _activeDetail.Tables.FirstOrDefault(candidate => candidate.Rows.Count > 0); + if (table is not null) + { + return table.Rows.Select((row, index) => new NormalizedRow( + index + 1, + RowValue(row, "Member", "Cedent", "Line of Business"), + Money(RowValue(row, "Premium", "Premium (USD)")), + Money(RowValue(row, "Claims", "Claims (USD)")), + Money(RowValue(row, "Commission", "Commission (USD)")), + RowValue(row, "Net Ceded", "Ceded", "Ceded (USD)"), + RowValue(row, "Cession %", "Cession", "Share"), + CockpitFormat.PercentWhole(_activeDetail.Confidence))).ToList(); + } + + if (ActiveFields.Count == 0) + { + return []; + } + + return [new NormalizedRow( + 1, + FinancialField("Line of Business"), + Money(FinancialField("Premium")), + Money(FinancialField("Claims")), + Money(FinancialField("Commission")), + EstimatedCeded, + FinancialField("Cession %", "Cession"), + CockpitFormat.PercentWhole(_activeDetail.Confidence))]; + } + } + + private string EstimatedCeded + { + get + { + var premium = DecimalField("Premium"); + var cession = PercentField("Cession %", "Cession"); + if (premium is null || cession is null) + { + return "—"; + } + + return Money((premium.Value * cession.Value).ToString("0.##")); + } + } + + private List Filtered => string.IsNullOrWhiteSpace(_search) + ? _documents + : [.. _documents.Where(d => + d.FileName.Contains(_search, StringComparison.OrdinalIgnoreCase) || + CockpitFormat.DocumentTypeLabel(d.DocumentType).Contains(_search, StringComparison.OrdinalIgnoreCase))]; + + private List Flagged => [.. _documents.Where(d => d.ExceptionCount > 0) + .OrderByDescending(d => d.ExceptionCount) + .Take(8)]; private string ReviewHref(Guid id) => $"review/{id}"; - private static string ExceptionSeverityClass(int count) => count switch + private string FinancialField(params string[] names) + { + if (_activeDetail is null) + { + return "—"; + } + + foreach (var name in names) + { + var extractedField = _activeDetail.Fields.FirstOrDefault(candidate => candidate.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(extractedField?.Value)) + { + return extractedField.Value; + } + } + + return "—"; + } + + private decimal? DecimalField(params string[] names) { - >= 3 => "sev-critical", - >= 1 => "sev-warning", - _ => "sev-info" - }; + var value = FinancialField(names).Replace("USD", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace(",", string.Empty, StringComparison.Ordinal) + .Trim(); + return decimal.TryParse(value, out var parsed) ? parsed : null; + } + + private decimal? PercentField(params string[] names) + { + var value = FinancialField(names).Replace("%", string.Empty, StringComparison.Ordinal).Trim(); + return decimal.TryParse(value, out var parsed) ? parsed / 100m : null; + } + + private static string RowValue(IReadOnlyDictionary row, params string[] names) + { + foreach (var name in names) + { + if (row.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return "—"; + } + + private static string Money(string value) + { + var cleaned = value.Replace("USD", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace(",", string.Empty, StringComparison.Ordinal) + .Trim(); + return decimal.TryParse(cleaned, out var amount) ? $"USD {amount:N0}" : value; + } private readonly record struct Metrics(int Total, int Extracted, double AvgConfidence, int TotalExceptions, int Flagged, int Pending, int Approved, int NeedsAttention) { @@ -338,7 +589,6 @@ } var extracted = docs.Where(d => d.Status == DocumentStatus.Extracted).ToList(); - return new Metrics( docs.Count, extracted.Count, @@ -351,5 +601,3 @@ } } } - - diff --git a/src/Reva.Web/Components/Pages/Review.razor b/src/Reva.Web/Components/Pages/Review.razor index cf56fd1..8272ade 100644 --- a/src/Reva.Web/Components/Pages/Review.razor +++ b/src/Reva.Web/Components/Pages/Review.razor @@ -1,42 +1,43 @@ @page "/review/{Id:guid}" @rendermode @(new InteractiveServerRenderMode(false)) @inject NavigationManager Nav +@inject IDocumentWorkflow Workflow @(_detail is null ? "Document review" : $"Review · {_detail.FileName}") -
+
Back to cockpit

@(_detail?.FileName ?? "Document review")

@if (_detail is not null) { -

@CockpitFormat.DocumentTypeLabel(_detail.DocumentType) · @_detail.ParserProfile

+

@CockpitFormat.DocumentTypeLabel(_detail.DocumentType) · @_detail.ParserProfile · updated @CockpitFormat.RelativeTime(_detail.UpdatedAt, _now)

+ } + else + { +

Review, correct, approve, and export canonical reinsurance intelligence

}
@if (_detail is not null) { -
+
@if (CanAct) { - JSON - CSV - + JSON + CSV + } else { JSON CSV - Approve + Approve }
}
-
+
@if (_toast is not null) {
@@ -47,106 +48,174 @@ @if (_loading) { -
Loading document…
+
Loading review workspace…
} else if (_detail is null) { -
+
- +

Document not found

This document may have been removed. Return to the cockpit to pick another.

Back to cockpit
-
+ } else { -
-
-
-
Status
-
Review
-
Confidence
-
Exceptions@_detail.Exceptions.Count
-
Document hash@ShortHash(_detail.Sha256Hash)
-
+
+
+ Review workspace +

@ReviewHeadline

+

@ReviewSummary

+
+
+
Status
+
Review
+
Confidence
+
Exceptions@_detail.Exceptions.Count
@if (!CanAct) { -
- -
-

@(_detail.Status == DocumentStatus.Failed ? "Processing failed" : "Unsupported document — needs attention")

-

- @(_detail.Status == DocumentStatus.Failed - ? "This document could not be processed, so no fields were extracted. Export and approval are unavailable." - : "This file was not recognised as a supported reinsurance document, so no canonical fields were extracted. Export and approval are unavailable. Upload a treaty, statement of account, bordereau, or loss run to extract structured data.") -

+
+
+
+ Needs attention +

@AttentionTitle

+

@AttentionCopy

+
+
+ Treaty + Statement of account + Bordereau + Loss run + Claim notice
} -
-
-
- -

Document preview

-
- @_detail.ParserProfile -
+
+
+
+

Document Preview

+ @_detail.ParserProfile +
+
+ + + + + 1 / @PreviewPageCount + + 100% + +
-
-
-
@_detail.ParsedMarkdown
+
+ @if (CanAct) + { +
+
@DocumentDisplayTitle
+
Canonical extraction workspace
+
+ @foreach (var edit in _fields.Take(8)) + { +
+
@edit.Name
+
@edit.Value
+
+ } +
+ @if (_detail.Tables.Count > 0) + { + var table = _detail.Tables[0]; + + + + @foreach (var header in table.Headers.Take(5)) + { + + } + + + + @foreach (var row in table.Rows.Take(4)) + { + + @foreach (var header in table.Headers.Take(5)) + { + + } + + } + +
@header
@(row.TryGetValue(header, out var cell) ? cell : string.Empty)
+ } + else if (!string.IsNullOrWhiteSpace(_detail.ParsedMarkdown)) + { +
@PreviewExcerpt
+ } +
+ } + else + { +
+
+ Quarantined intake +

Not a supported reinsurance document

+

Reva preserved the upload and technical parse, but it will not fabricate canonical fields for a resume, random PDF, or non-reinsurance file.

+
+ Next best action + Upload a treaty, technical account, bordereau, loss run, endorsement, facultative slip, or claim notice. +
+
+ } +
+ @for (var index = 1; index <= PreviewPageCount; index++) + { + @index + }
-
+ @if (ShowFields) { -
+
- -

Extracted fields

-
- AI confidence @CockpitFormat.Percent(_detail.Confidence) -
+

Extracted Reinsurance Fields

+
AI Confidence: @CockpitFormat.PercentWhole(_detail.Confidence)
-
-
- @for (var i = 0; i < _fields.Count; i++) - { - var index = i; - var field = _fields[index]; -
- - - -
- } -
+
+ @for (var i = 0; i < _fields.Count; i++) + { + var index = i; + var edit = _fields[index]; +
+ + + +
+ }
-
+
+ @EditedCount correction(s) staged + +
+ } -
+ +
+ + @if (!CanAct && !string.IsNullOrWhiteSpace(_detail.ParsedMarkdown)) + { +
+
+

Technical parse preview

+
Raw text kept for audit
+
+
@PreviewExcerpt
-
+ } @if (_detail.Tables.Count > 0) { @foreach (var table in _detail.Tables) { -
+
- -

@table.Name

-
- @table.Rows.Count rows -
+

@table.Name

+
@table.Rows.Count rows
-
-
- - +
+
+ + + @foreach (var header in table.Headers) + { + + } + + + + @foreach (var row in table.Rows) + { @foreach (var header in table.Headers) { - + } - - - @foreach (var row in table.Rows) - { - - @foreach (var header in table.Headers) - { - - } - - } - -
@header
@header@(row.TryGetValue(header, out var cell) ? cell : string.Empty)
@(row.TryGetValue(header, out var cell) ? cell : string.Empty)
-
+ } + +
} } -
+
- -

Review decision

+

Review decision

@if (EditedCount > 0) { - @EditedCount field correction(s) + @EditedCount correction(s) }
@if (!CanAct) { -

This document is unsupported and cannot be reviewed, corrected, or exported. Re-upload a supported reinsurance document to continue.

+

This document is unsupported and cannot be reviewed, corrected, exported, or approved. Re-upload a supported reinsurance document to continue.

} else { -
-
- +
+
-
- - -
+ +
- - - + +
@if (!CanSubmit) { @@ -280,7 +346,6 @@ public bool IsEdited => !string.Equals(Value, Original, StringComparison.Ordinal); } - private DocumentApiClient _api = default!; private DocumentDetail? _detail; private List _fields = []; private string _reviewer = "Underwriting Team"; @@ -288,20 +353,23 @@ private bool _loading = true; private bool _submitting; private Toast? _toast; + private DateTimeOffset _now = DateTimeOffset.UtcNow; protected override async Task OnInitializedAsync() { - _api = new DocumentApiClient(Nav.BaseUri); await LoadAsync(); } + private string ExportHref(string format) => $"api/documents/{Id}/export?format={format}"; + private async Task LoadAsync() { _loading = true; + _now = DateTimeOffset.UtcNow; StateHasChanged(); try { - _detail = await _api.GetAsync(Id); + _detail = await Workflow.GetAsync(Id, CancellationToken.None); BindFields(); } catch (Exception ex) @@ -318,12 +386,12 @@ { _fields = _detail is null ? [] - : [.. _detail.Fields.Select(f => new FieldEdit + : [.. _detail.Fields.Select(extracted => new FieldEdit { - Name = f.Name, - Original = f.Value, - Value = f.Value, - Confidence = f.Confidence + Name = extracted.Name, + Original = extracted.Value, + Value = extracted.Value, + Confidence = extracted.Confidence })]; } @@ -335,14 +403,66 @@ } } - private int EditedCount => _fields.Count(f => f.IsEdited); + private int EditedCount => _fields.Count(candidate => candidate.IsEdited); private bool CanAct => _detail is not null && CockpitFormat.IsActionable(_detail.Status); - private bool ShowFields => _fields.Count > 0; + private bool ShowFields => CanAct && _fields.Count > 0; private bool CanSubmit => CanAct && !string.IsNullOrWhiteSpace(_reviewer); + private int PreviewPageCount => Math.Clamp((_detail?.Tables.Count ?? 0) + 1, 1, 6); + + private string ReviewShellClass => ShowFields ? "has-fields" : "attention-only"; + + private string ActionToneClass => _detail?.Status switch + { + DocumentStatus.Extracted => "is-ready", + DocumentStatus.Failed => "is-critical", + DocumentStatus.Unsupported => "is-attention", + _ => "is-attention" + }; + + private string ReviewHeadline => _detail?.Status switch + { + DocumentStatus.Extracted => "Canonical reinsurance fields are ready for analyst review", + DocumentStatus.Failed => "Processing failed before extraction could complete", + DocumentStatus.Unsupported => "Unsupported intake isolated without polluting reinsurance data", + _ => "Document needs attention before approval" + }; + + private string ReviewSummary => _detail?.Status switch + { + DocumentStatus.Extracted => "Validate the extracted fields, correct any exceptions, then export or record a review decision.", + DocumentStatus.Failed => "The parser could not produce a usable extraction. Keep the audit trail and re-upload a clean document.", + DocumentStatus.Unsupported => "Reva kept the file for audit, but blocked approval and export because this is not a recognized reinsurance document.", + _ => "Review the intake result and resolve any blocker before sending it downstream." + }; + + private string AttentionTitle => _detail?.Status == DocumentStatus.Failed + ? "Processing failed" + : "Unsupported document — no fabricated fields"; + + private string AttentionCopy => _detail?.Status == DocumentStatus.Failed + ? "No trusted extraction was created, so export and approval remain locked." + : "This file was not recognized as a supported reinsurance document. Export and approval stay locked until a supported source file is uploaded."; + + private string DocumentDisplayTitle => _detail?.DocumentType == ReinsuranceDocumentType.Bordereau + ? "BORDEREAUX REGISTER" + : "TECHNICAL ACCOUNT STATEMENT"; + + private string PreviewExcerpt + { + get + { + var text = string.IsNullOrWhiteSpace(_detail?.ParsedMarkdown) + ? "No preview text available." + : _detail.ParsedMarkdown.Trim(); + const int limit = 1800; + return text.Length <= limit ? text : text[..limit] + "\n…"; + } + } + private Task ApproveAsync() => SubmitAsync("Approve"); private Task RequestCorrectionAsync() => SubmitAsync("RequestCorrection"); @@ -362,14 +482,14 @@ try { var corrections = _fields - .Where(f => f.IsEdited) - .Select(f => new FieldCorrection(f.Name, f.Value)) + .Where(candidate => candidate.IsEdited) + .Select(candidate => new FieldCorrection(candidate.Name, candidate.Value)) .ToList(); var payload = new ReviewDecision(decision, _reviewer.Trim(), string.IsNullOrWhiteSpace(_notes) ? null : _notes.Trim(), corrections); - var updated = await _api.ReviewAsync(Id, payload); + var updated = await Workflow.ReviewAsync(Id, payload, CancellationToken.None); if (updated is null) { _toast = new Toast("err", "The review could not be saved."); @@ -377,6 +497,7 @@ else { _detail = updated; + _now = DateTimeOffset.UtcNow; BindFields(); _toast = new Toast("ok", $"Decision recorded: {DecisionLabel(decision)}."); } @@ -398,8 +519,10 @@ _ => "approved" }; - private static string ShortHash(string hash) => - string.IsNullOrEmpty(hash) ? "—" : hash.Length <= 12 ? hash : hash[..12]; + private static string SeverityScoreClass(ExceptionSeverity severity) => severity switch + { + ExceptionSeverity.Critical => "score-low", + ExceptionSeverity.Warning => "score-mid", + _ => "score-info" + }; } - - diff --git a/src/Reva.Web/Components/_Imports.razor b/src/Reva.Web/Components/_Imports.razor index b484995..cb96d0c 100644 --- a/src/Reva.Web/Components/_Imports.razor +++ b/src/Reva.Web/Components/_Imports.razor @@ -11,6 +11,7 @@ @using Reva.Web.Components.Cockpit @using Reva.Web.Components.Layout @using Reva.Web.Components.Services +@using Reva.Infrastructure @using Reva.Core.Contracts @using Reva.Core.Documents @using Reva.Core.Reinsurance diff --git a/src/Reva.Web/Program.cs b/src/Reva.Web/Program.cs index 7159eba..dd75925 100644 --- a/src/Reva.Web/Program.cs +++ b/src/Reva.Web/Program.cs @@ -24,7 +24,7 @@ using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); + await dbContext.Database.MigrateAsync(); await NormalizeUnsupportedDocumentsAsync(dbContext); } @@ -54,6 +54,7 @@ static async Task NormalizeUnsupportedDocumentsAsync(RevaDbContext dbContext) { var documents = await dbContext.Documents + .AsSplitQuery() .Include(document => document.Fields) .Include(document => document.Exceptions) .Where(document => document.Status == "Extracted"