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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b0d129 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# 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 + +#### Execution viewer +- **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. Multi-photo fields render as `N image(s) (#id, #id, …)`. +- **Issues badge.** The Issues tab label shows the linked-issue count; the old red full-page alert banner is gone. +- **NC step-hold.** Logging a non-conformance against a step automatically puts the step into `ON_HOLD` and shows a banner across the top of the step content listing the open NCs. START/COMPLETE/SKIP are suppressed. The step auto-resumes to `IN_PROGRESS` the moment every linked NC reaches a terminal disposition (`disposition_approved` or `closed`). +- **Execution gating** from the version snapshot. Ops whose prerequisite ops aren't all in a terminal status surface a yellow `PENDING` chip in the sidebar and `PENDING — waiting on OP X, Y` in the main pane; the START API also refuses the transition. + +#### Procedure-template editor +- **Tabbed editor shell** at `/procedures/{id}` — Meta / Operations / Flow / Kit / Outputs / Versions. Mirrors the execution viewer's HTMX-driven tab pattern. +- **Inline step editor.** Each step row in the Operations tab expands to a full editor (title, instructions, duration, sign-off / contingency flags, step-parts table, data-capture schema builder) — no more separate `/steps/{id}/edit` page. The legacy URL redirects to the inline view with the right step pre-expanded. +- **Drag-to-reorder.** Sidebar ops and main-pane sub-steps are drag-reorderable. On drop the API runs the reorder and the OP / sub-step *display numbers* renumber to match (`OP 1`, `OP 2`, …, contingency ops as `C1`, `C2`, …, sub-steps as `.`). +- **Flow tab — operation dependency graph.** Resolve-style node graph: each op is a node auto-laid-out by longest-path topological depth; drag from a node's right-edge output port to another's left-edge input port to add a dependency; hover an edge to surface a red X to remove it. Cycle and self-loop are rejected with clear errors. +- **Data-capture `photo` field type.** Single or multi-image per field — operators upload during execution (mobile triggers the camera natively); thumbnails render inline with a REMOVE button; the schema builder offers an "Allow multiple photos" toggle. +- **Inline images in step instructions.** Drag-drop an image onto the instructions textarea or click `INSERT IMAGE`; the helper uploads to `/api/attachments/upload` (scoped to the procedure for cascade cleanup), then splices `![filename](/api/attachments/N/download)` at the caret. The markdown renders the image inline in both the editor's preview and during execution. + +### 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. +- **Textareas auto-grow with content.** Resize handles removed app-wide; CSS `field-sizing: content` powers modern browsers, a small JS handler covers the rest. +- **Mono/sans rule applied consistently.** Sans for prose (op titles, step titles, login subtitles, narrative); mono retained for chrome and data (IDs, timestamps, status pills, table data). +- **JetBrains Mono and IBM Plex Sans are self-hosted.** One variable-axis woff2 per family (latin subset), served under `/static/fonts/`. No external CDN dependency; visual experience is now identical across macOS / Windows / Linux. +- 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. +- Procedure ops in the editor are now sorted by `order` (the field the reorder API updates) instead of by their display step-number label, so drag-reorder is actually reflected on the next render. +- `/api/attachments/upload` FK fields (`procedure_instance_id`, `step_execution_id`, `issue_id`) are now read from the multipart form body via `Form()`. They were silently dropped before — uploads from the JS layer were never linking to their parent record. +- Sidebar drag-reorder works on `` rows by suppressing native link-drag and text-selection so the pointer-event handler can actually run. + +### Known limitations +- **Inline images in published versions can 404 after the procedure is deleted.** Soft-deleting a procedure cascades attachment rows, so markdown stored in a `ProcedureVersion.content` snapshot referencing those images will render broken thumbnails. Versions are supposed to be immutable; this is a real gap that will be addressed in a follow-up (likely by promoting "delete with versions" to "archive" or by copying image bytes into the snapshot). +- **Sidebar drag-reorder is mouse-only.** Touch / pen pointer support is on the 1.3.x list. +- **`OPAL_manual.md` predates the 1.3.0 UI rewrites.** A docs-only point release will bring it up to date. + +### Internal +- Ruff pass + reformat across `src/`. +- Release CI builds binaries for macOS arm64, macOS x86_64, Linux x86_64, and Windows x86_64. +- Two Alembic migrations: `bb285af00e88` (step_dependency table) and `eb217417d6d2` (attachment.procedure_id column). 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/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/migrations/versions/a310ab9da1d8_add_kind_column_to_attachment.py b/migrations/versions/a310ab9da1d8_add_kind_column_to_attachment.py new file mode 100644 index 0000000..f06d54c --- /dev/null +++ b/migrations/versions/a310ab9da1d8_add_kind_column_to_attachment.py @@ -0,0 +1,36 @@ +"""Add kind column to attachment + +Revision ID: a310ab9da1d8 +Revises: eb217417d6d2 +Create Date: 2026-05-17 06:22:33.821880 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a310ab9da1d8' +down_revision: Union[str, None] = 'eb217417d6d2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('attachment', schema=None) as batch_op: + batch_op.add_column(sa.Column('kind', sa.String(length=20), nullable=True, comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; null = legacy/unscoped")) + batch_op.create_index(batch_op.f('ix_attachment_kind'), ['kind'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('attachment', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_attachment_kind')) + batch_op.drop_column('kind') + + # ### end Alembic commands ### diff --git a/migrations/versions/bb285af00e88_add_step_dependency_table_for_operation_.py b/migrations/versions/bb285af00e88_add_step_dependency_table_for_operation_.py new file mode 100644 index 0000000..5522844 --- /dev/null +++ b/migrations/versions/bb285af00e88_add_step_dependency_table_for_operation_.py @@ -0,0 +1,48 @@ +"""Add step_dependency table for operation-level gating + +Revision ID: bb285af00e88 +Revises: 00d3ac121e5e +Create Date: 2026-05-17 04:51:50.070485 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bb285af00e88' +down_revision: Union[str, None] = '00d3ac121e5e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('step_dependency', + sa.Column('step_id', sa.Integer(), nullable=False, comment='The dependent (gated) step'), + sa.Column('depends_on_step_id', sa.Integer(), nullable=False, comment='The prerequisite step'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['depends_on_step_id'], ['procedure_step.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['step_id'], ['procedure_step.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('step_id', 'depends_on_step_id', name='uq_step_dependency') + ) + with op.batch_alter_table('step_dependency', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_step_dependency_depends_on_step_id'), ['depends_on_step_id'], unique=False) + batch_op.create_index(batch_op.f('ix_step_dependency_step_id'), ['step_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('step_dependency', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_step_dependency_step_id')) + batch_op.drop_index(batch_op.f('ix_step_dependency_depends_on_step_id')) + + op.drop_table('step_dependency') + # ### end Alembic commands ### diff --git a/migrations/versions/eb217417d6d2_add_procedure_id_to_attachment_for_.py b/migrations/versions/eb217417d6d2_add_procedure_id_to_attachment_for_.py new file mode 100644 index 0000000..ca90a52 --- /dev/null +++ b/migrations/versions/eb217417d6d2_add_procedure_id_to_attachment_for_.py @@ -0,0 +1,41 @@ +"""Add procedure_id to attachment for template-level images + +Revision ID: eb217417d6d2 +Revises: bb285af00e88 +Create Date: 2026-05-17 05:47:36.286130 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'eb217417d6d2' +down_revision: Union[str, None] = 'bb285af00e88' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('attachment', schema=None) as batch_op: + batch_op.add_column(sa.Column('procedure_id', sa.Integer(), nullable=True, comment='Template-level scope: inline images in step instructions / procedure description')) + batch_op.create_index(batch_op.f('ix_attachment_procedure_id'), ['procedure_id'], unique=False) + batch_op.create_foreign_key( + 'fk_attachment_procedure_id', 'master_procedure', + ['procedure_id'], ['id'], ondelete='CASCADE', + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('attachment', schema=None) as batch_op: + batch_op.drop_constraint('fk_attachment_procedure_id', type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_attachment_procedure_id')) + batch_op.drop_column('procedure_id') + + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index d5470af..e1cff4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opal" -version = "1.2.3" +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/__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/src/opal/api/routes/attachments.py b/src/opal/api/routes/attachments.py index cf4b5ef..d0f5c37 100644 --- a/src/opal/api/routes/attachments.py +++ b/src/opal/api/routes/attachments.py @@ -3,7 +3,7 @@ import uuid from pathlib import Path -from fastapi import APIRouter, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Form, HTTPException, Query, UploadFile, status from fastapi.responses import FileResponse from pydantic import BaseModel @@ -28,6 +28,8 @@ class AttachmentResponse(BaseModel): procedure_instance_id: int | None step_execution_id: int | None issue_id: int | None = None + procedure_id: int | None = None + kind: str | None = None created_at: str model_config = {"from_attributes": True} @@ -43,6 +45,8 @@ def _attachment_to_response(att: Attachment) -> AttachmentResponse: procedure_instance_id=att.procedure_instance_id, step_execution_id=att.step_execution_id, issue_id=att.issue_id, + procedure_id=att.procedure_id, + kind=att.kind, created_at=att.created_at.isoformat(), ) @@ -52,13 +56,17 @@ async def upload_attachment( db: DbSession, user_id: CurrentUserId, file: UploadFile, - procedure_instance_id: int | None = None, - step_execution_id: int | None = None, - issue_id: int | None = None, + procedure_instance_id: int | None = Form(default=None), + step_execution_id: int | None = Form(default=None), + issue_id: int | None = Form(default=None), + procedure_id: int | None = Form(default=None), + kind: str | None = Form(default=None), ) -> AttachmentResponse: """Upload a file attachment. - Can be linked to a procedure instance, step execution, and/or issue. + Can be linked to a procedure instance, step execution, issue, and/or + procedure template. `procedure_id` scopes inline images used in step + instructions so they're cleaned up when the procedure is deleted. """ settings = get_active_settings() @@ -101,6 +109,20 @@ async def upload_attachment( if not issue: raise HTTPException(status_code=404, detail=f"Issue {issue_id} not found") + if procedure_id: + from opal.db.models import MasterProcedure + + procedure = ( + db.query(MasterProcedure) + .filter( + MasterProcedure.id == procedure_id, + MasterProcedure.deleted_at.is_(None), + ) + .first() + ) + if not procedure: + raise HTTPException(status_code=404, detail=f"Procedure {procedure_id} not found") + # Sanitize filename and generate stored name original_name = file.filename or "unnamed" # Strip path separators and limit length @@ -122,6 +144,8 @@ async def upload_attachment( procedure_instance_id=procedure_instance_id, step_execution_id=step_execution_id, issue_id=issue_id, + procedure_id=procedure_id, + kind=kind, ) db.add(attachment) db.flush() @@ -161,8 +185,11 @@ async def list_attachments( procedure_instance_id: int | None = Query(None), step_execution_id: int | None = Query(None), issue_id: int | None = Query(None), + procedure_id: int | None = Query(None), + kind: str | None = Query(None), ) -> list[AttachmentResponse]: - """List attachments, optionally filtered by instance, step, or issue.""" + """List attachments, optionally filtered by instance, step, issue, + procedure, or kind.""" query = db.query(Attachment) if procedure_instance_id is not None: @@ -171,6 +198,10 @@ async def list_attachments( query = query.filter(Attachment.step_execution_id == step_execution_id) if issue_id is not None: query = query.filter(Attachment.issue_id == issue_id) + if procedure_id is not None: + query = query.filter(Attachment.procedure_id == procedure_id) + if kind is not None: + query = query.filter(Attachment.kind == kind) attachments = query.order_by(Attachment.created_at.desc()).limit(200).all() return [_attachment_to_response(a) for a in attachments] diff --git a/src/opal/api/routes/execution.py b/src/opal/api/routes/execution.py index e345ab2..31f9b57 100644 --- a/src/opal/api/routes/execution.py +++ b/src/opal/api/routes/execution.py @@ -556,6 +556,42 @@ async def start_step( if step_status != StepStatus.PENDING.value: raise HTTPException(status_code=400, detail="Step already started or completed") + # Gating: top-level ops can have prerequisite ops declared on the procedure + # version snapshot. All prereqs must be in a terminal state before this op + # can start. (Sub-steps inside an op execute linearly and have no deps.) + if step_exec.level == 0: + version = ( + db.query(ProcedureVersion).filter(ProcedureVersion.id == instance.version_id).first() + ) + if version is not None: + version_step = next( + (s for s in version.content.get("steps", []) if s.get("order") == step_number), + None, + ) + dep_orders = (version_step or {}).get("depends_on") or [] + if dep_orders: + terminal = { + StepStatus.COMPLETED.value, + StepStatus.SIGNED_OFF.value, + StepStatus.SKIPPED.value, + } + exec_lookup = {se.step_number: se for se in instance.step_executions} + blockers: list[str] = [] + for dep_order in dep_orders: + prereq = exec_lookup.get(dep_order) + if prereq is None: + continue + prereq_status = ( + prereq.status.value if hasattr(prereq.status, "value") else prereq.status + ) + if prereq_status not in terminal: + blockers.append(prereq.step_number_str or str(dep_order)) + if blockers: + raise HTTPException( + status_code=400, + detail="Cannot start: waiting on OP " + ", ".join(blockers), + ) + step_exec.status = StepStatus.IN_PROGRESS step_exec.started_at = datetime.now(UTC) @@ -796,6 +832,12 @@ async def skip_step( step_status = step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status if step_status == StepStatus.COMPLETED.value: raise HTTPException(status_code=400, detail="Cannot skip completed step") + if step_status == StepStatus.ON_HOLD.value: + raise HTTPException( + status_code=400, + detail="Cannot skip a step that is on hold for an open NC; " + "resolve the NC disposition first.", + ) step_exec.status = StepStatus.SKIPPED step_exec.completed_at = datetime.now(UTC) @@ -1033,6 +1075,16 @@ async def log_non_conformance( db.flush() log_create(db, issue, user_id) + + # Put the step on hold until the NC disposition is approved or the issue closed. + step_status_now = ( + step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status + ) + if step_status_now != StepStatus.ON_HOLD.value: + step_old = get_model_dict(step_exec) + step_exec.status = StepStatus.ON_HOLD + log_update(db, step_exec, step_old, user_id) + db.commit() db.refresh(issue) diff --git a/src/opal/api/routes/issues.py b/src/opal/api/routes/issues.py index cc666d6..89ad374 100644 --- a/src/opal/api/routes/issues.py +++ b/src/opal/api/routes/issues.py @@ -8,6 +8,7 @@ from opal.api.deps import CurrentUserId, DbSession from opal.core.audit import get_model_dict, log_create, log_delete, log_update from opal.core.designators import generate_issue_number +from opal.db.models.execution import StepExecution, StepStatus from opal.db.models.issue import ( DispositionType, Issue, @@ -396,12 +397,52 @@ async def update_issue( issue.disposition_approved_by_id = data.disposition_approved_by_id log_update(db, issue, old_values, user_id) + + # Auto-resume step on hold once all linked NCs reach a terminal disposition. + _maybe_resume_step_after_nc_update(db, issue, user_id) + db.commit() db.refresh(issue) return _issue_to_response(issue) +def _maybe_resume_step_after_nc_update(db, issue: "Issue", user_id: int | None) -> None: + """If this NC just reached a terminal state and no other open NCs remain on + its step, pop the step back to IN_PROGRESS.""" + if issue.step_execution_id is None: + return + issue_type = issue.issue_type.value if hasattr(issue.issue_type, "value") else issue.issue_type + if issue_type != IssueType.NON_CONFORMANCE.value: + return + issue_status = issue.status.value if hasattr(issue.status, "value") else issue.status + if issue_status not in (IssueStatus.DISPOSITION_APPROVED.value, IssueStatus.CLOSED.value): + return + + step_exec = db.get(StepExecution, issue.step_execution_id) + if step_exec is None: + return + step_status = step_exec.status.value if hasattr(step_exec.status, "value") else step_exec.status + if step_status != StepStatus.ON_HOLD.value: + return + + remaining = ( + db.query(Issue) + .filter( + Issue.step_execution_id == step_exec.id, + Issue.issue_type == IssueType.NON_CONFORMANCE, + Issue.id != issue.id, + Issue.status.notin_([IssueStatus.DISPOSITION_APPROVED, IssueStatus.CLOSED]), + Issue.deleted_at.is_(None), + ) + .count() + ) + if remaining == 0: + step_old = get_model_dict(step_exec) + step_exec.status = StepStatus.IN_PROGRESS + log_update(db, step_exec, step_old, user_id) + + @router.delete("/{issue_id}", status_code=204) async def delete_issue( issue_id: int, diff --git a/src/opal/api/routes/procedures.py b/src/opal/api/routes/procedures.py index 85d35ce..be5c90b 100644 --- a/src/opal/api/routes/procedures.py +++ b/src/opal/api/routes/procedures.py @@ -17,6 +17,7 @@ ProcedureStep, ProcedureType, ProcedureVersion, + StepDependency, StepKit, UsageType, ) @@ -560,6 +561,35 @@ async def delete_step( db.commit() +def _renumber_procedure_steps(steps: list[ProcedureStep]) -> None: + """Recompute every step.step_number for a procedure based on the current + `order` values. Normal top-level ops → "1", "2", ...; contingency top-level + ops → "C1", "C2", ...; sub-steps → ".". + Mutates the steps in place; caller is responsible for committing.""" + top_level = sorted([s for s in steps if s.parent_step_id is None], key=lambda s: s.order) + normal_idx = 0 + contingency_idx = 0 + for op in top_level: + if op.is_contingency: + contingency_idx += 1 + op.step_number = f"C{contingency_idx}" + else: + normal_idx += 1 + op.step_number = str(normal_idx) + # Sub-steps inherit their parent's new prefix. + children_by_parent: dict[int, list[ProcedureStep]] = {} + for s in steps: + if s.parent_step_id is not None: + children_by_parent.setdefault(s.parent_step_id, []).append(s) + for parent_id, kids in children_by_parent.items(): + parent = next((s for s in steps if s.id == parent_id), None) + if parent is None: + continue + kids.sort(key=lambda s: s.order) + for i, kid in enumerate(kids, start=1): + kid.step_number = f"{parent.step_number}.{i}" + + @router.post("/{procedure_id}/steps/reorder", response_model=list[StepSchema]) async def reorder_steps( procedure_id: int, @@ -587,6 +617,9 @@ async def reorder_steps( for i, step_id in enumerate(data.step_ids, start=1): step_map[step_id].order = i + # Renumber step_number labels to follow the new order. + _renumber_procedure_steps(steps) + db.commit() # Return updated steps in order @@ -599,6 +632,132 @@ async def reorder_steps( return [StepSchema.model_validate(s) for s in steps] +# ============ Operation dependencies ============ + + +class StepDependencyPayload(BaseModel): + """Replace a step's full prerequisite list (step.ids of other ops).""" + + depends_on: list[int] = Field(default_factory=list) + + +@router.get("/{procedure_id}/dependencies") +async def list_dependencies(procedure_id: int, db: DbSession) -> list[dict]: + """Return all op-level dependency edges for a procedure as + [{step_id, depends_on_step_id}, ...].""" + procedure = ( + db.query(MasterProcedure) + .filter(MasterProcedure.id == procedure_id, MasterProcedure.deleted_at.is_(None)) + .first() + ) + if not procedure: + raise HTTPException(status_code=404, detail="Procedure not found") + + rows = ( + db.query(StepDependency) + .join(ProcedureStep, StepDependency.step_id == ProcedureStep.id) + .filter(ProcedureStep.procedure_id == procedure_id) + .all() + ) + return [{"step_id": d.step_id, "depends_on_step_id": d.depends_on_step_id} for d in rows] + + +@router.put("/{procedure_id}/steps/{step_id}/dependencies") +async def set_step_dependencies( + procedure_id: int, + step_id: int, + data: StepDependencyPayload, + db: DbSession, + user_id: CurrentUserId, +) -> dict: + """Replace the prerequisite set for an operation atomically. Validates + same-procedure scope, op-level only, no self-loops, no cycles.""" + procedure = ( + db.query(MasterProcedure) + .filter(MasterProcedure.id == procedure_id, MasterProcedure.deleted_at.is_(None)) + .first() + ) + if not procedure: + raise HTTPException(status_code=404, detail="Procedure not found") + + target = ( + db.query(ProcedureStep) + .filter(ProcedureStep.id == step_id, ProcedureStep.procedure_id == procedure_id) + .first() + ) + if not target: + raise HTTPException(status_code=404, detail="Step not found") + if target.parent_step_id is not None: + raise HTTPException( + status_code=400, detail="Dependencies are only allowed on top-level operations" + ) + + requested = list(dict.fromkeys(data.depends_on)) # dedupe, preserve order + if step_id in requested: + raise HTTPException(status_code=400, detail="A step cannot depend on itself") + + if requested: + prereqs = db.query(ProcedureStep).filter(ProcedureStep.id.in_(requested)).all() + prereq_map = {p.id: p for p in prereqs} + for pid in requested: + p = prereq_map.get(pid) + if p is None or p.procedure_id != procedure_id: + raise HTTPException(status_code=400, detail=f"Step {pid} is not in this procedure") + if p.parent_step_id is not None: + raise HTTPException( + status_code=400, + detail=f"Step {pid} is a sub-step; only operations can be prerequisites", + ) + + # Cycle check: simulate the new edge set, then DFS from target reaching itself. + # Existing edges (excluding outgoing-from-step_id which we're replacing). + existing = db.query(StepDependency).all() + # In the dependency graph, an edge A->B means "B depends on A". + # We model it as adjacency from prereq -> dependent so we walk reachability forward. + adj: dict[int, set[int]] = {} + for d in existing: + if d.step_id == step_id: + continue + adj.setdefault(d.depends_on_step_id, set()).add(d.step_id) + for pid in requested: + adj.setdefault(pid, set()).add(step_id) + + # If we can reach any prereq from step_id, a cycle exists. + def reachable_from(start: int) -> set[int]: + seen = {start} + stack = [start] + while stack: + cur = stack.pop() + for nxt in adj.get(cur, ()): + if nxt not in seen: + seen.add(nxt) + stack.append(nxt) + return seen + + downstream = reachable_from(step_id) + cycle_prereqs = downstream & set(requested) + if cycle_prereqs: + raise HTTPException( + status_code=400, + detail=f"Cycle: step {step_id} already gates {sorted(cycle_prereqs)}; " + "cannot depend on them.", + ) + + # Apply: delete current deps for step_id, insert new ones, audit log each. + current = db.query(StepDependency).filter(StepDependency.step_id == step_id).all() + for d in current: + log_delete(db, d, user_id) + db.delete(d) + db.flush() + for pid in requested: + new_dep = StepDependency(step_id=step_id, depends_on_step_id=pid) + db.add(new_dep) + db.flush() + log_create(db, new_dep, user_id) + db.commit() + return {"step_id": step_id, "depends_on": requested} + + # ============ Versions ============ @@ -643,6 +802,17 @@ async def publish_version( for sk in all_step_kits: step_kit_map.setdefault(sk.step_id, []).append(sk) + # Bulk-load step dependencies; emit deps as a list of prerequisite `order` + # values per step so the execution engine can resolve them against the + # frozen snapshot without joining tables. + all_deps = db.query(StepDependency).filter(StepDependency.step_id.in_(step_ids)).all() + step_id_to_order = {s.id: s.order for s in steps} + depends_on_map: dict[int, list[int]] = {} + for d in all_deps: + prereq_order = step_id_to_order.get(d.depends_on_step_id) + if prereq_order is not None: + depends_on_map.setdefault(d.step_id, []).append(prereq_order) + # Create snapshot with hierarchical structure def step_to_dict(step: ProcedureStep) -> dict: return { @@ -658,6 +828,7 @@ def step_to_dict(step: ProcedureStep) -> dict: "requires_signoff": step.requires_signoff, "estimated_duration_minutes": step.estimated_duration_minutes, "workcenter_id": step.workcenter_id, + "depends_on": sorted(depends_on_map.get(step.id, [])), "step_kit": [ { "part_id": sk.part_id, diff --git a/src/opal/config.py b/src/opal/config.py index ea6cc75..030952d 100644 --- a/src/opal/config.py +++ b/src/opal/config.py @@ -115,7 +115,20 @@ def onshape_enabled(self) -> bool: description="Maximum upload file size in bytes", ) allowed_mime_types: str = Field( - default="image/jpeg,image/png,image/gif,application/pdf,text/plain,text/csv", + default=( + "image/jpeg,image/png,image/gif,image/webp,image/svg+xml," + "application/pdf,text/plain,text/csv,text/markdown," + "application/msword," + "application/vnd.openxmlformats-officedocument.wordprocessingml.document," + "application/vnd.ms-excel," + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet," + "application/vnd.ms-powerpoint," + "application/vnd.openxmlformats-officedocument.presentationml.presentation," + "image/vnd.dwg,application/acad,application/x-acad," + "model/step+xml,application/step,application/x-step," + "model/stl,application/sla,application/vnd.ms-pki.stl," + "application/zip,application/json" + ), description="Comma-separated list of allowed MIME types", ) diff --git a/src/opal/db/models/__init__.py b/src/opal/db/models/__init__.py index 6c3570a..ef381ce 100644 --- a/src/opal/db/models/__init__.py +++ b/src/opal/db/models/__init__.py @@ -24,6 +24,7 @@ ProcedureOutput, ProcedureStep, ProcedureVersion, + StepDependency, StepKit, ) from opal.db.models.purchase import Purchase, PurchaseLine @@ -62,6 +63,7 @@ "ReferenceType", "Risk", "RiskReference", + "StepDependency", "StepExecution", "StepKit", "StockTestResult", diff --git a/src/opal/db/models/attachment.py b/src/opal/db/models/attachment.py index db5066d..db738bb 100644 --- a/src/opal/db/models/attachment.py +++ b/src/opal/db/models/attachment.py @@ -17,8 +17,14 @@ class Attachment(Base, IdMixin, TimestampMixin): ) mime_type: Mapped[str] = mapped_column(String(100), nullable=False) size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + kind: Mapped[str | None] = mapped_column( + String(20), + nullable=True, + index=True, + comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; null = legacy/unscoped", + ) - # Optional links - attachment can belong to instance, step, issue, or neither + # Optional links - attachment can belong to instance, step, issue, procedure, or neither procedure_instance_id: Mapped[int | None] = mapped_column( ForeignKey("procedure_instance.id", ondelete="CASCADE"), nullable=True, index=True ) @@ -28,6 +34,12 @@ class Attachment(Base, IdMixin, TimestampMixin): issue_id: Mapped[int | None] = mapped_column( ForeignKey("issue.id", ondelete="CASCADE"), nullable=True, index=True ) + procedure_id: Mapped[int | None] = mapped_column( + ForeignKey("master_procedure.id", ondelete="CASCADE"), + nullable=True, + index=True, + comment="Template-level scope: inline images in step instructions / procedure description", + ) # Relationships procedure_instance: Mapped["ProcedureInstance | None"] = relationship( diff --git a/src/opal/db/models/execution.py b/src/opal/db/models/execution.py index dc0fbec..bab08c4 100644 --- a/src/opal/db/models/execution.py +++ b/src/opal/db/models/execution.py @@ -24,6 +24,7 @@ class StepStatus(str, Enum): PENDING = "pending" IN_PROGRESS = "in_progress" + ON_HOLD = "on_hold" # NC logged; blocked until all open NC dispositions terminal COMPLETED = "completed" # Work done (leaf steps or sub-steps) AWAITING_SIGNOFF = "awaiting_signoff" # Parent OP waiting for sign-off SIGNED_OFF = "signed_off" # Parent OP signed off diff --git a/src/opal/db/models/procedure.py b/src/opal/db/models/procedure.py index d2c0106..ff8d9a2 100644 --- a/src/opal/db/models/procedure.py +++ b/src/opal/db/models/procedure.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Any -from sqlalchemy import JSON, ForeignKey, Integer, Numeric, String, Text +from sqlalchemy import JSON, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from opal.db.base import Base, IdMixin, SoftDeleteMixin, TimestampMixin @@ -246,3 +246,31 @@ class StepKit(Base, IdMixin, TimestampMixin): def __repr__(self) -> str: return f"" + + +class StepDependency(Base, IdMixin, TimestampMixin): + """Operation-level prerequisite: `step_id` cannot start until + `depends_on_step_id` reaches a terminal status. Both sides must be + top-level ops (parent_step_id IS NULL) of the same procedure.""" + + __tablename__ = "step_dependency" + __table_args__ = (UniqueConstraint("step_id", "depends_on_step_id", name="uq_step_dependency"),) + + step_id: Mapped[int] = mapped_column( + ForeignKey("procedure_step.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="The dependent (gated) step", + ) + depends_on_step_id: Mapped[int] = mapped_column( + ForeignKey("procedure_step.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="The prerequisite step", + ) + + def __repr__(self) -> str: + return ( + f"" + ) 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) 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..c306f83 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,26 @@ 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/src/opal/web/routes.py b/src/opal/web/routes.py index f7e32ee..dc7d97a 100644 --- a/src/opal/web/routes.py +++ b/src/opal/web/routes.py @@ -1154,9 +1154,19 @@ async def procedures_new(request: Request, db: DbSession) -> HTMLResponse: return templates.TemplateResponse("procedures/new.html", context) +_PROCEDURE_TABS = ("meta", "operations", "flow", "kit", "outputs", "versions") + + @router.get("/procedures/{procedure_id}", response_class=HTMLResponse) -async def procedures_detail(request: Request, db: DbSession, procedure_id: int) -> HTMLResponse: - """Procedure detail page.""" +async def procedures_detail( + request: Request, + db: DbSession, + procedure_id: int, + tab: str = "meta", + op: int | None = None, + step: int | None = None, +) -> HTMLResponse: + """Procedure detail / editor page.""" procedure = ( db.query(MasterProcedure) .filter(MasterProcedure.id == procedure_id, MasterProcedure.deleted_at.is_(None)) @@ -1259,13 +1269,76 @@ async def procedures_detail(request: Request, db: DbSession, procedure_id: int) else: ops.append(step_data) - # Sort ops by step_number - ops.sort(key=lambda x: int(x["step"].step_number) if x["step"].step_number.isdigit() else 0) - contingency_ops.sort(key=lambda x: x["step"].step_number) + # Sort by `order` (the sequence field the reorder API updates). `step_number` + # is a stable display label and never changes when ops are rearranged. + ops.sort(key=lambda x: x["step"].order) + contingency_ops.sort(key=lambda x: x["step"].order) context["ops"] = ops context["contingency_ops"] = contingency_ops + # Validate tab + pick selected op for the Operations tab. + context["tab"] = tab if tab in _PROCEDURE_TABS else "meta" + all_ops = ops + contingency_ops + valid_op_orders = {o["step"].order for o in all_ops} + + def _default_op_order() -> int | None: + if not all_ops: + return None + return all_ops[0]["step"].order + + context["selected_op_order"] = op if op in valid_op_orders else _default_op_order() + context["selected_step_id"] = step + + # Per-step kit lookup keyed by step.id for the inline editor. + from opal.db.models.procedure import ProcedureStep, StepDependency, StepKit + + step_kit_rows = ( + db.query(StepKit) + .join(ProcedureStep, StepKit.step_id == ProcedureStep.id) + .filter(ProcedureStep.procedure_id == procedure_id) + .all() + ) + step_kit_by_step: dict[int, list[dict]] = {} + for sk in step_kit_rows: + step_kit_by_step.setdefault(sk.step_id, []).append( + { + "id": sk.id, + "part_id": sk.part_id, + "part_name": sk.part.name, + "quantity_required": float(sk.quantity_required), + "usage_type": sk.usage_type.value + if hasattr(sk.usage_type, "value") + else sk.usage_type, + "notes": sk.notes, + } + ) + context["step_kit_by_step"] = step_kit_by_step + + # Dependency edges for the Flow tab — only top-level op-to-op edges. + dep_rows = ( + db.query(StepDependency) + .join(ProcedureStep, StepDependency.step_id == ProcedureStep.id) + .filter(ProcedureStep.procedure_id == procedure_id) + .all() + ) + context["dependencies"] = [ + {"step_id": d.step_id, "depends_on_step_id": d.depends_on_step_id} for d in dep_rows + ] + + # Reference documents (drawings, PDFs, datasheets) attached at template level. + from opal.db.models.attachment import Attachment + + context["reference_attachments"] = ( + db.query(Attachment) + .filter( + Attachment.procedure_id == procedure_id, + Attachment.kind == "reference", + ) + .order_by(Attachment.original_filename.asc()) + .all() + ) + return templates.TemplateResponse("procedures/detail.html", context) @@ -1289,62 +1362,31 @@ async def procedures_edit(request: Request, db: DbSession, procedure_id: int) -> return templates.TemplateResponse("procedures/edit.html", context) -@router.get("/procedures/{procedure_id}/steps/{step_id}/edit", response_class=HTMLResponse) -async def procedures_step_edit( - request: Request, db: DbSession, procedure_id: int, step_id: int -) -> HTMLResponse: - """Edit a procedure step.""" +@router.get("/procedures/{procedure_id}/steps/{step_id}/edit") +async def procedures_step_edit(db: DbSession, procedure_id: int, step_id: int) -> RedirectResponse: + """Redirect the deep-link step editor URL to the inline editor in the + Operations tab. Keeps old bookmarks working.""" from opal.db.models.procedure import ProcedureStep - procedure = ( - db.query(MasterProcedure) - .filter(MasterProcedure.id == procedure_id, MasterProcedure.deleted_at.is_(None)) - .first() - ) - if not procedure: - return templates.TemplateResponse( - "errors/404.html", - {"request": request, "message": f"Procedure {procedure_id} not found"}, - status_code=404, - ) - step = ( db.query(ProcedureStep) .filter(ProcedureStep.id == step_id, ProcedureStep.procedure_id == procedure_id) .first() ) - if not step: - return templates.TemplateResponse( - "errors/404.html", - {"request": request, "message": f"Step {step_id} not found"}, - status_code=404, - ) - - context = get_base_context(request, db, f"Edit Step - {procedure.name} - OPAL") - context["procedure"] = procedure - context["step"] = step - - # Get step kit items - from opal.db.models.procedure import StepKit - - step_kit_items = db.query(StepKit).filter(StepKit.step_id == step_id).all() - context["step_kit"] = [ - { - "id": sk.id, - "part_id": sk.part_id, - "part_name": sk.part.name, - "quantity_required": float(sk.quantity_required), - "usage_type": sk.usage_type.value if hasattr(sk.usage_type, "value") else sk.usage_type, - "notes": sk.notes, - } - for sk in step_kit_items - ] - - # Get parts for dropdown - parts = db.query(Part).filter(Part.deleted_at.is_(None)).order_by(Part.name).all() - context["parts"] = parts + parent_order = None + if step is not None: + if step.parent_step_id is not None: + parent = db.query(ProcedureStep).filter(ProcedureStep.id == step.parent_step_id).first() + if parent is not None: + parent_order = parent.order + else: + parent_order = step.order - return templates.TemplateResponse("procedures/step_edit.html", context) + target = f"/procedures/{procedure_id}?tab=operations" + if parent_order is not None: + target += f"&op={parent_order}" + target += f"&step={step_id}" + return RedirectResponse(url=target, status_code=302) @router.get("/procedures/{proc_id}/versions/{v1_id}/diff/{v2_id}", response_class=HTMLResponse) @@ -1605,8 +1647,17 @@ 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) -> 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() if not instance: @@ -1704,6 +1755,25 @@ 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() + + 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} @@ -1813,6 +1883,100 @@ def sort_key_contingency(x): ) context["linked_issues"] = linked_issues + # Step-hold lookup: open NCs that put their step on hold, keyed by step_execution_id. + holding_ncs_by_step: dict[int, list[Issue]] = {} + for iss in linked_issues: + iss_type = iss.issue_type.value if hasattr(iss.issue_type, "value") else iss.issue_type + iss_status = iss.status.value if hasattr(iss.status, "value") else iss.status + if ( + iss_type == "non_conformance" + and iss.step_execution_id + and iss_status not in ("disposition_approved", "closed") + ): + holding_ncs_by_step.setdefault(iss.step_execution_id, []).append(iss) + context["step_holding_ncs"] = holding_ncs_by_step + + # Gating lookup: top-level ops whose prerequisite ops haven't reached + # a terminal status yet. Keyed by op.order → list of blocking step_number_str. + terminal_step_statuses = {"completed", "signed_off", "skipped"} + exec_by_order = {se.step_number: se for se in instance.step_executions} + gated_ops_by_order: dict[int, list[str]] = {} + version_steps_for_gating = version.content.get("steps", []) if version else [] + for vs in version_steps_for_gating: + if vs.get("level", 0) != 0: + continue + deps = vs.get("depends_on") or [] + if not deps: + continue + blockers: list[str] = [] + for dep_order in deps: + prereq = exec_by_order.get(dep_order) + if prereq is None: + continue + prereq_status = ( + prereq.status.value if hasattr(prereq.status, "value") else prereq.status + ) + if prereq_status not in terminal_step_statuses: + blockers.append(prereq.step_number_str or str(dep_order)) + if blockers: + gated_ops_by_order[vs["order"]] = blockers + context["gated_ops_by_order"] = gated_ops_by_order + + # Reference documents on the procedure template (eng drawings, PDFs, etc.). + from opal.db.models.attachment import Attachment as _Attachment + + context["reference_attachments"] = ( + db.query(_Attachment) + .filter( + _Attachment.procedure_id == instance.procedure_id, + _Attachment.kind == "reference", + ) + .order_by(_Attachment.original_filename.asc()) + .all() + ) + + # 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 = "—" + elif isinstance(value, list): + # Multi-photo (and any future list-valued capture) — render + # as "N image(s) (#12, #17)" rather than leaking the raw + # storage format "[12, 17]" into the audit table. + if value: + display = f"{len(value)} image(s) (" + ", ".join(f"#{v}" for v in value) + ")" + else: + 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 330d67e..1dfc9c8 100644 --- a/src/opal/web/static/css/main.css +++ b/src/opal/web/static/css/main.css @@ -3,6 +3,23 @@ * No rounded corners, shadows, or gradients */ +/* Self-hosted Google Fonts (variable, latin subset). No external requests. */ +@font-face { + font-family: 'IBM Plex Sans'; + src: url('/static/fonts/ibm-plex-sans-latin.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/static/fonts/jetbrains-mono-latin.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + :root { /* Colors */ --bg-primary: #0a0a0a; @@ -28,8 +45,8 @@ --status-info: var(--accent-orange); /* Fonts */ - --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace; - --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace; + --font-sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; /* Spacing */ --space-xs: 4px; @@ -37,6 +54,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 */ @@ -246,6 +278,9 @@ select option { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-muted); + position: sticky; + bottom: 0; + z-index: var(--z-sticky); } .footer-status { @@ -431,6 +466,14 @@ select option { border: 1px solid var(--border-color); } +textarea { + resize: none; + overflow: hidden; + /* Modern browsers auto-grow with content; older browsers fall back to the + JS autoresize handler in layouts/base.html. */ + field-sizing: content; +} + .form-input:focus, .form-textarea:focus, .form-select:focus { @@ -529,7 +572,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; @@ -542,7 +585,7 @@ select option { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.8); + background: var(--modal-backdrop); } .cmd-palette-dialog { @@ -551,7 +594,7 @@ select option { max-width: 90vw; background: var(--bg-secondary); border: 1px solid var(--border-color); - z-index: 10000; + z-index: 1; } .cmd-input { @@ -625,7 +668,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; @@ -643,7 +686,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); @@ -1211,8 +1254,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; @@ -1318,21 +1361,113 @@ 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, +body.procedure-detail .main { + padding-top: 0; +} + +body.exec-detail .exec-tabs, +body.procedure-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, +body.procedure-detail { + height: 100vh; + overflow: hidden; +} + +body.exec-detail .main, +body.procedure-detail .main { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding-bottom: 0; +} + +body.exec-detail .exec-tab-content, +body.procedure-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, +body.procedure-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; } @@ -1350,14 +1485,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 +1508,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.75rem; + color: var(--text-muted); + letter-spacing: 0.02em; +} + .exec-sidebar-progress { margin-left: auto; font-size: 0.75rem; @@ -1399,6 +1564,157 @@ select option { border-color: var(--accent-yellow); } +.exec-sidebar-status--on_hold { + background: var(--accent-yellow); + border-color: var(--accent-yellow); +} + +.step-hold-banner { + margin-bottom: var(--space-md); + padding: var(--space-sm) var(--space-md); + background: var(--diff-modified-bg); + border-left: 3px solid var(--accent-yellow); + font-family: var(--font-mono); + font-size: 0.875rem; + color: var(--text-primary); +} + +.step-hold-banner strong { + color: var(--accent-yellow); + letter-spacing: 0.05em; +} + +/* Flow tab — operation-dependency node graph */ +.flow-canvas { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + width: 100%; + height: 60vh; + min-height: 400px; + overflow: auto; + user-select: none; +} + +.flow-node-rect { + fill: var(--bg-tertiary); + stroke: var(--border-color); + stroke-width: 1; +} + +.flow-node-rect--contingency { + stroke: var(--accent-yellow); + stroke-dasharray: 4 2; +} + +.flow-node-number { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + fill: var(--accent-orange); +} + +.flow-node-title { + font-family: var(--font-sans); + font-size: 13px; + fill: var(--text-primary); +} + +.flow-node-port { + fill: transparent; + cursor: crosshair; +} + +.flow-node-port-out:hover, +.flow-node-port-in:hover { + fill: var(--accent-orange); + fill-opacity: 0.2; +} + +.flow-edge { + stroke: var(--text-secondary); + stroke-width: 1.5; + fill: none; +} + +.flow-edge-delete { + opacity: 0; + transition: opacity 0.1s; +} + +.flow-edge-delete:hover { + opacity: 1; +} + +#flow-edges:hover .flow-edge-delete { + opacity: 1; +} + +/* Drag-to-reorder visual feedback for sidebar / sub-step rows */ +.drop-indicator-above { + border-top: 2px solid var(--accent-orange) !important; +} + +.drop-indicator-below { + border-bottom: 2px solid var(--accent-orange) !important; +} + +/* Step photo capture (data-capture field type = photo) */ +.step-photo-capture { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.step-photo-gallery { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.step-photo-gallery:empty { + display: none; +} + +.step-photo-item { + display: flex; + flex-direction: column; + gap: var(--space-xs); + align-items: flex-start; +} + +.step-photo-thumb { + max-width: 240px; + max-height: 240px; + border: 1px solid var(--border-color); + display: block; +} + +.step-photo-input { + font-family: var(--font-mono); + font-size: 0.875rem; +} + +/* Markdown image upload — drag-drop target highlight + toolbar */ +.md-toolbar { + display: flex; + gap: var(--space-sm); + align-items: center; + margin-bottom: var(--space-xs); +} + +textarea.md-image-target.md-drop-target { + outline: 2px dashed var(--accent-orange); + outline-offset: -2px; +} + +/* Inline images rendered from markdown — keep them readable but not full-bleed. */ +.markdown-content img { + max-width: 100%; + height: auto; + border: 1px solid var(--border-color); + margin: var(--space-sm) 0; +} + .exec-sidebar-status--skipped { background: var(--text-muted); border-color: var(--text-muted); @@ -1451,6 +1767,7 @@ select option { } .op-title { + font-family: var(--font-sans); font-weight: 600; } @@ -1508,6 +1825,7 @@ select option { } .step-title { + font-family: var(--font-sans); color: var(--text-primary); } @@ -1704,17 +2022,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); } @@ -1723,6 +2041,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/static/fonts/ibm-plex-sans-latin.woff2 b/src/opal/web/static/fonts/ibm-plex-sans-latin.woff2 new file mode 100644 index 0000000..b757bc5 Binary files /dev/null and b/src/opal/web/static/fonts/ibm-plex-sans-latin.woff2 differ diff --git a/src/opal/web/static/fonts/jetbrains-mono-latin.woff2 b/src/opal/web/static/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/src/opal/web/static/fonts/jetbrains-mono-latin.woff2 differ diff --git a/src/opal/web/templates/executions/detail.html b/src/opal/web/templates/executions/detail.html index 9f47f89..f52d0a4 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,640 +12,37 @@ {% 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 -
-
-
-
-
-
-
-
- - -
- -
-
OPERATIONS
- {% for op_data in ops %} - {% set op = op_data.step %} - - OP {{ op.step_number }} - {{ op_data.completed_steps }}/{{ op_data.total_steps }} - - - {% 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 }} - - - {% endfor %} - {% endif %} -
- - -
- {% for op_data in ops + contingency_ops %} - {% set op = op_data.step %} -
-
- 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 %} -
- {% endfor %} -
-
-{% 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' %}
-