From cde58d37b59a192f00df57152a50a8022f28a954 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Tue, 12 May 2026 13:50:19 -0700 Subject: [PATCH 01/28] Bump version to 1.2.4 and auto-add to PATH on Windows install The PowerShell installer now automatically adds the install directory to the user's PATH instead of just printing instructions. Co-Authored-By: Claude Opus 4.6 (1M context) --- install.ps1 | 16 ++++++---------- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/install.ps1 b/install.ps1 index 0f8adcf..6623a04 100644 --- a/install.ps1 +++ b/install.ps1 @@ -103,20 +103,16 @@ function Install-Binary { # --- PATH check --- -function Update-PathAdvice { +function Update-Path { $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($UserPath -and $UserPath.Split(";") -contains $InstallDir) { return } - Write-Warn "$InstallDir is not in your PATH" - Write-Host "" - Write-Host " Run this to add it permanently:" - Write-Host "" - Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$InstallDir;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" -ForegroundColor Cyan - Write-Host "" - Write-Host " Then restart your terminal." - Write-Host "" + [Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User") + $env:Path = "$InstallDir;$env:Path" + Write-Info "Added $InstallDir to your PATH" + Write-Warn "Restart your terminal for the PATH change to take effect in new sessions" } # --- Main --- @@ -133,7 +129,7 @@ function Install-Opal { $DownloadUrl = Find-DownloadUrl -AssetPattern $AssetName Install-Binary -Url $DownloadUrl -AssetName $AssetName - Update-PathAdvice + Update-Path Write-Host "" Write-Host "OPAL $Tag installed successfully." -ForegroundColor White diff --git a/pyproject.toml b/pyproject.toml index d5470af..e4f9944 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opal" -version = "1.2.3" +version = "1.2.4" description = "Operations, Procedures, Assets, Logistics - ERP for small teams and hardware projects" readme = "README.md" requires-python = ">=3.11" From fe8f5b1c93d9119dd712f759b37e81ae42c2e361 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Wed, 13 May 2026 17:35:27 -0700 Subject: [PATCH 02/28] Bump version to 1.2.5 and unify version management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive __version__ from pyproject.toml via importlib.metadata instead of a hardcoded string — single source of truth, no more drift between __init__.py and pyproject.toml. Add tag-driven version step to release workflow so pushing a v* tag automatically sets the correct version in the built binaries. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 10 ++++++++++ pyproject.toml | 2 +- src/opal/__init__.py | 4 +++- uv.lock | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76162a2..a3abd45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,16 @@ jobs: - uses: astral-sh/setup-uv@v4 + - name: Set version from tag + if: startsWith(github.ref, 'refs/tags/v') + shell: python + run: | + import re, pathlib + version = "${{ github.ref_name }}"[1:] # strip leading 'v' + p = pathlib.Path("pyproject.toml") + p.write_text(re.sub(r'^version = .*', f'version = "{version}"', p.read_text(), count=1, flags=re.MULTILINE)) + print(f"Set version to {version}") + - name: Install dependencies run: uv sync --extra app diff --git a/pyproject.toml b/pyproject.toml index e4f9944..75c0c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opal" -version = "1.2.4" +version = "1.2.5" description = "Operations, Procedures, Assets, Logistics - ERP for small teams and hardware projects" readme = "README.md" requires-python = ">=3.11" diff --git a/src/opal/__init__.py b/src/opal/__init__.py index 368cbb8..08dda54 100644 --- a/src/opal/__init__.py +++ b/src/opal/__init__.py @@ -3,4 +3,6 @@ An enterprise resource planning system for small teams and hardware projects. """ -__version__ = "1.2.1" +from importlib.metadata import version + +__version__ = version("opal") diff --git a/uv.lock b/uv.lock index 2f99ab9..3279e8b 100644 --- a/uv.lock +++ b/uv.lock @@ -524,7 +524,7 @@ wheels = [ [[package]] name = "opal" -version = "1.2.2" +version = "1.2.4" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From c05c72b7e2c8e3e5197b522d685c360bd84cc360 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Wed, 13 May 2026 17:48:13 -0700 Subject: [PATCH 03/28] Fix auto-updater crash: remove incorrect call_from_thread usage The progress callback in _apply_update was using call_from_thread, but _apply_update is async and already runs on the app's event loop. Textual rejects call_from_thread from the app's own thread. Call _log directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/opal/launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opal/launcher.py b/src/opal/launcher.py index 84eeafd..59a3685 100644 --- a/src/opal/launcher.py +++ b/src/opal/launcher.py @@ -408,7 +408,7 @@ async def _apply_update(self) -> None: def on_progress(downloaded: int, total: int) -> None: if total > 0: pct = int(downloaded / total * 100) - self.call_from_thread(self._log, f" Download: {pct}% ({downloaded}/{total} bytes)") + self._log(f" Download: {pct}% ({downloaded}/{total} bytes)") try: tmp_path = await download_update(asset_url, progress_callback=on_progress) From 1cc011ce03db2a384917a2fdceb7742a86b4ebdd Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Sun, 17 May 2026 01:12:47 -0700 Subject: [PATCH 04/28] Refocus execution detail UI on a single operation at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /executions/{instance_id} page used to render every operation and every sub-step in one long column, with a sidebar that only showed "OP 1", "OP 2", etc. — no operation name and no way to focus on a single op. - Add ?op= query param to executions_detail; default to the in-progress op, else the first incomplete op, else the first op. - Right pane now renders only the selected op's block. - Sidebar items become 3-line cards: operation name (truncated with ellipsis), OP number + progress + status dot, and the StepExecution.id as a stable global handle. - Sidebar clicks do an HTMX partial swap (innerHTML on .exec-main with hx-select=".exec-main > *") so window scroll and the sidebar's own scroll position are preserved across op switches. hx-push-url keeps ?op=N in the address bar; href stays as a fallback for middle-click and JS-disabled clients. - Reserve a transparent 3px left border on every sidebar item so the active orange bar doesn't shift the text right when selected, and suppress the global [LOADING] ::after on sidebar items so it doesn't flicker the row during the request. --- src/opal/web/routes.py | 24 ++++++++++- src/opal/web/static/css/main.css | 34 ++++++++++++++- src/opal/web/templates/executions/detail.html | 43 +++++++++++++++---- 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index f7e32ee..0149117 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -1606,7 +1606,12 @@ async def executions_new(request: Request, db: DbSession) -> HTMLResponse: @router.get("/executions/{instance_id}", response_class=HTMLResponse) -async def executions_detail(request: Request, db: DbSession, instance_id: int) -> HTMLResponse: +async def executions_detail( + request: Request, + db: DbSession, + instance_id: int, + op: int | None = None, +) -> HTMLResponse: """Execution detail/run page.""" instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() if not instance: @@ -1704,6 +1709,23 @@ def sort_key_contingency(x): context["ops"] = ops context["contingency_ops"] = contingency_ops + # Pick which op's steps to render on the right pane. + all_ops = ops + contingency_ops + valid_orders = {o["step"]["order"] for o in all_ops} + + def _pick_default_order() -> int | None: + if not all_ops: + return None + for o in all_ops: + if o["step"]["status"] == "in_progress": + return o["step"]["order"] + for o in all_ops: + if o["step"]["status"] not in ("completed", "signed_off", "skipped"): + return o["step"]["order"] + return all_ops[0]["step"]["order"] + + context["selected_op_order"] = op if op in valid_orders else _pick_default_order() + # Map step order -> version step data (for data capture schemas, requires_signoff) context["version_steps_map"] = {s["order"]: s for s in version_steps} diff --git a/src/opal/web/static/css/main.css b/src/opal/web/static/css/main.css index 330d67e..8902cc3 100644 --- a/src/opal/web/static/css/main.css +++ b/src/opal/web/static/css/main.css @@ -1350,14 +1350,16 @@ select option { .exec-sidebar-item { display: flex; - align-items: center; - gap: var(--space-sm); + flex-direction: column; + gap: 2px; padding: var(--space-xs) var(--space-md); font-family: var(--font-mono); font-size: 0.875rem; color: var(--text-secondary); text-decoration: none; border-bottom: 1px solid var(--border-color); + border-left: 3px solid transparent; + min-width: 0; } .exec-sidebar-item:hover { @@ -1371,6 +1373,34 @@ select option { border-left: 3px solid var(--accent-orange); } +.exec-sidebar-item-title { + font-family: var(--font-sans); + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.exec-sidebar-item.htmx-request::after { + content: none; +} + +.exec-sidebar-item-meta { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 0.75rem; + color: var(--text-muted); +} + +.exec-sidebar-item-gid { + font-size: 0.7rem; + color: var(--text-muted); + letter-spacing: 0.02em; +} + .exec-sidebar-progress { margin-left: auto; font-size: 0.75rem; diff --git a/src/opal/web/templates/executions/detail.html b/src/opal/web/templates/executions/detail.html index 9f47f89..7e12d56 100644 --- a/src/opal/web/templates/executions/detail.html +++ b/src/opal/web/templates/executions/detail.html @@ -84,20 +84,42 @@
OPERATIONS
{% for op_data in ops %} {% set op = op_data.step %} - - OP {{ op.step_number }} - {{ op_data.completed_steps }}/{{ op_data.total_steps }} - + + {{ op.title }} + + OP {{ op.step_number }} + {{ op_data.completed_steps }}/{{ op_data.total_steps }} + + + {% if op.execution %}#{{ op.execution.id }}{% else %}—{% endif %} {% endfor %} {% if contingency_ops %}
{% for op_data in contingency_ops %} {% set op = op_data.step %} - - OP {{ op.step_number }} - {{ op_data.completed_steps }}/{{ op_data.total_steps }} - + + {{ op.title }} + + OP {{ op.step_number }} + {{ op_data.completed_steps }}/{{ op_data.total_steps }} + + + {% if op.execution %}#{{ op.execution.id }}{% else %}—{% endif %} {% endfor %} {% endif %} @@ -107,6 +129,7 @@
{% for op_data in ops + contingency_ops %} {% set op = op_data.step %} + {% if op.order == selected_op_order %}
OP {{ op.step_number }} @@ -407,7 +430,11 @@ {% endif %}
+ {% endif %} {% endfor %} + {% if not selected_op_order %} +
NO OPERATIONS
+ {% endif %}
From 4a2101ae75675d453403f71f388184e85b6959eb Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Sun, 17 May 2026 01:13:53 -0700 Subject: [PATCH 05/28] Update uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 3279e8b..895b912 100644 --- a/uv.lock +++ b/uv.lock @@ -524,7 +524,7 @@ wheels = [ [[package]] name = "opal" -version = "1.2.4" +version = "1.2.5" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 79472e67da494d4cb3b309fb7d59c4f87edd9a98 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Sun, 17 May 2026 02:58:58 -0700 Subject: [PATCH 06/28] Split execution detail UI into tabs Break the ~1200-line execution detail page into a six-tab shell so the operations checklist, kitting/production, BOM reconciliation, captured data, issues, and run metadata each have their own focused view instead of fighting for the same scroll line. - Tab nav (Meta default, Operations, Data, BOM, Issues, Kitting) uses the same hx-get + hx-select partial-swap pattern as the per-op switch, with hx-push-url so ?tab=&op= is bookmarkable. - Each tab body now lives in src/opal/web/templates/executions/tabs/. The shell template keeps the modals and JS once at page level; an htmx:afterSwap hook re-runs initExecTab() so attachments, kit availability, step-kit availability, and the collaboration singleton bind correctly after each swap. - Visual integration: tab strip mirrors .nav-dropdown-btn (transparent 1px border that colors in on hover/active, no fills/underlines), runs edge-to-edge via negative margins on the strip plus padding-top: 0 on .main, and the strip's [LOADING] pseudo is suppressed. - Page is locked to viewport on the execution-detail page so the tab bar stays visible and each tab scrolls within its own content area. display: contents on each partial's wrapper lets short tabs stack naturally and the operations .exec-layout flex: 1 fill remaining space; sidebar and main scroll independently inside the card. - Operations sub-step rendering prefixes the parent OP number when the version's step_number is stored as the sub-step part alone, so step headers read "8.5 Verify Charge Continuity" instead of just "5". Single-step ops also show their op number on the inner summary row. - New data_rows precompute in executions_detail() drives the flat Data-tab audit table (step / field / value / by / at). Meta extras add last_activity_at (max of instance and step-execution updated_at) and the procedure-version author. - Issues tab badges the tab label with the linked-issue count and drops the red full-width alert banner. - Collaboration panel moved from Operations to Meta. - Status footer is now position: sticky bottom: 0 so it stays at the viewport bottom on long pages. - .exec-layout has a small margin-bottom so the Operations card doesn't sit flush against the status bar. --- src/opal/web/routes.py | 40 + src/opal/web/static/css/main.css | 97 ++- src/opal/web/templates/executions/detail.html | 730 ++---------------- .../web/templates/executions/tabs/bom.html | 41 + .../web/templates/executions/tabs/data.html | 38 + .../web/templates/executions/tabs/issues.html | 43 ++ .../templates/executions/tabs/kitting.html | 179 +++++ .../web/templates/executions/tabs/meta.html | 78 ++ .../templates/executions/tabs/operations.html | 355 +++++++++ src/opal/web/templates/layouts/base.html | 2 +- 10 files changed, 924 insertions(+), 679 deletions(-) create mode 100644 src/opal/web/templates/executions/tabs/bom.html create mode 100644 src/opal/web/templates/executions/tabs/data.html create mode 100644 src/opal/web/templates/executions/tabs/issues.html create mode 100644 src/opal/web/templates/executions/tabs/kitting.html create mode 100644 src/opal/web/templates/executions/tabs/meta.html create mode 100644 src/opal/web/templates/executions/tabs/operations.html diff --git a/src/opal/web/routes.py b/src/opal/web/routes.py index 0149117..336e5ff 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -1605,12 +1605,16 @@ async def executions_new(request: Request, db: DbSession) -> HTMLResponse: return templates.TemplateResponse("executions/new.html", context) +_EXECUTION_TABS = ("meta", "operations", "data", "bom", "issues", "kitting") + + @router.get("/executions/{instance_id}", response_class=HTMLResponse) async def executions_detail( request: Request, db: DbSession, instance_id: int, op: int | None = None, + tab: str = "meta", ) -> HTMLResponse: """Execution detail/run page.""" instance = db.query(ProcedureInstance).filter(ProcedureInstance.id == instance_id).first() @@ -1726,6 +1730,8 @@ def _pick_default_order() -> int | None: context["selected_op_order"] = op if op in valid_orders else _pick_default_order() + context["tab"] = tab if tab in _EXECUTION_TABS else "meta" + # Map step order -> version step data (for data capture schemas, requires_signoff) context["version_steps_map"] = {s["order"]: s for s in version_steps} @@ -1835,6 +1841,40 @@ def _pick_default_order() -> int | None: ) context["linked_issues"] = linked_issues + # Meta tab extras: last-activity timestamp + flat data-capture audit rows. + step_update_times = [ + se.updated_at for se in instance.step_executions if se.updated_at is not None + ] + candidate_times = [t for t in [instance.updated_at, *step_update_times] if t is not None] + context["last_activity_at"] = max(candidate_times) if candidate_times else None + + data_rows = [] + for se in instance.step_executions: + if not se.data_captured: + continue + step_num = se.step_number_str or str(se.step_number) + by_name = se.completed_by_user.name if se.completed_by_user else None + at = se.completed_at or se.updated_at + for field, value in se.data_captured.items(): + if isinstance(value, bool): + display = "YES" if value else "NO" + elif value is None or value == "": + display = "—" + else: + display = str(value) + data_rows.append( + { + "step_number": step_num, + "step_sort": se.step_number, + "field": field, + "value": display, + "by": by_name, + "at": at, + } + ) + data_rows.sort(key=lambda r: (r["step_sort"], r["field"])) + context["data_rows"] = data_rows + return templates.TemplateResponse("executions/detail.html", context) diff --git a/src/opal/web/static/css/main.css b/src/opal/web/static/css/main.css index 8902cc3..3ecdc25 100644 --- a/src/opal/web/static/css/main.css +++ b/src/opal/web/static/css/main.css @@ -246,6 +246,9 @@ select option { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-muted); + position: sticky; + bottom: 0; + z-index: 10; } .footer-status { @@ -1318,21 +1321,107 @@ select option { color: var(--status-warn); } -/* Execution Layout — Sidebar + Main */ +/* Execution Tabs — mirrors .nav-dropdown-btn so the chrome reads as one family. + On the exec-detail page the strip breaks out of .main padding so it runs + edge-to-edge; the .main padding-top is also zeroed so the strip hugs the + breadcrumbs above. */ +.exec-tabs { + display: flex; + gap: var(--space-xs); + border-bottom: 1px solid var(--border-color); + margin-bottom: var(--space-md); + padding-bottom: var(--space-xs); + font-family: var(--font-mono); + font-size: 0.875rem; +} + +body.exec-detail .main { + padding-top: 0; +} + +body.exec-detail .exec-tabs { + margin-left: calc(-1 * var(--space-md)); + margin-right: calc(-1 * var(--space-md)); + padding-left: var(--space-md); + padding-right: var(--space-md); + padding-top: var(--space-xs); +} + +.exec-tab { + padding: var(--space-xs) var(--space-sm); + color: var(--text-secondary); + text-decoration: none; + background: transparent; + border: 1px solid transparent; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.exec-tab:hover { + color: var(--text-primary); + border-color: var(--border-light); +} + +.exec-tab.active { + color: var(--accent-orange); + border-color: var(--accent-orange); +} + +.exec-tab.htmx-request::after { + content: none; +} + +.exec-tab-content { + min-height: 400px; +} + +/* Execution-detail page: lock to viewport so each tab scrolls within .exec-tab-content, + and the Operations exec-layout fills the remaining space naturally. */ +body.exec-detail { + height: 100vh; + overflow: hidden; +} + +body.exec-detail .main { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding-bottom: 0; +} + +body.exec-detail .exec-tab-content { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Each tab partial wraps in a single
for hx-select. Make it transparent for + layout so its children (panels, or .exec-layout) become direct flex children + of .exec-tab-content. Operations: .exec-layout flex:1 fills available space. + Other tabs: panels stack naturally and .exec-tab-content scrolls if they overflow. */ +body.exec-detail .exec-tab-content > div { + display: contents; +} + +/* Execution Layout — Sidebar + Main (fills available space; columns scroll within) */ .exec-layout { display: flex; gap: 0; border: 1px solid var(--border-color); background: var(--bg-secondary); + flex: 1; + min-height: 0; + overflow: hidden; + margin-bottom: var(--space-sm); } .exec-sidebar { width: 250px; flex-shrink: 0; border-right: 1px solid var(--border-color); - position: sticky; - top: 0; - max-height: 80vh; overflow-y: auto; } diff --git a/src/opal/web/templates/executions/detail.html b/src/opal/web/templates/executions/detail.html index 7e12d56..0e82971 100644 --- a/src/opal/web/templates/executions/detail.html +++ b/src/opal/web/templates/executions/detail.html @@ -1,6 +1,8 @@ {% extends "layouts/base.html" %} {% import 'opalkit/_macros.html' as ok %} +{% block body_class %}exec-detail{% endblock %} + {% block breadcrumbs %} {{ ok.crumb("HOME", "/") }} {{ ok.crumb("EXECUTIONS", "/executions") }} @@ -10,663 +12,33 @@ {% block content %} {% set inst_status = instance.status.value if instance.status.value is defined else instance.status %} -{% if linked_issues %} -
- {{ linked_issues|length }} ISSUE{{ 's' if linked_issues|length > 1 }} LINKED — - {% for issue in linked_issues %} - - {{ issue.issue_number }}: {{ issue.title }} - {{ ', ' if not loop.last }} +{# Tab navigation #} +
+ {% set tabs = [ + ('meta', 'META'), + ('operations', 'OPERATIONS'), + ('data', 'DATA'), + ('bom', 'BOM'), + ('issues', 'ISSUES' ~ (' (' ~ linked_issues|length ~ ')' if linked_issues else '')), + ('kitting', 'KITTING'), + ] %} + {% for slug, label in tabs %} + {% set href = '?tab=' ~ slug ~ ('&op=' ~ selected_op_order if slug == 'operations' and selected_op_order else '') %} + + {{ label }} + {% endfor %}
-{% endif %} - - -
- {% call ok.panel(instance.work_order_number or "EXECUTION #" ~ instance.id, actions=ok.status(inst_status | upper | replace('_', ' '))) %} - {% call ok.table() %} - {{ ok.detail_row("PROCEDURE", ok.link(instance.procedure.name, "/procedures/" ~ instance.procedure_id), th_width="120px") }} - {{ ok.detail_row("VERSION", ok.mono("v" ~ version.version_number), th_width="120px") }} - {{ ok.detail_row("WORK ORDER", ok.mono(instance.work_order_number or '-'), th_width="120px") }} - {{ ok.detail_row("CREATED", ok.timestamp(instance.created_at), th_width="120px") }} - {{ ok.detail_row("STARTED", ok.timestamp(instance.started_at) if instance.started_at else '-', th_width="120px") }} - {{ ok.detail_row("COMPLETED", ok.timestamp(instance.completed_at) if instance.completed_at else '-', th_width="120px") }} - {% endcall %} - {% if inst_status == 'in_progress' %} -
- {{ ok.btn("ABORT EXECUTION", variant="danger", attrs='onclick="abortExecution()"') }} -
- {% endif %} - {% endcall %} - -
-
- COLLABORATION -
- - - OFFLINE - -
-
-
- {% if inst_status in ['pending', 'in_progress'] %} -
- - -
- {% endif %} -
ACTIVE PARTICIPANTS
-
- Loading... -
- - - {% set completed = steps | selectattr('status', 'in', ['completed', 'signed_off', 'skipped']) | list | length %} - {% set total = steps | length %} -
-
- {{ completed }} / {{ total }} STEPS -
-
-
-
-
-
-
-
- - -
- - - - -
- {% for op_data in ops + contingency_ops %} - {% set op = op_data.step %} - {% if op.order == selected_op_order %} -
-
- OP {{ op.step_number }} - {% if op.is_contingency %}{{ ok.status("CONTINGENCY", "warn") }}{% endif %} - {{ op.title }} - {{ op_data.completed_steps }}/{{ op_data.total_steps }} - {% if op.status in ['completed', 'signed_off'] and op.execution and op.execution.completed_by_user %} - {{ ok.status("COMPLETED BY " ~ op.execution.completed_by_user.name|upper, "ok") }} - {% else %} - {{ ok.status(op.status | upper | replace('_', ' '), "ok" if op.status in ['completed', 'signed_off'] else ("info" if op.status == 'in_progress' else ("warn" if op.status in ['skipped', 'awaiting_signoff'] else "default"))) }} - {% endif %} - {% if op.status in ['awaiting_signoff', 'in_progress'] and inst_status in ['pending', 'in_progress'] and op_data.sub_steps %} - - {% endif %} -
- - {% if op_data.sub_steps %} - - {% for step in op_data.sub_steps %} - {% set vs = version_steps_map.get(step.order, {}) %} - {% set schema = vs.get('required_data_schema') %} - {% set has_schema = schema and schema.get('fields') %} -
- - {{ step.step_number }} - {{ step.title }} - {% if step.status in ['completed', 'signed_off'] and step.execution and step.execution.completed_by_user %} - {{ ok.status("COMPLETED BY " ~ step.execution.completed_by_user.name|upper, "ok") }} - {% else %} - {{ ok.status(step.status | upper | replace('_', ' '), "ok" if step.status in ['completed', 'signed_off'] else ("info" if step.status == 'in_progress' else ("warn" if step.status in ['skipped', 'awaiting_signoff'] else "default"))) }} - {% endif %} - {% if step.execution and step.execution.started_at %} - - {{ step.execution.started_at.strftime('%H:%M:%S') }}{% if step.execution.completed_at %} - {{ step.execution.completed_at.strftime('%H:%M:%S') }}{% endif %} - - {% endif %} - -
- {% if step.instructions %} -
{{ step.instructions }}
- {% endif %} - - {% set step_kit = vs.get('step_kit', []) %} - {% set step_exec = step.execution %} - {% set step_cons = step_consumptions.get(step_exec.id, []) if step_exec else [] %} - {% if step_kit or step_cons %} -
-
- STEP PARTS - {% if step_cons %}{{ ok.status("CONSUMED " ~ step_cons|length, "ok") }}{% endif %} -
- {% if step_kit %} - - - - - - - - - - {% for sk in step_kit %} - - - - - - - - {% endfor %} - -
PARTQTYTYPESOURCEQTY
{{ sk.part_name }}{% if sk.notes %} ({{ sk.notes }}){% endif %}{{ sk.quantity_required }}{{ ok.status(sk.usage_type|upper, "info" if sk.usage_type == 'tooling' else "default") }}
- {% if step.status == 'in_progress' and inst_status in ['in_progress'] %} -
- -
- {% endif %} - {% endif %} - {% if step_cons %} -
- {% for c in step_cons %} - - {{ c.inventory_record.part.name }} x{{ c.quantity }} from {{ c.inventory_record.location }} - {% if c.usage_type.value == 'tooling' %}(TOOLING){% endif %} -
- {% endfor %} -
- {% endif %} -
- {% endif %} - - {% if has_schema %} - -
-
DATA CAPTURE
- {% for field in schema.get('fields', []) %} -
- - {% if step.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} - {% if field.type == 'text' %} - - {% elif field.type == 'number' %} - - {% elif field.type == 'checkbox' %} - - {% elif field.type == 'select' %} - - {% elif field.type == 'textarea' %} - - {% endif %} -
- {% else %} - {# Read-only display for completed/pending steps #} - {% if step.execution and step.execution.data_captured and step.execution.data_captured[field.name] is defined %} -
- {% if field.type == 'checkbox' %}{{ 'YES' if step.execution.data_captured[field.name] else 'NO' }}{% else %}{{ step.execution.data_captured[field.name] }}{% endif %} -
- {% else %} - - - {% endif %} -
- {% endif %} - {% endfor %} -
- {% endif %} - - - {% if step.status in ['in_progress', 'completed', 'signed_off', 'awaiting_signoff'] %} -
- - {% if step.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} - - {% elif step.execution and step.execution.notes %} -
{{ step.execution.notes }}
- {% else %} - - - {% endif %} -
- {% endif %} - - -
- {% if inst_status in ['pending', 'in_progress'] %} - {% if step.status == 'pending' %} - - - {% elif step.status == 'in_progress' %} - - - - {% elif step.status == 'awaiting_signoff' %} - - {% endif %} - {% endif %} -
-
- - {% endfor %} - {% else %} - - {% set vs = version_steps_map.get(op.order, {}) %} - {% set schema = vs.get('required_data_schema') %} - {% set has_schema = schema and schema.get('fields') %} -
- - {{ op.title }} - {% if op.status in ['completed', 'signed_off'] and op.execution and op.execution.completed_by_user %} - {{ ok.status("COMPLETED BY " ~ op.execution.completed_by_user.name|upper, "ok") }} - {% endif %} - {% if op.execution and op.execution.started_at %} - - {{ op.execution.started_at.strftime('%H:%M:%S') }}{% if op.execution.completed_at %} - {{ op.execution.completed_at.strftime('%H:%M:%S') }}{% endif %} - - {% endif %} - -
- {% if op.instructions %} -
{{ op.instructions }}
- {% endif %} - - {% set step_kit = vs.get('step_kit', []) %} - {% set step_exec = op.execution %} - {% set step_cons = step_consumptions.get(step_exec.id, []) if step_exec else [] %} - {% if step_kit or step_cons %} -
-
- STEP PARTS - {% if step_cons %}{{ ok.status("CONSUMED " ~ step_cons|length, "ok") }}{% endif %} -
- {% if step_kit %} - - - - - - - - - - {% for sk in step_kit %} - - - - - - - - {% endfor %} - -
PARTQTYTYPESOURCEQTY
{{ sk.part_name }}{% if sk.notes %} ({{ sk.notes }}){% endif %}{{ sk.quantity_required }}{{ ok.status(sk.usage_type|upper, "info" if sk.usage_type == 'tooling' else "default") }}
- {% if op.status == 'in_progress' and inst_status in ['in_progress'] %} -
- -
- {% endif %} - {% endif %} - {% if step_cons %} -
- {% for c in step_cons %} - - {{ c.inventory_record.part.name }} x{{ c.quantity }} from {{ c.inventory_record.location }} - {% if c.usage_type.value == 'tooling' %}(TOOLING){% endif %} -
- {% endfor %} -
- {% endif %} -
- {% endif %} - - {% if has_schema %} -
-
DATA CAPTURE
- {% for field in schema.get('fields', []) %} -
- - {% if op.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} - {% if field.type == 'text' %} - - {% elif field.type == 'number' %} - - {% elif field.type == 'checkbox' %} - - {% elif field.type == 'select' %} - - {% elif field.type == 'textarea' %} - - {% endif %} - {% else %} - {% if op.execution and op.execution.data_captured and op.execution.data_captured[field.name] is defined %} -
- {% if field.type == 'checkbox' %}{{ 'YES' if op.execution.data_captured[field.name] else 'NO' }}{% else %}{{ op.execution.data_captured[field.name] }}{% endif %} -
- {% else %} - - - {% endif %} - {% endif %} -
- {% endfor %} -
- {% endif %} - - {% if op.status in ['in_progress', 'completed', 'signed_off', 'awaiting_signoff'] %} -
- - {% if op.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} - - {% elif op.execution and op.execution.notes %} -
{{ op.execution.notes }}
- {% endif %} -
- {% endif %} - -
- {% if inst_status in ['pending', 'in_progress'] %} - {% if op.status == 'pending' %} - - - {% elif op.status == 'in_progress' %} - - - - {% elif op.status == 'awaiting_signoff' %} - - {% endif %} - {% endif %} -
-
-
- {% endif %} -
- {% endif %} - {% endfor %} - {% if not selected_op_order %} -
NO OPERATIONS
- {% endif %} -
-
- -{% if kit_items %} -
-
- KIT / PARTS CONSUMPTION -
- {% if consumptions %} - {{ ok.status("CONSUMED", "ok") }} - {% elif instance.status.value == 'completed' %} - {{ ok.status("PENDING", "warn") }} - {% endif %} -
-
-
- {% if consumptions %} - - - - - - - - - - - - {% for c in consumptions %} - - - - - - - - {% endfor %} - -
PARTQTYLOCATIONLOTSTEP
{{ c.inventory_record.part.name }}{{ c.quantity }}{{ c.inventory_record.location }}{{ c.inventory_record.lot_number or '-' }}{% if c.step_execution_id %}{{ step_exec_lookup.get(c.step_execution_id, '-') }}{% else %}-{% endif %}
- {% else %} - - - - - - - - - - - - {% for kit in kit_items %} - - - - - - - - {% endfor %} - -
PARTREQUIREDAVAILABLECONSUME FROMQTY
{{ kit.part.name }}{{ kit.quantity_required }}- - - - -
- {% if inst_status in ['in_progress', 'completed'] %} -
- -
- - {% elif inst_status == 'pending' %} -

Parts can be consumed after the execution is started.

- {% endif %} - {% endif %} -
-
-{% endif %} - -{% if productions %} -
-
- PRODUCTION -
- {% for p in productions %} - {% set pstatus = p.status.value if p.status.value is defined else p.status %} - {% if pstatus == 'completed' %} - {{ ok.status("FINALIZED", "ok") }} - {% elif pstatus == 'wip' %} - {{ ok.status("WIP", "info") }} - {% else %} - {{ ok.status("PLANNED", "default") }} - {% endif %} - {% endfor %} -
-
-
- - - - - - - - - - - - - - {% for p in productions %} - {% set pstatus = p.status.value if p.status.value is defined else p.status %} - - - - - - - - - - {% endfor %} - -
PARTSERIALOPAL #QTYLOT (WO)LOCATIONSTATUS
{{ p.inventory_record.part.name }}{{ p.serial_number or '-' }}{% if p.produced_opal_number %}{{ p.produced_opal_number }}{% else %}-{% endif %}{% if pstatus == 'completed' %}{{ p.quantity }}{% else %}({{ p.quantity }}){% endif %}{{ p.inventory_record.lot_number or '-' }}{{ p.inventory_record.location or '-' }}{{ ok.status(pstatus | upper, "ok" if pstatus == 'completed' else ("info" if pstatus == 'wip' else "default")) }}
- - {% if can_finalize %} -
-
-
- - -
- -
- -
- {% endif %} -
-
-{% elif output_items %} -
-
ASSEMBLY OUTPUT
-
- - - - {% for out in output_items %} - - - - - - - - - {% endfor %} - -
PARTEXPECTEDQTYLOCATION *LOTSERIAL
{{ out.part.name }}{{ out.quantity_produced }}
- {% if inst_status in ['in_progress', 'completed'] %} -
- -
- - {% elif inst_status == 'pending' %} -

Output can be recorded after the execution is started.

- {% endif %} -
-
-{% endif %} - -{% if bom_items or unplanned_consumptions %} -
-
BOM RECONCILIATION
-
- {% if bom_items %} - - - - {% for item in bom_items %} - - - - - - - {% endfor %} - -
PARTREQUIREDCONSUMEDVARIANCE
{{ item.part_name }}{{ item.qty_required }}{{ item.qty_consumed }}{% if item.variance > 0 %}+{% endif %}{{ item.variance }}
- {% endif %} - {% if unplanned_consumptions %} -
- UNPLANNED CONSUMPTIONS - - - - {% for item in unplanned_consumptions %} - - - - - {% endfor %} - -
PARTQTY
{{ item.part_name }}{{ item.qty_consumed }}
-
- {% endif %} -
-
-{% endif %} - -
-
- ATTACHMENTS -
- 0 -
-
-
- {% if inst_status in ['pending', 'in_progress'] %} -
- - {{ ok.btn("UPLOAD", variant="primary", attrs='onclick="uploadAttachment()"') }} -
- {% endif %} -
- Loading... -
-
+
+ {% include 'executions/tabs/' ~ tab ~ '.html' %}
@@ -761,7 +133,6 @@ async function completeStep(stepNumber) { const data = {}; - // Collect data capture fields const stepEl = document.getElementById('step-' + stepNumber); if (stepEl) { const fields = stepEl.querySelectorAll('[data-capture-field]'); @@ -774,18 +145,15 @@ else data.data_captured[name] = f.value || null; }); - // Validate data capture fields against constraints let errors = []; fields.forEach(f => { const name = f.dataset.captureField; const val = data.data_captured[name]; - // Required check if (f.hasAttribute('required') && (val === null || val === '' || val === undefined)) { const label = f.closest('.form-group')?.querySelector('.form-label'); const fieldName = label ? label.textContent.replace(/\s*\*\s*$/, '').replace(/\s*\(.*\)\s*$/, '').trim() : name; errors.push(fieldName + ' is required'); } - // Number range check if (f.type === 'number' && val !== null && val !== undefined && val !== '') { const numVal = parseFloat(val); if (f.hasAttribute('min') && numVal < parseFloat(f.min)) { @@ -802,7 +170,6 @@ } } - // Collect notes const notesEl = stepEl.querySelector('.step-notes-input'); if (notesEl && notesEl.value.trim()) { data.notes = notesEl.value.trim(); @@ -947,8 +314,8 @@ if (e.key === 'Escape') { hideNCModal(); hideSkipModal(); } }); -// Kit availability async function loadKitAvailability() { + if (!document.getElementById('kit-table')) return; try { const response = await fetch(`/api/procedure-instances/${instanceId}/kit-availability`, { headers: getHeaders() }); if (response.ok) { @@ -963,9 +330,11 @@ const row = document.querySelector(`tr[data-part-id="${item.part_id}"]`); if (!row) continue; const availCell = row.querySelector('.availability-cell'); + if (!availCell) continue; availCell.textContent = item.quantity_available.toFixed(4); if (!item.is_available) availCell.classList.add('text-red'); const select = row.querySelector('.inv-select'); + if (!select) continue; select.innerHTML = ''; for (const loc of item.available_locations) { const opt = document.createElement('option'); @@ -1045,16 +414,11 @@ } catch (e) { const msg = 'Network error: ' + e.message; if (errorDiv) { errorDiv.textContent = msg; errorDiv.style.display = 'block'; } else alert(msg); } } -if (document.getElementById('kit-table')) { loadKitAvailability(); } - -// ============ Step Kit ============ async function loadStepKitAvailability() { const tables = document.querySelectorAll('[data-step-kit]'); if (!tables.length) return; - // Collect unique part IDs across all step kit tables const partIds = new Set(); tables.forEach(t => t.querySelectorAll('tbody tr[data-part-id]').forEach(r => partIds.add(r.dataset.partId))); - // Fetch inventory for each part const inventoryByPart = {}; await Promise.all([...partIds].map(async (pid) => { try { @@ -1062,7 +426,6 @@ if (resp.ok) { inventoryByPart[pid] = await resp.json(); } } catch (e) { console.error('Failed to load inventory for part', pid, e); } })); - // Populate selects document.querySelectorAll('.sk-inv-select').forEach(select => { const pid = select.dataset.partId; const records = inventoryByPart[pid]; @@ -1076,7 +439,6 @@ opt.textContent = `${rec.location} (${parseFloat(rec.quantity).toFixed(4)})${rec.lot_number ? ' - Lot: ' + rec.lot_number : ''}`; select.appendChild(opt); } - // Auto-select first option with enough quantity const row = select.closest('tr'); const required = parseFloat(row?.dataset.required || 0); for (const rec of items) { @@ -1096,7 +458,6 @@ if (!select || !select.value) { alert('Please select a source location for all parts'); return; } const qty = parseFloat(qtyInput.value); if (qty <= 0) continue; - // Determine usage type from the row's status badge const typeBadge = row.querySelector('.status'); const usageType = typeBadge && typeBadge.textContent.trim().toLowerCase() === 'tooling' ? 'tooling' : 'consume'; items.push({ inventory_record_id: parseInt(select.value), quantity: qty, usage_type: usageType }); @@ -1111,13 +472,19 @@ } catch (e) { alert('Network error: ' + e.message); } } -document.addEventListener('DOMContentLoaded', loadStepKitAvailability); - -// ============ Collaboration ============ +// ============ Collaboration (singleton) ============ let collaboration = null; let isParticipant = false; +let collaborationInitialized = false; function initCollaboration() { + if (collaborationInitialized) { + loadParticipants(); + updateConnectionStatus(); + return; + } + if (!document.getElementById('connection-status')) return; + collaborationInitialized = true; collaboration = new ExecutionCollaboration(instanceId); window.opalEvents.on('connected', updateConnectionStatus); window.opalEvents.on('disconnected', updateConnectionStatus); @@ -1128,11 +495,13 @@ function updateConnectionStatus() { const statusEl = document.getElementById('connection-status'); const textEl = document.getElementById('connection-text'); + if (!statusEl || !textEl) return; if (window.opalEvents.isConnected) { statusEl.classList.add('connected'); textEl.textContent = 'LIVE'; } else { statusEl.classList.remove('connected'); textEl.textContent = 'OFFLINE'; } } async function loadParticipants() { + if (!document.getElementById('participants-list')) return; try { const response = await fetch(`/api/procedure-instances/${instanceId}/participants`, { headers: getHeaders() }); if (response.ok) { const data = await response.json(); renderParticipants(data.participants); } @@ -1141,6 +510,7 @@ function renderParticipants(participants) { const container = document.getElementById('participants-list'); + if (!container) return; const currentUserId = parseInt(localStorage.getItem('opal_user_id')); if (!participants || participants.length === 0) { container.innerHTML = 'No active participants'; @@ -1181,10 +551,9 @@ catch (e) { alert('Network error: ' + e.message); } } -document.addEventListener('DOMContentLoaded', initCollaboration); - // ============ Attachments ============ async function loadAttachments() { + if (!document.getElementById('attachments-list')) return; try { const response = await fetch(`/api/attachments?procedure_instance_id=${instanceId}`, { headers: getHeaders() }); if (response.ok) { const data = await response.json(); renderAttachments(data); } @@ -1196,6 +565,7 @@ function renderAttachments(attachments) { const container = document.getElementById('attachments-list'); const countEl = document.getElementById('attachment-count'); + if (!container || !countEl) return; countEl.textContent = attachments.length; if (attachments.length === 0) { container.innerHTML = 'No attachments'; return; } let html = ''; @@ -1232,7 +602,19 @@ } catch (e) { alert('Network error: ' + e.message); } } -document.addEventListener('DOMContentLoaded', loadAttachments); +function initExecTab() { + loadKitAvailability(); + loadStepKitAvailability(); + initCollaboration(); + loadAttachments(); +} + +document.addEventListener('DOMContentLoaded', initExecTab); +document.body.addEventListener('htmx:afterSwap', function(e) { + if (e.target && e.target.classList && e.target.classList.contains('exec-tab-content')) { + initExecTab(); + } +}); window.addEventListener('beforeunload', function() { if (isParticipant) { diff --git a/src/opal/web/templates/executions/tabs/bom.html b/src/opal/web/templates/executions/tabs/bom.html new file mode 100644 index 0000000..bc63d65 --- /dev/null +++ b/src/opal/web/templates/executions/tabs/bom.html @@ -0,0 +1,41 @@ +
+
+
BOM RECONCILIATION
+
+ {% if bom_items %} +
FILENAMESIZETYPEUPLOADED
+ + + {% for item in bom_items %} + + + + + + + {% endfor %} + +
PARTREQUIREDCONSUMEDVARIANCE
{{ item.part_name }}{{ item.qty_required }}{{ item.qty_consumed }}{% if item.variance > 0 %}+{% endif %}{{ item.variance }}
+ {% else %} +

No BOM items defined for this procedure.

+ {% endif %} + + {% if unplanned_consumptions %} +
+ UNPLANNED CONSUMPTIONS + + + + {% for item in unplanned_consumptions %} + + + + + {% endfor %} + +
PARTQTY
{{ item.part_name }}{{ item.qty_consumed }}
+
+ {% endif %} +
+
+ diff --git a/src/opal/web/templates/executions/tabs/data.html b/src/opal/web/templates/executions/tabs/data.html new file mode 100644 index 0000000..3568255 --- /dev/null +++ b/src/opal/web/templates/executions/tabs/data.html @@ -0,0 +1,38 @@ +
+
+
+ CAPTURED DATA +
+ {{ data_rows | length }} ROW{{ 'S' if data_rows | length != 1 }} +
+
+
+ {% if data_rows %} + + + + + + + + + + + + {% for row in data_rows %} + + + + + + + + {% endfor %} + +
STEPFIELDVALUEBYAT
{{ row.step_number }}{{ row.field }}{{ row.value }}{{ row.by or '-' }}{{ ok.timestamp(row.at) if row.at else '-' }}
+ {% else %} +

No structured data has been captured yet for this execution.

+ {% endif %} +
+
+
diff --git a/src/opal/web/templates/executions/tabs/issues.html b/src/opal/web/templates/executions/tabs/issues.html new file mode 100644 index 0000000..bbbe41f --- /dev/null +++ b/src/opal/web/templates/executions/tabs/issues.html @@ -0,0 +1,43 @@ +
+
+
+ ISSUE TICKETS + +
+
+ {% if linked_issues %} + + + + + + + + + + + + + {% for issue in linked_issues %} + {% set istatus = issue.status.value if issue.status.value is defined else issue.status %} + {% set itype = issue.issue_type.value if issue.issue_type and issue.issue_type.value is defined else (issue.issue_type or '') %} + {% set ipriority = issue.priority.value if issue.priority and issue.priority.value is defined else (issue.priority or '') %} + + + + + + + + + {% endfor %} + +
ISSUETITLETYPEPRIORITYSTATUSOPENED
{{ issue.issue_number }}{{ issue.title }}{{ itype | upper }}{{ ipriority | upper }}{{ ok.status(istatus | upper | replace('_', ' '), "ok" if istatus in ['resolved', 'closed'] else ("warn" if istatus == 'in_progress' else "default")) }}{{ ok.timestamp(issue.created_at) }}
+ {% else %} +

No issues are linked to this execution.

+ {% endif %} +
+
+
diff --git a/src/opal/web/templates/executions/tabs/kitting.html b/src/opal/web/templates/executions/tabs/kitting.html new file mode 100644 index 0000000..d10c50e --- /dev/null +++ b/src/opal/web/templates/executions/tabs/kitting.html @@ -0,0 +1,179 @@ +
+{% if kit_items %} +
+
+ KIT / PARTS CONSUMPTION +
+ {% if consumptions %} + {{ ok.status("CONSUMED", "ok") }} + {% elif inst_status == 'completed' %} + {{ ok.status("PENDING", "warn") }} + {% endif %} +
+
+
+ {% if consumptions %} + + + + + + + + + + + + {% for c in consumptions %} + + + + + + + + {% endfor %} + +
PARTQTYLOCATIONLOTSTEP
{{ c.inventory_record.part.name }}{{ c.quantity }}{{ c.inventory_record.location }}{{ c.inventory_record.lot_number or '-' }}{% if c.step_execution_id %}{{ step_exec_lookup.get(c.step_execution_id, '-') }}{% else %}-{% endif %}
+ {% else %} + + + + + + + + + + + + {% for kit in kit_items %} + + + + + + + + {% endfor %} + +
PARTREQUIREDAVAILABLECONSUME FROMQTY
{{ kit.part.name }}{{ kit.quantity_required }}- + + + +
+ {% if inst_status in ['in_progress', 'completed'] %} +
+ +
+ + {% elif inst_status == 'pending' %} +

Parts can be consumed after the execution is started.

+ {% endif %} + {% endif %} +
+
+{% endif %} + +{% if productions %} +
+
+ PRODUCTION +
+ {% for p in productions %} + {% set pstatus = p.status.value if p.status.value is defined else p.status %} + {% if pstatus == 'completed' %} + {{ ok.status("FINALIZED", "ok") }} + {% elif pstatus == 'wip' %} + {{ ok.status("WIP", "info") }} + {% else %} + {{ ok.status("PLANNED", "default") }} + {% endif %} + {% endfor %} +
+
+
+ + + + + + + + + + + + + + {% for p in productions %} + {% set pstatus = p.status.value if p.status.value is defined else p.status %} + + + + + + + + + + {% endfor %} + +
PARTSERIALOPAL #QTYLOT (WO)LOCATIONSTATUS
{{ p.inventory_record.part.name }}{{ p.serial_number or '-' }}{% if p.produced_opal_number %}{{ p.produced_opal_number }}{% else %}-{% endif %}{% if pstatus == 'completed' %}{{ p.quantity }}{% else %}({{ p.quantity }}){% endif %}{{ p.inventory_record.lot_number or '-' }}{{ p.inventory_record.location or '-' }}{{ ok.status(pstatus | upper, "ok" if pstatus == 'completed' else ("info" if pstatus == 'wip' else "default")) }}
+ + {% if can_finalize %} +
+
+
+ + +
+ +
+ +
+ {% endif %} +
+
+{% elif output_items %} +
+
ASSEMBLY OUTPUT
+
+ + + + {% for out in output_items %} + + + + + + + + + {% endfor %} + +
PARTEXPECTEDQTYLOCATION *LOTSERIAL
{{ out.part.name }}{{ out.quantity_produced }}
+ {% if inst_status in ['in_progress', 'completed'] %} +
+ +
+ + {% elif inst_status == 'pending' %} +

Output can be recorded after the execution is started.

+ {% endif %} +
+
+{% endif %} + +{% if not kit_items and not productions and not output_items %} +
+
KITTING
+

No kit, output, or production records for this procedure.

+
+{% endif %} +
diff --git a/src/opal/web/templates/executions/tabs/meta.html b/src/opal/web/templates/executions/tabs/meta.html new file mode 100644 index 0000000..b778c80 --- /dev/null +++ b/src/opal/web/templates/executions/tabs/meta.html @@ -0,0 +1,78 @@ +
+{% call ok.panel(instance.work_order_number or "EXECUTION #" ~ instance.id, actions=ok.status(inst_status | upper | replace('_', ' '))) %} + {% call ok.table() %} + {{ ok.detail_row("PROCEDURE", ok.link(instance.procedure.name, "/procedures/" ~ instance.procedure_id), th_width="160px") }} + {{ ok.detail_row("VERSION", ok.mono("v" ~ version.version_number) if version else "-", th_width="160px") }} + {{ ok.detail_row("WORK ORDER", ok.mono(instance.work_order_number or '-'), th_width="160px") }} + {{ ok.detail_row("CREATED", ok.timestamp(instance.created_at), th_width="160px") }} + {{ ok.detail_row("STARTED", ok.timestamp(instance.started_at) if instance.started_at else '-', th_width="160px") }} + {{ ok.detail_row("COMPLETED", ok.timestamp(instance.completed_at) if instance.completed_at else '-', th_width="160px") }} + {{ ok.detail_row("LAST ACTIVITY", ok.timestamp(last_activity_at) if last_activity_at else '-', th_width="160px") }} + {{ ok.detail_row("STARTED BY", instance.started_by_user.name if instance.started_by_user else '-', th_width="160px") }} + {{ ok.detail_row("VERSION AUTHOR", (version.created_by_user.name if version and version.created_by_user else '-'), th_width="160px") }} + {{ ok.detail_row("VERSION CREATED", ok.timestamp(version.created_at) if version else '-', th_width="160px") }} + {% if instance.scheduled_start_at %}{{ ok.detail_row("SCHEDULED START", ok.timestamp(instance.scheduled_start_at), th_width="160px") }}{% endif %} + {% if instance.target_completion_at %}{{ ok.detail_row("TARGET COMPLETION", ok.timestamp(instance.target_completion_at), th_width="160px") }}{% endif %} + {% endcall %} + {% if inst_status == 'in_progress' %} +
+ {{ ok.btn("ABORT EXECUTION", variant="danger", attrs='onclick="abortExecution()"') }} +
+ {% endif %} +{% endcall %} + +
+
+ COLLABORATION +
+ + + OFFLINE + +
+
+
+ {% if inst_status in ['pending', 'in_progress'] %} +
+ + +
+ {% endif %} +
ACTIVE PARTICIPANTS
+
+ Loading... +
+ + {% set completed = steps | selectattr('status', 'in', ['completed', 'signed_off', 'skipped']) | list | length %} + {% set total = steps | length %} +
+
+ {{ completed }} / {{ total }} STEPS +
+
+
+
+
+
+
+ +
+
+ ATTACHMENTS +
+ 0 +
+
+
+ {% if inst_status in ['pending', 'in_progress'] %} +
+ + {{ ok.btn("UPLOAD", variant="primary", attrs='onclick="uploadAttachment()"') }} +
+ {% endif %} +
+ Loading... +
+
+
+
diff --git a/src/opal/web/templates/executions/tabs/operations.html b/src/opal/web/templates/executions/tabs/operations.html new file mode 100644 index 0000000..d126e33 --- /dev/null +++ b/src/opal/web/templates/executions/tabs/operations.html @@ -0,0 +1,355 @@ +
+
+ + +
+ {% for op_data in ops + contingency_ops %} + {% set op = op_data.step %} + {% if op.order == selected_op_order %} +
+
+ OP {{ op.step_number }} + {% if op.is_contingency %}{{ ok.status("CONTINGENCY", "warn") }}{% endif %} + {{ op.title }} + {{ op_data.completed_steps }}/{{ op_data.total_steps }} + {% if op.status in ['completed', 'signed_off'] and op.execution and op.execution.completed_by_user %} + {{ ok.status("COMPLETED BY " ~ op.execution.completed_by_user.name|upper, "ok") }} + {% else %} + {{ ok.status(op.status | upper | replace('_', ' '), "ok" if op.status in ['completed', 'signed_off'] else ("info" if op.status == 'in_progress' else ("warn" if op.status in ['skipped', 'awaiting_signoff'] else "default"))) }} + {% endif %} + {% if op.status in ['awaiting_signoff', 'in_progress'] and inst_status in ['pending', 'in_progress'] and op_data.sub_steps %} + + {% endif %} +
+ + {% if op_data.sub_steps %} + {% for step in op_data.sub_steps %} + {% set vs = version_steps_map.get(step.order, {}) %} + {% set schema = vs.get('required_data_schema') %} + {% set has_schema = schema and schema.get('fields') %} + {% set step_display_number = step.step_number if '.' in step.step_number else (op.step_number ~ '.' ~ step.step_number) %} +
+ + {{ step_display_number }} + {{ step.title }} + {% if step.status in ['completed', 'signed_off'] and step.execution and step.execution.completed_by_user %} + {{ ok.status("COMPLETED BY " ~ step.execution.completed_by_user.name|upper, "ok") }} + {% else %} + {{ ok.status(step.status | upper | replace('_', ' '), "ok" if step.status in ['completed', 'signed_off'] else ("info" if step.status == 'in_progress' else ("warn" if step.status in ['skipped', 'awaiting_signoff'] else "default"))) }} + {% endif %} + {% if step.execution and step.execution.started_at %} + + {{ step.execution.started_at.strftime('%H:%M:%S') }}{% if step.execution.completed_at %} - {{ step.execution.completed_at.strftime('%H:%M:%S') }}{% endif %} + + {% endif %} + +
+ {% if step.instructions %} +
{{ step.instructions }}
+ {% endif %} + + {% set step_kit = vs.get('step_kit', []) %} + {% set step_exec = step.execution %} + {% set step_cons = step_consumptions.get(step_exec.id, []) if step_exec else [] %} + {% if step_kit or step_cons %} +
+
+ STEP PARTS + {% if step_cons %}{{ ok.status("CONSUMED " ~ step_cons|length, "ok") }}{% endif %} +
+ {% if step_kit %} + + + + + + + + + + {% for sk in step_kit %} + + + + + + + + {% endfor %} + +
PARTQTYTYPESOURCEQTY
{{ sk.part_name }}{% if sk.notes %} ({{ sk.notes }}){% endif %}{{ sk.quantity_required }}{{ ok.status(sk.usage_type|upper, "info" if sk.usage_type == 'tooling' else "default") }}
+ {% if step.status == 'in_progress' and inst_status in ['in_progress'] %} +
+ +
+ {% endif %} + {% endif %} + {% if step_cons %} +
+ {% for c in step_cons %} + + {{ c.inventory_record.part.name }} x{{ c.quantity }} from {{ c.inventory_record.location }} + {% if c.usage_type.value == 'tooling' %}(TOOLING){% endif %} +
+ {% endfor %} +
+ {% endif %} +
+ {% endif %} + + {% if has_schema %} +
+
DATA CAPTURE
+ {% for field in schema.get('fields', []) %} +
+ + {% if step.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} + {% if field.type == 'text' %} + + {% elif field.type == 'number' %} + + {% elif field.type == 'checkbox' %} + + {% elif field.type == 'select' %} + + {% elif field.type == 'textarea' %} + + {% endif %} +
+ {% else %} + {% if step.execution and step.execution.data_captured and step.execution.data_captured[field.name] is defined %} +
+ {% if field.type == 'checkbox' %}{{ 'YES' if step.execution.data_captured[field.name] else 'NO' }}{% else %}{{ step.execution.data_captured[field.name] }}{% endif %} +
+ {% else %} + - + {% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endif %} + + {% if step.status in ['in_progress', 'completed', 'signed_off', 'awaiting_signoff'] %} +
+ + {% if step.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} + + {% elif step.execution and step.execution.notes %} +
{{ step.execution.notes }}
+ {% else %} + - + {% endif %} +
+ {% endif %} + +
+ {% if inst_status in ['pending', 'in_progress'] %} + {% if step.status == 'pending' %} + + + {% elif step.status == 'in_progress' %} + + + + {% elif step.status == 'awaiting_signoff' %} + + {% endif %} + {% endif %} +
+
+ + {% endfor %} + {% else %} + {% set vs = version_steps_map.get(op.order, {}) %} + {% set schema = vs.get('required_data_schema') %} + {% set has_schema = schema and schema.get('fields') %} +
+ + {{ op.step_number }} + {{ op.title }} + {% if op.status in ['completed', 'signed_off'] and op.execution and op.execution.completed_by_user %} + {{ ok.status("COMPLETED BY " ~ op.execution.completed_by_user.name|upper, "ok") }} + {% endif %} + {% if op.execution and op.execution.started_at %} + + {{ op.execution.started_at.strftime('%H:%M:%S') }}{% if op.execution.completed_at %} - {{ op.execution.completed_at.strftime('%H:%M:%S') }}{% endif %} + + {% endif %} + +
+ {% if op.instructions %} +
{{ op.instructions }}
+ {% endif %} + + {% set step_kit = vs.get('step_kit', []) %} + {% set step_exec = op.execution %} + {% set step_cons = step_consumptions.get(step_exec.id, []) if step_exec else [] %} + {% if step_kit or step_cons %} +
+
+ STEP PARTS + {% if step_cons %}{{ ok.status("CONSUMED " ~ step_cons|length, "ok") }}{% endif %} +
+ {% if step_kit %} + + + + + + + + + + {% for sk in step_kit %} + + + + + + + + {% endfor %} + +
PARTQTYTYPESOURCEQTY
{{ sk.part_name }}{% if sk.notes %} ({{ sk.notes }}){% endif %}{{ sk.quantity_required }}{{ ok.status(sk.usage_type|upper, "info" if sk.usage_type == 'tooling' else "default") }}
+ {% if op.status == 'in_progress' and inst_status in ['in_progress'] %} +
+ +
+ {% endif %} + {% endif %} + {% if step_cons %} +
+ {% for c in step_cons %} + + {{ c.inventory_record.part.name }} x{{ c.quantity }} from {{ c.inventory_record.location }} + {% if c.usage_type.value == 'tooling' %}(TOOLING){% endif %} +
+ {% endfor %} +
+ {% endif %} +
+ {% endif %} + + {% if has_schema %} +
+
DATA CAPTURE
+ {% for field in schema.get('fields', []) %} +
+ + {% if op.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} + {% if field.type == 'text' %} + + {% elif field.type == 'number' %} + + {% elif field.type == 'checkbox' %} + + {% elif field.type == 'select' %} + + {% elif field.type == 'textarea' %} + + {% endif %} + {% else %} + {% if op.execution and op.execution.data_captured and op.execution.data_captured[field.name] is defined %} +
+ {% if field.type == 'checkbox' %}{{ 'YES' if op.execution.data_captured[field.name] else 'NO' }}{% else %}{{ op.execution.data_captured[field.name] }}{% endif %} +
+ {% else %} + - + {% endif %} + {% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if op.status in ['in_progress', 'completed', 'signed_off', 'awaiting_signoff'] %} +
+ + {% if op.status == 'in_progress' and inst_status in ['pending', 'in_progress'] %} + + {% elif op.execution and op.execution.notes %} +
{{ op.execution.notes }}
+ {% endif %} +
+ {% endif %} + +
+ {% if inst_status in ['pending', 'in_progress'] %} + {% if op.status == 'pending' %} + + + {% elif op.status == 'in_progress' %} + + + + {% elif op.status == 'awaiting_signoff' %} + + {% endif %} + {% endif %} +
+
+
+ {% endif %} +
+ {% endif %} + {% endfor %} + {% if not selected_op_order %} +
NO OPERATIONS
+ {% endif %} +
+
+ diff --git a/src/opal/web/templates/layouts/base.html b/src/opal/web/templates/layouts/base.html index 535a48a..dc1e1fa 100644 --- a/src/opal/web/templates/layouts/base.html +++ b/src/opal/web/templates/layouts/base.html @@ -17,7 +17,7 @@ - +
OPAL From ec99882283d74b9aad2e8be4175295b21dfee32f Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Sun, 17 May 2026 03:01:11 -0700 Subject: [PATCH 07/28] Bump version to 1.2.8 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 75c0c58..574940c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opal" -version = "1.2.5" +version = "1.2.8" description = "Operations, Procedures, Assets, Logistics - ERP for small teams and hardware projects" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 895b912..01ae5b6 100644 --- a/uv.lock +++ b/uv.lock @@ -524,7 +524,7 @@ wheels = [ [[package]] name = "opal" -version = "1.2.5" +version = "1.2.8" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 0c59a96481ce287d96e0b937f9acf2187ef246d7 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Sun, 17 May 2026 03:20:50 -0700 Subject: [PATCH 08/28] Prepare v1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First major release under amorphous engineering. - Bump version 1.2.8 → 1.3.0 in pyproject.toml + uv.lock. - OPAL_manual.md: refresh the stale "Version 0.4.5" header. - Add CHANGELOG.md summarizing the journey v1.2.0 → v1.3.0 (tabbed execution UI, auto-updater fix, org migration, etc.). - updater.py: log a warning instead of silently dropping a malformed release tag during the version-parse step, so a bad upstream tag doesn't quietly disable updates for everyone. - updater.py: replace_binary() now raises a clear RuntimeError pointing at the path and likely cause (Homebrew, /usr/local/bin) when rename hits a write-protected location, instead of a bare OSError. - mcp/server.py: replace startup-time print(..., file=sys.stderr) calls with logging so MCP logs format consistently. --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ OPAL_manual.md | 2 +- pyproject.toml | 2 +- src/opal/mcp/server.py | 10 ++++++---- src/opal/updater.py | 28 ++++++++++++++++++++++++---- uv.lock | 2 +- 6 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8107ec2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable user-visible changes to OPAL. Dates are release dates. + +## 1.3.0 — 2026-05-17 + +First major release under the **amorphous engineering** org. + +### Added +- **Tabbed execution detail UI.** `/executions/{id}` is split into six focused tabs — Meta, Operations, Data, BOM, Issue Tickets, Kitting — replacing the long stacked-panel page. Tab switching is an HTMX partial-swap so URL state (`?tab=&op=`), back/forward, and scroll position are all preserved. +- **Per-operation focus in the Operations tab.** The right pane renders only the selected op's steps; the sidebar lists every op with name (truncated), `OP ` + progress + status, and the global StepExecution ID. Sidebar clicks swap just the op block. +- **Captured-data audit table.** The Data tab shows a flat audit-style table (step / field / value / by / at) of every `data_captured` value across the run. +- **Issues badge.** The Issues tab label shows the linked-issue count; the old red full-page alert banner is gone. + +### Changed +- Tab strip styled to match `.nav-dropdown-btn` (transparent → orange-bordered active state) so the new chrome reads as the same family as the top nav. +- Status footer is now `position: sticky; bottom: 0` — stays visible at the viewport bottom on long pages. +- Sub-step headers in the Operations tab render `op#.step#` (e.g. `8.5 Verify Charge Continuity`) even when the version snapshot only stores the sub-step part. +- Repo migrated to the `amorphous-engineering/OPAL` GitHub org; all install scripts, auto-updater URLs, and CI references updated. +- Single source of truth for `__version__` — derived from `pyproject.toml` via `importlib.metadata`; the release workflow writes the tag-derived version into the binary at build time. + +### Fixed +- **Auto-updater crash on Textual app thread** (regression in 1.2.1). `_apply_update` no longer wraps its UI callback in `call_from_thread`, which Textual rejects on the app's own thread. Affected installs on ≤1.2.4 need a manual binary swap to escape; 1.2.5+ updates cleanly. +- Windows installer now auto-adds the install location to PATH. +- Updater silently dropped malformed release tags (returned `None` with no log) — now logs a warning when a tag fails to parse. +- Updater raised a bare `OSError` when the running binary lived at a write-protected path (e.g. `/usr/local/bin/opal`) — now raises a clear `RuntimeError` naming the path and pointing to permissions. +- MCP server's startup-time `print(..., file=sys.stderr)` calls replaced with `logging` so MCP logs format consistently with the rest of the app. + +### Internal +- Ruff pass + reformat across `src/`. +- Release CI builds binaries for macOS arm64, macOS x86_64, Linux x86_64, and Windows x86_64. diff --git a/OPAL_manual.md b/OPAL_manual.md index 002598f..550fdfb 100644 --- a/OPAL_manual.md +++ b/OPAL_manual.md @@ -1,6 +1,6 @@ # OPAL User Manual -**Version 0.4.5** +**Version 1.3.0** **Operations, Procedures, Assets, Logistics** diff --git a/pyproject.toml b/pyproject.toml index 574940c..e1cff4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opal" -version = "1.2.8" +version = "1.3.0" description = "Operations, Procedures, Assets, Logistics - ERP for small teams and hardware projects" readme = "README.md" requires-python = ">=3.11" diff --git a/src/opal/mcp/server.py b/src/opal/mcp/server.py index 48508c4..c49054a 100644 --- a/src/opal/mcp/server.py +++ b/src/opal/mcp/server.py @@ -1,7 +1,7 @@ """OPAL MCP Server implementation.""" import json -import sys +import logging from datetime import UTC, datetime from typing import Any @@ -24,6 +24,8 @@ from opal.db.models.procedure import ProcedureStatus, ProcedureStep from opal.db.models.risk import RiskStatus +logger = logging.getLogger(__name__) + # Create MCP server server = Server("opal") @@ -1417,12 +1419,12 @@ async def _remove_component(db, args: dict) -> list[TextContent]: async def run_server(): """Run the MCP server.""" - print("OPAL MCP Server started", file=sys.stderr) - print(f"Database: {get_active_settings().database_url}", file=sys.stderr) + logger.info("OPAL MCP Server started") + logger.info("Database: %s", get_active_settings().database_url) project = get_active_project() if project: - print(f"Project: {project.name}", file=sys.stderr) + logger.info("Project: %s", project.name) async with stdio_server() as (read_stream, write_stream): await server.run( diff --git a/src/opal/updater.py b/src/opal/updater.py index ce8b15a..216b3e6 100644 --- a/src/opal/updater.py +++ b/src/opal/updater.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import platform import stat @@ -13,6 +14,8 @@ from opal import __version__ +logger = logging.getLogger(__name__) + GITHUB_RELEASES_URL = "https://api.github.com/repos/amorphous-engineering/OPAL/releases/latest" GITHUB_HEADERS = {"Accept": "application/vnd.github.v3+json"} @@ -80,7 +83,13 @@ async def check_for_update() -> dict | None: try: latest = Version(tag) current = Version(__version__) - except Exception: + except Exception as e: + logger.warning( + "Skipping update check: could not parse version (tag=%r, current=%r): %s", + tag, + __version__, + e, + ) return None if latest <= current: @@ -170,16 +179,27 @@ def replace_binary(new_binary: Path) -> Path: backup.unlink(missing_ok=True) # Rename current → backup - current.rename(backup) + try: + current.rename(backup) + except OSError as e: + raise RuntimeError( + f"Cannot replace binary at {current}: {e}. " + "The OPAL binary lives in a directory you don't have write access to " + "(common when installed via Homebrew or to /usr/local/bin). " + "Re-run the installer with sudo, or move OPAL to a user-writable location." + ) from e try: # Move new binary into place new_binary.rename(current) - except Exception: + except OSError as e: # Restore from backup on failure if backup.exists() and not current.exists(): backup.rename(current) - raise + raise RuntimeError( + f"Cannot install new binary at {current}: {e}. " + "Original binary restored from backup." + ) from e # Make executable on Unix if sys.platform != "win32": diff --git a/uv.lock b/uv.lock index 01ae5b6..e5d7d1b 100644 --- a/uv.lock +++ b/uv.lock @@ -524,7 +524,7 @@ wheels = [ [[package]] name = "opal" -version = "1.2.8" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From aa8c0344c98113d7dff753860c459881717f3ec3 Mon Sep 17 00:00:00 2001 From: Abby Bigaouette Date: Sun, 17 May 2026 03:31:37 -0700 Subject: [PATCH 09/28] Visual polish pass for v1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 fixes: - Setup welcome error message used var(--text-red) which is not defined in :root, falling back to currentColor and rendering invisible. Switched to var(--accent-red) (welcome/setup.html:44). - z-index scale was non-monotonic across six magnitudes (10, 100, 1000, 9000, 9999, 10000). Introduced --z-sticky / --z-dropdown / --z-modal / --z-palette tokens and brought sticky footer, ok-modal, cmd-palette, and the 14 inline NC/Skip/add-* modals onto the new scale. Inline modals were at z=100 (below other 1000-level chrome); now correctly layered as modals at z=9000, palette above at z=10000. P1 cleanup: - Tokenized --modal-backdrop (rgba 0,0,0,0.8) instead of duplicating the literal across 14 modal templates and the cmd-palette/ok-modal CSS. - Tokenized --diff-added-bg / --diff-removed-bg / --diff-modified-bg / --diff-changed-bg instead of inlining the same rgba in main.css. - Off-scale font-size sweep: 0.6875rem / 0.7rem / 0.8rem / 0.85em / 1.2em / 1.3em / 0.75em / 0.85rem → in-scale 0.75rem / 0.875rem / 1.25rem across cmd-palette CSS, exec-sidebar-item-gid, and a dozen templates. - Letter-spacing 0.15em (login.html, setup_profile.html) → 0.1em to match the brand-link letter-spacing used everywhere else for uppercase display text. - font-weight: bold → 600 across 18 sites for chrome consistency with the rest of the app's semibold treatment. Tab-refactor carry-over: - Six redundant style="font-size: 0.75rem" overrides on .form-label in tabs/operations.html removed (the class already defines that size). - meta.html step counter font-weight: bold → 600. - exec-sidebar-item-gid font-size 0.7rem → 0.75rem. --- src/opal/web/static/css/main.css | 41 +++++++++++++------ src/opal/web/templates/executions/detail.html | 4 +- .../web/templates/executions/tabs/meta.html | 2 +- .../templates/executions/tabs/operations.html | 12 +++--- .../web/templates/inventory/opal_detail.html | 12 +++--- .../web/templates/inventory/table_rows.html | 6 +-- src/opal/web/templates/issues/detail.html | 20 ++++----- src/opal/web/templates/issues/new.html | 6 +-- src/opal/web/templates/login.html | 2 +- src/opal/web/templates/parts/detail.html | 6 +-- src/opal/web/templates/procedures/detail.html | 8 ++-- .../web/templates/procedures/step_edit.html | 4 +- .../templates/procedures/version_diff.html | 4 +- src/opal/web/templates/project/wizard.html | 2 +- src/opal/web/templates/purchases/detail.html | 2 +- src/opal/web/templates/risks/matrix.html | 6 +-- src/opal/web/templates/risks/new.html | 2 +- src/opal/web/templates/setup_profile.html | 4 +- src/opal/web/templates/welcome/setup.html | 8 ++-- 19 files changed, 83 insertions(+), 68 deletions(-) diff --git a/src/opal/web/static/css/main.css b/src/opal/web/static/css/main.css index 3ecdc25..0040c30 100644 --- a/src/opal/web/static/css/main.css +++ b/src/opal/web/static/css/main.css @@ -37,6 +37,21 @@ --space-md: 16px; --space-lg: 24px; --space-xl: 32px; + + /* Modal / overlay */ + --modal-backdrop: rgba(0, 0, 0, 0.8); + + /* Diff view tints (accent-color + low opacity) */ + --diff-added-bg: rgba(74, 222, 128, 0.1); + --diff-removed-bg: rgba(248, 113, 113, 0.1); + --diff-modified-bg: rgba(251, 191, 36, 0.1); + --diff-changed-bg: rgba(251, 191, 36, 0.15); + + /* Z-index scale: base 1, sticky 10, dropdown 100, modal 9000, palette 10000 */ + --z-sticky: 10; + --z-dropdown: 100; + --z-modal: 9000; + --z-palette: 10000; } /* Reset */ @@ -248,7 +263,7 @@ select option { color: var(--text-muted); position: sticky; bottom: 0; - z-index: 10; + z-index: var(--z-sticky); } .footer-status { @@ -532,7 +547,7 @@ select option { left: 0; right: 0; bottom: 0; - z-index: 9999; + z-index: var(--z-palette); display: flex; align-items: flex-start; justify-content: center; @@ -545,7 +560,7 @@ select option { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.8); + background: var(--modal-backdrop); } .cmd-palette-dialog { @@ -554,7 +569,7 @@ select option { max-width: 90vw; background: var(--bg-secondary); border: 1px solid var(--border-color); - z-index: 10000; + z-index: 1; } .cmd-input { @@ -628,7 +643,7 @@ select option { .cmd-group-header { padding: var(--space-xs) var(--space-md); font-family: var(--font-mono); - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; @@ -646,7 +661,7 @@ select option { .cmd-status { font-family: var(--font-mono); - font-size: 0.6875rem; + font-size: 0.75rem; padding: 1px 4px; border: 1px solid var(--text-muted); color: var(--text-muted); @@ -1214,8 +1229,8 @@ select option { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.8); - z-index: 9000; + background: var(--modal-backdrop); + z-index: var(--z-modal); display: none; align-items: flex-start; justify-content: center; @@ -1485,7 +1500,7 @@ body.exec-detail .exec-tab-content > div { } .exec-sidebar-item-gid { - font-size: 0.7rem; + font-size: 0.75rem; color: var(--text-muted); letter-spacing: 0.02em; } @@ -1823,17 +1838,17 @@ a.user-menu-link.active { /* Diff view styles */ .diff-added { - background: rgba(74, 222, 128, 0.1); + background: var(--diff-added-bg); border-left: 3px solid var(--accent-green); } .diff-removed { - background: rgba(248, 113, 113, 0.1); + background: var(--diff-removed-bg); border-left: 3px solid var(--accent-red); } .diff-modified { - background: rgba(251, 191, 36, 0.1); + background: var(--diff-modified-bg); border-left: 3px solid var(--accent-yellow); } @@ -1842,6 +1857,6 @@ a.user-menu-link.active { } .diff-field-changed { - background: rgba(251, 191, 36, 0.15); + background: var(--diff-changed-bg); padding: 1px 4px; } diff --git a/src/opal/web/templates/executions/detail.html b/src/opal/web/templates/executions/detail.html index 0e82971..8b07634 100644 --- a/src/opal/web/templates/executions/detail.html +++ b/src/opal/web/templates/executions/detail.html @@ -42,7 +42,7 @@
-