Compare commits

...

7 Commits

Author SHA1 Message Date
ca8c36a889 Add in-site Admin Console (gated diagnostics + tests)
html/admin.html + admin.js — a passphrase-gated console served at /admin.html:
- API connectivity check (clearly flags the /api/ 404 if the proxy isn't routing).
- Database snapshot (project/SOP/WP/comment counts via the API).
- End-to-end smoke test in the browser (mirrors smoketest.py: issue gate,
  status, metrics, comments) with self-cleanup.
- Demo data: seed a DEMO project + clean DEMO-/SMOKE- projects.

Gate is SHA-256-based (default passphrase "prime-admin"; documented how to
change) — obfuscation only, not real auth; restrict at the network/proxy for
real protection. Not linked from the main nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:06:04 -07:00
39230adf07 Merge fix/round1-feedback into main
Round-1 test feedback + verification tooling:
- Constraints: fix custom constraints never appearing; free-text add + remove.
- Sources: column headers; preset (hard-coded) data types, Add Source for extras.
- Issuance strategy: tooltip + worked examples.
- Remove "comment submitted" popups; keep commenter name.
- WP: offer to issue when the last constraint clears; collapsible sections.
- server/smoketest.py (end-to-end API/SQL check) and server/seed_demo.py
  (loadable demo project); DEPLOYMENT.md documents both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:50:04 -07:00
2bdb65e580 Add seed_demo.py loadable demo project + document both test scripts
server/seed_demo.py seeds a realistic DEMO project (complete SOP + a spread of
Work Packages: issued, gated, multi-discipline master with split instances,
overdue, over-threshold draft) via the API. --clean removes it. DEPLOYMENT.md
documents both smoketest.py and seed_demo.py, including the localStorage caveat
(seeded project shows in the UI picker; seeded SOP/WPs are SQL-only until Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:28:11 -07:00
e3ef3b0023 Round-1 test feedback + smoke-test script
- API smoke test (server/smoketest.py): stdlib end-to-end check of health,
  projects, SOPs, WPs, the AWP issue gate (409 → 200), status, metrics,
  comments, and cascade delete. Referenced from DEPLOYMENT.md.

SOP config:
- Constraints: fix custom constraints never appearing — renderStandardConstraints
  no longer clobbers state.constraints; customs render in their own list with
  remove buttons; modal gains a free-text "Add" field.
- Sources: add column headers (Data Type / Location-Platform / URL / Notes);
  preset data types are now fixed labels, "Add Source" creates an editable
  custom row.
- Issuance strategy: add a tooltip + worked examples for each option.
- Remove the "Comment submitted" acknowledgement popup (home + suite); keep the
  commenter name between comments.

WP creator:
- Clearing the last open constraint now offers to mark the package Issued and
  scrolls to the status control.
- Form sections are collapsible (click a section heading to fold it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:04:46 -07:00
c64b5c8b49 Merge feat/gui-polish into main
Integrates the full feature set built on top of the Docker/Postgres deployment:
- Discipline strategy, per-discipline scope/status, Split by Discipline (WP01A/B/C),
  material-by-discipline.
- WP Dashboard (metrics, gating, filters) + backend issue/status/metrics endpoints.
- Multi-project support: projects entity + CRUD, project picker home page,
  per-project data isolation.
- WP sizing presets, Duplicate WP, custom WP types, menu/UX cleanup.
- GUI polish: Help section, tooltips, sticky save bar + section nav, status pills.
- Rewritten DEPLOYMENT.md for the SQL-backed Docker stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:36:39 -07:00
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
12 changed files with 1166 additions and 99 deletions

View File

@@ -1,81 +1,232 @@
# 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;"
```
### Automated smoke test
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
cleanup). Stdlib only — no pip/jq.
```bash
# Through the proxy (use --insecure for a self-signed internal cert):
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Or from inside the api container (hits FastAPI directly):
docker compose exec api python /app/server/smoketest.py http://localhost:8000
# Add --keep to leave a demo project in the DB so you can open it in the UI.
```
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
working. It cleans up after itself (the test project and its SOP/WPs are
deleted via cascade); a single tagged test comment remains (there's no comment
delete endpoint).
### Loadable demo project
`server/seed_demo.py` populates a realistic **DEMO** project (a complete SOP plus
a spread of Work Packages: issued, gated, a multi-discipline master with split
instances, an overdue one, an over-threshold draft) so there's data to look at.
```bash
python3 server/seed_demo.py https://wp-suite.company.local --insecure
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
```
> **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
> the home-page project picker right away (this is the visible proof that the
> projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
> written to SQL too, but the current front end still reads SOPs/WPs from the
> browser, so they won't render in the Creator/Dashboard until the Phase 2
> wiring. Inspect them at the SQL layer with `smoketest.py` or:
> ```bash
> docker compose exec db psql -U wpsuite -d wpsuite \
> -c "select number, subject, status from work_packages order by number;"
> ```
---
## 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).

107
html/admin.html Normal file
View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Console — Work Package Suite</title>
<link rel="icon" href="favicon.ico" sizes="any">
<style>
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
--muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
--red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
h1{ font-size:20px; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
button.primary:hover{ background:#1e54bb; color:#fff; }
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); }
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 4px; font-size:17px; }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
</style>
</head>
<body>
<!-- GATE -->
<div class="gate-overlay" id="admin-gate">
<div class="gate-box">
<h2>🔒 Admin Console</h2>
<p>Enter the admin passphrase to continue.</p>
<input type="password" id="gate-input" placeholder="Passphrase" autocomplete="off"
onkeydown="if(event.key==='Enter') tryUnlock()">
<div class="gate-msg" id="gate-msg"></div>
<button class="primary" style="width:100%" onclick="tryUnlock()">Unlock</button>
</div>
</div>
<!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between">
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a> <button onclick="lock()">Lock</button></div>
</div>
<div class="secwarn">⚠ This page is gated client-side only — that stops casual access, not a determined user. For real protection, restrict this host/route at the network or reverse-proxy layer.</div>
<!-- CONNECTIVITY -->
<div class="card">
<h2>API connectivity</h2>
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<div class="banner" id="health-banner"></div>
</div>
<!-- DB SNAPSHOT -->
<div class="card">
<h2>Database snapshot</h2>
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
</div>
<!-- SMOKE TEST -->
<div class="card">
<h2>End-to-end smoke test</h2>
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<pre class="out" id="smoke-out">Ready.</pre>
</div>
<!-- DEMO DATA -->
<div class="card">
<h2>Demo data</h2>
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
<div class="row">
<button class="primary" onclick="seedDemo()">Seed demo project</button>
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
</div>
<pre class="out" id="demo-out">Ready.</pre>
<div class="note">Note: the seeded <strong>project</strong> appears in the home picker; its SOP/WPs live in SQL but won't render in the Creator/Dashboard until the front end is wired to the API (Phase 2).</div>
</div>
</div>
<script src="admin.js"></script>
</body>
</html>

170
html/admin.js Normal file
View File

@@ -0,0 +1,170 @@
/* Admin console for the Work Package Suite.
Browser-side diagnostics + tests that call the same /api on this host.
PASSPHRASE GATE (lightweight / obfuscation only):
The gate compares a SHA-256 hash so the passphrase isn't in the source, but a
determined user can still bypass client-side JS. For real protection, restrict
this host/route at the network or reverse-proxy layer.
Default passphrase: "prime-admin"
To change it: compute a new hash and replace ADMIN_PASSPHRASE_SHA256 below —
python3 -c "import hashlib,sys;print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "your-new-passphrase"
or in a browser console:
crypto.subtle.digest('SHA-256', new TextEncoder().encode('your-new-passphrase'))
.then(b=>console.log([...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,'0')).join('')));
*/
const ADMIN_PASSPHRASE_SHA256 = 'ae1fb92c43fccbad26f05434a194f574ec98a2197e0ff4080f84e6e26a8dd00f';
// ── gate ──────────────────────────────────────────────────────────────────────
async function sha256hex(s){
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
return [...new Uint8Array(buf)].map(b=>b.toString(16).padStart(2,'0')).join('');
}
async function tryUnlock(){
const v = document.getElementById('gate-input').value || '';
const msg = document.getElementById('gate-msg');
if(!v){ msg.textContent='Enter the passphrase.'; return; }
let h;
try { h = await sha256hex(v); }
catch(e){ msg.textContent='This page must be served over HTTPS (or localhost) to unlock.'; return; }
if(h === ADMIN_PASSPHRASE_SHA256){ sessionStorage.setItem('wp_admin_ok','1'); reveal(); }
else { msg.textContent='Incorrect passphrase.'; }
}
function reveal(){
document.getElementById('admin-gate').style.display='none';
document.getElementById('admin-main').style.display='';
checkHealth();
}
function lock(){ sessionStorage.removeItem('wp_admin_ok'); location.reload(); }
// ── api helper ──────────────────────────────────────────────────────────────
async function api(method, path, body){
const opt = { method, headers:{ 'Accept':'application/json' } };
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
try {
const r = await fetch(path, opt);
const t = await r.text();
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
return { status:r.status, json };
} catch(e){ return { status:0, json:String(e) }; }
}
// ── connectivity ──────────────────────────────────────────────────────────────
async function checkHealth(){
const b = document.getElementById('health-banner');
b.className='banner'; b.textContent='Checking…';
const { status, json } = await api('GET','/api/health');
if(status===200 && json && json.ok){
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
} else if(status===404){
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
} else if(status===0){
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
} else {
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
}
}
// ── db snapshot ───────────────────────────────────────────────────────────────
async function snapshot(){
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
const [p,s,w,c] = await Promise.all([
api('GET','/api/projects'), api('GET','/api/sops'),
api('GET','/api/wps'), api('GET','/api/comments')]);
if(p.status!==200){
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
}
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
out.innerHTML = `<table class="kv">
<tr><th>Projects</th><td>${n(p)}</td></tr>
<tr><th>SOPs</th><td>${n(s)}</td></tr>
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
}
// ── smoke test ────────────────────────────────────────────────────────────────
function smLog(html){ const o=document.getElementById('smoke-out'); o.innerHTML += html + '\n'; o.scrollTop=o.scrollHeight; }
async function runSmokeTest(){
const o=document.getElementById('smoke-out'); o.innerHTML=''; let pass=0, fail=0, pid=null;
const chk=(name,cond,detail)=>{ if(cond){ pass++; smLog('<span class="p">PASS</span> '+name); }
else { fail++; smLog('<span class="f">FAIL</span> '+name+(detail?' ('+detail+')':'')); } return cond; };
try {
let r = await api('GET','/api/health');
if(!chk('health endpoint ok', r.status===200 && r.json && r.json.ok, 'status '+r.status)){
smLog('\nAborting — API unreachable (fix /api/ routing).'); return finishSmoke(pass,fail);
}
r = await api('POST','/api/projects',{name:'ZZ Smoke Test Project',number:'SMOKE-001',client:'Internal QA',created_by:'admin-console'});
pid = r.json && r.json.id; chk('create project', r.status===200 && !!pid, 'status '+r.status);
r = await api('GET','/api/projects/'+pid); chk('fetch project by id', r.status===200 && r.json.number==='SMOKE-001');
r = await api('GET','/api/projects'); chk('project in list', r.status===200 && r.json.some(p=>p.id===pid));
r = await api('POST','/api/sops',{project_id:pid,name:'ZZ Smoke SOP',number:'SMOKE-001',complete:true,data:{governance:{disciplines:['Mechanical','Electrical','Tech']}}});
const sid = r.json && r.json.id; chk('create SOP linked to project', r.status===200 && !!sid && r.json.project_id===pid);
r = await api('GET','/api/sops/latest?project_id='+pid); chk('latest SOP resolves', r.status===200 && r.json.id===sid);
r = await api('POST','/api/wps',{project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'open',comment:'awaiting delivery'},{name:'Safety',status:'cleared',comment:''}]}});
const wid = r.json && r.json.id; chk('create work package', r.status===200 && !!wid);
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue blocked while a constraint is open (409)', r.status===409, 'status '+r.status);
await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'cleared',comment:''},{name:'Safety',status:'cleared',comment:''}]}});
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue succeeds once cleared', r.status===200 && r.json.status==='Issued', 'status '+r.status);
chk('issued_at timestamp set', !!(r.json && r.json.issued_at));
r = await api('POST','/api/wps/'+wid+'/status',{status:'In Progress'}); chk('status transition', r.status===200 && r.json.status==='In Progress');
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
} catch(e){ chk('unexpected error', false, String(e)); }
finally {
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
finishSmoke(pass,fail);
}
}
function finishSmoke(pass,fail){
const total=pass+fail;
smLog('\n'+pass+'/'+total+' checks passed.');
smLog(fail ? '<span class="f">RESULT: FAIL ('+fail+')</span>' : '<span class="p">RESULT: ALL PASS — API, Python logic, and SQL are working.</span>');
}
// ── demo data ─────────────────────────────────────────────────────────────────
function demoLog(s){ const o=document.getElementById('demo-out'); o.innerHTML += s + '\n'; o.scrollTop=o.scrollHeight; }
function stdConstraints(open){ return ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)']
.map(n=>({name:n, status:(open&&open.includes(n))?'open':'cleared', comment:''})); }
async function seedDemo(){
const o=document.getElementById('demo-out'); o.innerHTML='';
let r = await api('GET','/api/health');
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
const pid=r.json.id; demoLog('Project created: '+r.json.name);
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 35 days (≈4080 hrs)',sizeHoursMax:'80'}}});
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
const mk=async(num,subj,typ,status,data,parent)=>{ const body={project_id:pid,sop_id:sid,number:num,subject:subj,type:typ,status,created_by:'admin-console',data}; if(parent)body.parent_id=parent; const rr=await api('POST','/api/wps',body); demoLog(' WP '+num+' ['+status+']'); return rr.json; };
await mk('WP01-1P-CONDUIT','1P horn/strobe conduit','Conduit Install','Issued',{disciplines:['Electrical'],hours:'40',constraints:stdConstraints(),due:'2026-06-30'});
await mk('WP02-1P-WIRE','1P wire pull','Wire Pull','Scheduled',{disciplines:['Electrical'],hours:'60',constraints:stdConstraints(['Materials (on site, bagged & tagged)']),due:'2026-07-04'});
const masterId='wp_demo_master_chiller';
const kids=[['WP03-CHILLER_Mech','Mechanical','A','Mechanical Install','In Progress'],['WP03-CHILLER_Elec','Electrical','B','Wire Pull','Scheduled'],['WP03-CHILLER_Tech','Tech','C','Terminations','Draft']];
const kidIds=[];
for(const [num,disc,label,typ,status] of kids){ const id='wp_demo_'+label.toLowerCase(); kidIds.push(id);
await api('POST','/api/wps',{id,project_id:pid,sop_id:sid,parent_id:masterId,number:num,subject:'Chiller skid — '+disc,type:typ,status,created_by:'admin-console',data:{disciplines:[disc],instanceOf:masterId,instanceLabel:label,parentNumber:'WP03-CHILLER',hours:'50',constraints:stdConstraints(),due:'2026-07-10'}});
demoLog(' WP '+num+' ['+status+'] (instance '+label+')'); }
await api('POST','/api/wps',{id:masterId,project_id:pid,sop_id:sid,number:'WP03-CHILLER',subject:'Chiller skid (multi-discipline master)',type:'Mechanical Install',status:'Scheduled',created_by:'admin-console',data:{disciplines:['Mechanical','Electrical','Tech'],split:true,children:kidIds,hours:'150',constraints:stdConstraints(),due:'2026-07-10'}});
demoLog(' WP WP03-CHILLER [master, split into A/B/C]');
await mk('WP04-2P-TERM','2P terminations','Terminations','In Progress',{disciplines:['Tech'],hours:'30',actualHrs:'20',constraints:stdConstraints(),due:'2026-06-10'});
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
r = await api('GET','/api/wps/metrics?project_id='+pid);
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
snapshot();
}
async function cleanDemo(){
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
const o=document.getElementById('demo-out'); o.innerHTML='';
const r = await api('GET','/api/projects');
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
if(!targets.length){ demoLog('Nothing to remove.'); return; }
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
demoLog('\n✅ Removed '+targets.length+' project(s).');
snapshot();
}
// reveal immediately if already unlocked this session
if(sessionStorage.getItem('wp_admin_ok')==='1'){ reveal(); }
else { const i=document.getElementById('gate-input'); if(i) i.focus(); }

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

