From 7fbfeca4510bdaefb02d023cf2f99adcce0f56dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 14:36:48 -0400 Subject: [PATCH] feat(uns): equipment detail page shell + Details tab + create-redirect --- .../Components/Pages/Uns/EquipmentPage.razor | 281 ++++++++++++++++++ .../Uns/EquipmentPickContext.cs | 7 + .../Uns/IUnsTreeService.cs | 12 + .../Uns/UnsTreeService.cs | 27 ++ ...UnsTreeServiceEquipmentPickContextTests.cs | 36 +++ 5 files changed, 363 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentPickContext.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentPickContextTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor new file mode 100644 index 00000000..d7407835 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor @@ -0,0 +1,281 @@ +@page "/uns/equipment/new" +@page "/uns/equipment/{EquipmentId}" +@* Dedicated tabbed editor for a single equipment, replacing the /uns EquipmentModal. This task builds + the shell + the Details tab (lifted from EquipmentModal's EditForm) + the create→edit redirect; the + Tags / Virtual Tags / Alarms tabs render placeholders wired in later tasks. On a successful create the + page redirects to /uns/equipment/{newId} so the other tabs (disabled while new) become available. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@inject IUnsTreeService Svc +@inject NavigationManager Nav + +Equipment + +
+

@(IsNew ? "New equipment" : (_equipment?.Name ?? EquipmentId))

+ Back to UNS +
+ +@if (_loading) +{ +

Loading…

+} +else if (!IsNew && _equipment is null) +{ +
@EquipmentId not found.
+} +else +{ + + + @if (_activeTab == "details") + { + + + +
Identity
+ @if (!IsNew) + { +
+ + +
System-generated; never operator-edited.
+
+ } +
+
+ + +
UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.
+ +
+
+ + + +
+
+
+
+ + + + @foreach (var (id, display) in _lineOptions) + { + + } + + +
+
+ + + + @foreach (var (id, display) in _driverOptions) + { + + } + +
+
+
+
+ + +
Unique fleet-wide via ExternalIdReservation.
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+
OPC 40010 identification (optional)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
} + +
+ +
+
+ } + else if (_activeTab == "tags") {

Tags tab — wired in a later task.

} + else if (_activeTab == "vtags") {

Virtual Tags tab — wired in a later task.

} + else if (_activeTab == "alarms") {

Alarms tab — wired in a later task.

} +} + +@code { + /// The equipment id from the route; null/empty on the /new route (create mode). + [Parameter] public string? EquipmentId { get; set; } + + /// Optional parent line id supplied as a query string on create, used to default the line select. + [SupplyParameterFromQuery] public string? LineId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(EquipmentId); + + private string _activeTab = "details"; + private bool _loading = true; + private bool _busy; + private string? _error; + private EquipmentEditDto? _equipment; + private FormModel _form = new(); + private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>(); + private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>(); + + private string TabClass(string tab) => _activeTab == tab ? "active" : ""; + + protected override async Task OnParametersSetAsync() + { + _loading = true; + _error = null; + if (!IsNew) + { + _equipment = await Svc.LoadEquipmentAsync(EquipmentId!); + if (_equipment is not null) + { + LoadFormFrom(_equipment); + var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId); + _lineOptions = ctx.Lines; + _driverOptions = ctx.Drivers; + } + } + else + { + _form = new FormModel { UnsLineId = LineId ?? "" }; + var ctx = await Svc.LoadEquipmentPickContextAsync(LineId); + _lineOptions = ctx.Lines; + _driverOptions = ctx.Drivers; + } + _loading = false; + } + + /// Maps a loaded equipment's fields into the working form (mirrors EquipmentModal's edit branch). + private void LoadFormFrom(EquipmentEditDto e) + { + _form = new FormModel + { + Name = e.Name, + MachineCode = e.MachineCode, + UnsLineId = e.UnsLineId, + DriverInstanceId = e.DriverInstanceId, + ZTag = e.ZTag, + SAPID = e.SAPID, + Manufacturer = e.Manufacturer, + Model = e.Model, + SerialNumber = e.SerialNumber, + HardwareRevision = e.HardwareRevision, + SoftwareRevision = e.SoftwareRevision, + YearOfConstruction = e.YearOfConstruction, + AssetLocation = e.AssetLocation, + ManufacturerUri = e.ManufacturerUri, + DeviceManualUri = e.DeviceManualUri, + Enabled = e.Enabled, + }; + } + + private async Task SaveAsync() + { + _busy = true; + _error = null; + try + { + var input = new EquipmentInput( + _form.Name, + _form.MachineCode, + _form.UnsLineId, + _form.DriverInstanceId, + _form.ZTag, + _form.SAPID, + _form.Manufacturer, + _form.Model, + _form.SerialNumber, + _form.HardwareRevision, + _form.SoftwareRevision, + _form.YearOfConstruction, + _form.AssetLocation, + _form.ManufacturerUri, + _form.DeviceManualUri, + _form.Enabled); + + var result = IsNew + ? await Svc.CreateEquipmentAsync(input) + : await Svc.UpdateEquipmentAsync(_equipment!.EquipmentId, input, _equipment.RowVersion); + + if (!result.Ok) { _error = result.Error; return; } + + if (IsNew) + { + // Redirect to the persisted editor so the other tabs (disabled while new) become available. + Nav.NavigateTo($"/uns/equipment/{result.CreatedId}"); + } + else + { + // Reload to pick up the fresh RowVersion for the next save. + _equipment = await Svc.LoadEquipmentAsync(EquipmentId!); + if (_equipment is not null) { LoadFormFrom(_equipment); } + } + } + finally + { + _busy = false; + } + } + + /// The working form for the Details tab — identical to EquipmentModal.FormModel. + private sealed class FormModel + { + [Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")] + public string Name { get; set; } = ""; + [Required] public string MachineCode { get; set; } = ""; + [Required] public string UnsLineId { get; set; } = ""; + public string? DriverInstanceId { get; set; } + public string? ZTag { get; set; } + public string? SAPID { get; set; } + public string? Manufacturer { get; set; } + public string? Model { get; set; } + public string? SerialNumber { get; set; } + public string? HardwareRevision { get; set; } + public string? SoftwareRevision { get; set; } + public short? YearOfConstruction { get; set; } + public string? AssetLocation { get; set; } + public string? ManufacturerUri { get; set; } + public string? DeviceManualUri { get; set; } + public bool Enabled { get; set; } = true; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentPickContext.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentPickContext.cs new file mode 100644 index 00000000..390c9c1e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentPickContext.cs @@ -0,0 +1,7 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// The UNS-line and driver options the equipment page's Details tab offers, scoped to the +/// cluster that owns a given line. Empty lists when the line can't be resolved to a cluster. +public sealed record EquipmentPickContext( + IReadOnlyList<(string Id, string Display)> Lines, + IReadOnlyList<(string Id, string Display)> Drivers); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 38c84cf9..9a3617c2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -205,6 +205,18 @@ public interface IUnsTreeService /// The cluster's drivers projected to (DriverInstanceId, Display) pairs. Task> LoadDriversForClusterAsync(string clusterId, CancellationToken ct = default); + /// + /// Loads the UNS-line and driver (Id, Display) option lists the equipment page's Details tab + /// offers, scoped to the cluster that owns the supplied line: every line in that cluster (for the + /// line picker) and that cluster's drivers (reusing ). + /// Centralizes the resolution the page would otherwise need the loaded tree to perform. Returns + /// empty lists when is null/empty or cannot be resolved to a cluster. + /// + /// The line whose owning cluster scopes the option lists; null/empty yields empties. + /// A token to cancel the load. + /// The cluster-scoped line and driver options, or empty lists when the line can't be resolved. + Task LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default); + /// /// Creates a new UNS area under a cluster. Fails if an area with the same id already exists. /// Whitespace-only notes are stored as null. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 056d722d..e8094890 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -266,6 +266,33 @@ public sealed class UnsTreeService(IDbContextFactory dbF .ToList(); } + /// + public async Task LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default) + { + var empty = new EquipmentPickContext(Array.Empty<(string, string)>(), Array.Empty<(string, string)>()); + if (string.IsNullOrEmpty(lineId)) return empty; + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // line -> area -> clusterId + var clusterId = await (from l in db.UnsLines.AsNoTracking() + join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId + where l.UnsLineId == lineId + select a.ClusterId).FirstOrDefaultAsync(ct); + if (string.IsNullOrEmpty(clusterId)) return empty; + + // all lines in that cluster (for the line