Compare commits

...

7 Commits

Author SHA1 Message Date
a32c275f76 Rewrite DEPLOYMENT.md for the SQL-backed Docker deployment
Replaces the stale non-Docker/systemd guide with an admin-facing, start-to-
finish guide for the actual stack (nginx serving html/, FastAPI api, Postgres
db). Covers prerequisites (external proxy network), the root .env credentials,
reverse-proxy wiring, bring-up, and verification. Adds the current data model
(projects + project_id/parent_id/issued_at columns), the full endpoint list, a
"what's stored in SQL today vs Phase 2" table, backups, and the schema-migration
caveat (create_all adds tables, not columns). Points to server/README.md for the
deep container reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:27:38 -07:00
e5f77846ad GUI polish: Help section, tooltips, sticky save bar + section nav, dashboard filters
- Help: shared help.js injects a Help modal (workflow + key concepts) and the
  .help-tip tooltip component. " Help" added to the suite header and home nav.
- Tooltips: ⓘ hover hints on the trickiest fields (WP number auto-build,
  disciplines, scope/split, constraints, materials-by-discipline, discipline
  strategy, split threshold).
- WP creator: sticky section-nav jump chips at the top and an always-visible
  sticky save bar (Save Draft / Save & View) showing live release readiness.
- Dashboard: metric cards (Release-ready / On hold / Overdue / Total) and the
  status chips are now clickable filters for the board.
- Consistent colored status pills in the dashboard board and the saved list.

Theme unification (home Carbon vs tools palette) intentionally left for a
separate pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:37:01 -07:00
561d4f2408 Menu/UX cleanup: cost codes, Duplicate WP, condensed toolbars, Dashboard tab
- Cost codes: drop everything after 4990 (material/quality/admin codes removed).
- WP creator: add "Duplicate" (asks how many copies; each a clean Draft with
  unique number/subject, approvals & closeout cleared). When embedded the WP
  toolbar now shows only New + Duplicate.
- Condense duplicated toolbars: the embedded creator's Usage Data, Load Example/
  Sample SOP, Comments, View SOP and Dashboard buttons are hidden; the suite's
  top-right "Usage Logs" and "Load Sample" are the single instances.
- Load Sample is context-aware: SOP tab loads the sample SOP, WP/Dashboard tab
  loads the example Work Package in the creator.
- Dashboard moved to a nav-tab next to SOP Configuration / Work Package Creation.
- Remove "Prime Controls" branding from the embedded WP menu.
- Remove "Bill Clarida" from the example WP distribution and the step-comments
  name placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:36:14 -07:00
a18ae487f6 Fix SOP→home flow, stop sample-data fallback for real projects, add custom WP types
- SOP Complete now returns to the project home page after the confirmation
  popup (instead of staying on the SOP tab), and stamps projectId onto the SOP.
- WP creator no longer substitutes the Micron SAMPLE_SOP when a project is
  active but its SOP isn't found — it shows a "complete the SOP first" empty
  state instead. Sample is only used for a standalone (no-project) preview.
  This was the source of "loaded with sample data I didn't select."
- SOP WP Types step gains "+ Add Custom Type" (editable name + remove);
  blank-named custom types are dropped from the generated SOP. Custom types
  round-trip via the saved state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:29:14 -07:00
68c1c803d6 WP size: preset dropdown that sets the split threshold + show band in creator
Replaces the free-text "Typical WP Size" with a preset dropdown (Small /
Standard / Large / Custom). Choosing a preset auto-fills the max-hours split
threshold (still editable). The chosen band is now surfaced next to Est. Hrs
in the creator's size hint (not just the View-SOP modal), alongside the
threshold/over-threshold warning. Sample SOP aligned to Standard (80 hrs).
repopulateForm preserves a non-preset saved value as a dropdown option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:17:18 -07:00
e5c450597a Isolate SOP/WP data per project (namespaced localStorage)
SOP and Work Package localStorage keys are now namespaced by the active
project via ProjectData.key(base) -> base+'__'+<projectId> (SK() in the
suite, wpKey() in the creator), so switching projects shows that project's
own SOP and packages. project-data.js runs a one-time discard of the legacy
un-namespaced keys (guarded by wp_ns_migrated_v1), per the chosen approach.

Active project is resolved before the store loads in both the suite and the
creator so namespaced keys resolve correctly, including standalone deep-links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:46:59 -07:00
3c40b58ff8 Add multi-project support: projects entity, picker home page, project context
Projects become the top-level container; SOPs and Work Packages belong to one.

Backend:
- New projects table + CRUD (/api/projects).
- sops.project_id (FK, cascade) and work_packages.project_id added;
  list/latest/metrics endpoints accept a project_id filter.

Front end (now under html/):
- project-data.js: shared API-first ProjectData adapter with localStorage
  fallback + active-project helpers.
- Home page: removed "About This Suite"; added a Project picker (create /
  use sample / select). Tool cards stay hidden until a project is active and
  carry &project=<id>; hero shows the active project.
- Suite reads ?project, resolves it, shows it in the header, and prefills the
  SOP project fields; passes &project into the WP-creator iframe.
- WP creator stamps projectId onto saved packages.

