feat(uns): equipment CSV import folded into the tree toolbar
This commit is contained in:
+187
@@ -0,0 +1,187 @@
|
||||
@* Bulk equipment import modal wired straight into IUnsTreeService. Paste a CSV (header + rows),
|
||||
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
|
||||
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
|
||||
so the page can reload the whole tree (an import can add equipment across many lines/clusters).
|
||||
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId.
|
||||
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
|
||||
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@
|
||||
@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-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import equipment CSV</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Paste CSV below. Required header columns (in order):
|
||||
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
|
||||
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
|
||||
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
|
||||
are detected by MachineCode and skipped (additive-only — no updates).
|
||||
</p>
|
||||
|
||||
<textarea class="form-control form-control-sm mono" rows="12"
|
||||
@bind="_csv" @bind:event="oninput" disabled="@_busy"
|
||||
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_parseError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_parseError</div>
|
||||
}
|
||||
|
||||
@if (_result is not null)
|
||||
{
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">
|
||||
<div>
|
||||
<strong>Inserted:</strong> @_result.Inserted
|
||||
· <strong>Skipped (existing MachineCode):</strong> @_result.Skipped
|
||||
· <strong>Errors:</strong> @_result.Errors.Count
|
||||
</div>
|
||||
@if (_result.Errors.Count > 0)
|
||||
{
|
||||
<ul class="small mb-0 mt-2">
|
||||
@foreach (var err in _result.Errors)
|
||||
{
|
||||
<li>@err</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">
|
||||
@(_result is null ? "Cancel" : "Close")
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="ImportAsync" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment).
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
|
||||
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user closes the modal after an import so the host can reload the tree and
|
||||
/// dismiss the modal. Fired on close regardless of whether anything was inserted — closing always
|
||||
/// returns control to the host.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnImported { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels before importing so the host can close.</summary>
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private string _csv = "";
|
||||
private bool _busy;
|
||||
private string? _parseError;
|
||||
private EquipmentImportResult? _result;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Reset the working state each time the host (re)opens the modal.
|
||||
if (!Visible)
|
||||
{
|
||||
_csv = "";
|
||||
_parseError = null;
|
||||
_result = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_parseError = null;
|
||||
_result = null;
|
||||
try
|
||||
{
|
||||
var rows = ParseCsv(_csv);
|
||||
if (rows is null) { return; }
|
||||
|
||||
_result = await Svc.ImportEquipmentAsync(rows);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the modal. Once an import has run (the user has seen the summary), closing raises
|
||||
/// OnImported so the host reloads the tree; before any import it raises OnCancel.
|
||||
/// </summary>
|
||||
private Task CloseAsync() =>
|
||||
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
|
||||
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional
|
||||
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
|
||||
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
|
||||
/// </summary>
|
||||
private List<EquipmentInput>? ParseCsv(string csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
|
||||
|
||||
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
|
||||
|
||||
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
|
||||
for (var i = 0; i < RequiredColumns.Length; i++)
|
||||
{
|
||||
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<EquipmentInput>();
|
||||
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
|
||||
{
|
||||
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
|
||||
if (parts.Length < RequiredColumns.Length)
|
||||
{
|
||||
_parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
|
||||
return null;
|
||||
}
|
||||
|
||||
rows.Add(new EquipmentInput(
|
||||
Name: parts[0],
|
||||
MachineCode: parts[1],
|
||||
UnsLineId: parts[2],
|
||||
DriverInstanceId: NullIfEmpty(parts, 3),
|
||||
ZTag: NullIfEmpty(parts, 4),
|
||||
SAPID: NullIfEmpty(parts, 5),
|
||||
Manufacturer: NullIfEmpty(parts, 6),
|
||||
Model: NullIfEmpty(parts, 7),
|
||||
SerialNumber: null,
|
||||
HardwareRevision: null,
|
||||
SoftwareRevision: null,
|
||||
YearOfConstruction: null,
|
||||
AssetLocation: null,
|
||||
ManufacturerUri: null,
|
||||
DeviceManualUri: null,
|
||||
Enabled: true));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string[] parts, int idx) =>
|
||||
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
|
||||
}
|
||||
Reference in New Issue
Block a user