feat(uns): equipment is a tree leaf linking to the detail page; drop EquipmentModal
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns
|
||||||
@inject IUnsTreeService Svc
|
@inject IUnsTreeService Svc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<PageTitle>UNS</PageTitle>
|
<PageTitle>UNS</PageTitle>
|
||||||
|
|
||||||
@@ -41,7 +42,6 @@
|
|||||||
<UnsTree Roots="_roots" Filter="@_filter"
|
<UnsTree Roots="_roots" Filter="@_filter"
|
||||||
OnToggleExpand="ToggleAsync"
|
OnToggleExpand="ToggleAsync"
|
||||||
OnAddChild="HandleAddChild"
|
OnAddChild="HandleAddChild"
|
||||||
OnAddVirtualTag="HandleAddVirtualTag"
|
|
||||||
OnEdit="HandleEdit"
|
OnEdit="HandleEdit"
|
||||||
OnDelete="HandleDelete" />
|
OnDelete="HandleDelete" />
|
||||||
</div>
|
</div>
|
||||||
@@ -64,31 +64,6 @@
|
|||||||
OnSaved="OnModalSavedAsync"
|
OnSaved="OnModalSavedAsync"
|
||||||
OnCancel="CloseModals" />
|
OnCancel="CloseModals" />
|
||||||
|
|
||||||
<EquipmentModal Visible="_equipmentModalVisible"
|
|
||||||
IsNew="_equipmentModalIsNew"
|
|
||||||
UnsLineId="@_equipmentModalLineId"
|
|
||||||
Existing="_equipmentModalExisting"
|
|
||||||
Lines="_equipmentModalLineOptions"
|
|
||||||
Drivers="_equipmentModalDriverOptions"
|
|
||||||
OnSaved="OnModalSavedAsync"
|
|
||||||
OnCancel="CloseModals" />
|
|
||||||
|
|
||||||
<TagModal Visible="_tagModalVisible"
|
|
||||||
IsNew="_tagModalIsNew"
|
|
||||||
EquipmentId="@_tagModalEquipmentId"
|
|
||||||
Existing="_tagModalExisting"
|
|
||||||
Drivers="_tagModalDriverOptions"
|
|
||||||
OnSaved="OnEquipmentChildModalSavedAsync"
|
|
||||||
OnCancel="CloseModals" />
|
|
||||||
|
|
||||||
<VirtualTagModal Visible="_vtagModalVisible"
|
|
||||||
IsNew="_vtagModalIsNew"
|
|
||||||
EquipmentId="@_vtagModalEquipmentId"
|
|
||||||
Existing="_vtagModalExisting"
|
|
||||||
Scripts="_vtagModalScriptOptions"
|
|
||||||
OnSaved="OnEquipmentChildModalSavedAsync"
|
|
||||||
OnCancel="CloseModals" />
|
|
||||||
|
|
||||||
<ImportEquipmentModal Visible="_importModalVisible"
|
<ImportEquipmentModal Visible="_importModalVisible"
|
||||||
OnImported="OnImportedAsync"
|
OnImported="OnImportedAsync"
|
||||||
OnCancel="CloseModals" />
|
OnCancel="CloseModals" />
|
||||||
@@ -127,7 +102,7 @@
|
|||||||
private string? _filter;
|
private string? _filter;
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
|
|
||||||
// Guards the async modal openers (HandleAddChild/HandleAddVirtualTag/HandleEdit) so a rapid
|
// Guards the async modal openers (HandleAddChild/HandleEdit) so a rapid
|
||||||
// double-action can't race two service loads into the same modal state.
|
// double-action can't race two service loads into the same modal state.
|
||||||
private bool _modalBusy;
|
private bool _modalBusy;
|
||||||
|
|
||||||
@@ -144,34 +119,9 @@
|
|||||||
private LineEditDto? _lineModalExisting;
|
private LineEditDto? _lineModalExisting;
|
||||||
private IReadOnlyList<(string Id, string Display)> _lineModalAreaOptions = Array.Empty<(string, string)>();
|
private IReadOnlyList<(string Id, string Display)> _lineModalAreaOptions = Array.Empty<(string, string)>();
|
||||||
|
|
||||||
// --- Equipment modal state ---
|
|
||||||
private bool _equipmentModalVisible;
|
|
||||||
private bool _equipmentModalIsNew;
|
|
||||||
private string? _equipmentModalLineId;
|
|
||||||
private EquipmentEditDto? _equipmentModalExisting;
|
|
||||||
private IReadOnlyList<(string Id, string Display)> _equipmentModalLineOptions = Array.Empty<(string, string)>();
|
|
||||||
private IReadOnlyList<(string Id, string Display)> _equipmentModalDriverOptions = Array.Empty<(string, string)>();
|
|
||||||
|
|
||||||
// --- Tag modal state ---
|
|
||||||
private bool _tagModalVisible;
|
|
||||||
private bool _tagModalIsNew;
|
|
||||||
private string? _tagModalEquipmentId;
|
|
||||||
private TagEditDto? _tagModalExisting;
|
|
||||||
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagModalDriverOptions = Array.Empty<(string, string, string)>();
|
|
||||||
|
|
||||||
// --- Virtual-tag modal state ---
|
|
||||||
private bool _vtagModalVisible;
|
|
||||||
private bool _vtagModalIsNew;
|
|
||||||
private string? _vtagModalEquipmentId;
|
|
||||||
private VirtualTagEditDto? _vtagModalExisting;
|
|
||||||
private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>();
|
|
||||||
|
|
||||||
// --- Import-equipment-CSV modal state ---
|
// --- Import-equipment-CSV modal state ---
|
||||||
private bool _importModalVisible;
|
private bool _importModalVisible;
|
||||||
|
|
||||||
// --- Owning equipment to refresh in place after a tag/virtual-tag mutation ---
|
|
||||||
private string? _childRefreshEquipmentId;
|
|
||||||
|
|
||||||
// --- Delete-confirm state ---
|
// --- Delete-confirm state ---
|
||||||
private UnsNode? _confirmNode;
|
private UnsNode? _confirmNode;
|
||||||
private bool _confirmBusy;
|
private bool _confirmBusy;
|
||||||
@@ -214,43 +164,21 @@
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Toggles a node's expansion. For equipment nodes whose children have not yet
|
/// Toggles a structural node's expansion. Equipment nodes are leaves (no expander),
|
||||||
/// been loaded, lazily fetches the tag/virtual-tag leaves on first expand.
|
/// so this path only ever fires for Enterprise/Cluster/Area/Line.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ToggleAsync(UnsNode node)
|
private Task ToggleAsync(UnsNode node)
|
||||||
{
|
{
|
||||||
if (node.Loading) return;
|
if (node.Loading) { return Task.CompletedTask; }
|
||||||
|
|
||||||
node.Expanded = !node.Expanded;
|
node.Expanded = !node.Expanded;
|
||||||
|
return Task.CompletedTask;
|
||||||
if (node.Kind == UnsNodeKind.Equipment && node.Expanded && !node.Loaded)
|
|
||||||
{
|
|
||||||
node.Error = null;
|
|
||||||
node.Loading = true;
|
|
||||||
StateHasChanged();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var kids = await Svc.LoadEquipmentChildrenAsync(node.EntityId!);
|
|
||||||
node.Children.Clear();
|
|
||||||
node.Children.AddRange(kids);
|
|
||||||
node.Loaded = true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
node.Error = ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
node.Loading = false;
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the create modal for a node's primary child: a cluster gets a new area; an area gets a
|
/// Opens the create modal for a structural node's primary child: a cluster gets a new area; an area
|
||||||
/// new line scoped to its cluster; a line gets a new equipment scoped to its cluster; an equipment
|
/// gets a new line scoped to its cluster. A line's "+ Equipment" navigates to the equipment detail
|
||||||
/// gets a new tag scoped to its candidate drivers.
|
/// page in create mode rather than opening a modal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task HandleAddChild(UnsNode node)
|
private async Task HandleAddChild(UnsNode node)
|
||||||
{
|
{
|
||||||
@@ -277,21 +205,7 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case UnsNodeKind.Line:
|
case UnsNodeKind.Line:
|
||||||
_equipmentModalIsNew = true;
|
Nav.NavigateTo($"/uns/equipment/new?lineId={node.EntityId}");
|
||||||
_equipmentModalExisting = null;
|
|
||||||
_equipmentModalLineId = node.EntityId;
|
|
||||||
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
|
|
||||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
|
||||||
_equipmentModalVisible = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UnsNodeKind.Equipment:
|
|
||||||
_tagModalIsNew = true;
|
|
||||||
_tagModalExisting = null;
|
|
||||||
_tagModalEquipmentId = node.EntityId;
|
|
||||||
_childRefreshEquipmentId = node.EntityId;
|
|
||||||
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(node.EntityId!);
|
|
||||||
_tagModalVisible = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,31 +215,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens the create modal for a new virtual tag scoped to the clicked equipment.</summary>
|
|
||||||
private async Task HandleAddVirtualTag(UnsNode node)
|
|
||||||
{
|
|
||||||
if (_modalBusy) { return; }
|
|
||||||
_modalBusy = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CloseModals();
|
|
||||||
_vtagModalIsNew = true;
|
|
||||||
_vtagModalExisting = null;
|
|
||||||
_vtagModalEquipmentId = node.EntityId;
|
|
||||||
_childRefreshEquipmentId = node.EntityId;
|
|
||||||
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
|
|
||||||
_vtagModalVisible = true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_modalBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the edit modal for the clicked node, loading the entity first to prefill the form and
|
/// Opens the edit modal for the clicked node, loading the entity first to prefill the form and
|
||||||
/// capture its RowVersion. Tag/VirtualTag edits also stash the owning equipment id so a successful
|
/// capture its RowVersion. Only Area and Line are edited via a modal here; equipment is edited on
|
||||||
/// save can refresh just that equipment's children in place.
|
/// its own detail page (opened via the tree's "Open" link).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task HandleEdit(UnsNode node)
|
private async Task HandleEdit(UnsNode node)
|
||||||
{
|
{
|
||||||
@@ -354,39 +247,6 @@
|
|||||||
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
|
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
|
||||||
_lineModalVisible = true;
|
_lineModalVisible = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case UnsNodeKind.Equipment:
|
|
||||||
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
|
|
||||||
if (equipment is null) { return; }
|
|
||||||
_equipmentModalIsNew = false;
|
|
||||||
_equipmentModalExisting = equipment;
|
|
||||||
_equipmentModalLineId = equipment.UnsLineId;
|
|
||||||
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
|
|
||||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
|
||||||
_equipmentModalVisible = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UnsNodeKind.Tag:
|
|
||||||
var tag = await Svc.LoadTagAsync(node.EntityId!);
|
|
||||||
if (tag is null) { return; }
|
|
||||||
_tagModalIsNew = false;
|
|
||||||
_tagModalExisting = tag;
|
|
||||||
_tagModalEquipmentId = tag.EquipmentId;
|
|
||||||
_childRefreshEquipmentId = tag.EquipmentId;
|
|
||||||
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(tag.EquipmentId);
|
|
||||||
_tagModalVisible = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UnsNodeKind.VirtualTag:
|
|
||||||
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
|
|
||||||
if (vtag is null) { return; }
|
|
||||||
_vtagModalIsNew = false;
|
|
||||||
_vtagModalExisting = vtag;
|
|
||||||
_vtagModalEquipmentId = vtag.EquipmentId;
|
|
||||||
_childRefreshEquipmentId = vtag.EquipmentId;
|
|
||||||
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
|
|
||||||
_vtagModalVisible = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -422,8 +282,7 @@
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind.
|
/// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind.
|
||||||
/// Area/Line/Equipment trigger a full structural reload on success; Tag/VirtualTag refresh only the
|
/// Area/Line/Equipment trigger a full structural reload on success.
|
||||||
/// owning equipment's children in place so the rest of the user's expansion is preserved.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ConfirmDeleteAsync()
|
private async Task ConfirmDeleteAsync()
|
||||||
{
|
{
|
||||||
@@ -436,42 +295,8 @@
|
|||||||
var node = _confirmNode;
|
var node = _confirmNode;
|
||||||
UnsMutationResult result;
|
UnsMutationResult result;
|
||||||
|
|
||||||
// Tag/VirtualTag deletes refresh just the owning equipment's children rather than the
|
|
||||||
// whole tree, so they're handled separately from the structural Area/Line/Equipment path.
|
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
{
|
{
|
||||||
case UnsNodeKind.Tag:
|
|
||||||
var tag = await Svc.LoadTagAsync(node.EntityId!);
|
|
||||||
if (tag is null) { await ReloadAndCloseAsync(); return; }
|
|
||||||
result = await Svc.DeleteTagAsync(node.EntityId!, tag.RowVersion);
|
|
||||||
if (result.Ok)
|
|
||||||
{
|
|
||||||
await RefreshEquipmentChildrenAsync(tag.EquipmentId);
|
|
||||||
CloseModals();
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_confirmError = result.Error;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
|
|
||||||
case UnsNodeKind.VirtualTag:
|
|
||||||
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
|
|
||||||
if (vtag is null) { await ReloadAndCloseAsync(); return; }
|
|
||||||
result = await Svc.DeleteVirtualTagAsync(node.EntityId!, vtag.RowVersion);
|
|
||||||
if (result.Ok)
|
|
||||||
{
|
|
||||||
await RefreshEquipmentChildrenAsync(vtag.EquipmentId);
|
|
||||||
CloseModals();
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_confirmError = result.Error;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
|
|
||||||
case UnsNodeKind.Area:
|
case UnsNodeKind.Area:
|
||||||
var area = await Svc.LoadAreaAsync(node.EntityId!);
|
var area = await Svc.LoadAreaAsync(node.EntityId!);
|
||||||
if (area is null) { await ReloadAndCloseAsync(); return; }
|
if (area is null) { await ReloadAndCloseAsync(); return; }
|
||||||
@@ -519,70 +344,6 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles a successful Tag/VirtualTag modal save by refreshing only the owning equipment's children
|
|
||||||
/// in place — never a full structural reload, which would collapse the user's expansion.
|
|
||||||
/// </summary>
|
|
||||||
private async Task OnEquipmentChildModalSavedAsync()
|
|
||||||
{
|
|
||||||
if (_childRefreshEquipmentId is not null)
|
|
||||||
{
|
|
||||||
await RefreshEquipmentChildrenAsync(_childRefreshEquipmentId);
|
|
||||||
}
|
|
||||||
CloseModals();
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reloads a single equipment node's tag/virtual-tag children in place, leaving the rest of the tree
|
|
||||||
/// (and the user's expansion) untouched. Falls back to a full structural reload only if the node
|
|
||||||
/// can no longer be found in the current tree. Either branch only mutates state — the caller is
|
|
||||||
/// responsible for calling StateHasChanged() afterwards (every current caller does).
|
|
||||||
/// </summary>
|
|
||||||
private async Task RefreshEquipmentChildrenAsync(string equipmentId)
|
|
||||||
{
|
|
||||||
var node = FindEquipmentNode(equipmentId);
|
|
||||||
if (node is null)
|
|
||||||
{
|
|
||||||
// Fallback: the equipment node is no longer in the current tree — reload the whole structure.
|
|
||||||
_roots = await Svc.LoadStructureAsync();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var kids = await Svc.LoadEquipmentChildrenAsync(equipmentId);
|
|
||||||
node.Children.Clear();
|
|
||||||
node.Children.AddRange(kids);
|
|
||||||
node.ChildCount = node.Children.Count;
|
|
||||||
node.Loaded = true;
|
|
||||||
node.Expanded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Recursively walks the current tree for the Equipment node with the given id, or null.</summary>
|
|
||||||
private UnsNode? FindEquipmentNode(string equipmentId)
|
|
||||||
{
|
|
||||||
foreach (var root in _roots)
|
|
||||||
{
|
|
||||||
var found = FindEquipmentNode(root, equipmentId);
|
|
||||||
if (found is not null) { return found; }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UnsNode? FindEquipmentNode(UnsNode node, string equipmentId)
|
|
||||||
{
|
|
||||||
if (node.Kind == UnsNodeKind.Equipment && node.EntityId == equipmentId)
|
|
||||||
{
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var child in node.Children)
|
|
||||||
{
|
|
||||||
var found = FindEquipmentNode(child, equipmentId);
|
|
||||||
if (found is not null) { return found; }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reloads the tree after a successful delete and closes the confirm modal.</summary>
|
/// <summary>Reloads the tree after a successful delete and closes the confirm modal.</summary>
|
||||||
private async Task ReloadAndCloseAsync()
|
private async Task ReloadAndCloseAsync()
|
||||||
{
|
{
|
||||||
@@ -603,24 +364,7 @@
|
|||||||
_lineModalAreaId = null;
|
_lineModalAreaId = null;
|
||||||
_lineModalExisting = null;
|
_lineModalExisting = null;
|
||||||
_lineModalAreaOptions = Array.Empty<(string, string)>();
|
_lineModalAreaOptions = Array.Empty<(string, string)>();
|
||||||
_equipmentModalVisible = false;
|
|
||||||
_equipmentModalIsNew = false;
|
|
||||||
_equipmentModalLineId = null;
|
|
||||||
_equipmentModalExisting = null;
|
|
||||||
_equipmentModalLineOptions = Array.Empty<(string, string)>();
|
|
||||||
_equipmentModalDriverOptions = Array.Empty<(string, string)>();
|
|
||||||
_tagModalVisible = false;
|
|
||||||
_tagModalIsNew = false;
|
|
||||||
_tagModalEquipmentId = null;
|
|
||||||
_tagModalExisting = null;
|
|
||||||
_tagModalDriverOptions = Array.Empty<(string, string, string)>();
|
|
||||||
_vtagModalVisible = false;
|
|
||||||
_vtagModalIsNew = false;
|
|
||||||
_vtagModalEquipmentId = null;
|
|
||||||
_vtagModalExisting = null;
|
|
||||||
_vtagModalScriptOptions = Array.Empty<(string, string)>();
|
|
||||||
_importModalVisible = false;
|
_importModalVisible = false;
|
||||||
_childRefreshEquipmentId = null;
|
|
||||||
_confirmNode = null;
|
_confirmNode = null;
|
||||||
_confirmError = null;
|
_confirmError = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
@* Create/edit modal for an equipment, wired straight into IUnsTreeService. The host page owns
|
|
||||||
visibility and supplies the parent line id (create) or the loaded EquipmentEditDto (edit), plus
|
|
||||||
the cluster-scoped UNS-line and driver lists. The EquipmentId is system-generated (decision #125)
|
|
||||||
so it is never an editable field — only shown read-only on edit. On a successful save it raises
|
|
||||||
OnSaved so the host can reload the tree. *@
|
|
||||||
@using System.ComponentModel.DataAnnotations
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
|
||||||
@inject IUnsTreeService Svc
|
|
||||||
|
|
||||||
@if (Visible)
|
|
||||||
{
|
|
||||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
|
||||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
|
||||||
<div class="modal-dialog modal-xl" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="equipmentModal">
|
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">@(IsNew ? "New equipment" : "Edit equipment")</h5>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<h6 class="text-muted">Identity</h6>
|
|
||||||
@if (!IsNew)
|
|
||||||
{
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">EquipmentId</label>
|
|
||||||
<input class="form-control form-control-sm mono" value="@Existing?.EquipmentId" disabled />
|
|
||||||
<div class="form-text">System-generated; never operator-edited.</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label" for="eq-name">Name</label>
|
|
||||||
<InputText id="eq-name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
|
|
||||||
placeholder="machine-01" />
|
|
||||||
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
|
|
||||||
<ValidationMessage For="@(() => _form.Name)" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label" for="eq-machinecode">MachineCode</label>
|
|
||||||
<InputText id="eq-machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
|
|
||||||
placeholder="machine_001" />
|
|
||||||
<ValidationMessage For="@(() => _form.MachineCode)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label" for="eq-line">UNS line</label>
|
|
||||||
<InputSelect id="eq-line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
|
|
||||||
<option value="">— pick a line —</option>
|
|
||||||
@foreach (var (id, display) in Lines)
|
|
||||||
{
|
|
||||||
<option value="@id">@display</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
<ValidationMessage For="@(() => _form.UnsLineId)" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label" for="eq-driver">Driver instance</label>
|
|
||||||
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
|
|
||||||
<option value="">(none / driver-less)</option>
|
|
||||||
@foreach (var (id, display) in Drivers)
|
|
||||||
{
|
|
||||||
<option value="@id">@display</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
|
|
||||||
<InputText id="eq-ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
|
|
||||||
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label" for="eq-sap">SAPID</label>
|
|
||||||
<InputText id="eq-sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Enabled</label>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
|
|
||||||
<label class="form-check-label">Surface in deployments</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<h6 class="text-muted">OPC 40010 identification (optional)</h6>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
|
|
||||||
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
|
|
||||||
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
|
|
||||||
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
|
|
||||||
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
|
|
||||||
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
|
|
||||||
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(_error))
|
|
||||||
{
|
|
||||||
<div class="text-danger small mt-2">@_error</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary" disabled="@_busy">
|
|
||||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
||||||
@(IsNew ? "Create" : "Save changes")
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</EditForm>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
|
||||||
[Parameter] public bool Visible { get; set; }
|
|
||||||
|
|
||||||
/// <summary><c>true</c> to create a new equipment; <c>false</c> to edit <see cref="Existing"/>.</summary>
|
|
||||||
[Parameter] public bool IsNew { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The parent line id used to default the UNS-line select on create.</summary>
|
|
||||||
[Parameter] public string? UnsLineId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The equipment being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
|
|
||||||
[Parameter] public EquipmentEditDto? Existing { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The selectable UNS lines — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
|
|
||||||
[Parameter] public IReadOnlyList<(string Id, string Display)> Lines { get; set; } = Array.Empty<(string, string)>();
|
|
||||||
|
|
||||||
/// <summary>The selectable drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
|
|
||||||
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
|
|
||||||
|
|
||||||
/// <summary>Raised after a successful create/save so the host can reload and close.</summary>
|
|
||||||
[Parameter] public EventCallback OnSaved { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Raised when the user cancels so the host can close.</summary>
|
|
||||||
[Parameter] public EventCallback OnCancel { get; set; }
|
|
||||||
|
|
||||||
private FormModel _form = new();
|
|
||||||
private bool _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
|
||||||
if (IsNew)
|
|
||||||
{
|
|
||||||
_form = new FormModel { UnsLineId = UnsLineId ?? "" };
|
|
||||||
}
|
|
||||||
else if (Existing is not null)
|
|
||||||
{
|
|
||||||
_form = new FormModel
|
|
||||||
{
|
|
||||||
Name = Existing.Name,
|
|
||||||
MachineCode = Existing.MachineCode,
|
|
||||||
UnsLineId = Existing.UnsLineId,
|
|
||||||
DriverInstanceId = Existing.DriverInstanceId,
|
|
||||||
ZTag = Existing.ZTag,
|
|
||||||
SAPID = Existing.SAPID,
|
|
||||||
Manufacturer = Existing.Manufacturer,
|
|
||||||
Model = Existing.Model,
|
|
||||||
SerialNumber = Existing.SerialNumber,
|
|
||||||
HardwareRevision = Existing.HardwareRevision,
|
|
||||||
SoftwareRevision = Existing.SoftwareRevision,
|
|
||||||
YearOfConstruction = Existing.YearOfConstruction,
|
|
||||||
AssetLocation = Existing.AssetLocation,
|
|
||||||
ManufacturerUri = Existing.ManufacturerUri,
|
|
||||||
DeviceManualUri = Existing.DeviceManualUri,
|
|
||||||
Enabled = Existing.Enabled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
_error = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(Existing!.EquipmentId, input, Existing.RowVersion);
|
|
||||||
|
|
||||||
if (result.Ok)
|
|
||||||
{
|
|
||||||
await OnSaved.InvokeAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_error = result.Error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task CancelAsync() => OnCancel.InvokeAsync();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,13 +13,10 @@
|
|||||||
/// <summary>The top-level UNS nodes to render (typically the enterprise roots).</summary>
|
/// <summary>The top-level UNS nodes to render (typically the enterprise roots).</summary>
|
||||||
[Parameter, EditorRequired] public IReadOnlyList<UnsNode> Roots { get; set; } = default!;
|
[Parameter, EditorRequired] public IReadOnlyList<UnsNode> Roots { get; set; } = default!;
|
||||||
|
|
||||||
/// <summary>Raised for the primary "add child" action of a node (e.g. "+ Area" on a cluster, "+ Tag" on equipment).</summary>
|
/// <summary>Raised for the primary "add child" action of a structural node (e.g. "+ Area" on a cluster, "+ Equipment" on a line).</summary>
|
||||||
[Parameter] public EventCallback<UnsNode> OnAddChild { get; set; }
|
[Parameter] public EventCallback<UnsNode> OnAddChild { get; set; }
|
||||||
|
|
||||||
/// <summary>Raised for the equipment-only "+ Virtual tag" action, kept distinct from OnAddChild ("+ Tag").</summary>
|
/// <summary>Raised when the user edits a node (Area/Line).</summary>
|
||||||
[Parameter] public EventCallback<UnsNode> OnAddVirtualTag { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Raised when the user edits a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
|
|
||||||
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
|
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
|
||||||
|
|
||||||
/// <summary>Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
|
/// <summary>Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
|
||||||
@@ -117,20 +114,7 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case UnsNodeKind.Equipment:
|
case UnsNodeKind.Equipment:
|
||||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
|
<a class="btn btn-sm btn-link p-0 ms-2" href="@($"/uns/equipment/{node.EntityId}")">Open</a>
|
||||||
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Tag</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
|
|
||||||
@onclick="@(() => OnAddVirtualTag.InvokeAsync(node))">+ Virtual tag</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
|
|
||||||
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
|
|
||||||
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
|
|
||||||
break;
|
|
||||||
|
|
||||||
case UnsNodeKind.Tag:
|
|
||||||
case UnsNodeKind.VirtualTag:
|
|
||||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
|
|
||||||
@onclick="@(() => OnEdit.InvokeAsync(node))">Edit</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
|
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
|
||||||
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
|
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ public static class UnsTreeAssembly
|
|||||||
ClusterId = clusterId,
|
ClusterId = clusterId,
|
||||||
EntityId = equipment.EquipmentId,
|
EntityId = equipment.EquipmentId,
|
||||||
ChildCount = childCount,
|
ChildCount = childCount,
|
||||||
HasLazyChildren = childCount > 0,
|
HasLazyChildren = false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public sealed class UnsTreeAssemblyTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_sets_equipment_child_count_and_lazy_flag()
|
public void Build_sets_equipment_child_count_and_leaf_flag()
|
||||||
{
|
{
|
||||||
var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
|
var clusters = new[] { new ClusterRow("c1", "zb", "SiteA", "Alpha") };
|
||||||
var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
|
var areas = new[] { new AreaRow("a1", "c1", "AreaOne") };
|
||||||
@@ -82,9 +82,12 @@ public sealed class UnsTreeAssemblyTests
|
|||||||
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
|
var tree = UnsTreeAssembly.Build(clusters, areas, lines, equipment);
|
||||||
var line = tree.Single().Children.Single().Children.Single().Children.Single();
|
var line = tree.Single().Children.Single().Children.Single().Children.Single();
|
||||||
|
|
||||||
|
// Equipment is a tree leaf that links to its own detail page, so it never carries an
|
||||||
|
// expander — HasLazyChildren is always false regardless of its tag/virtual-tag count.
|
||||||
|
// The ChildCount badge still reflects tags + virtual tags.
|
||||||
var withChildren = line.Children.Single(e => e.Key == "eq:e1");
|
var withChildren = line.Children.Single(e => e.Key == "eq:e1");
|
||||||
withChildren.ChildCount.ShouldBe(3);
|
withChildren.ChildCount.ShouldBe(3);
|
||||||
withChildren.HasLazyChildren.ShouldBeTrue();
|
withChildren.HasLazyChildren.ShouldBeFalse();
|
||||||
|
|
||||||
var empty = line.Children.Single(e => e.Key == "eq:e2");
|
var empty = line.Children.Single(e => e.Key == "eq:e2");
|
||||||
empty.ChildCount.ShouldBe(0);
|
empty.ChildCount.ShouldBe(0);
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ public sealed class UnsTreeServiceStructureTests
|
|||||||
equipment.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId);
|
equipment.ClusterId.ShouldBe(UnsTreeTestDb.PopulatedClusterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The seeded equipment node's badge count equals tags + virtual tags and it is
|
/// <summary>The seeded equipment node's badge count equals tags + virtual tags. Equipment is a
|
||||||
/// flagged as lazily expandable.</summary>
|
/// tree leaf (no expander), so HasLazyChildren is always false.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LoadStructure_counts_tags_and_vtags_per_equipment()
|
public async Task LoadStructure_counts_tags_and_vtags_per_equipment()
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@ public sealed class UnsTreeServiceStructureTests
|
|||||||
|
|
||||||
// Seed: 2 driver tags + 1 virtual tag (the orphan tag has no equipment and is excluded).
|
// Seed: 2 driver tags + 1 virtual tag (the orphan tag has no equipment and is excluded).
|
||||||
equipment.ChildCount.ShouldBe(3);
|
equipment.ChildCount.ShouldBe(3);
|
||||||
equipment.HasLazyChildren.ShouldBeTrue();
|
equipment.HasLazyChildren.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An empty cluster (no areas) is still rendered as a Cluster node with no children.</summary>
|
/// <summary>An empty cluster (no areas) is still rendered as a Cluster node with no children.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user