("auditGrid.load", key);
+ }
+ catch (JSDisconnectedException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// JS callback: the user finished resizing a column. Persists the new
+ /// per-column width and re-renders so the body cells track the header.
+ ///
+ [JSInvokable]
+ public async Task OnColumnResized(string columnKey, int widthPx)
+ {
+ if (!AllColumns.Any(c => c.Key == columnKey))
+ {
+ return;
+ }
+
+ _columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx);
+ await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths));
+ StateHasChanged();
+ }
+
+ ///
+ /// JS callback: the user dropped column onto the
+ /// header of . Moves the dragged column into the
+ /// target's slot, persists the resulting order, and re-renders.
+ ///
+ [JSInvokable]
+ public async Task OnColumnReordered(string fromKey, string toKey)
+ {
+ // Start from the current effective order so successive drags compose.
+ var order = OrderedColumns().Select(c => c.Key).ToList();
+ var fromIndex = order.IndexOf(fromKey);
+ var toIndex = order.IndexOf(toKey);
+ if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex)
+ {
+ return;
+ }
+
+ order.RemoveAt(fromIndex);
+ // After the removal the target index shifts left by one when the
+ // dragged column originally sat before it.
+ if (fromIndex < toIndex)
+ {
+ toIndex--;
+ }
+ order.Insert(toIndex, fromKey);
+
+ _columnOrder = order;
+ await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order));
+ StateHasChanged();
+ }
+
+ private async Task SaveAsync(string key, string json)
+ {
+ try
+ {
+ await JS.InvokeVoidAsync("auditGrid.save", key, json);
+ }
+ catch (JSDisconnectedException)
+ {
+ // Circuit gone — the in-memory state still drives this render.
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _selfRef?.Dispose();
+ return ValueTask.CompletedTask;
+ }
+
private static string StatusBadgeClass(AuditStatus status) => status switch
{
AuditStatus.Delivered => "badge bg-success",
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css
new file mode 100644
index 0000000..f89edad
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css
@@ -0,0 +1,82 @@
+/* Audit results grid — column resize + reorder UX (#23 follow-ups Task 10).
+ The base .table classes come from Bootstrap; the rules below add the
+ resize-handle affordance and the drag-to-reorder drop feedback. The
+ interaction itself lives in wwwroot/js/audit-grid.js — this file is purely
+ the visual treatment. Internal-tool aesthetic: subtle, no flashy motion. */
+
+/* A persisted width is delivered as the --audit-col-width custom property on
+ the | and matching | cells (set inline by the component / by
+ audit-grid.js during a drag). When present it pins the cell; when absent
+ the column falls back to Bootstrap auto-layout. The body cells also clip
+ overflowing text so a narrowed column stays tidy. */
+.audit-grid-th[style*="--audit-col-width"],
+.audit-grid-td[style*="--audit-col-width"] {
+ width: var(--audit-col-width);
+ min-width: var(--audit-col-width);
+ max-width: var(--audit-col-width);
+}
+
+.audit-grid-td[style*="--audit-col-width"] {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* The header cell hosts the resize handle on its right edge, so it must be a
+ positioning context. Padding on the right is trimmed so the 6px handle does
+ not crowd the label text. */
+.audit-grid-th {
+ position: relative;
+ padding-right: 0.75rem;
+ /* The whole header is draggable for reorder — a grab cursor signals it. */
+ cursor: grab;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.audit-grid-th:active {
+ cursor: grabbing;
+}
+
+/* V — resize handle. A thin invisible hit-strip on the right edge: 6px wide
+ for a comfortable grab target, transparent at rest so the header reads
+ clean. On hover a hairline primary rule fades in via the inset box-shadow
+ so the affordance is discoverable without being visually noisy. */
+.audit-grid-resize-handle {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 6px;
+ height: 100%;
+ cursor: col-resize;
+ /* Sit above the draggable header so a resize never starts a reorder. */
+ z-index: 1;
+ transition: box-shadow 0.08s linear, background-color 0.08s linear;
+}
+
+.audit-grid-resize-handle:hover {
+ /* Hairline rule centred on the strip's right edge. */
+ box-shadow: inset -2px 0 0 -1px rgba(var(--bs-primary-rgb), 0.55);
+ background-color: rgba(var(--bs-primary-rgb), 0.06);
+}
+
+/* While a drag-resize is in progress the column gets a steady primary rule on
+ its right edge so the user keeps a clear visual anchor. */
+.audit-grid-th.resizing {
+ box-shadow: inset -2px 0 0 0 var(--bs-primary);
+}
+
+.audit-grid-th.resizing .audit-grid-resize-handle {
+ background-color: rgba(var(--bs-primary-rgb), 0.55);
+}
+
+/* V — reorder feedback. The dragged header dims slightly; the prospective
+ drop target gets a left-edge accent rule + a faint info wash, matching the
+ TreeView drop-target idiom (a quiet, unmistakable cue, not an animation). */
+.audit-grid-th.dragging {
+ opacity: 0.45;
+}
+
+.audit-grid-th.drop-target {
+ background-color: rgba(var(--bs-info-rgb), 0.18);
+ box-shadow: inset 2px 0 0 0 var(--bs-info);
+}
diff --git a/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js b/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js
new file mode 100644
index 0000000..a1ce628
--- /dev/null
+++ b/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js
@@ -0,0 +1,184 @@
+// Audit results grid column UX (#23 follow-ups Task 10).
+//
+// A tiny, dependency-free helper for the AuditResultsGrid component:
+// - drag-to-resize: a pointer-driven handle on each | 's right edge,
+// - drag-to-reorder: native HTML5 drag-and-drop on the header row,
+// - save/load: a sessionStorage round-trip, mirroring treeview-storage.js.
+//
+// The Blazor component owns the column model; this file is purely the
+// browser-side drag plumbing. After a resize or reorder it calls back into
+// .NET via a DotNetObjectReference so the component can persist + re-render.
+//
+// No drag-drop libraries — hand-rolled pointer + native-DnD handlers only.
+window.auditGrid = {
+ // --- sessionStorage wrapper (mirrors window.treeviewStorage) -----------
+ // Keys are namespaced under "auditGrid:" so they never collide with the
+ // treeview's "treeview:" namespace.
+ save: function (key, json) {
+ try {
+ sessionStorage.setItem("auditGrid:" + key, json);
+ } catch {
+ // Quota / privacy-mode failures are non-fatal — the grid simply
+ // falls back to defaults on the next load.
+ }
+ },
+
+ load: function (key) {
+ try {
+ return sessionStorage.getItem("auditGrid:" + key);
+ } catch {
+ return null;
+ }
+ },
+
+ // Minimum column width in pixels. A column can never be dragged narrower
+ // than this so a header can't collapse to an unclickable sliver.
+ minWidth: 64,
+
+ // --- wire-up ----------------------------------------------------------
+ // `table` is the element, `dotNet` is a DotNetObjectReference
+ // exposing OnColumnResized / OnColumnReordered. Safe to call on every
+ // render: it re-scans the header and binds only cells not already bound,
+ // and always refreshes the live .NET reference. Handlers read the column
+ // key live from data-col-key at event time, so Blazor reusing a | DOM
+ // node for a different column (after a reorder re-render) is harmless.
+ init: function (table, dotNet) {
+ if (!table) {
+ return;
+ }
+ table.__auditGridDotNet = dotNet;
+
+ var headerRow = table.tHead && table.tHead.rows[0];
+ if (!headerRow) {
+ return;
+ }
+
+ for (var i = 0; i < headerRow.cells.length; i++) {
+ this._bindHeaderCell(table, headerRow.cells[i]);
+ }
+ },
+
+ // Bind resize + reorder handlers to a single | . Idempotent — a cell
+ // already carrying handlers is skipped. The handlers resolve the column
+ // key live (th.getAttribute) so they stay correct if the renderer reuses
+ // the element for another column.
+ _bindHeaderCell: function (table, th) {
+ var self = this;
+ if (th.__auditGridCellBound) {
+ return;
+ }
+ th.__auditGridCellBound = true;
+
+ // --- resize: pointer drag on the handle ---------------------------
+ var handle = th.querySelector(".audit-grid-resize-handle");
+ if (handle) {
+ handle.addEventListener("pointerdown", function (ev) {
+ ev.preventDefault();
+ // Stop the pointerdown from also starting a header drag.
+ ev.stopPropagation();
+
+ var startX = ev.clientX;
+ var startWidth = th.getBoundingClientRect().width;
+ handle.setPointerCapture(ev.pointerId);
+ th.classList.add("resizing");
+
+ function onMove(moveEv) {
+ var next = Math.max(self.minWidth, startWidth + (moveEv.clientX - startX));
+ self._applyWidth(th, next);
+ }
+
+ function onUp() {
+ handle.releasePointerCapture(ev.pointerId);
+ handle.removeEventListener("pointermove", onMove);
+ handle.removeEventListener("pointerup", onUp);
+ handle.removeEventListener("pointercancel", onUp);
+ th.classList.remove("resizing");
+
+ var key = th.getAttribute("data-col-key");
+ var finalWidth = Math.round(th.getBoundingClientRect().width);
+ var dn = table.__auditGridDotNet;
+ if (key && dn) {
+ dn.invokeMethodAsync("OnColumnResized", key, finalWidth);
+ }
+ }
+
+ handle.addEventListener("pointermove", onMove);
+ handle.addEventListener("pointerup", onUp);
+ handle.addEventListener("pointercancel", onUp);
+ });
+ }
+
+ // --- reorder: native HTML5 drag-and-drop on the header ------------
+ // The whole | is draggable; dropping it onto another header swaps
+ // the dragged column into the drop target's position.
+ th.setAttribute("draggable", "true");
+
+ th.addEventListener("dragstart", function (ev) {
+ // A resize in progress sets .resizing; never start a reorder then.
+ if (th.classList.contains("resizing")) {
+ ev.preventDefault();
+ return;
+ }
+ var key = th.getAttribute("data-col-key");
+ if (!key) {
+ ev.preventDefault();
+ return;
+ }
+ table.__auditGridDragKey = key;
+ ev.dataTransfer.effectAllowed = "move";
+ // Some browsers require data to be set for the drag to begin.
+ try { ev.dataTransfer.setData("text/plain", key); } catch { /* ignore */ }
+ th.classList.add("dragging");
+ });
+
+ th.addEventListener("dragend", function () {
+ th.classList.remove("dragging");
+ table.__auditGridDragKey = null;
+ self._clearDropTargets(table);
+ });
+
+ th.addEventListener("dragover", function (ev) {
+ // Allowing the drop is what lets dragover/drop fire at all.
+ var key = th.getAttribute("data-col-key");
+ if (key && table.__auditGridDragKey && table.__auditGridDragKey !== key) {
+ ev.preventDefault();
+ ev.dataTransfer.dropEffect = "move";
+ th.classList.add("drop-target");
+ }
+ });
+
+ th.addEventListener("dragleave", function () {
+ th.classList.remove("drop-target");
+ });
+
+ th.addEventListener("drop", function (ev) {
+ ev.preventDefault();
+ th.classList.remove("drop-target");
+ var key = th.getAttribute("data-col-key");
+ var fromKey = table.__auditGridDragKey;
+ table.__auditGridDragKey = null;
+ if (!key || !fromKey || fromKey === key) {
+ return;
+ }
+ var dn = table.__auditGridDotNet;
+ if (dn) {
+ // fromKey moves to occupy toKey's slot; the component computes
+ // the resulting order and re-renders + persists.
+ dn.invokeMethodAsync("OnColumnReordered", fromKey, key);
+ }
+ });
+ },
+
+ // Apply a width to a | via a CSS custom property. The scoped stylesheet
+ // reads --audit-col-width; absent it, the column falls back to auto.
+ _applyWidth: function (th, widthPx) {
+ th.style.setProperty("--audit-col-width", widthPx + "px");
+ },
+
+ _clearDropTargets: function (table) {
+ var hits = table.querySelectorAll(".drop-target, .dragging");
+ for (var i = 0; i < hits.length; i++) {
+ hits[i].classList.remove("drop-target", "dragging");
+ }
+ }
+};
diff --git a/src/ScadaLink.Host/Components/App.razor b/src/ScadaLink.Host/Components/App.razor
index ac6d3d2..c426b3e 100644
--- a/src/ScadaLink.Host/Components/App.razor
+++ b/src/ScadaLink.Host/Components/App.razor
@@ -77,6 +77,7 @@
+
| |