@@ -386,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>
@@ -475,6 +476,7 @@
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
<script src="project-data.js"></script> <script src="project-data.js"></script>
<script src="help.js"></script>
<script> <script>
// ── PROJECT SELECTION ───────────────────────────────────────────────────── // ── PROJECT SELECTION ─────────────────────────────────────────────────────
const esc = ProjectData.esc; const esc = ProjectData.esc;
@@ -662,9 +664,7 @@
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment }); if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
document.getElementById('comment-text').value = ''; document.getElementById('comment-text').value = '';
document.getElementById('commenter-name').value = '';
loadComments(); loadComments();
alert('Thank you! Feedback submitted.');
} }
function exportFeedback() { function exportFeedback() {

View File

@@ -477,18 +477,45 @@ function removeRole(i){
renderOptionalRoles(); renderOptionalRoles();
} }
// Seed the standard 10 once; after that, render reflects state.constraints
// (checkbox = whether each standard one is active) and never clobbers customs.
let _constraintsSeeded = false;
function renderStandardConstraints(){ function renderStandardConstraints(){
const container = document.getElementById('standard-constraints'); const container = document.getElementById('standard-constraints');
if(!_constraintsSeeded){
if(!state.constraints || !state.constraints.length){
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
}
_constraintsSeeded = true;
}
const active = name => state.constraints.some(c=>c.name===name);
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>` container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;"> <div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;"> <input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
<div style="flex:1;"> <div style="flex:1;">
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label> <label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div> <div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
</div> </div>
</div> </div>
`).join(''); `).join('');
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c})); renderCustomConstraints();
}
// Render the custom (non-standard) constraints into their own list with remove buttons.
function renderCustomConstraints(){
const el = document.getElementById('custom-constraints-list'); if(!el) return;
const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name);
const customs = state.constraints.filter(c=>!stdNames.includes(c.name));
el.innerHTML = customs.length ? customs.map(c=>`
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<strong>${escAttr(c.name)}</strong>
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
function removeCustomConstraint(name){
state.constraints = state.constraints.filter(c=>c.name!==name);
renderCustomConstraints();
} }
function toggleConstraint(name){ function toggleConstraint(name){
@@ -514,13 +541,24 @@ function closeConstraintModal(){
} }
function addCustomConstraint(name){ function addCustomConstraint(name){
if(!state.constraints.find(c=>c.name===name)){ if(name && !state.constraints.find(c=>c.name===name)){
state.constraints.push({name,description:''}); state.constraints.push({name,description:''});
} }
closeConstraintModal(); closeConstraintModal();
renderStandardConstraints(); renderStandardConstraints();
} }
// Free-text custom constraint from the modal's input.
function addCustomConstraintText(){
const inp = document.getElementById('custom-constraint-input');
const name = (inp && inp.value || '').trim();
if(!name){ if(inp) inp.focus(); return; }
if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; }
state.constraints.push({name, description:''});
if(inp) inp.value='';
renderStandardConstraints();
}
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning']; const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
let seqDragIndex = null; let seqDragIndex = null;
@@ -600,20 +638,30 @@ const DEFAULT_SOURCES = [
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function renderSources(){ function renderSources(){
const container = document.getElementById('sources-list'); const container = document.getElementById('sources-list');
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph})); if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
container.innerHTML = state.sources.map((s,i)=>` const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;";
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);"> const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;";
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> const header = `<div style="${grid} padding:0 1rem 0.4rem; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--text-dim);">
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> <div>Data Type</div><div>Location / Platform</div><div>URL</div><div>Notes</div><div></div>
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> </div>`;
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"> container.innerHTML = header + state.sources.map((s,i)=>{
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button> // Preset data types are fixed labels; custom rows (Add Source) get an editable name.
</div> const dataType = s.preset
`).join(''); ? `<div style="font-weight:600; font-size:13px;">${escAttr(s.label)}</div>`
: `<input type="text" value="${escAttr(s.label)}" placeholder="Custom data type" onchange="state.sources[${i}].label=this.value" style="${inStyle} font-weight:600;">`;
return `<div style="${grid} padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
${dataType}
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'Procore / Bluebeam / SharePoint…')}" onchange="state.sources[${i}].system=this.value" style="${inStyle}">
<input type="text" value="${escAttr(s.link)}" placeholder="Paste the 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="${inStyle}">
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="${inStyle}">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()" title="Remove">✕</button>
</div>`;
}).join('');
} }
function addSource(){ function addSource(){
state.sources.push({label:'',system:'',notes:'',link:''}); // Added rows are custom — the user types their own data type here.
state.sources.push({label:'', system:'', notes:'', link:'', preset:false});
renderSources(); renderSources();
} }
@@ -823,9 +871,7 @@ function submitComment(){
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment}); if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
document.getElementById('comment-text').value = ''; document.getElementById('comment-text').value = '';
document.getElementById('commenter-name').value = '';
loadStepComments(); loadStepComments();
alert('✓ Comment submitted!');
} }
function exportComments(){ function exportComments(){

View File

@@ -25,6 +25,7 @@
<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 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>
@@ -171,14 +172,23 @@
<small>Use ## for counter, [Sector] [TYPE] as variables</small> <small>Use ## for counter, [Sector] [TYPE] as variables</small>
</div> </div>
<div class="field"> <div class="field">
<label>Issuance Strategy</label> <label>Issuance Strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
<select id="gov_issuance" multiple size="3"> <select id="gov_issuance" multiple size="4">
<option selected>By Sector / Area</option> <option selected>By Sector / Area</option>
<option>By Discipline</option> <option>By Discipline</option>
<option>By Phase / Sequence</option> <option>By Phase / Sequence</option>
<option>By Resource Availability</option> <option>By Resource Availability</option>
</select> </select>
<small>Hold Ctrl to select multiple</small> <small>Hold Ctrl (Cmd on Mac) to select multiple.</small>
<div class="notice" style="margin-top:0.6rem; font-size:12px;">
<strong>Examples:</strong>
<ul style="margin:0.35rem 0 0; padding-left:1.1rem;">
<li><strong>By Sector / Area</strong> — one package per physical area, e.g. <em>all work in Sector 1P, Level 2 chase</em>.</li>
<li><strong>By Discipline</strong> — separate packages per trade, e.g. <em>Electrical wire-pull</em> vs <em>Mechanical install</em>.</li>
<li><strong>By Phase / Sequence</strong> — follow the build order, e.g. <em>rough-in → wire pull → terminations</em>.</li>
<li><strong>By Resource Availability</strong> — size to a crew/equipment window, e.g. <em>one boom-lift crew's week</em>.</li>
</ul>
</div>
</div> </div>
</div> </div>
@@ -191,7 +201,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>
@@ -216,7 +226,7 @@
<small>Sets the split threshold automatically; choose Custom to enter your own.</small> <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., 80"> <input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
<small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small> <small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
</div> </div>
@@ -373,13 +383,19 @@
<h3>Add Custom Constraint</h3> <h3>Add Custom Constraint</h3>
<button class="modal-close" onclick="closeConstraintModal()"></button> <button class="modal-close" onclick="closeConstraintModal()"></button>
</div> </div>
<div id="constraint-library" style="max-height: 400px; overflow-y: auto; margin: 1rem 0;"></div> <div style="display:flex; gap:0.5rem; margin:1rem 0 0.5rem;">
<input type="text" id="custom-constraint-input" placeholder="Type a custom constraint name…" style="flex:1; padding:0.55rem 0.65rem; border:1px solid var(--border); border-radius:4px;" onkeydown="if(event.key==='Enter'){addCustomConstraintText();event.preventDefault();}">
<button class="add-btn" onclick="addCustomConstraintText()">Add</button>
</div>
<div style="font-size:12px; color:var(--text-dim); margin-bottom:0.5rem;">…or pick from the library:</div>
<div id="constraint-library" style="max-height: 320px; overflow-y: auto; margin: 0 0 1rem;"></div>
<button class="nav-btn" onclick="closeConstraintModal()">Done</button> <button class="nav-btn" onclick="closeConstraintModal()">Done</button>
</div> </div>
</div> </div>
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
<script src="project-data.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

@@ -529,6 +529,20 @@ function setConstraint(i,val){
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){ if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
prevStatus=getRadio('status'); holdContext={index:i, before}; prevStatus=getRadio('status'); holdContext={index:i, before};
openHoldModal(pkgConstraints[i].name, true); openHoldModal(pkgConstraints[i].name, true);
return;
}
// Clearing the LAST open constraint makes the package release-ready — offer to
// issue it and scroll up to the status control so the change is visible.
if(before==='open' && val!=='open' && readiness().open===0){
const st=getRadio('status');
if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
track('status_change',{status:'Issued',via:'constraint_clear'});
}
const sg=document.getElementById('status-group');
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
}
} }
} }
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; } function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
@@ -539,6 +553,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);
@@ -761,8 +776,54 @@ 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(); makeCollapsible(); }
}
// Make each form card collapsible by clicking its heading (idempotent).
function makeCollapsible(){
document.querySelectorAll('.main > .card').forEach(card=>{
if(card.id==='saved-card') return;
const head=card.querySelector('.section-header, .sub-heading');
if(!head || head.dataset.collapsible) return;
head.dataset.collapsible='1';
head.style.cursor='pointer';
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
head.insertBefore(chev, head.firstChild);
head.addEventListener('click', e=>{
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
const collapsed=card.classList.toggle('collapsed');
chev.textContent = collapsed ? '▸' : '▾';
});
});
}
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';
@@ -780,7 +841,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('');
} }
@@ -898,13 +959,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='';
@@ -923,19 +997,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>`;
@@ -965,6 +1045,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>
@@ -980,7 +1063,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>`;

