diff --git a/docs/plans/2026-06-11-equipment-page-design.md b/docs/plans/2026-06-11-equipment-page-design.md new file mode 100644 index 00000000..b01f3934 --- /dev/null +++ b/docs/plans/2026-06-11-equipment-page-design.md @@ -0,0 +1,219 @@ +# Equipment Detail Page (UNS) — Design + +**Date:** 2026-06-11 +**Status:** Approved (brainstorming) — ready for implementation plan +**Author:** design session + +## Goal + +Replace the modal-based equipment editor on the global UNS page with a +dedicated, tabbed **Equipment detail page**. The page owns the equipment's +identity form plus its child collections — **Tags**, **Virtual Tags**, and +**Alarms** — each on its own tab. The `/uns` tree is simplified so that +Equipment is a leaf that links to this page; tags and virtual tags no longer +appear inline in the tree, and the standalone scripted-alarm pages are removed. + +## Motivation + +Today `GlobalUns.razor` (`/uns`) is a single page juggling seven modals +(Area / Line / Equipment / Tag / VirtualTag / Import / delete-confirm) and a +lazy-expanding tree whose Equipment nodes load Tags + VirtualTags as leaf +children. Scripted-alarm *definitions* live on a separate pair of pages +(`/scripted-alarms`, `/scripted-alarms/{id}`) that the UNS tree never links to, +so an equipment's alarms are managed in a different place from its tags. This +spreads one equipment's configuration across a deep tree and three unrelated +surfaces. Consolidating everything for a single equipment onto one tabbed page +makes the per-equipment workflow coherent and trims the `/uns` tree back to the +pure hierarchy (Area → Line → Equipment). + +## Decisions (settled in brainstorming) + +1. **Tab set:** `Details · Tags · Virtual Tags · Alarms`. *Details* is the first + tab and hosts the equipment identity form + save. +2. **"Scripts" = Virtual Tags with their inline bound script source** — i.e. + today's `VirtualTagModal` behaviour (the inline Monaco editor) surfaced under + the *Virtual Tags* tab. +3. **Route:** `/uns/equipment/{equipmentId}` (nested under UNS), plus + `/uns/equipment/new` for create. +4. **Alarms:** the standalone global pages (`/scripted-alarms` list + + `/scripted-alarms/{id}` editor) are **removed**; scripted-alarm definitions + are managed only from the equipment page's *Alarms* tab. Tradeoff accepted: + no fleet-wide alarm list — alarms are reached per equipment. +5. **In-page tabs, single route** (not route-per-tab): one page, a thin + `nav-tabs` strip flips `_activeTab`, content switches via `@if`. No state loss + on tab switch; no reusable `` component is introduced (YAGNI). +6. **Maximum reuse of the risky UI:** the existing `TagModal` (driver-typed + `TagConfigEditorMap` editors) and `VirtualTagModal` (inline Monaco script + editor) are reused **unchanged** as the add/edit dialogs on their tabs — they + already accept a fixed `EquipmentId`. Only the *Alarms* editor is genuinely + new. + +## Approach (chosen) vs alternatives + +- **A — Single page, in-page tabs, reuse existing modals (CHOSEN).** Faithful to + "tabs on the new page"; reuses the two hardest pieces (driver-typed tag + editors, Monaco) with zero rework; keeps everything testable through + `IUnsTreeService`; one route, no tab-switch state loss. +- **B — Route-per-tab (the `ClusterNav` precedent).** `/uns/equipment/{id}`, + `/…/tags`, `/…/virtual-tags`, `/…/alarms` as separate routed pages sharing an + `EquipmentNav`. Rejected: every tab switch is a full navigation (unsaved + Details edits lost), create flow is awkward before an id exists, and more + files for no functional gain. +- **C — Keep modals, add a thin detail page.** Rejected: doesn't satisfy moving + tags/scripts/alarms into tabs. + +## Architecture + +### New page — `Components/Pages/Uns/EquipmentPage.razor` + +```razor +@page "/uns/equipment/new" +@page "/uns/equipment/{EquipmentId}" +@attribute [Authorize] // mirror GlobalUns +@rendermode RenderMode.InteractiveServer +@inject IUnsTreeService Svc +@inject NavigationManager Nav +``` + +- `[Parameter] public string? EquipmentId`; `IsNew => string.IsNullOrEmpty(EquipmentId)`. +- `[SupplyParameterFromQuery] public string? LineId` — carries the parent line on + create (set by the tree's "Add equipment"). +- `OnParametersSetAsync` loads the `EquipmentEditDto` (when editing), the line + options, the driver options, then the active tab's list. +- **Tab mechanism:** plain `_activeTab` field (default `"details"`); a Bootstrap + `nav-tabs` strip flips it; content is `@if (_activeTab == "...")`. While + `IsNew`, the Tags / Virtual Tags / Alarms tabs render a "Save the equipment + first" placeholder (no id to scope children to yet). + +### Create → edit flow (ScriptEdit `/new` → `/{id}` precedent) + +- On *create*, the Details tab's `SaveAsync` calls `CreateEquipmentAsync(input)` + then `Nav.NavigateTo($"/uns/equipment/{createdId}")`, landing on the edit page + with all tabs live. +- This requires the system-generated `EQ-…` id back from the create. Add a + nullable **`CreatedId`** to `UnsMutationResult` (populated by the create + methods); `Ok` / `Error` / concurrency semantics are unchanged. +- Edit saves call `UpdateEquipmentAsync(id, input, rowVersion)`; + `DbUpdateConcurrencyException` surfaces the existing reload message. + +### Details tab + +The `FormModel` + DataAnnotations + `SaveAsync` are lifted **verbatim** out of +`EquipmentModal.razor` (Name regex `^[a-z0-9-]{1,32}$`, MachineCode, UnsLineId +select, DriverInstanceId select, ZTag/SAPID, the OPC 40010 identity fields, +Enabled). Once this lands, `EquipmentModal.razor` is deleted. + +### Tags tab + +- Table of the equipment's tags (Name, driver, DataType, AccessLevel) with + Add / Edit / Delete. +- Add/Edit **reuse `TagModal` unchanged** (fixed `EquipmentId`, `Existing + TagEditDto`, driver options via `LoadTagDriversForEquipmentAsync`); `OnSaved` + reloads the list and closes the modal. +- Delete → `DeleteTagAsync(tagId, rowVersion)`. +- New service method `LoadTagsForEquipmentAsync(equipmentId)` returns a small + row-DTO list with the table columns (the existing `LoadEquipmentChildrenAsync` + returns tree `UnsNode`s without driver/type columns). + +### Virtual Tags tab + +- Table of VirtualTags (Name, DataType, bound Script, triggers, Enabled); + Add/Edit **reuse `VirtualTagModal` unchanged** (inline Monaco source editor + comes along for free); Delete → `DeleteVirtualTagAsync`. +- New `LoadVirtualTagsForEquipmentAsync(equipmentId)` for the table; script + options via existing `LoadScriptsAsync()`. + +### Alarms tab + +Mirrors the Tags / Virtual Tags shape (table + modal + delete). The standalone +alarm pages used EF directly; that logic moves **into `IUnsTreeService`** so the +tab is service-tested like the rest: + +- `LoadAlarmsForEquipmentAsync(equipmentId)` → row DTOs (Name, AlarmType, + Severity, predicate script, Enabled, RowVersion). +- `LoadScriptedAlarmAsync(scriptedAlarmId)` → full `ScriptedAlarmEditDto`. +- `CreateScriptedAlarmAsync(equipmentId, ScriptedAlarmInput)` (returns + `CreatedId`), `UpdateScriptedAlarmAsync(id, input, rowVersion)`, + `DeleteScriptedAlarmAsync(id, rowVersion)` — same `RowVersion` guard pattern as + the rest of the service. +- New AdminUI types `ScriptedAlarmInput` (Name, AlarmType, Severity, + MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) and + `ScriptedAlarmEditDto`. **No equipment picker** — equipment is fixed by the tab + (the one field the old page had that we drop). +- New `Components/Shared/Uns/ScriptedAlarmModal.razor` (shell modeled on + `TagModal`): the alarm fields, a script `