sqlinsight-pro/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor
Søren Eggert Lundsteen Olsen 83c247d536 Hub: support case numbers, customer case API, mail, support report
- Case numbers SUP-000001, SUP-000002 … per customer, unique per customer
  (new index; a migration step renames any old duplicates first). The
  portal suggests the next number on the finding page and under the new
  "Ny sag" on the customer page; an empty field gets the next number, a
  typed one that is taken is refused. Concurrent creates retry.
- SupportCase gains Source (Staff/Customer), description, contact name,
  e-mail, phone, the customer's Windows user and ClientRequestId.
- POST/GET /api/v1/cases for the customer's agent (license with priority
  support): creates a case idempotently on ClientRequestId, links the
  finding it came from, lists the customer's cases incl. staff-created.
  Customer/license creation is shared with the findings endpoint.
- Optional mail to support for customer cases (MailKit; prefers AUTH LOGIN
  as Microsoft 365 needs). A failed mail is logged, never fails the call.
  deploy-hub.yml writes optional SUPPORT_SMTP_* secrets to .env.
- Supportrapport: created, closed, open now, average/median resolution
  time, oldest open case and customer-created share per customer for a
  period (default 90 days), plus the cases; CSV for Danish Excel.
- Cases page: newest first, customer cases marked, contact/description.
- Customer page: findings can be filtered to one instance (?instans=id,
  ignored for another customer's instance); active findings per instance
  by severity.
- dotnet-ef as a local tool (dotnet-tools.json) for the migration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:33:55 +02:00

123 lines
5.3 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@page "/portal/rapporter/support"
@attribute [Authorize]
@using SQLInsight.Hub.Support
@inject PortalQueries Queries
<PageTitle>Supportrapport · SQLInsight Hub</PageTitle>
@if (_report is null)
{
return;
}
<h1>Supportrapport</h1>
<p class="lead">
Sager oprettet og lukket i perioden, åbne sager nu og hvor hurtigt sagerne blev løst.
Løsningstid er tiden fra oprettelse til lukning for sager lukket i perioden. Tider er UTC.
</p>
<form method="get" action="/portal/rapporter/support" class="inline-form report-filter">
<label>Fra <input type="date" name="fra" value="@_report.From.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)" /></label>
<label>Til og med <input type="date" name="til" value="@_report.To.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)" /></label>
<label>Kunde
<select name="kunde">
<option value="">Alle kunder</option>
@foreach (var (id, name) in _customers)
{
<option value="@id" selected="@(id == Kunde)">@name</option>
}
</select>
</label>
<button type="submit" class="primary">Vis</button>
<a class="theme-toggle" href="@CsvUrl">Hent CSV</a>
</form>
<section class="kpis">
<div class="kpi"><div><strong>@_report.Total.Created</strong><span>oprettet</span></div></div>
<div class="kpi"><div><strong>@_report.Total.Closed</strong><span>lukket</span></div></div>
<div class="kpi"><div><strong>@_report.Total.OpenNow</strong><span>åbne nu</span></div></div>
<div class="kpi"><div><strong>@SupportReport.Duration(_report.Total.MedianResolution)</strong><span>median løsningstid</span></div></div>
<div class="kpi"><div><strong>@SupportReport.Duration(_report.Total.AverageResolution)</strong><span>gennemsnitlig løsningstid</span></div></div>
</section>
<section class="card">
<div class="card-head"><h2>Pr. kunde</h2></div>
@if (_report.Customers.Count == 0)
{
<p class="empty">Ingen sager i perioden.</p>
}
else
{
<table>
<thead>
<tr>
<th>Kunde</th><th class="num">Oprettet</th><th class="num">Lukket</th><th class="num">Åbne nu</th>
<th>Gennemsnit</th><th>Median</th><th>Ældste åbne</th><th class="num">Oprettet af kunden</th>
</tr>
</thead>
<tbody>
@foreach (var c in _report.Customers.Append(_report.Total))
{
<tr class="@(c.CustomerId is null ? "total" : "")">
<td>@if (c.CustomerId is { } id) { <a href="/portal/kunder/@id">@c.Customer</a> } else { <strong>@c.Customer</strong> }</td>
<td class="num">@c.Created</td>
<td class="num">@c.Closed</td>
<td class="num">@c.OpenNow</td>
<td class="nowrap">@SupportReport.Duration(c.AverageResolution)</td>
<td class="nowrap">@SupportReport.Duration(c.MedianResolution)</td>
<td class="nowrap">@SupportReport.Duration(c.OldestOpenAge)</td>
<td class="num">@(c.CustomerCreatedShare is { } share ? share.ToString("P0", Danish) : "–")</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<div class="card-head"><h2>Sager i perioden</h2></div>
@if (_report.Cases.Count == 0)
{
<p class="empty">Ingen.</p>
}
else
{
<table>
<thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th>Kilde</th><th>Oprettet</th><th>Lukket</th><th>Løsningstid</th></tr></thead>
<tbody>
@foreach (var c in _report.Cases)
{
<tr>
<td class="mono nowrap">@c.Reference</td>
<td><a href="/portal/kunder/@c.CustomerId">@c.Customer</a></td>
<td>@c.Title</td>
<td>@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))</td>
</tr>
}
</tbody>
</table>
}
</section>
@code {
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
[SupplyParameterFromQuery] public DateTime? Fra { get; set; }
[SupplyParameterFromQuery] public DateTime? Til { get; set; }
[SupplyParameterFromQuery] public int? Kunde { get; set; }
private SupportReportResult? _report;
private IReadOnlyList<(int Id, string Name)> _customers = [];
private string CsvUrl =>
$"/portal/actions/reports/support.csv?fra={_report!.From:yyyy-MM-dd}&til={_report.To.AddDays(-1):yyyy-MM-dd}{(Kunde is { } k ? $"&kunde={k}" : "")}";
protected override async Task OnInitializedAsync()
{
var (from, to) = SupportReportPeriod.Resolve(Fra, Til, Queries.Now);
_report = await Queries.SupportReportAsync(from, to, Kunde);
_customers = await Queries.CustomerNamesAsync();
}
}