View File

@@ -38,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 -->
@@ -45,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>
@@ -88,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>
@@ -113,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">
@@ -157,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>
@@ -276,8 +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="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,31 @@
.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; }
/* Collapsible form sections */
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
.card.collapsed .section-desc { display:none; }
/* 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 +611,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; }

175
server/seed_demo.py Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Seed a realistic DEMO project into the Work Package Suite database via the API.
Creates one project, a complete SOP, and a spread of Work Packages that exercise
the features and dashboard: an issued package, a gated (open-constraint) package,
a multi-discipline master with its split instances (A/B/C), an overdue package,
and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end
and to have data to inspect.
USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure
docker compose exec api python /app/server/seed_demo.py http://localhost:8000
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects
IMPORTANT — what shows where:
* The DEMO **project** is API/SQL-backed, so it appears in the home-page
project picker immediately (proves the projects → SQL path in the UI).
* The DEMO **SOP and Work Packages** are written to SQL too, but the current
front end still reads SOPs/WPs from the browser (localStorage), so they will
NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2
wiring. Verify them at the SQL/API layer instead:
python3 server/smoketest.py <url> # automated end-to-end check
docker compose exec db psql -U wpsuite -d wpsuite \
-c "select number,subject,status from work_packages order by number;"
"""
import argparse
import json
import ssl
import sys
import urllib.error
import urllib.request
BASE = ""
CTX = None
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
def call(method, path, body=None):
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Content-Type": "application/json", "Accept": "application/json"})
try:
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
raw = r.read().decode(); status = r.status
except urllib.error.HTTPError as e:
raw = e.read().decode(); status = e.code
try:
parsed = json.loads(raw) if raw else None
except ValueError:
parsed = raw
return status, parsed
def constraints(open_names=()):
base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs",
"Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"]
return [{"name": n, "status": ("open" if n in open_names else "cleared"),
"comment": ("awaiting delivery" if n in open_names else "")} for n in base]
def main():
global BASE, CTX
ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite")
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
help="Site root, no /api (default: http://localhost:8000)")
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
# health gate
try:
st, _ = call("GET", "/api/health")
except urllib.error.URLError as e:
print(f"ABORT: cannot reach {BASE}/api/health — {e}"); return 1
if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
st, projects = call("GET", "/api/projects")
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
if args.clean:
for p in demos:
call("DELETE", f"/api/projects/{p['id']}")
print(f"Removed {len(demos)} DEMO project(s).")
return 0
if demos:
print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n")
# 1) Project
st, proj = call("POST", "/api/projects", {
"name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER,
"client": "Micron Technology, Inc.", "division": "Semiconductor",
"site": "Boise, ID — Fab", "created_by": "seed_demo"})
pid = proj["id"]
print(f"Project: {proj['name']} ({pid})")
# 2) SOP (complete)
st, sop = call("POST", "/api/sops", {
"project_id": pid, "name": "DEMO SOP", "number": DEMO_NUMBER, "complete": True,
"created_by": "seed_demo",
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
"disciplines": ["Mechanical", "Electrical", "Tech"],
"discMode": "choice", "instanceSuffix": "letter",
"woSize": "Standard — 35 days (≈4080 hrs)", "sizeHoursMax": "80"}}})
sid = sop["id"]
print(f"SOP: complete ({sid})")
# 3) Work packages
def wp(number, subject, typ, status, data, parent_id=None):
body = {"project_id": pid, "sop_id": sid, "number": number, "subject": subject,
"type": typ, "status": status, "created_by": "seed_demo", "data": data}
if parent_id:
body["parent_id"] = parent_id
st, w = call("POST", "/api/wps", body)
print(f" WP {number:<16} {status:<12} {subject}")
return w
# a) issued, all clear
wp("WP01-1P-CONDUIT", "1P horn/strobe conduit", "Conduit Install", "Issued",
{"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
"constraints": constraints(), "due": "2026-06-30"})
# b) gated — one open constraint, still Scheduled
wp("WP02-1P-WIRE", "1P wire pull", "Wire Pull", "Scheduled",
{"disciplines": ["Electrical"], "hours": "60", "actualHrs": "",
"constraints": constraints(open_names=["Materials (on site, bagged & tagged)"]), "due": "2026-07-04"})
# c) multi-discipline master + split instances (master excluded from metrics)
master_id = "wp_demo_master_chiller"
instances = [("WP03-CHILLER_Mech", "Mechanical", "A", "Mechanical Install", "In Progress"),
("WP03-CHILLER_Elec", "Electrical", "B", "Wire Pull", "Scheduled"),
("WP03-CHILLER_Tech", "Tech", "C", "Terminations", "Draft")]
child_ids = []
for num, disc, label, typ, status in instances:
cid = f"wp_demo_{label.lower()}"
child_ids.append(cid)
body = {"project_id": pid, "sop_id": sid, "parent_id": master_id, "id": cid,
"number": num, "subject": "Chiller skid — " + disc, "type": typ, "status": status,
"created_by": "seed_demo",
"data": {"disciplines": [disc], "instanceOf": master_id, "instanceLabel": label,
"parentNumber": "WP03-CHILLER", "hours": "50", "actualHrs": "",
"constraints": constraints(), "due": "2026-07-10"}}
call("POST", "/api/wps", body)
print(f" WP {num:<16} {status:<12} (instance {label})")
wp("WP03-CHILLER", "Chiller skid (multi-discipline master)", "Mechanical Install", "Scheduled",
{"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True, "children": child_ids,
"hours": "150", "constraints": constraints(), "due": "2026-07-10"})
call("POST", "/api/wps", {"project_id": pid, "sop_id": sid, "id": master_id,
"number": "WP03-CHILLER", "subject": "Chiller skid (multi-discipline master)",
"type": "Mechanical Install", "status": "Scheduled", "created_by": "seed_demo",
"data": {"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True,
"children": child_ids, "hours": "150", "constraints": constraints(),
"due": "2026-07-10"}})
# d) overdue, in progress
wp("WP04-2P-TERM", "2P terminations", "Terminations", "In Progress",
{"disciplines": ["Tech"], "hours": "30", "actualHrs": "20",
"constraints": constraints(), "due": "2026-06-10"}) # past today (2026-06-16) → overdue
# e) over-threshold draft (hours > 80)
wp("WP05-3P-PANEL", "3P panel install", "Panel Install", "Draft",
{"disciplines": ["Electrical"], "hours": "120", "actualHrs": "",
"constraints": constraints(open_names=["Schedule"]), "due": "2026-07-20"})
# metrics readback
st, m = call("GET", f"/api/wps/metrics?project_id={pid}")
print(f"\nMetrics (masters excluded): {m}")
print(f"\nDone. The DEMO project '{proj['name']}' now appears in the home-page picker.")
print("SOP/WPs are in SQL (see header note) — verify with smoketest.py or psql.")
print("Remove later with: python3 server/seed_demo.py <url> --clean")
return 0
if __name__ == "__main__":
sys.exit(main())

196
server/smoketest.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the Work Package Suite API + PostgreSQL.
Exercises the real HTTP endpoints the way the front end does, proving that
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
USAGE
# Against the deployed site (through the NGINX proxy):
python3 server/smoketest.py https://wp-suite.company.local
# Self-signed / internal TLS cert? skip verification:
python3 server/smoketest.py https://wp-suite.company.local --insecure
# From inside the api container (hits FastAPI directly):
docker compose exec api python /app/server/smoketest.py http://localhost:8000
# Leave the demo project in the database so you can open it in the UI:
python3 server/smoketest.py https://wp-suite.company.local --keep
The base URL is the SITE root (no /api). Default: http://localhost:8000
Exit code 0 = all checks passed, 1 = one or more failed.
"""
import argparse
import json
import ssl
import sys
import urllib.error
import urllib.request
# ── tiny colored reporter ─────────────────────────────────────────────────────
_PASS, _FAIL = [], []
def _c(s, code): # color if a TTY
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
def ok(msg): _PASS.append(msg); print(" " + _c("PASS", "32") + " " + msg)
def bad(msg): _FAIL.append(msg); print(" " + _c("FAIL", "31") + " " + msg)
def check(name, cond, detail=""):
(ok if cond else bad)(name + (f" ({detail})" if detail and not cond else ""))
return cond
BASE = ""
CTX = None
def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, data=data, method=method,
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
raw = r.read().decode(); status = r.status
except urllib.error.HTTPError as e:
raw = e.read().decode(); status = e.code
try:
parsed = json.loads(raw) if raw else None
except ValueError:
parsed = raw
return status, parsed
def main():
global BASE, CTX
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
help="Site root, no /api (default: http://localhost:8000)")
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
project_id = None
try:
# 1) Health — API is up and reachable through the proxy.
try:
st, body = call("GET", "/api/health")
except urllib.error.URLError as e:
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
" Is the stack up (docker compose ps) and the URL correct?\n")
return 1
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
f"status={st} body={body}")
# 2) Create a project (writes to the projects table).
st, proj = call("POST", "/api/projects", {
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
"client": "Internal QA", "division": "Controls", "site": "Test Host",
"created_by": "smoketest",
})
project_id = proj.get("id") if isinstance(proj, dict) else None
check("create project", st == 200 and bool(project_id), f"status={st}")
# 3) Read it back + confirm it's in the list (SQL round-trip).
st, got = call("GET", f"/api/projects/{project_id}")
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
st, lst = call("GET", "/api/projects")
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
# 4) Create a SOP linked to the project.
st, sop = call("POST", "/api/sops", {
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
"complete": True, "created_by": "smoketest",
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
"disciplines": ["Mechanical", "Electrical", "Tech"]}},
})
sop_id = sop.get("id") if isinstance(sop, dict) else None
check("create SOP linked to project", st == 200 and bool(sop_id) and sop.get("project_id") == project_id,
f"status={st}")
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
# 5) Create a Work Package with one OPEN constraint (not release-ready).
st, wp = call("POST", "/api/wps", {
"project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
"status": "Scheduled", "created_by": "smoketest",
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
"constraints": [{"name": "Materials", "status": "open", "comment": "awaiting delivery"},
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
})
wp_id = wp.get("id") if isinstance(wp, dict) else None
check("create work package", st == 200 and bool(wp_id), f"status={st}")
# 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
# 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
call("POST", "/api/wps", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
"status": "Scheduled",
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""},
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
})
st, issued = call("POST", f"/api/wps/{wp_id}/issue")
check("issue succeeds once constraints clear", st == 200 and issued.get("status") == "Issued",
f"status={st}")
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
# 8) Status transition endpoint.
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
# 9) Metrics aggregate for the project (Python aggregation over SQL rows).
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
f"status={st} metrics={m}")
# 10) Comment / feedback write + read.
st, c = call("POST", "/api/feedback", {
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
check("post comment/feedback", st == 200 and isinstance(c, dict) and bool(c.get("id")), f"status={st}")
st, comments = call("GET", f"/api/comments?wp_id={wp_id}")
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
f"status={st}")
# 11) WPs filter by project.
st, wps = call("GET", f"/api/wps?project_id={project_id}")
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
finally:
# 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
if project_id and not args.keep:
st, _ = call("DELETE", f"/api/projects/{project_id}")
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
st, after = call("GET", f"/api/wps?project_id={project_id}")
check("WPs removed by cascade", st == 200 and isinstance(after, list) and len(after) == 0,
f"status={st} remaining={after}")
elif project_id and args.keep:
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
# ── summary ────────────────────────────────────────────────────────────────
total = len(_PASS) + len(_FAIL)
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
if _FAIL:
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
for f in _FAIL:
print(" - " + f)
print("\nResult: " + _c("FAIL", "31") + "\n")
return 1
print("\nResult: " + _c("ALL PASS — API, Python logic, and SQL are working.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())