SOP/WP localStorage is not yet namespaced per project (next step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:39:45 -07:00
12 changed files with 1009 additions and 174 deletions

View File

@@ -1,81 +1,189 @@
# Deployment # Deployment
The Work Package Suite has two parts: Audience: the IT admin standing this up inside the firewall. This covers the
**SQL-backed deployment** — NGINX serving the static front end and a Python API
backed by **PostgreSQL**.
- a **static front end** (plain HTML/CSS/JS — no build step), and The repo already contains everything needed to run it as a Docker stack:
- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project `Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in
SOPs, Work Packages, and comments so they are shared across users instead of `html/`, and the API in `server/`. The detailed container reference (endpoints,
living in each person's browser. password rotation, day-to-day commands) lives in
[`server/README.md`](server/README.md) — this doc is the start-to-finish guide.
``` ```
browser → NGINX ──serves──> static site (index.html, …) [ your TLS reverse proxy / traefik ] ← HTTPS terminates here
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL │ (external "proxy" network)
┌────▼────┐ internal network ┌──────────┐ ┌────────────┐
browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │
│ (html/) │ │ FastAPI │ │ (db) │
└─────────┘ └──────────┘ └────────────┘
``` ```
Everything runs inside your firewall; the app makes **no outbound internet Everything runs inside your firewall; the app makes **no outbound internet
calls** (the logo and scripts are local and the old Google-Fonts dependency was calls** (logo and scripts are local).
removed).
## 1. Front end (NGINX) > **Architecture note:** all static files live under **`html/`** and are *baked
> into the nginx image* at build time (not bind-mounted). So after any front-end
> change you rebuild the `webserver` image (see *Updating* below). The API image
> is built from the root `Dockerfile`.
Copy the project files to a web root and serve them over HTTPS. The provided ---
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package ## 1. Prerequisites
Creator (`<iframe>`) and any browser-side caching behave reliably.
## 2. API + database - A Linux host with **Docker** and **Docker Compose v2** (`docker compose …`).
- An external Docker network named `proxy` that your TLS-terminating reverse
proxy also sits on (the compose file marks it `external: true`):
```bash
docker network create proxy
```
If you don't run a separate reverse proxy, you can instead publish the nginx
container's port 80 directly (see the note in step 4) and terminate TLS there.
- The repository checked out on the host.
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in ## 2. Create the database credentials (`.env`)
[`server/README.md`](server/README.md). In short:
1. Create the `wpsuite` Postgres database/user. Create a file named `.env` in the **project root** (same folder as
2. `pip install -r server/requirements.txt` into a venv. `docker-compose.yml`). It is git-ignored and must never be committed.
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
4. Tables are created automatically on first start.
Interactive API docs are at `/api/docs` once it's running. ```bash
# .env — project root
POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>
## 3. Comments / feedback # Must match the POSTGRES_* values above. Host is the compose service name "db".
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
posts to `/api/feedback`, which the API stores in the `comments` table. The
**Export / Import** buttons remain as an offline fallback — a reviewer can export
a JSON file and someone can import/merge it — but with the API running, comments
are collected centrally with no manual steps.
> The earlier Power Automate route is **no longer needed** — comments go straight
> to Postgres. If you still want a Power App view, point a Power App at the
> Postgres `comments` table via the on-prem data gateway, or have a flow read the
> table; no change to this app is required.
### Comment payload shape
```json
{
"app": "Work Package Suite",
"page": "/work-package-suite.html",
"submittedAt": "2026-06-15T18:20:00.000Z",
"type": "sop_step_comment",
"name": "J. Park",
"text": "Consider adding a fiber WP type",
"step": 4
}
``` ```
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`. The Generate a strong password with `openssl rand -base64 32`.
API maps `name`/`author` → the comment author and keeps any extra fields in the
row's `extra` JSON column. These are the only credentials in the system: the `db` container initialises
Postgres from `POSTGRES_*`, and the `api` container connects with the matching
`DATABASE_URL`. Neither value appears in the compose file or in git.
## 3. Point your reverse proxy at the nginx container
The nginx container listens on port **80** on the `proxy` network and expects
TLS to be terminated upstream (by your reverse proxy / traefik). Route your
chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
container on that network. The container already proxies `/api/` to the `api`
service internally — no extra app config needed.
## 4. Bring it up
From the project root:
```bash
docker compose up -d --build # builds the api + nginx images, starts all three containers
docker compose ps # confirm nginx_webserver, wp_api, wp_db are running/healthy
docker compose logs -f api # watch the API start (Ctrl-C to stop following)
```
The database schema is **created automatically** on first API start — no manual
`CREATE TABLE`. The Postgres data lives in the named volume `pgdata` and
survives `docker compose down` (only `down -v` deletes it).
> No separate reverse proxy? Publish nginx directly by adding a `ports:` mapping
> to the `webserver` service (e.g. `"8080:80"`) and terminate TLS at whatever
> sits in front of it. The internal `api`/`db` containers should **never** be
> published.
## 5. Verify
```bash
# API liveness (from the host, through the proxy hostname)
curl https://wp-suite.company.local/api/health # → {"ok": true}
# Interactive API docs
# https://wp-suite.company.local/api/docs
```
Then load the site in a browser: the home page should prompt to **select or
create a project**. Create one, complete an SOP, and confirm a row appears:
```bash
docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;"
```
---
## What is stored in SQL today
Be aware of the current persistence split — the API + Postgres are fully
deployed, and:
| Data | Stored in PostgreSQL today? |
|------|------------------------------|
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
| **SOPs** | Endpoints exist (`/api/sops`); the front end still keeps the SOP in the browser (namespaced per project). Wiring it to the API is the remaining **Phase 2** step. |
| **Work Packages** | Same — `/api/wps` (+ issue/status/metrics) exist and are ready; the creator still saves to the browser per project. |
So a fresh deployment gives you **shared, server-stored projects and comments
immediately**. Moving SOPs and Work Packages off the browser and onto the API
(so they're shared across users too) is a front-end change only — the database
and endpoints are already in place.
## Data model (PostgreSQL) ## Data model (PostgreSQL)
| Table | Holds | Key columns | | Table | Holds | Key columns |
|-------|-------|-------------| |-------|-------|-------------|
| `sops` | project SOP baselines | `name`, `number`, `complete`, `data` (full SOP JSON) | | `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
| `work_packages` | individual IWPs | `sop_id`, `number`, `subject`, `type`, `status`, `data` (full WP JSON) | | `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text` | | `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `issued_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
The complete client document is stored verbatim in each row's `data` column; The complete client document is stored verbatim in each row's `data` JSON
frequently-listed fields are promoted to real columns for filtering. column; frequently-listed fields are promoted to real columns for filtering.
### Endpoints (summary)
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments`.
List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
---
## Updating after a change
```bash
git pull
docker compose up -d --build webserver # front-end change (html/) — rebuild the baked image
docker compose up -d --build api # backend change (server/)
```
## Backups & retention
The whole dataset is in the `pgdata` volume — back it up on a schedule:
```bash
# Backup (run from project root)
docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
# Restore
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
```
## Schema migrations (important)
Tables are auto-created on API startup (`Base.metadata.create_all`). This
creates **missing tables**, but it does **not** alter existing ones. The
multi-project work added the `projects` table and new columns
(`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
- On a **fresh** database these appear automatically — nothing to do.
- On a database that **already has data** from an older schema, add the new
columns with a migration (introduce **Alembic**) or apply them manually with
`ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
## Local trial without Postgres
For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
§ *Local dev*. The front end alone can also be served statically from `html/`
(it falls back to browser storage when the API isn't reachable).

View File

@@ -130,6 +130,49 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
> real data this is fine; once there is, add Alembic (see open question #2) and > real data this is fine; once there is, add Alembic (see open question #2) and
> migrate rather than relying on `create_all`. > migrate rather than relying on `create_all`.
## Multi-project support
> **Note on layout:** the IT admin moved all static files into **`html/`** and
> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`).
> Front-end paths below are under `html/`. `server/` stayed at the repo root.
The suite is now multi-project. **Projects are the top-level container**; every
SOP and Work Package belongs to one.
- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained
`project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/
metrics endpoints accept a `project_id` filter.
- **Project layer:** [html/project-data.js](html/project-data.js) — a shared,
**API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`)
that **falls back to a localStorage mirror** (`wp_projects`) when the API is
unreachable, plus active-project helpers (`getActive`/`setActive`, stored in
`wp_active_project` / `wp_active_project_obj`).
- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed;
a **Project** picker added. With no projects it offers *Create Project* / *Use
Sample Project*; otherwise a dropdown to select. The tool cards stay hidden
until a project is active and then carry `&project=<id>`; the hero shows the
active project.
- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)):
reads `?project=<id>`, resolves it via `ProjectData`, shows it in the header,
and **prefills the SOP project fields** (step 1) from the project record when
empty. Passes `&project` into the WP-creator iframe.
- **WP creator:** stamps `projectId` onto every saved package (for API sync).
**Per-project isolation (done, local):** SOP/WP localStorage keys are now
namespaced per active project via `ProjectData.key(base)` →
`base + '__' + <projectId>` (`SK()` in the suite, `wpKey()` in the creator).
So each project keeps its own `wp_suite_sop` / `wp_suite_state` /
`wp_suite_sop_complete` / `wp_iwp_v1`. On first load after this change,
`project-data.js` runs a **one-time discard** of the legacy un-namespaced keys
(guarded by `wp_ns_migrated_v1`) — chosen over migrating, since the local data
was throwaway demo content.
**Still ahead (true Phase 2):** move SOP/WP reads+writes to the API filtered by
`project_id` (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`)
so projects are shared across users, not just isolated per browser. The
endpoints already accept the `project_id` filter; the front end still reads
localStorage.
**Pending — Phase 2: wire the front end to the API** **Pending — Phase 2: wire the front end to the API**
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`). - SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps` - WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`

80
html/help.js Normal file
View File

@@ -0,0 +1,80 @@
/* Shared Help + tooltip module for the Work Package Suite.
Included by the home page, the suite, and the embedded creator. It injects:
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
- a Help modal (workflow + key concepts) opened via window.openHelp()
Add a "❔ Help" button anywhere with onclick="openHelp()". */
(function (global) {
'use strict';
var css = `
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; }
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:flex-start;
justify-content:center; z-index:10000; padding:5vh 16px; overflow:auto; }
.ui-help-overlay.open{ display:flex; }
.ui-help-modal{ background:#fff; color:#1a2230; max-width:680px; width:100%; border-radius:10px;
box-shadow:0 12px 40px rgba(20,30,50,.3); font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px;
border-bottom:1px solid #e3e6ec; font-size:16px; }
.ui-help-head button{ background:none; border:none; font-size:18px; cursor:pointer; color:#5a6675; line-height:1; }
.ui-help-body{ padding:18px 22px; font-size:13.5px; line-height:1.6; }
.ui-help-body h4{ margin:18px 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:.03em; color:#2563d6; }
.ui-help-body h4:first-child{ margin-top:0; }
.ui-help-body ol, .ui-help-body ul{ margin:0 0 6px; padding-left:20px; }
.ui-help-body li{ margin-bottom:5px; }
.ui-help-body code{ background:#f0f2f5; padding:1px 5px; border-radius:4px; font-size:12px; }
`;
var style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
var HELP_HTML = `
<h4>How the suite works</h4>
<ol>
<li><strong>Pick or create a Project</strong> on the home page — projects are stored centrally and each keeps its own SOP and Work Packages.</li>
<li><strong>SOP Configuration</strong> — set the project baseline (team, sign-offs, WP types, governance &amp; sizing, quality, sequence, constraints, sources). Every Work Package inherits these defaults.</li>
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP. Use <strong>New</strong> for a blank one or <strong>Duplicate</strong> to copy an existing one.</li>
<li><strong>Dashboard</strong> — track status, hours, and what's gating each package across the project.</li>
</ol>
<h4>Key concepts</h4>
<ul>
<li><strong>Constraints &amp; release readiness:</strong> a package can't move to <em>Issued</em> until every constraint is <em>Cleared</em> or <em>N/A</em>. If a constraint reopens after release, the package drops to <em>Issue (Hold)</em>.</li>
<li><strong>Disciplines &amp; Split:</strong> a package can carry more than one discipline (e.g. Mechanical + Electrical + Tech), each with its own scope section and status. <strong>Split by Discipline</strong> breaks it into instances — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> — each tied to the master.</li>
<li><strong>WP size:</strong> the SOP sets a typical size band, which sets a max-hours <em>split threshold</em>. The creator warns when a package's estimated hours exceed it so it can be broken down.</li>
<li><strong>Material by discipline:</strong> on a multi-discipline package each material line can be tagged to a discipline; splitting routes each instance only its own materials.</li>
</ul>
<h4>Tips</h4>
<ul>
<li><strong>Load Sample</strong> is context-aware — it loads the sample SOP on the SOP tab and an example Work Package on the WP tab.</li>
<li>Data is kept <strong>per project</strong>; switch projects from the home page.</li>
<li>Hover the <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icons for inline hints.</li>
</ul>`;
function buildModal() {
if (document.getElementById('ui-help-overlay')) return;
var overlay = document.createElement('div');
overlay.className = 'ui-help-overlay';
overlay.id = 'ui-help-overlay';
overlay.innerHTML = '<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help">' +
'<div class="ui-help-head"><strong>❔ Help — Work Package Suite</strong>' +
'<button type="button" onclick="closeHelp()" aria-label="Close help">✕</button></div>' +
'<div class="ui-help-body">' + HELP_HTML + '</div></div>';
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
document.body.appendChild(overlay);
}
global.openHelp = function () { buildModal(); document.getElementById('ui-help-overlay').classList.add('open'); };
global.closeHelp = function () { var o = document.getElementById('ui-help-overlay'); if (o) o.classList.remove('open'); };
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') global.closeHelp(); });
})(window);

View File

@@ -348,6 +348,22 @@
color: var(--cds-text-primary); color: var(--cds-text-primary);
} }
/* PROJECT PICKER */
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
border-radius: 6px; padding: 1.25rem; }
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { flex-direction: column; text-align: center; } .header-content { flex-direction: column; text-align: center; }
@@ -370,6 +386,7 @@
<nav class="header-nav"> <nav class="header-nav">
<a href="#overview">Overview</a> <a href="#overview">Overview</a>
<a href="#comments">Feedback</a> <a href="#comments">Feedback</a>
<a href="#" onclick="openHelp();return false;">Help</a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -379,12 +396,19 @@
<!-- HERO --> <!-- HERO -->
<div class="hero"> <div class="hero">
<h1>Work Package Suite</h1> <h1 id="hero-title">Work Package Suite</h1>
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p> <p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
</div> </div>
<!-- TOOL CARDS --> <!-- PROJECT SELECTION -->
<div class="cards-grid" id="overview"> <div class="section" id="project-section">
<h2>Project</h2>
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
</div>
<!-- TOOL CARDS (shown once a project is active) -->
<div class="cards-grid" id="overview" style="display:none">
<!-- SOP CONFIG --> <!-- SOP CONFIG -->
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop"> <a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
@@ -443,25 +467,6 @@
</div> </div>
</div> </div>
<!-- SUPPORT SECTION -->
<div class="section">
<h2>About This Suite</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
<div>
<h3>Two-Step Workflow</h3>
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
</div>
<div>
<h3>Leave Feedback</h3>
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
</div>
<div>
<h3>Offline & Collaborative</h3>
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
</div>
</div>
</div>
</div> </div>
<!-- FOOTER --> <!-- FOOTER -->
@@ -470,13 +475,137 @@
</footer> </footer>
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script> <script>
// Reflect SOP completion on the tool cards. // ── PROJECT SELECTION ─────────────────────────────────────────────────────
(function reflectSOPStatus(){ const esc = ProjectData.esc;
let _projects = [];
function initProjects(){
ProjectData.list().then(list => {
_projects = list || [];
// Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive();
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
renderProjectPicker();
applyActiveProject();
});
}
function createFormHtml(){
return `<div class="proj-form" id="proj-form" style="display:none">
<div class="proj-form-grid">
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
</div>
<div class="proj-actions">
<button class="card-button" onclick="saveNewProject()">Create &amp; Select</button>
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
</div>
</div>`;
}
function renderProjectPicker(){
const box = document.getElementById('project-picker');
const activeId = ProjectData.getActiveId();
if(!_projects.length){
box.innerHTML = `<div class="proj-empty">
<p>No projects yet. Create your first project, or start from a sample.</p>
<div class="proj-actions">
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
</div>
</div>` + createFormHtml();
return;
}
const opts = _projects.map(p =>
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
).join('');
box.innerHTML = `<div class="proj-row">
<select id="project-select" onchange="selectProject(this.value)">
<option value="">Select a project…</option>${opts}
</select>
<button class="card-button" onclick="showCreateProject()">+ New</button>
<button class="close-btn" onclick="useSampleProject()">Sample</button>
</div>
<div id="active-project-info"></div>` + createFormHtml();
}
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
function saveNewProject(){
const v = id => (document.getElementById(id)?.value || '').trim();
const name = v('np_name');
if(!name){ alert('Project name is required.'); return; }
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
}
function useSampleProject(){
const existing = _projects.find(p => p.sample);
if(existing){ afterProjectChosen(existing); return; }
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
}
function selectProject(id){
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
const p = _projects.find(x => x.id === id);
if(p){ ProjectData.setActive(p); applyActiveProject(); }
}
function afterProjectChosen(p){
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
ProjectData.setActive(p);
renderProjectPicker();
applyActiveProject();
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
}
// Show/hide the tool cards and stamp the active project into their links.
function applyActiveProject(){
const active = ProjectData.getActive();
const cards = document.getElementById('overview');
const heroTitle = document.getElementById('hero-title');
const heroSub = document.getElementById('hero-sub');
const info = document.getElementById('active-project-info');
if(!active){
cards.style.display = 'none';
heroTitle.textContent = 'Work Package Suite';
heroSub.textContent = 'Select a project to begin — or create one.';
if(info) info.innerHTML = '';
return;
}
const q = '&project=' + encodeURIComponent(active.id);
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
setHref('card-sop', 'work-package-suite.html?tab=sop');
setHref('card-wp', 'work-package-suite.html?tab=wp');
setHref('card-dash', 'work-package-suite.html?view=dashboard');
cards.style.display = '';
heroTitle.textContent = active.name || 'Work Package Suite';
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
&nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
reflectSOPStatus(active);
}
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
// Reflect SOP completion on the tool cards (scoped to the active project).
function reflectSOPStatus(active){
let complete = false, projName = ''; let complete = false, projName = '';
try { try {
complete = localStorage.getItem('wp_suite_sop_complete') === '1'; // Storage is namespaced per project, so these already scope to `active`.
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null'); complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
projName = sop && sop.project && sop.project.name || ''; projName = sop && sop.project && sop.project.name || '';
} catch(e){} } catch(e){}
@@ -484,6 +613,12 @@
const sopBtn = document.getElementById('card-sop-btn'); const sopBtn = document.getElementById('card-sop-btn');
const wpCard = document.getElementById('card-wp'); const wpCard = document.getElementById('card-wp');
const wpBtn = document.getElementById('card-wp-btn'); const wpBtn = document.getElementById('card-wp-btn');
if(!sopCard) return;
// reset (re-render can run multiple times)
sopCard.classList.remove('complete');
wpCard && wpCard.classList.remove('disabled');
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
if(complete){ if(complete){
sopCard.classList.add('complete'); sopCard.classList.add('complete');
@@ -497,7 +632,9 @@
if(wpCard) wpCard.classList.add('disabled'); if(wpCard) wpCard.classList.add('disabled');
if(wpBtn) wpBtn.textContent = 'Complete SOP first'; if(wpBtn) wpBtn.textContent = 'Complete SOP first';
} }
})(); }
initProjects();
let allComments = []; let allComments = [];

96
html/project-data.js Normal file
View File

@@ -0,0 +1,96 @@
/* Shared project layer for the Work Package Suite.
Projects are the top-level container — every SOP and Work Package belongs to
one. Project records live in the SQL database (via /api/projects); this
adapter is API-first and falls back to a localStorage mirror so the suite
still works in local dev / offline. Included by the home page and the suite. */
(function (global) {
'use strict';
var API = '/api';
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
var LS_ACTIVE = 'wp_active_project'; // active project id
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
function cacheUpsert(p) {
var list = readLocal();
var ix = list.findIndex(function (x) { return x.id === p.id; });
if (ix >= 0) list[ix] = p; else list.unshift(p);
writeLocal(list);
}
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
var SAMPLE_PROJECT = {
name: 'Micron — INC Construction Work Packages', number: '26-67-008',
client: 'Micron Technology, Inc.', division: 'Semiconductor',
site: 'Boise, ID — Fab', sample: true
};
var ProjectData = {
SAMPLE: SAMPLE_PROJECT,
esc: esc,
// Returns the project list. Tries the API; falls back to the local mirror.
list: function () {
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (rows) { writeLocal(rows); return rows; })
.catch(function () { return readLocal(); });
},
get: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id))
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
},
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
save: function (p) {
if (!p.id) p.id = uid();
return fetch(API + '/projects', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
})
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (saved) { cacheUpsert(saved); return saved; })
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
},
remove: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () { cacheRemove(id); })
.catch(function () { cacheRemove(id); });
},
// ── active project context ────────────────────────────────────────────────
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
setActive: function (p) {
try {
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
} catch (e) {}
},
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
// Falls back to the bare key when no project is active.
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
};
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
// global state can't leak across projects. (User chose: discard, don't migrate.)
try {
if (!localStorage.getItem('wp_ns_migrated_v1')) {
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
try { localStorage.removeItem(k); } catch (e) {}
});
localStorage.setItem('wp_ns_migrated_v1', '1');
}
} catch (e) {}
global.ProjectData = ProjectData;
})(window);

View File

@@ -4,6 +4,25 @@ let currentStep = 1;
let sopComplete = false; let sopComplete = false;
let allComments = []; let allComments = [];
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' when a
// project is active. Keeps each project's SOP separate in the browser.
function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } }
// WP size presets — the dropdown label maps to a default split-threshold (max
// labor hours). The label is exported as governance.woSize (human-readable
// guidance); the number drives the Creator's "consider splitting" warning.
const WP_SIZE_PRESETS = {
'Small — 12 days (≈824 hrs)': 24,
'Standard — 35 days (≈4080 hrs)': 80,
'Large — 12 weeks (≈80160 hrs)': 160
};
function onSizePresetChange(){
const label = document.getElementById('gov_wosize').value;
const max = WP_SIZE_PRESETS[label];
if(max != null){ document.getElementById('gov_size_hours_max').value = max; }
// 'Custom…' / '' leave the threshold for manual entry.
}
let state = { let state = {
project: {name:'', number:'', client:'', division:'', site:''}, project: {name:'', number:'', client:'', division:'', site:''},
team: {pm:'', apm:'', cm:'', qm:''}, team: {pm:'', apm:'', cm:'', qm:''},
@@ -126,14 +145,17 @@ window.addEventListener('DOMContentLoaded',()=>{
renderStandardConstraints(); renderStandardConstraints();
renderSequenceSteps(); renderSequenceSteps();
renderSources(); renderSources();
// Resolve the active project FIRST so per-project storage keys are correct
// before we restore this project's SOP.
const params = new URLSearchParams(window.location.search);
applyProjectContext(params.get('project'));
restoreSavedSOP(); restoreSavedSOP();
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page cards. // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
const params = new URLSearchParams(window.location.search);
const tab = params.get('tab'); const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('wp'); if(params.get('view') === 'dashboard') switchTool('dashboard');
else if(tab === 'wp' || tab === 'sop') switchTool(tab); else if(tab === 'wp' || tab === 'sop') switchTool(tab);
track('app_open'); track('app_open');
@@ -154,7 +176,15 @@ function initializeWPTypes(){
} }
// ── LOAD SAMPLE DATA ────────────────────────────────────────────────────────── // ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard
// tab it loads the example Work Package inside the embedded creator.
function loadSampleData(){ function loadSampleData(){
if(currentTool && currentTool !== 'sop'){
const f = document.getElementById('wp-frame');
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
else { alert('Open the Work Package Creation tab first, then load the sample.'); }
return;
}
// Populate Step 1 // Populate Step 1
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL'; document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
document.getElementById('proj_number').value = '26-67-008'; document.getElementById('proj_number').value = '26-67-008';
@@ -174,10 +204,10 @@ function loadSampleData(){
// Populate Step 5 // Populate Step 5
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]'; document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
document.getElementById('gov_wosize').value = '35 days / 4080 hours'; document.getElementById('gov_wosize').value = 'Standard — 35 days (≈4080 hrs)';
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech'; document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
document.getElementById('gov_discmode').value = 'choice'; document.getElementById('gov_discmode').value = 'choice';
document.getElementById('gov_size_hours_max').value = '120'; document.getElementById('gov_size_hours_max').value = '80';
// Populate Step 6 // Populate Step 6
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items'; document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
@@ -202,9 +232,9 @@ function loadSampleData(){
function restoreSavedSOP(){ function restoreSavedSOP(){
let savedState = null, savedSop = null, complete = false; let savedState = null, savedSop = null, complete = false;
try { try {
complete = localStorage.getItem('wp_suite_sop_complete') === '1'; complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null'); savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null'); savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
} catch(e){} } catch(e){}
if(!complete || !savedState) return; if(!complete || !savedState) return;
@@ -239,6 +269,12 @@ function repopulateForm(){
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name); if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name); if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
set('gov_woformat', state.governance.woformat); set('gov_woformat', state.governance.woformat);
// gov_wosize is now a <select>; if a saved value isn't one of the presets
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
const wsEl = document.getElementById('gov_wosize');
if(wsEl && state.governance.wosize && !Array.from(wsEl.options).some(o=>o.value===state.governance.wosize)){
wsEl.add(new Option(state.governance.wosize, state.governance.wosize));
}
set('gov_wosize', state.governance.wosize); set('gov_wosize', state.governance.wosize);
set('gov_disciplines', (state.governance.disciplines||[]).join(', ')); set('gov_disciplines', (state.governance.disciplines||[]).join(', '));
set('gov_discmode', state.governance.discMode); set('gov_discmode', state.governance.discMode);
@@ -253,30 +289,32 @@ function repopulateForm(){
// ── TOOL SWITCHING ──────────────────────────────────────────────────────────── // ── TOOL SWITCHING ────────────────────────────────────────────────────────────
function switchTool(tool){ function switchTool(tool){
currentTool = tool; currentTool = tool;
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
// creator) but opens it straight to the dashboard view.
const isDash = (tool === 'dashboard');
const contentTool = isDash ? 'wp' : tool;
// Update nav tabs // Update nav tabs
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active')); document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
document.querySelector(`[data-tab="${tool}"]`).classList.add('active'); const tabBtn = document.querySelector(`[data-tab="${tool}"]`);
if(tabBtn) tabBtn.classList.add('active');
// Update content // Update content
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active')); document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
document.getElementById(`tool-${tool}`).classList.add('active'); document.getElementById(`tool-${contentTool}`).classList.add('active');
// Reset step counter // Reset step counter
if(tool === 'sop'){ document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
document.getElementById('total-steps').textContent = '10';
}else{
document.getElementById('total-steps').textContent = '—';
}
if(tool === 'wp') renderWPTab(); if(contentTool === 'wp') renderWPTab(isDash);
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
} }
// Show the gate or the embedded Work Package Creator depending on SOP status. // Show the gate or the embedded Work Package Creator depending on SOP status.
function renderWPTab(){ // wantDash=true opens the creator straight to the dashboard view.
function renderWPTab(wantDash){
const gate = document.getElementById('wp-gate'); const gate = document.getElementById('wp-gate');
const frame = document.getElementById('wp-frame'); const frame = document.getElementById('wp-frame');
if(!gate || !frame) return; if(!gate || !frame) return;
@@ -284,8 +322,11 @@ function renderWPTab(){
gate.style.display = 'none'; gate.style.display = 'none';
frame.style.display = 'block'; frame.style.display = 'block';
// Reload each time so the creator picks up the latest SOP from localStorage. // Reload each time so the creator picks up the latest SOP from localStorage.
const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard'; const sp = new URLSearchParams(window.location.search);
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now(); const dash = wantDash || sp.get('view') === 'dashboard';
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
}else{ }else{
gate.style.display = 'block'; gate.style.display = 'block';
frame.style.display = 'none'; frame.style.display = 'none';
@@ -297,8 +338,43 @@ function onSOPReady(){
if(currentTool === 'wp') renderWPTab(); if(currentTool === 'wp') renderWPTab();
} }
// Active project comes from the home page (?project=<id> + ProjectData.getActive()).
// When the SOP's project fields are still empty, prefill them from the project
// record so the SOP is authored against the chosen project.
let activeProject = null;
function applyProjectContext(projectId){
try {
if(typeof ProjectData !== 'undefined'){
if(projectId && ProjectData.getActiveId() !== projectId){
// Deep-linked to a project that isn't the cached active one. Seed the id
// immediately so namespaced storage keys resolve, then fetch the full record.
const cached = ProjectData.getActive();
ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId });
ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } });
}
activeProject = ProjectData.getActive();
}
} catch(e){}
prefillProjectFields();
}
function prefillProjectFields(){
if(!activeProject) return;
const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; };
set('proj_name', activeProject.name);
set('proj_number', activeProject.number);
set('proj_client', activeProject.client);
set('proj_division', activeProject.division);
set('proj_site', activeProject.site);
if(typeof state !== 'undefined' && state.project){
state.project.name = state.project.name || activeProject.name || '';
state.project.number = state.project.number || activeProject.number || '';
state.project.client = state.project.client || activeProject.client || '';
state.project.division = state.project.division || activeProject.division || '';
state.project.site = state.project.site || activeProject.site || '';
}
}
function updateProjectDisplay(){ function updateProjectDisplay(){
const projName = document.getElementById('proj_name')?.value || 'Project'; const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project';
const display = document.getElementById('project-display'); const display = document.getElementById('project-display');
if(display) display.textContent = sopComplete ? `${projName} (SOP Ready)` : projName; if(display) display.textContent = sopComplete ? `${projName} (SOP Ready)` : projName;
} }
@@ -315,14 +391,24 @@ function renderWPTypes(){
state.wpTypes.forEach((t,i)=>{ state.wpTypes.forEach((t,i)=>{
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'wp-type-row'; row.className = 'wp-type-row';
const nameCell = t.custom
? `<div style="display:flex; gap:6px; align-items:center;">
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
</div>`
: `<div style="font-weight:600;">${t.name}</div>`;
row.innerHTML = ` row.innerHTML = `
<div style="font-weight:600;">${t.name}</div> ${nameCell}
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div> <div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`; `;
container.appendChild(row); container.appendChild(row);
}); });
const addRow = document.createElement('div');
addRow.style.cssText = 'margin-top:0.85rem;';
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add Custom Type</button>`;
container.appendChild(addRow);
} }
function toggleWPType(i){ function toggleWPType(i){
@@ -330,6 +416,21 @@ function toggleWPType(i){
renderWPTypes(); renderWPTypes();
} }
function addCustomWPType(){
state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true});
renderWPTypes();
// Focus the new custom row's name input.
const rows = document.querySelectorAll('#wp-types-table .wp-type-row');
const last = rows[rows.length-1];
const nameInput = last && last.querySelector('input[type="text"]');
if(nameInput) nameInput.focus();
}
function removeWPType(i){
state.wpTypes.splice(i,1);
renderWPTypes();
}
function renderTeamMembers(){ function renderTeamMembers(){
const container = document.getElementById('team-members-list'); const container = document.getElementById('team-members-list');
if(!container) return; if(!container) return;
@@ -651,8 +752,8 @@ function completeSOP(){
instanceSuffix: state.governance.instanceSuffix || 'letter', instanceSuffix: state.governance.instanceSuffix || 'letter',
sizeHoursMax: state.governance.sizeHoursMax || '' sizeHoursMax: state.governance.sizeHoursMax || ''
}, },
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({ woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({
name: t.name, name: t.name.trim(),
enabled: true, enabled: true,
notes: t.notes || '', notes: t.notes || '',
approval: t.approval || '' approval: t.approval || ''
@@ -675,21 +776,26 @@ function completeSOP(){
}; };
sopComplete = true; sopComplete = true;
// Stamp the active project onto the SOP so it's unambiguously tied to it.
try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){}
updateProjectDisplay(); updateProjectDisplay();
// Persist for the home page (green / "Review") and for the WP Creator tab. // Persist for the home page (green / "Review") and for the WP Creator tab,
// namespaced to the active project so each project keeps its own SOP.
try { try {
localStorage.setItem('wp_suite_sop', JSON.stringify(sop)); localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
localStorage.setItem('wp_suite_state', JSON.stringify(state)); localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
localStorage.setItem('wp_suite_sop_complete', '1'); localStorage.setItem(SK('wp_suite_sop_complete'), '1');
} catch(e){} } catch(e){}
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length}); track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
alert('✓ SOP Configuration Complete!\n\nSwitch to "Work Package Creation" to start creating Work Packages.'); // Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
// the user stays), then return to the project home page per the requested flow.
// Hand the SOP to the embedded Work Package Creator and unlock its tab.
if(typeof onSOPReady === 'function') onSOPReady(sop); if(typeof onSOPReady === 'function') onSOPReady(sop);
alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.');
window.location.href = 'index.html';
} }
// ── COMMENTS ────────────────────────────────────────────────────────────────── // ── COMMENTS ──────────────────────────────────────────────────────────────────

View File

@@ -22,9 +22,10 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load example SOP data">⭐ Load Sample</button> <button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">⭐ Load Sample</button>
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button> <button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button> <button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">❔ Help</button>
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span> <span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
</div> </div>
</div> </div>
@@ -37,6 +38,9 @@
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')"> <button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
<span class="tab-icon">📋</span> Work Package Creation <span class="tab-icon">📋</span> Work Package Creation
</button> </button>
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
<span class="tab-icon">📊</span> Dashboard
</button>
</div> </div>
<!-- CONTENT AREA --> <!-- CONTENT AREA -->
@@ -188,7 +192,7 @@
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small> <small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
</div> </div>
<div class="field"> <div class="field">
<label>Discipline strategy *</label> <label>Discipline strategy *<span class="help-tip" data-tip="Decides whether a package can carry several disciplines (scope split per discipline) or one each. 'Let the planner choose' allows building a big multi-discipline package and splitting it later.">i</span></label>
<select id="gov_discmode"> <select id="gov_discmode">
<option value="choice">Let the planner choose per package (recommended)</option> <option value="choice">Let the planner choose per package (recommended)</option>
<option value="single">One discipline per package (many small packages)</option> <option value="single">One discipline per package (many small packages)</option>
@@ -202,13 +206,20 @@
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 12 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div> <div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 12 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
<div class="field-grid"> <div class="field-grid">
<div class="field"> <div class="field">
<label>Typical WP Size (guidance)</label> <label>Typical WP Size</label>
<input type="text" id="gov_wosize" placeholder="e.g., 35 days or 4080 hours"> <select id="gov_wosize" onchange="onSizePresetChange()">
<option value="">Select…</option>
<option value="Small — 12 days (≈824 hrs)">Small — 12 days (≈824 hrs)</option>
<option value="Standard — 35 days (≈4080 hrs)">Standard — 35 days (≈4080 hrs)</option>
<option value="Large — 12 weeks (≈80160 hrs)">Large — 12 weeks (≈80160 hrs)</option>
<option value="Custom…">Custom…</option>
</select>
<small>Sets the split threshold automatically; choose Custom to enter your own.</small>
</div> </div>
<div class="field"> <div class="field">
<label>Split threshold — max labor hours</label> <label>Split threshold — max labor hours<span class="help-tip" data-tip="The Work Package Creator flags any package whose estimated hours exceed this so the planner can break it down. Auto-set by the size band; override if needed.">i</span></label>
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 120"> <input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
<small>The Creator flags packages above this so they can be split (by discipline or scope).</small> <small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
</div> </div>
</div> </div>
</div> </div>
@@ -341,7 +352,7 @@
</div> </div>
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label> <label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
<input type="text" id="commenter-name" placeholder="e.g., Bill Clarida" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;"> <input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
</div> </div>
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Feedback</label> <label style="font-weight: 600; font-size: 13px;">Feedback</label>
@@ -369,6 +380,8 @@
</div> </div>
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="work-package-suite-app.js"></script> <script src="work-package-suite-app.js"></script>
</body> </body>
</html> </html>

View File

@@ -8,7 +8,7 @@ const SAMPLE_SOP = {
meta:{tool:'Work Package Configuration', sample:true}, meta:{tool:'Work Package Configuration', sample:true},
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'}, project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}], roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'35 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' }, governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 35 days (≈4080 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' },
woTypes:[ woTypes:[
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true}, {name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true}, {name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
@@ -34,15 +34,16 @@ const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued'); const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
// Acumatica cost codes (comment 10) — code|description // Acumatica cost codes (comment 10) — code|description
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract','5010|Instrument Material','5020|Network & Computers Material','5030|Software Material','5040|PLC Material','5050|Panel Material','5060|Electrical Material','5070|Mechanical Material','5080|Security Material','5090|Radio Material','6000|Production','7000|Quality','7100|Panel Quality Control','7200|Factory Acceptance Testing','7300|Site Acceptance Testing','8000|Safety','9000|Administration','9100|Warranty','9200|Freight','9300|Travel','9350|Jobsite Costs/Consumable/Other Direct Costs','9400|Contingency','9450|Other','9500|Accrued Incentive Compensation','9600|Sales Tax','9650|Job Cost Labor Burden','9700|Bonding','9800|Non-Billable Compensation']; const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract'];
// Acumatica allowed units of measure (comment 15) — common first // Acumatica allowed units of measure (comment 15) — common first
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN']; const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
// Example built work package (comment 4 / "Load Example") — WP02 export // Example built work package (comment 4 / "Load Example") — WP02 export
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Bill Clarida (Prime Controls), Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"}; const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
// ── STATE ──────────────────────────────────────────────────────────────────── // ── STATE ────────────────────────────────────────────────────────────────────
let SOP=null, editingId=null, numberDirty=false; let SOP=null, editingId=null, numberDirty=false;
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[]; let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3) let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
@@ -135,7 +136,12 @@ function editQuality(id){
} }
function renderCtxBar(){ function renderCtxBar(){
const bar=document.getElementById('ctx-bar'); const bar=document.getElementById('ctx-bar');
if(!SOP){ bar.innerHTML=`<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; return; } if(!SOP){
bar.innerHTML = activeProjectId
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`;
return;
}
const p=SOP.project||{}, g=SOP.governance||{}; const p=SOP.project||{}, g=SOP.governance||{};
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:''; const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div> bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
@@ -371,12 +377,15 @@ function rollupDisciplineStatus(){
// ── WP SIZING WARNING (governance.sizeHoursMax) ────────────────────────────── // ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
function onHoursChange(){ function onHoursChange(){
const el=document.getElementById('size-check'); if(!el) return; const el=document.getElementById('size-check'); if(!el) return;
const max=parseFloat((SOP&&SOP.governance&&SOP.governance.sizeHoursMax)||''); const g=(SOP&&SOP.governance)||{};
const band=g.woSize?('Target: '+g.woSize+'. '):'';
const max=parseFloat(g.sizeHoursMax||'');
const hrs=parseFloat(gv('wp_hours')); const hrs=parseFloat(gv('wp_hours'));
if(max && hrs && hrs>max){ if(max && hrs && hrs>max){
el.innerHTML=`<span style="color:var(--accent-amber)">⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`; el.innerHTML=`<span style="color:var(--accent-amber)">${esc(band)}${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
} else if(max){ el.textContent=`Split threshold: ${max} hrs (from SOP).`; } } else if(band || max){
else { el.textContent=''; } el.innerHTML=`<span>${esc(band)}${max?'Split threshold: '+max+' hrs.':''}</span>`;
} else { el.textContent=''; }
} }
// ── SPLIT BY DISCIPLINE ────────────────────────────────────────────────────── // ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
@@ -530,6 +539,7 @@ function updateReleaseBanner(){
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; } else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; } else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`; b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
updateStickyStatus();
} }
function onStatusChange(target){ function onStatusChange(target){
const idx=STATUS_ORDER.indexOf(target); const idx=STATUS_ORDER.indexOf(target);
@@ -631,6 +641,7 @@ function collectPackage(){
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
return { return {
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)), id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
projectId: (prev&&prev.projectId) || activeProjectId || '',
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined, instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined, parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'), number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
@@ -751,13 +762,44 @@ function printPackage(){
// ── VIEWS ──────────────────────────────────────────────────────────────────── // ── VIEWS ────────────────────────────────────────────────────────────────────
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; } function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
function showOutput(){ hideDashboard(); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); } function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); } function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
// Sticky save bar + section-nav chrome (shown only on the editable form view).
function setFormChrome(on){
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
if(nav) nav.style.display = on ? '' : 'none';
if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on);
if(on){ buildSectionNav(); updateStickyStatus(); }
}
function buildSectionNav(){
const nav=document.getElementById('section-nav'); if(!nav) return;
const chips=[];
document.querySelectorAll('.main > .card').forEach((card,i)=>{
if(card.id==='saved-card' || card.style.display==='none') return;
const h=card.querySelector('.section-title, .sub-heading'); if(!h) return;
const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove());
const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return;
if(!card.id) card.id='sec-'+i;
chips.push(`<span class="sec-chip" onclick="document.getElementById('${card.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(label)}</span>`);
});
nav.innerHTML=chips.join('');
}
function updateStickyStatus(){
const el=document.getElementById('sticky-status'); if(!el) return;
const r=readiness(); const st=getRadio('status');
if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; }
else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; }
else { el.className='sticky-status ss-notready'; el.textContent=`${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; }
}
// ── SAVED PACKAGES ─────────────────────────────────────────────────────────── // ── SAVED PACKAGES ───────────────────────────────────────────────────────────
const STORE_KEY='wp_iwp_v1'; const STORE_KEY='wp_iwp_v1';
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} } // Per-project namespaced key so each project keeps its own packages in the browser.
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} } function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } }
function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} }
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
function renderSavedList(){ function renderSavedList(){
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body'); const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:''; document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
@@ -768,7 +810,7 @@ function renderSavedList(){
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:''); const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:''; const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td> return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
<td>${esc(p.status||'')}</td><td>${ready}</td> <td>${statusPill(p.status)}</td><td>${ready}</td>
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`; <td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
}).join(''); }).join('');
} }
@@ -818,6 +860,36 @@ function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; bu
buildConstraints(); } buildConstraints(); }
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); } function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
// Duplicate the current work package N times (asks how many). Each copy is a
// fresh Draft with a unique number/subject and approvals/closeout cleared.
function duplicateWP(){
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; }
const ans=prompt('How many copies of this work package do you want to create?','1');
if(ans===null) return;
const n=parseInt(ans,10);
if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; }
const base=collectPackage();
const baseNum = base.number || ('WP'+pad2(editingSeq()));
const made=[];
for(let i=1;i<=n;i++){
const c=JSON.parse(JSON.stringify(base));
c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i;
c.number = baseNum + '-C' + i;
c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)');
c.status='Draft';
c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined;
if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''}));
c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons='';
c.projectId = base.projectId || activeProjectId || '';
c.updatedAt=new Date().toISOString();
savedPackages.push(c); made.push(c);
}
editingId=null; saveStore(); renderSavedList();
toast('Created '+n+' duplicate'+(n>1?'s':''));
track('wp_duplicated',{count:n});
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
}
function newPackage(){ function newPackage(){
editingId=null; editingId=null;
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); ['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
@@ -856,13 +928,26 @@ const WPData = {
p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status
}; };
let dashFilter={status:'',discipline:'',q:''}; let dashFilter={status:'',discipline:'',q:'',flag:''};
function dashToggleFlag(f){
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
renderDashboard();
}
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
// Consistent colored status pill, reused by the dashboard board and the saved list.
function statusPill(s){
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
}
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); } function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted. // Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
function countableWPs(){ return WPData.list().filter(p=>!p.split); } function countableWPs(){ return WPData.list().filter(p=>!p.split); }
function showDashboard(){ function showDashboard(){
setFormChrome(false);
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
document.getElementById('pkg-output').style.display='none'; document.getElementById('pkg-output').style.display='none';
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display=''; const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
@@ -881,19 +966,25 @@ function renderDashboard(){
if(isOverdue(p)) overdue++; if(isOverdue(p)) overdue++;
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1); (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
}); });
const card=(label,val,cls)=>`<div class="dash-metric ${cls||''}"><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`; // Clickable metric cards filter the board (flag-based); a card with no flag is static.
const card=(label,val,cls,flag)=>{
const active = flag && dashFilter.flag===flag ? ' dm-active' : '';
const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : '';
return `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
};
let h=`<div class="dash-metrics"> let h=`<div class="dash-metrics">
${card('Total WPs', all.length)} ${card('Total WPs', all.length, '', 'all')}
${card('Release-ready', ready, ready?'dm-green':'')} ${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
${card('On hold', hold, hold?'dm-red':'')} ${card('On hold', hold, hold?'dm-red':'', 'onhold')}
${card('Overdue', overdue, overdue?'dm-red':'')} ${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
${card('Est. hrs', Math.round(estH))} ${card('Est. hrs', Math.round(estH))}
${card('Actual hrs', Math.round(actH))} ${card('Actual hrs', Math.round(actH))}
</div>`; </div>`;
// status + discipline breakdown chips // status + discipline breakdown chips (status chips also filter the board)
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('') const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:''); const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>'; const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div> h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`; <div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
@@ -923,6 +1014,9 @@ function renderDashboard(){
if(dashFilter.status && p.status!==dashFilter.status) return false; if(dashFilter.status && p.status!==dashFilter.status) return false;
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false; if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false; if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
return true; return true;
}); });
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div> h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
@@ -938,7 +1032,7 @@ function renderDashboard(){
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td> h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td> <td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td> <td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
<td>${esc(p.status||'')}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td> <td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`; <td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
}); });
h+=`</tbody></table></div>`; h+=`</tbody></table></div>`;
@@ -1010,6 +1104,15 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
})); }));
// ── BOOT ───────────────────────────────────────────────────────────────────── // ── BOOT ─────────────────────────────────────────────────────────────────────
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
(function seedProject(){
const params = new URLSearchParams(location.search);
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){
const cached = ProjectData.getActive && ProjectData.getActive();
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
}
})();
loadStore(); loadStore();
(function bootSOP(){ (function bootSOP(){
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected) // When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
@@ -1017,13 +1120,17 @@ loadStore();
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if(params.get('embedded')) document.body.classList.add('embedded'); if(params.get('embedded')) document.body.classList.add('embedded');
try { try {
const raw = localStorage.getItem('wp_suite_sop'); const raw = localStorage.getItem(wpKey('wp_suite_sop'));
if(raw){ if(raw){
const d = JSON.parse(raw); const d = JSON.parse(raw);
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; } if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
} }
} catch(e){} } catch(e){}
loadSampleSOP(); // No SOP found. Only show the Micron SAMPLE for a standalone preview (no project
// context). For a real project, never substitute sample data — show the empty
// state so it's clear the project's SOP must be completed first.
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
else { loadSampleSOP(); }
})(); })();
setRadio('status','Draft'); setRadio('status','Draft');
renderSavedList(); renderSavedList();

View File

@@ -13,21 +13,22 @@
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div> <div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
<div class="header"> <div class="header">
<div class="logo-wrap"> <div class="logo-wrap embed-hide">
<div class="header-logo">Prime Controls</div> <div class="header-logo">Prime Controls</div>
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button> <button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
</div> </div>
<div class="header-sep">|</div> <div class="header-sep embed-hide">|</div>
<div class="header-title">Work Package (IWP)</div> <div class="header-title">Work Package (IWP)</div>
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button> <button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)"> <input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="newPackage()">+ New</button> <button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button> <button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button> <button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
</div> </div>
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div> <div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
@@ -37,6 +38,9 @@
<!-- RELEASE READINESS BANNER --> <!-- RELEASE READINESS BANNER -->
<div class="release-banner" id="release-banner"></div> <div class="release-banner" id="release-banner"></div>
<!-- SECTION NAV (jump links, built from the form cards) -->
<div class="section-nav-bar" id="section-nav"></div>
<div class="main"> <div class="main">
<!-- GENERAL INFORMATION --> <!-- GENERAL INFORMATION -->
@@ -44,7 +48,7 @@
<div class="section-header"><div class="section-title">General Information</div> <div class="section-header"><div class="section-title">General Information</div>
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div> <div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>WP Number <span class="auto-tag">auto</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div> <div class="field"><label>WP Number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
<div class="field"><label>Status</label> <div class="field"><label>Status</label>
<div class="radio-group" id="status-group" style="margin-bottom:0"> <div class="radio-group" id="status-group" style="margin-bottom:0">
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label> <label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
@@ -87,14 +91,14 @@
<!-- DISCIPLINES --> <!-- DISCIPLINES -->
<div class="card" id="discipline-card" style="display:none"> <div class="card" id="discipline-card" style="display:none">
<div class="sub-heading">Disciplines</div> <div class="sub-heading">Disciplines<span class="help-tip" data-tip="Pick every discipline this package covers. Choosing two or more turns Scope into per-discipline sections and enables Split by Discipline.">i</span></div>
<div class="notice" id="discipline-note"></div> <div class="notice" id="discipline-note"></div>
<div class="disc-picker" id="discipline-picker"></div> <div class="disc-picker" id="discipline-picker"></div>
</div> </div>
<!-- SCOPE & WORK --> <!-- SCOPE & WORK -->
<div class="card"> <div class="card">
<div class="sub-heading">Scope & Work</div> <div class="sub-heading">Scope & Work<span class="help-tip" data-tip="Ordered steps the crew performs. With multiple disciplines selected, each gets its own scope section and status. Use Split by Discipline to break a large package into WP01A / WP01B / WP01C instances.">i</span></div>
<div id="flat-scope"> <div id="flat-scope">
<div class="field"><label>Description of Work (sequenced steps)</label> <div class="field"><label>Description of Work (sequenced steps)</label>
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div> <div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
@@ -112,7 +116,7 @@
<!-- MATERIAL LIST --> <!-- MATERIAL LIST -->
<div class="card"> <div class="card">
<div class="sub-heading">Material List</div> <div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div> <div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
<div class="material-actions"> <div class="material-actions">
@@ -156,7 +160,7 @@
<!-- CONSTRAINTS / RELEASE READINESS --> <!-- CONSTRAINTS / RELEASE READINESS -->
<div class="card" id="constraint-card"> <div class="card" id="constraint-card">
<div class="sub-heading">Constraints — Release Readiness</div> <div class="sub-heading">Constraints — Release Readiness<span class="help-tip" data-tip="A package can't be Issued until every constraint is Cleared or N/A. If one reopens after release, the package drops to Issue (Hold).">i</span></div>
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div> <div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
</div> </div>
@@ -275,7 +279,18 @@
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div> <input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
</aside> </aside>
<!-- STICKY SAVE BAR (always-visible save + release status) -->
<div class="sticky-save" id="sticky-save" style="display:none">
<span class="sticky-status" id="sticky-status"></span>
<div class="sticky-actions">
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save &amp; View</button>
</div>
</div>
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="wp-creation-app.js"></script> <script src="wp-creation-app.js"></script>
</body> </body>
</html> </html>

View File

@@ -564,6 +564,26 @@
.so-date { font-size:13px; font-variant-numeric:tabular-nums; } .so-date { font-size:13px; font-variant-numeric:tabular-nums; }
.so-ovr { margin-left:8px; font-size:11px; } .so-ovr { margin-left:8px; font-size:11px; }
/* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); }
.section-nav-bar:empty{ display:none; }
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
/* Sticky save bar */
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }
.sticky-save .sticky-actions{ display:flex; gap:10px; }
.ss-ready{ color:var(--accent-green); }
.ss-notready{ color:var(--accent-amber); }
.ss-hold{ color:var(--red); }
body.has-sticky-save .main{ padding-bottom:74px; }
/* Disciplines + per-discipline scope */ /* Disciplines + per-discipline scope */
.disc-picker { display:flex; flex-wrap:wrap; gap:8px; } .disc-picker { display:flex; flex-wrap:wrap; gap:8px; }
.disc-pill { display:flex; align-items:center; gap:7px; padding:7px 13px; border:1px solid var(--border-strong); .disc-pill { display:flex; align-items:center; gap:7px; padding:7px 13px; border:1px solid var(--border-strong);
@@ -586,6 +606,11 @@
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; } .dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
.dash-metric.dm-green .dm-val { color:var(--accent-green); } .dash-metric.dm-green .dm-val { color:var(--accent-green); }
.dash-metric.dm-red .dm-val { color:var(--red); } .dash-metric.dm-red .dm-val { color:var(--red); }
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
.dash-metric[onclick]:hover { border-color:var(--accent); }
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
.dash-chip[onclick] { cursor:pointer; }
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; } .dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; } .dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; } .dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }

View File

@@ -41,8 +41,21 @@ def gen_id(prefix: str) -> str:
# ── Request bodies ─────────────────────────────────────────────────────────── # ── Request bodies ───────────────────────────────────────────────────────────
class ProjectIn(BaseModel):
id: Optional[str] = None
name: str = ""
number: str = ""
client: str = ""
division: str = ""
site: str = ""
sample: bool = False
created_by: str = ""
data: dict[str, Any] = Field(default_factory=dict)
class SopIn(BaseModel): class SopIn(BaseModel):
id: Optional[str] = None id: Optional[str] = None
project_id: Optional[str] = None
name: str = "" name: str = ""
number: str = "" number: str = ""
complete: bool = False complete: bool = False
@@ -52,6 +65,7 @@ class SopIn(BaseModel):
class WpIn(BaseModel): class WpIn(BaseModel):
id: Optional[str] = None id: Optional[str] = None
project_id: Optional[str] = None
sop_id: Optional[str] = None sop_id: Optional[str] = None
parent_id: Optional[str] = None parent_id: Optional[str] = None
number: str = "" number: str = ""
@@ -86,6 +100,50 @@ def health():
return {"ok": True} return {"ok": True}
# ── Projects ─────────────────────────────────────────────────────────────────
@app.post("/api/projects")
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
proj = db.get(models.Project, body.id) if body.id else None
if proj is None:
proj = models.Project(id=body.id or gen_id("proj"))
db.add(proj)
proj.name = body.name
proj.number = body.number
proj.client = body.client
proj.division = body.division
proj.site = body.site
proj.sample = body.sample
proj.created_by = body.created_by or proj.created_by
proj.data = body.data
db.commit()
db.refresh(proj)
return proj.to_dict()
@app.get("/api/projects")
def list_projects(db: Session = Depends(get_db)):
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
return [p.summary() for p in rows]
@app.get("/api/projects/{project_id}")
def get_project(project_id: str, db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
return proj.to_dict()
@app.delete("/api/projects/{project_id}")
def delete_project(project_id: str, db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
db.delete(proj)
db.commit()
return {"deleted": project_id}
# ── SOPs ───────────────────────────────────────────────────────────────────── # ── SOPs ─────────────────────────────────────────────────────────────────────
@app.post("/api/sops") @app.post("/api/sops")
def upsert_sop(body: SopIn, db: Session = Depends(get_db)): def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
@@ -93,6 +151,7 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
if sop is None: if sop is None:
sop = models.Sop(id=body.id or gen_id("sop")) sop = models.Sop(id=body.id or gen_id("sop"))
db.add(sop) db.add(sop)
sop.project_id = body.project_id
sop.name = body.name sop.name = body.name
sop.number = body.number sop.number = body.number
sop.complete = body.complete sop.complete = body.complete
@@ -104,16 +163,21 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
@app.get("/api/sops") @app.get("/api/sops")
def list_sops(db: Session = Depends(get_db)): def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all() stmt = select(models.Sop)
if project_id:
stmt = stmt.where(models.Sop.project_id == project_id)
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
return [s.summary() for s in rows] return [s.summary() for s in rows]
@app.get("/api/sops/latest") @app.get("/api/sops/latest")
def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)): def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
stmt = select(models.Sop) stmt = select(models.Sop)
if complete is not None: if complete is not None:
stmt = stmt.where(models.Sop.complete == complete) stmt = stmt.where(models.Sop.complete == complete)
if project_id:
stmt = stmt.where(models.Sop.project_id == project_id)
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first() sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
if not sop: if not sop:
raise HTTPException(status_code=404, detail="No SOP found") raise HTTPException(status_code=404, detail="No SOP found")
@@ -145,6 +209,7 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
if wp is None: if wp is None:
wp = models.WorkPackage(id=body.id or gen_id("wp")) wp = models.WorkPackage(id=body.id or gen_id("wp"))
db.add(wp) db.add(wp)
wp.project_id = body.project_id
wp.sop_id = body.sop_id wp.sop_id = body.sop_id
wp.parent_id = body.parent_id wp.parent_id = body.parent_id
wp.number = body.number wp.number = body.number
@@ -160,12 +225,15 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
@app.get("/api/wps") @app.get("/api/wps")
def list_wps( def list_wps(
project_id: Optional[str] = Query(None),
sop_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None),
parent_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
stmt = select(models.WorkPackage) stmt = select(models.WorkPackage)
if project_id:
stmt = stmt.where(models.WorkPackage.project_id == project_id)
if sop_id: if sop_id:
stmt = stmt.where(models.WorkPackage.sop_id == sop_id) stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
if parent_id: if parent_id:
@@ -177,11 +245,13 @@ def list_wps(
@app.get("/api/wps/metrics") @app.get("/api/wps/metrics")
def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
"""Aggregates for the dashboard. Masters (data.split == true) are excluded """Aggregates for the dashboard. Masters (data.split == true) are excluded
from counts so a split package's hours aren't double-counted with its from counts so a split package's hours aren't double-counted with its
instances.""" instances."""
stmt = select(models.WorkPackage) stmt = select(models.WorkPackage)
if project_id:
stmt = stmt.where(models.WorkPackage.project_id == project_id)
if sop_id: if sop_id:
stmt = stmt.where(models.WorkPackage.sop_id == sop_id) stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
rows = db.scalars(stmt).all() rows = db.scalars(stmt).all()

View File

@@ -21,10 +21,42 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
class Project(Base):
"""A construction project — the top-level container. SOPs and Work Packages
belong to a project so the suite can be used for many jobs at once."""
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(300), default="")
number: Mapped[str] = mapped_column(String(100), default="", index=True)
client: Mapped[str] = mapped_column(String(300), default="")
division: Mapped[str] = mapped_column(String(200), default="")
site: Mapped[str] = mapped_column(String(300), default="")
sample: Mapped[bool] = mapped_column(Boolean, default=False)
data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
def summary(self) -> dict:
return {
"id": self.id, "name": self.name, "number": self.number,
"client": self.client, "division": self.division, "site": self.site,
"sample": self.sample, "created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
def to_dict(self) -> dict:
return {**self.summary(), "data": self.data or {}}
class Sop(Base): class Sop(Base):
__tablename__ = "sops" __tablename__ = "sops"
id: Mapped[str] = mapped_column(String(40), primary_key=True) id: Mapped[str] = mapped_column(String(40), primary_key=True)
project_id: Mapped[Optional[str]] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
)
name: Mapped[str] = mapped_column(String(300), default="") name: Mapped[str] = mapped_column(String(300), default="")
number: Mapped[str] = mapped_column(String(100), default="") number: Mapped[str] = mapped_column(String(100), default="")
complete: Mapped[bool] = mapped_column(Boolean, default=False) complete: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -35,8 +67,8 @@ class Sop(Base):
def summary(self) -> dict: def summary(self) -> dict:
return { return {
"id": self.id, "name": self.name, "number": self.number, "id": self.id, "project_id": self.project_id, "name": self.name,
"complete": self.complete, "created_by": self.created_by, "number": self.number, "complete": self.complete, "created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
} }
@@ -48,6 +80,9 @@ class WorkPackage(Base):
__tablename__ = "work_packages" __tablename__ = "work_packages"
id: Mapped[str] = mapped_column(String(40), primary_key=True) id: Mapped[str] = mapped_column(String(40), primary_key=True)
project_id: Mapped[Optional[str]] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
)
sop_id: Mapped[Optional[str]] = mapped_column( sop_id: Mapped[Optional[str]] = mapped_column(
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
) )
@@ -65,9 +100,9 @@ class WorkPackage(Base):
def summary(self) -> dict: def summary(self) -> dict:
return { return {
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id, "id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
"number": self.number, "subject": self.subject, "type": self.type, "parent_id": self.parent_id, "number": self.number, "subject": self.subject,
"status": self.status, "issued_at": _iso(self.issued_at), "type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
"created_by": self.created_by, "created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
} }