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)
+{
+
+}
+else
+{
+
+ _activeTab = "details"'>Details
+ _activeTab = "tags"' disabled="@IsNew">Tags
+ _activeTab = "vtags"' disabled="@IsNew">Virtual Tags
+ _activeTab = "alarms"' disabled="@IsNew">Alarms
+
+
+ @if (_activeTab == "details")
+ {
+
+
+
+ Identity
+ @if (!IsNew)
+ {
+
+
EquipmentId
+
+
System-generated; never operator-edited.
+
+ }
+
+
+
Name
+
+
UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.
+
+
+
+ MachineCode
+
+
+
+
+
+
+ UNS line
+
+ — pick a line —
+ @foreach (var (id, display) in _lineOptions)
+ {
+ @display
+ }
+
+
+
+
+ Driver instance
+
+ (none / driver-less)
+ @foreach (var (id, display) in _driverOptions)
+ {
+ @display
+ }
+
+
+
+
+
+
ZTag (ERP)
+
+
Unique fleet-wide via ExternalIdReservation.
+
+
+ SAPID
+
+
+
+
+
Enabled
+
+
+ Surface in deployments
+
+
+
+
+ OPC 40010 identification (optional)
+
+
Manufacturer
+
Model
+
SerialNumber
+
+
+
HardwareRevision
+
SoftwareRevision
+
Year of construction
+
AssetLocation
+
+
+
ManufacturerUri
+
DeviceManualUri
+
+
+ @if (!string.IsNullOrWhiteSpace(_error)) { @_error
}
+
+
+
+ @if (_busy) { }
+ @(IsNew ? "Create" : "Save changes")
+
+
+
+ }
+ 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 )
+ var lines = await (from l in db.UnsLines.AsNoTracking()
+ join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
+ where a.ClusterId == clusterId
+ orderby l.Name
+ select new { l.UnsLineId, l.Name }).ToListAsync(ct);
+
+ var lineOptions = lines.Select(x => (x.UnsLineId, x.Name)).ToList();
+ var drivers = await LoadDriversForClusterAsync(clusterId, ct);
+ return new EquipmentPickContext(lineOptions, drivers);
+ }
+
///
public async Task CreateAreaAsync(
string clusterId,
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentPickContextTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentPickContextTests.cs
new file mode 100644
index 00000000..d6dc7800
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentPickContextTests.cs
@@ -0,0 +1,36 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Verifies resolves a line to its owning
+/// cluster and returns that cluster's lines (for the equipment page's Details-tab line picker), and
+/// returns empty option lists when the line is null or cannot be resolved.
+///
+[Trait("Category", "Unit")]
+public sealed class UnsTreeServiceEquipmentPickContextTests
+{
+ private static UnsTreeService SeededService()
+ {
+ var dbName = $"uns-pickctx-{Guid.NewGuid():N}";
+ UnsTreeTestDb.SeedNamed(dbName);
+ return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+ }
+
+ [Fact]
+ public async Task ResolvesClusterLines_forKnownLine()
+ {
+ var ctx = await SeededService().LoadEquipmentPickContextAsync("LINE-1");
+ ctx.Lines.ShouldContain(o => o.Id == "LINE-1");
+ }
+
+ [Fact]
+ public async Task Empty_for_null_or_unknown_line()
+ {
+ var svc = SeededService();
+ (await svc.LoadEquipmentPickContextAsync(null)).Lines.ShouldBeEmpty();
+ (await svc.LoadEquipmentPickContextAsync("NOPE")).Lines.ShouldBeEmpty();
+ }
+}