feat(admin): add five-identifier ranked equipment search (Phase 6.4 Stream B.5)
Implements the missing Stream B.5 search from the Phase 6.4 plan: - EquipmentService.SearchAsync scopes to a cluster, scores hits across ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid (decision #117): exact = 100, prefix = 50, fuzzy (opt-in) = 20; published generation outranks draft on equal scores per spec. - EquipmentSearchHit record carries Score + MatchedField for badge display. - EquipmentTab.razor gains a search panel with per-row matched-field chips (green exact, amber prefix, grey fuzzy) and fuzzy opt-in checkbox. - 14 new unit tests in EquipmentSearchTests.cs (Category=Unit) cover exact, prefix, fuzzy, case-insensitivity, tie-break, cross-cluster isolation, and maxResults cap; all 148 admin unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,98 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@
|
||||
<section class="panel rise mb-3" style="animation-delay:.02s">
|
||||
<div class="panel-head">Search equipment</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small mb-1">
|
||||
Search by ZTag, MachineCode, SAPID, EquipmentId, or EquipmentUuid
|
||||
</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="e.g. z-001 or MC-42 or SAP-…"
|
||||
@bind="_searchQuery"
|
||||
@bind:event="oninput"
|
||||
@onkeydown="OnSearchKeyDown"/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="fuzzyCheck" @bind="_searchFuzzy"/>
|
||||
<label class="form-check-label small" for="fuzzyCheck">Fuzzy (substring)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RunSearchAsync" disabled="@_searchBusy">Search</button>
|
||||
@if (_searchHits is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-link ms-1" @onclick="ClearSearch">Clear</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (_searchError is not null)
|
||||
{
|
||||
<p class="small text-danger mt-2 mb-0">@_searchError</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_searchHits is not null)
|
||||
{
|
||||
@if (_searchHits.Count == 0)
|
||||
{
|
||||
<p class="p-3 text-muted small mb-0">No matches.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap" style="max-height: 340px; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
|
||||
<th style="width:110px">Matched</th><th style="width:80px">Gen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var hit in _searchHits)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@hit.Equipment.EquipmentId</span></td>
|
||||
<td>@hit.Equipment.Name</td>
|
||||
<td>@hit.Equipment.MachineCode</td>
|
||||
<td>@hit.Equipment.ZTag</td>
|
||||
<td>@hit.Equipment.SAPID</td>
|
||||
<td>
|
||||
@if (hit.MatchedField is not null)
|
||||
{
|
||||
var chipClass = hit.Score switch
|
||||
{
|
||||
100 => "chip chip-ok",
|
||||
50 => "chip chip-warn",
|
||||
_ => "chip chip-idle",
|
||||
};
|
||||
<span class="@chipClass">@hit.MatchedField</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (hit.IsPublished)
|
||||
{ <span class="chip chip-ok">pub</span> }
|
||||
else
|
||||
{ <span class="chip chip-idle">draft</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="p-2 text-muted small mb-0">
|
||||
@_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s").
|
||||
Exact = green, prefix = amber, fuzzy = grey.
|
||||
Fuzzy matching requires the "Fuzzy" checkbox.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (_equipment is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
@@ -114,6 +206,41 @@ else if (_equipment.Count > 0)
|
||||
private Equipment _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
|
||||
// ── Five-identifier search ──────────────────────────────────────────
|
||||
private string _searchQuery = string.Empty;
|
||||
private bool _searchFuzzy;
|
||||
private IReadOnlyList<EquipmentSearchHit>? _searchHits;
|
||||
private bool _searchBusy;
|
||||
private string? _searchError;
|
||||
|
||||
private async Task RunSearchAsync()
|
||||
{
|
||||
_searchError = null;
|
||||
if (string.IsNullOrWhiteSpace(_searchQuery)) { _searchHits = null; return; }
|
||||
_searchBusy = true;
|
||||
try
|
||||
{
|
||||
_searchHits = await EquipmentSvc.SearchAsync(
|
||||
_searchQuery, ClusterId, CancellationToken.None,
|
||||
maxResults: 50, allowFuzzy: _searchFuzzy);
|
||||
}
|
||||
catch (Exception ex) { _searchError = ex.Message; }
|
||||
finally { _searchBusy = false; }
|
||||
}
|
||||
|
||||
private void ClearSearch()
|
||||
{
|
||||
_searchQuery = string.Empty;
|
||||
_searchHits = null;
|
||||
_searchError = null;
|
||||
}
|
||||
|
||||
private async Task OnSearchKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter") await RunSearchAsync();
|
||||
}
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Equipment NewBlankDraft() => new()
|
||||
{
|
||||
EquipmentId = string.Empty, DriverInstanceId = string.Empty,
|
||||
|
||||
Reference in New Issue
Block a user