Compare commits
33 Commits
savepoint-
...
shared-dat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51024466b | ||
| eefa76e460 | |||
| 5ad3ffa58e | |||
| bdb798efdd | |||
| 151ccea0ac | |||
| 1f8c23c9bb | |||
| 20afb0e565 | |||
| deaf13c724 | |||
| a02f7ec511 | |||
| c010bc22a0 | |||
| 66da5b708a | |||
| 1e31aa535e | |||
| ca8c36a889 | |||
| 39230adf07 | |||
| 2bdb65e580 | |||
| e3ef3b0023 | |||
| c64b5c8b49 | |||
| a32c275f76 | |||
| e5f77846ad | |||
| 561d4f2408 | |||
| a18ae487f6 | |||
| 68c1c803d6 | |||
| e5c450597a | |||
| 3c40b58ff8 | |||
| 4d111d608d | |||
| a37cf14e89 | |||
| 362aa633ed | |||
| fd668f0ea2 | |||
| 960b4a4b94 | |||
| 8598606165 | |||
| 65996c2c0a | |||
| b0a3d74412 | |||
| ffcaa571d1 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,3 +11,9 @@ venv/
|
|||||||
# Local SQLite dev database
|
# Local SQLite dev database
|
||||||
*.db
|
*.db
|
||||||
wpsuite.db
|
wpsuite.db
|
||||||
|
|
||||||
|
# Runtime directories (created by containers)
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Local server logs
|
||||||
|
*.log
|
||||||
|
|||||||
103
DEPLOY-login-portal.md
Normal file
103
DEPLOY-login-portal.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Deploy: Work Package Suite — login portal update
|
||||||
|
|
||||||
|
Instructions for the **Portainer admin** to take the new secure login portal live.
|
||||||
|
No prior context needed.
|
||||||
|
|
||||||
|
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
||||||
|
|
||||||
|
**What changed:** the app now has a username/password login. Going live needs:
|
||||||
|
1. one new environment variable,
|
||||||
|
2. a **rebuild** of the stack (not just a restart), and
|
||||||
|
3. creating the first admin account.
|
||||||
|
|
||||||
|
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
||||||
|
> bake the code in at build time (`COPY html/` and `COPY server/` in their
|
||||||
|
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
|
||||||
|
> be **rebuilt** from the latest `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Add an environment variable to the stack
|
||||||
|
|
||||||
|
In the stack's **Environment variables** section, add:
|
||||||
|
|
||||||
|
| Name | Value | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
|
||||||
|
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
|
||||||
|
|
||||||
|
Generate the secret on the host with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
|
||||||
|
> per-process key — logins then reset on every restart and break across the 2 gunicorn
|
||||||
|
> workers. It must be set to a fixed value.
|
||||||
|
|
||||||
|
The existing database variables (`POSTGRES_*`) are unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pull latest `main`, rebuild, and redeploy
|
||||||
|
|
||||||
|
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
|
||||||
|
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
|
||||||
|
`api` images.
|
||||||
|
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
|
||||||
|
automatically during the rebuild.
|
||||||
|
- The `users` table is created automatically on API startup — **no DB migration needed.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verify the containers
|
||||||
|
|
||||||
|
- Confirm `wp_api` and the webserver container are both **running**.
|
||||||
|
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
|
||||||
|
warning — it won't crash — but please confirm it's set.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create the first admin account
|
||||||
|
|
||||||
|
The login system needs one admin user in the production (Postgres) database. Open the
|
||||||
|
**`wp_api`** container's **Console** (`/bin/sh`) and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m server.manage_users create-admin <username> --name "<Full Name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
|
||||||
|
|
||||||
|
Non-interactive alternative:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Other CLI commands (run the same way): `list`, `create <user> --role user`,
|
||||||
|
`reset-password <user>`, `disable <user>`, `enable <user>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Confirm it works
|
||||||
|
|
||||||
|
1. Load the site's normal URL — it should redirect to a **login page**.
|
||||||
|
2. Sign in with the admin account from step 4.
|
||||||
|
3. That admin can then add all other users from the in-app **Admin → User
|
||||||
|
administration** page (top-right **Admin** link), so no further shell access is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference — what's in this release
|
||||||
|
|
||||||
|
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
|
||||||
|
- `server/app.py` — `/api/auth/*` endpoints + middleware that refuses every `/api` data
|
||||||
|
route without a valid session.
|
||||||
|
- `server/manage_users.py` — the CLI used in step 4.
|
||||||
|
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
|
||||||
|
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
|
||||||
|
administration UI.
|
||||||
|
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
|
||||||
|
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).
|
||||||
274
DEPLOYMENT.md
274
DEPLOYMENT.md
@@ -1,81 +1,239 @@
|
|||||||
# 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
|
||||||
## 3. Comments / feedback
|
POSTGRES_DB=wpsuite
|
||||||
|
POSTGRES_USER=wpsuite
|
||||||
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
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
|
That's it — the API now builds its own connection string from these three
|
||||||
API maps `name`/`author` → the comment author and keeps any extra fields in the
|
values and **encodes the password automatically**, so a password with special
|
||||||
row's `extra` JSON column.
|
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
|
||||||
|
is **optional** and only needed if you want to point the API at some other
|
||||||
|
database; if you do set it, you must URL-encode the password yourself, and it's
|
||||||
|
ignored whenever the three `POSTGRES_*` values are present.
|
||||||
|
|
||||||
|
Generate a strong password with `openssl rand -base64 32`.
|
||||||
|
|
||||||
|
> **Portainer note:** for a Git-based stack these go in the stack's
|
||||||
|
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
||||||
|
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
|
||||||
|
|
||||||
|
These are the only credentials in the system, and they never appear 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).
|
||||||
|
|||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/requirements.txt ./server/
|
||||||
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
|
COPY server/ ./server/
|
||||||
|
EXPOSE 8000
|
||||||
|
# --preload imports the app once in the master (so create_all runs a single time)
|
||||||
|
# before forking workers, preventing a table-creation race on first startup.
|
||||||
|
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
|
||||||
|
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||||
@@ -130,6 +130,49 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
|
|||||||
> real data this is fine; once there is, add Alembic (see open question #2) and
|
> real data this is fine; once there is, add Alembic (see open question #2) and
|
||||||
> migrate rather than relying on `create_all`.
|
> migrate rather than relying on `create_all`.
|
||||||
|
|
||||||
|
## Multi-project support
|
||||||
|
|
||||||
|
> **Note on layout:** the IT admin moved all static files into **`html/`** and
|
||||||
|
> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`).
|
||||||
|
> Front-end paths below are under `html/`. `server/` stayed at the repo root.
|
||||||
|
|
||||||
|
The suite is now multi-project. **Projects are the top-level container**; every
|
||||||
|
SOP and Work Package belongs to one.
|
||||||
|
|
||||||
|
- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained
|
||||||
|
`project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/
|
||||||
|
metrics endpoints accept a `project_id` filter.
|
||||||
|
- **Project layer:** [html/project-data.js](html/project-data.js) — a shared,
|
||||||
|
**API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`)
|
||||||
|
that **falls back to a localStorage mirror** (`wp_projects`) when the API is
|
||||||
|
unreachable, plus active-project helpers (`getActive`/`setActive`, stored in
|
||||||
|
`wp_active_project` / `wp_active_project_obj`).
|
||||||
|
- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed;
|
||||||
|
a **Project** picker added. With no projects it offers *Create Project* / *Use
|
||||||
|
Sample Project*; otherwise a dropdown to select. The tool cards stay hidden
|
||||||
|
until a project is active and then carry `&project=<id>`; the hero shows the
|
||||||
|
active project.
|
||||||
|
- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)):
|
||||||
|
reads `?project=<id>`, resolves it via `ProjectData`, shows it in the header,
|
||||||
|
and **prefills the SOP project fields** (step 1) from the project record when
|
||||||
|
empty. Passes `&project` into the WP-creator iframe.
|
||||||
|
- **WP creator:** stamps `projectId` onto every saved package (for API sync).
|
||||||
|
|
||||||
|
**Per-project isolation (done, local):** SOP/WP localStorage keys are now
|
||||||
|
namespaced per active project via `ProjectData.key(base)` →
|
||||||
|
`base + '__' + <projectId>` (`SK()` in the suite, `wpKey()` in the creator).
|
||||||
|
So each project keeps its own `wp_suite_sop` / `wp_suite_state` /
|
||||||
|
`wp_suite_sop_complete` / `wp_iwp_v1`. On first load after this change,
|
||||||
|
`project-data.js` runs a **one-time discard** of the legacy un-namespaced keys
|
||||||
|
(guarded by `wp_ns_migrated_v1`) — chosen over migrating, since the local data
|
||||||
|
was throwaway demo content.
|
||||||
|
|
||||||
|
**Still ahead (true Phase 2):** move SOP/WP reads+writes to the API filtered by
|
||||||
|
`project_id` (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`)
|
||||||
|
so projects are shared across users, not just isolated per browser. The
|
||||||
|
endpoints already accept the `project_id` filter; the front end still reads
|
||||||
|
localStorage.
|
||||||
|
|
||||||
**Pending — Phase 2: wire the front end to the API**
|
**Pending — Phase 2: wire the front end to the API**
|
||||||
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
||||||
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
||||||
|
|||||||
67
docker-compose.yml
Normal file
67
docker-compose.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: nginx/Dockerfile
|
||||||
|
container_name: nginx_webserver
|
||||||
|
volumes:
|
||||||
|
- nginx_logs:/var/log/nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- proxy # external — reachable by your reverse proxy / traefik
|
||||||
|
- internal # needs a path to the api container
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: wp_api
|
||||||
|
environment:
|
||||||
|
# Preferred: the API builds its own connection string from these and
|
||||||
|
# encodes the password automatically (no manual URL-encoding needed).
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
# Optional full-URL override (must be URL-encoded if used).
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
|
# Signs login session cookies. MUST be set (see server/.env.example).
|
||||||
|
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY}
|
||||||
|
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wp_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
nginx_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
internal: true # no outbound internet access from api/db
|
||||||
159
html/admin.html
Normal file
159
html/admin.html
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<!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>
|
||||||
|
<script src="auth-guard.js"></script>
|
||||||
|
<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; }
|
||||||
|
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||||
|
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
|
||||||
|
border-radius:6px; background:#fff; color:var(--text); }
|
||||||
|
.urow input{ flex:1; min-width:130px; }
|
||||||
|
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
|
||||||
|
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
|
||||||
|
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||||
|
table.users tr:last-child td{ border-bottom:none; }
|
||||||
|
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
|
||||||
|
.tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
|
||||||
|
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
|
||||||
|
button.mini{ padding:4px 9px; font-size:12px; }
|
||||||
|
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
|
||||||
|
<div class="wrap" id="admin-denied" style="display:none">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Admins only</h2>
|
||||||
|
<p class="sub" style="margin:0 0 12px">Your account doesn’t have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
|
||||||
|
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
|
||||||
|
</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 & tests · talks to <code>/api</code> on this host</div></div>
|
||||||
|
<div class="row"><a class="home" href="index.html">← Site</a></div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- USER ADMINISTRATION -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>User administration</h2>
|
||||||
|
<div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||||
|
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
|
||||||
|
<div id="users-banner"></div>
|
||||||
|
<div id="users-table" style="margin-top:12px"></div>
|
||||||
|
|
||||||
|
<h2 style="margin-top:22px">Add a user</h2>
|
||||||
|
<div class="urow">
|
||||||
|
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||||
|
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||||
|
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||||
|
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select>
|
||||||
|
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password">
|
||||||
|
<button class="primary" onclick="createUser()">Create user</button>
|
||||||
|
</div>
|
||||||
|
<div id="users-create-msg" class="note"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ALL FEEDBACK / COMMENTS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>All feedback & comments</h2>
|
||||||
|
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page & step) when they commented.</div>
|
||||||
|
<div class="row">
|
||||||
|
<button onclick="loadComments()">Refresh comments</button>
|
||||||
|
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
|
||||||
|
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
|
||||||
|
</div>
|
||||||
|
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USAGE LOGS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Usage logs</h2>
|
||||||
|
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
|
||||||
|
<div class="row">
|
||||||
|
<button onclick="loadUsage()">Refresh</button>
|
||||||
|
<button onclick="downloadUsage()">Download JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</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>
|
||||||
386
html/admin.js
Normal file
386
html/admin.js
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/* Admin console for the Work Package Suite.
|
||||||
|
Browser-side diagnostics + tests that call the same /api on this host.
|
||||||
|
|
||||||
|
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||||
|
already requires a login (redirecting to login.html otherwise) and publishes
|
||||||
|
window.WP_USER; here we show the console only when that user is an admin, and
|
||||||
|
show an "Admins only" notice otherwise. Every user-management API is also
|
||||||
|
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
|
||||||
|
|
||||||
|
function reveal(){
|
||||||
|
document.getElementById('admin-main').style.display='';
|
||||||
|
checkHealth();
|
||||||
|
loadUsers();
|
||||||
|
loadComments();
|
||||||
|
loadUsage();
|
||||||
|
}
|
||||||
|
function showDenied(){
|
||||||
|
document.getElementById('admin-denied').style.display='';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 — 3–5 days (≈40–80 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── user administration ────────────────────────────────────────────────────────
|
||||||
|
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
async function currentUserId(){
|
||||||
|
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||||||
|
const { status, json } = await api('GET','/api/auth/me');
|
||||||
|
return (status===200 && json && json.user) ? json.user.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers(){
|
||||||
|
const banner=document.getElementById('users-banner');
|
||||||
|
const wrap=document.getElementById('users-table');
|
||||||
|
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||||
|
const { status, json } = await api('GET','/api/auth/users');
|
||||||
|
if(status===403){
|
||||||
|
banner.className='banner bad';
|
||||||
|
banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users';
|
||||||
|
wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
if(status===401){
|
||||||
|
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
if(status!==200 || !Array.isArray(json)){
|
||||||
|
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
banner.style.display='none';
|
||||||
|
const meId = await currentUserId();
|
||||||
|
renderUsers(json, meId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(list, meId){
|
||||||
|
const wrap=document.getElementById('users-table');
|
||||||
|
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||||
|
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||||
|
let rows = list.map(u=>{
|
||||||
|
const me = u.id===meId;
|
||||||
|
const active = u.is_active;
|
||||||
|
const disableBtn = me
|
||||||
|
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||||||
|
: '<button class="mini" onclick="toggleActive(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||||||
|
const delBtn = me
|
||||||
|
? ''
|
||||||
|
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||||
|
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||||
|
'<td>'+uesc(u.email||'')+'</td>'+
|
||||||
|
'<td><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
|
||||||
|
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||||||
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||||
|
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
|
||||||
|
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
|
||||||
|
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
|
||||||
|
disableBtn+delBtn+
|
||||||
|
'</div></td>'+
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
wrap.innerHTML='<table class="users"><thead><tr>'+
|
||||||
|
'<th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||||
|
'</tr></thead><tbody>'+rows+'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(){
|
||||||
|
const msg=document.getElementById('users-create-msg');
|
||||||
|
const username=document.getElementById('nu-username').value.trim();
|
||||||
|
const full_name=document.getElementById('nu-fullname').value.trim();
|
||||||
|
const email=document.getElementById('nu-email').value.trim();
|
||||||
|
const role=document.getElementById('nu-role').value;
|
||||||
|
const password=document.getElementById('nu-password').value;
|
||||||
|
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||||||
|
if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; }
|
||||||
|
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||||||
|
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,password});
|
||||||
|
if(status===200){
|
||||||
|
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
|
||||||
|
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
msg.style.color='var(--red)';
|
||||||
|
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPw(id, username){
|
||||||
|
const pw=prompt('New password for "'+username+'" (min 8 characters):');
|
||||||
|
if(pw===null) return;
|
||||||
|
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
|
||||||
|
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||||
|
if(status===200) alert('Password reset for '+username+'.');
|
||||||
|
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(id, makeActive){
|
||||||
|
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||||
|
if(status===200) loadUsers();
|
||||||
|
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id, username){
|
||||||
|
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
|
||||||
|
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||||
|
if(status===200) loadUsers();
|
||||||
|
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── project access assignment ───────────────────────────────────────────────────
|
||||||
|
async function manageProjects(id, username){
|
||||||
|
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||||
|
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||||||
|
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
|
||||||
|
}
|
||||||
|
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||||
|
function openProjectModal(userId, username, projects, assigned, userObj){
|
||||||
|
closeProjectModal();
|
||||||
|
const isAdmin = userObj && userObj.role==='admin';
|
||||||
|
const items = projects.length ? projects.map(p =>
|
||||||
|
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
|
||||||
|
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
|
||||||
|
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
||||||
|
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'proj-modal';
|
||||||
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
||||||
|
modal.innerHTML =
|
||||||
|
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||||||
|
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
|
||||||
|
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||||
|
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
|
||||||
|
'<div id="proj-list">'+items+'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||||
|
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||||||
|
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||||||
|
'</div>'+
|
||||||
|
'</div>';
|
||||||
|
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
const saveBtn = document.getElementById('proj-save');
|
||||||
|
if(saveBtn) saveBtn.onclick = async () => {
|
||||||
|
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||||||
|
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
|
||||||
|
if(status===200) closeProjectModal();
|
||||||
|
else alert('Save failed (HTTP '+status+').');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── all feedback / comments ─────────────────────────────────────────────────────
|
||||||
|
let _comments = [];
|
||||||
|
async function loadComments(){
|
||||||
|
const box = document.getElementById('comments-admin');
|
||||||
|
box.textContent = 'Loading…';
|
||||||
|
const { status, json } = await api('GET','/api/comments');
|
||||||
|
if(status!==200 || !Array.isArray(json)){
|
||||||
|
box.innerHTML = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; return;
|
||||||
|
}
|
||||||
|
_comments = json;
|
||||||
|
const sel = document.getElementById('cmt-filter'); const cur = sel.value;
|
||||||
|
const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort();
|
||||||
|
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').join('');
|
||||||
|
sel.value = cur;
|
||||||
|
renderComments();
|
||||||
|
}
|
||||||
|
function renderComments(){
|
||||||
|
const box = document.getElementById('comments-admin');
|
||||||
|
const src = document.getElementById('cmt-filter').value;
|
||||||
|
const q = (document.getElementById('cmt-search').value||'').toLowerCase();
|
||||||
|
let rows = _comments.filter(c => (!src || c.source===src) &&
|
||||||
|
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
|
||||||
|
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
|
||||||
|
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
|
||||||
|
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||||
|
const where = c => {
|
||||||
|
const bits = [];
|
||||||
|
if(c.page) bits.push(uesc(c.page));
|
||||||
|
if(c.step!=null) bits.push('step '+c.step);
|
||||||
|
if(c.sop_id) bits.push('SOP '+uesc(c.sop_id));
|
||||||
|
if(c.wp_id) bits.push('WP '+uesc(c.wp_id));
|
||||||
|
return bits.join(' · ') || '—';
|
||||||
|
};
|
||||||
|
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
|
||||||
|
rows.map(c => '<tr>'+
|
||||||
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
|
||||||
|
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
|
||||||
|
'<td>'+uesc(c.source||'—')+'</td>'+
|
||||||
|
'<td style="color:var(--muted)">'+where(c)+'</td>'+
|
||||||
|
'<td>'+uesc(c.text||'')+'</td>'+
|
||||||
|
'</tr>').join('')+'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── usage logs (read from this browser's localStorage) ──────────────────────────
|
||||||
|
const USAGE_KEY = 'wp_suite_analytics_v1';
|
||||||
|
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
|
||||||
|
function loadUsage(){
|
||||||
|
const box = document.getElementById('usage-admin');
|
||||||
|
const evs = (usageLoad().events) || [];
|
||||||
|
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
|
||||||
|
const byEvent = {}, byStep = {}, sessions = new Set();
|
||||||
|
let first = evs[0].ts, last = evs[0].ts;
|
||||||
|
evs.forEach(e => {
|
||||||
|
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||||||
|
if(e.session) sessions.add(e.session);
|
||||||
|
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
|
||||||
|
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||||||
|
});
|
||||||
|
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||||
|
let html = '<table class="kv">'+
|
||||||
|
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
||||||
|
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
||||||
|
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
|
||||||
|
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
|
||||||
|
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
|
||||||
|
html += '</tbody></table>';
|
||||||
|
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
|
||||||
|
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
|
||||||
|
html += '</tbody></table>';
|
||||||
|
box.innerHTML = html;
|
||||||
|
}
|
||||||
|
function downloadUsage(){
|
||||||
|
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
|
||||||
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
|
||||||
|
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── access control: admins only ─────────────────────────────────────────────────
|
||||||
|
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||||||
|
// Show the console for admins; otherwise show the "Admins only" notice.
|
||||||
|
let _adminGated = false;
|
||||||
|
function gateByRole(){
|
||||||
|
if(_adminGated) return;
|
||||||
|
const u = window.WP_USER;
|
||||||
|
if(!u) return; // not resolved yet — wait for wp-auth-ready
|
||||||
|
_adminGated = true;
|
||||||
|
if(u.role === 'admin') reveal();
|
||||||
|
else showDenied();
|
||||||
|
}
|
||||||
|
document.addEventListener('wp-auth-ready', gateByRole);
|
||||||
|
gateByRole(); // in case WP_USER was already set before this ran
|
||||||
153
html/auth-guard.js
Normal file
153
html/auth-guard.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/* Auth guard for the Work Package Suite.
|
||||||
|
Included in the <head> of every protected page (before other scripts). It
|
||||||
|
confirms there is a valid session by calling /api/auth/me; if not, it sends
|
||||||
|
the user to the login page. The real protection is server-side (the API
|
||||||
|
refuses data requests without a session) — this guard is for UX so people
|
||||||
|
land on the login screen instead of an empty app.
|
||||||
|
|
||||||
|
It also exposes:
|
||||||
|
window.WP_USER the logged-in user object (set once verified)
|
||||||
|
window.wpLogout() clears the session and returns to the login page
|
||||||
|
and dispatches a 'wp-auth-ready' event on document once WP_USER is set. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||||
|
|
||||||
|
// Hide the page until we know the user is allowed, to avoid a flash of the app
|
||||||
|
// before a redirect. A safety timer reveals it even if the check hangs.
|
||||||
|
var root = document.documentElement;
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = '.wp-auth-pending body{visibility:hidden!important}';
|
||||||
|
(document.head || root).appendChild(style);
|
||||||
|
root.className += ' wp-auth-pending';
|
||||||
|
function reveal() { root.className = root.className.replace(/\bwp-auth-pending\b/, ''); }
|
||||||
|
var safety = setTimeout(reveal, 4000);
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
clearTimeout(safety);
|
||||||
|
var next = encodeURIComponent(location.pathname + location.search);
|
||||||
|
var url = 'login.html?next=' + next;
|
||||||
|
// If we're inside the WP-creator iframe, redirect the whole window.
|
||||||
|
var w = inIframe ? window.top : window;
|
||||||
|
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.wpLogout = function () {
|
||||||
|
fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
.catch(function () {})
|
||||||
|
.then(function () { window.location.replace('login.html'); });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change-password dialog (uses POST /api/auth/password, which requires the
|
||||||
|
// current password). Available from the top-right pill on any page.
|
||||||
|
window.wpChangePassword = function () {
|
||||||
|
if (document.getElementById('wp-pw-modal')) return;
|
||||||
|
var ov = document.createElement('div');
|
||||||
|
ov.id = 'wp-pw-modal';
|
||||||
|
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
||||||
|
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||||
|
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
|
||||||
|
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
|
||||||
|
ov.innerHTML =
|
||||||
|
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||||
|
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
|
||||||
|
'<div style="padding:16px 18px;">' +
|
||||||
|
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
||||||
|
'<label style="' + lbl + '">Current password</label>' +
|
||||||
|
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||||
|
'<label style="' + lbl + '">New password (at least 8 characters)</label>' +
|
||||||
|
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||||
|
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||||
|
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
||||||
|
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||||
|
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
||||||
|
function msg(text, ok) {
|
||||||
|
var el = document.getElementById('wp-pw-msg');
|
||||||
|
el.style.display = 'block'; el.textContent = text;
|
||||||
|
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
|
||||||
|
}
|
||||||
|
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
document.getElementById('wp-pw-cancel').onclick = close;
|
||||||
|
document.getElementById('wp-pw-cur').focus();
|
||||||
|
document.getElementById('wp-pw-save').onclick = function () {
|
||||||
|
var cur = document.getElementById('wp-pw-cur').value;
|
||||||
|
var n1 = document.getElementById('wp-pw-new').value;
|
||||||
|
var n2 = document.getElementById('wp-pw-new2').value;
|
||||||
|
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
||||||
|
if (n1.length < 8) { msg('New password must be at least 8 characters.', false); return; }
|
||||||
|
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
||||||
|
fetch('/api/auth/password', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
||||||
|
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
||||||
|
})
|
||||||
|
.catch(function () { msg('Could not reach the server.', false); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function addLogoutPill(user) {
|
||||||
|
if (inIframe) return; // the parent page already shows it
|
||||||
|
if (document.getElementById('wp-logout-pill')) return;
|
||||||
|
var pill = document.createElement('div');
|
||||||
|
pill.id = 'wp-logout-pill';
|
||||||
|
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
|
||||||
|
'display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
|
||||||
|
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
|
||||||
|
'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
|
||||||
|
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
|
||||||
|
|
||||||
|
var who = document.createElement('span');
|
||||||
|
who.textContent = user.full_name || user.username;
|
||||||
|
pill.appendChild(who);
|
||||||
|
|
||||||
|
// Admins get a link to the Admin Console (hidden when already on it).
|
||||||
|
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||||
|
if (user.role === 'admin' && !onAdmin) {
|
||||||
|
var adm = document.createElement('a');
|
||||||
|
adm.href = 'admin.html'; adm.textContent = 'Admin';
|
||||||
|
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||||
|
pill.appendChild(sep()); pill.appendChild(adm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pw = document.createElement('a');
|
||||||
|
pw.href = '#'; pw.textContent = 'Password';
|
||||||
|
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||||
|
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
|
||||||
|
pill.appendChild(sep()); pill.appendChild(pw);
|
||||||
|
|
||||||
|
var out = document.createElement('a');
|
||||||
|
out.href = '#'; out.textContent = 'Sign out';
|
||||||
|
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||||
|
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
|
||||||
|
pill.appendChild(sep()); pill.appendChild(out);
|
||||||
|
document.body.appendChild(pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401 || r.status === 403) { goToLogin(); return; }
|
||||||
|
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
||||||
|
return r.json().then(function (data) {
|
||||||
|
clearTimeout(safety);
|
||||||
|
window.WP_USER = data && data.user;
|
||||||
|
reveal();
|
||||||
|
if (window.WP_USER) {
|
||||||
|
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
||||||
|
if (document.body) addLogoutPill(window.WP_USER);
|
||||||
|
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { goToLogin(); }); // API unreachable → send to login
|
||||||
|
})();
|
||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
474
html/help.js
Normal file
474
html/help.js
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
/* Shared Help center + 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 searchable, multi-topic Help center modal opened via window.openHelp()
|
||||||
|
- a floating "?" launcher on any page that doesn't already have a Help button
|
||||||
|
|
||||||
|
API (unchanged + extended):
|
||||||
|
openHelp() open the help center
|
||||||
|
openHelp('topicId') open and jump to a topic (e.g. openHelp('constraints'))
|
||||||
|
closeHelp() close it
|
||||||
|
Add a Help button anywhere with onclick="openHelp()". */
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── styles ────────────────────────────────────────────────────────────────
|
||||||
|
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:center;
|
||||||
|
justify-content:center; z-index:10000; padding:4vh 16px; }
|
||||||
|
.ui-help-overlay.open{ display:flex; }
|
||||||
|
.ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||||
|
border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
|
||||||
|
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
||||||
|
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
|
||||||
|
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
|
||||||
|
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||||
|
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
|
||||||
|
font-size:13px; outline:none; background:#f7f8fa; }
|
||||||
|
.ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
|
||||||
|
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; line-height:1; }
|
||||||
|
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||||
|
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e3e6ec; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
||||||
|
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:6px; color:#27313f; text-decoration:none; font-size:13px;
|
||||||
|
cursor:pointer; margin-bottom:1px; }
|
||||||
|
.ui-help-nav a:hover{ background:#eef1f6; }
|
||||||
|
.ui-help-nav a.active{ background:#e7effe; color:#1d4ed8; font-weight:600; }
|
||||||
|
.ui-help-nav a.nohit{ display:none; }
|
||||||
|
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
||||||
|
.ui-help-sec{ margin-bottom:30px; }
|
||||||
|
.ui-help-sec.hide{ display:none; }
|
||||||
|
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#16213a; scroll-margin-top:10px; }
|
||||||
|
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#2563d6; }
|
||||||
|
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
|
||||||
|
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
|
||||||
|
.ui-help-content li{ margin-bottom:5px; }
|
||||||
|
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||||
|
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
|
||||||
|
.ui-help-content th, .ui-help-content td{ border:1px solid #e3e6ec; padding:6px 9px; text-align:left; vertical-align:top; }
|
||||||
|
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
|
||||||
|
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
|
||||||
|
.pill-draft{ background:#eef1f6; color:#5a6675; } .pill-sched{ background:#e7effe; color:#1d4ed8; }
|
||||||
|
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
|
||||||
|
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
|
||||||
|
.pill-hold{ background:#fde8e8; color:#c0392b; }
|
||||||
|
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #2563d6; padding:10px 14px; border-radius:0 6px 6px 0;
|
||||||
|
font-size:13px; line-height:1.55; margin:10px 0; }
|
||||||
|
.ui-help-noresult{ display:none; color:#5a6675; font-size:14px; padding:10px 2px; }
|
||||||
|
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
|
||||||
|
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
|
||||||
|
border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
|
||||||
|
box-shadow:0 2px 10px rgba(20,30,50,.28); }
|
||||||
|
.ui-help-fab:hover{ background:#1d4ed8; }
|
||||||
|
@media (max-width:760px){
|
||||||
|
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
|
||||||
|
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e3e6ec; }
|
||||||
|
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||||
|
.ui-help-head{ flex-wrap:wrap; }
|
||||||
|
}`;
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = css;
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
|
||||||
|
// ── content ─────────────────────────────────────────────────────────────────
|
||||||
|
// Each topic: { id, title, body(HTML) }. Order here is the nav order.
|
||||||
|
var TOPICS = [
|
||||||
|
{ id: 'overview', title: 'Getting started', body: `
|
||||||
|
<h3>Getting started</h3>
|
||||||
|
<p>The Work Package Suite turns a project's standard procedure into release-ready <strong>Installation Work Packages (IWPs)</strong>. You work in three stages, always in the same order:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Pick or create a Project</strong> on the home page. Each project keeps its own SOP and its own Work Packages, so you can run many jobs at once.</li>
|
||||||
|
<li><strong>SOP Configuration</strong> — set the project baseline in 10 steps (team, sign-offs, WP types, governance & sizing, quality, platforms, sequence, constraints, sources). Every Work Package inherits these defaults. The Creator stays locked until the SOP is marked complete.</li>
|
||||||
|
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP, clear their constraints, and issue them to the field.</li>
|
||||||
|
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
||||||
|
</ol>
|
||||||
|
<h4>Moving around</h4>
|
||||||
|
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
|
||||||
|
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
|
||||||
|
|
||||||
|
{ id: 'projects', title: 'Projects', body: `
|
||||||
|
<h3>Projects</h3>
|
||||||
|
<p>A <strong>project</strong> is the top-level container — every SOP and Work Package belongs to one. Create or select projects on the home page.</p>
|
||||||
|
<h4>Project fields</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Project Name</strong> (required)</li>
|
||||||
|
<li><strong>Project Number</strong></li>
|
||||||
|
<li><strong>Client</strong></li>
|
||||||
|
<li><strong>Division / Sector</strong></li>
|
||||||
|
<li><strong>Site / Location</strong></li>
|
||||||
|
</ul>
|
||||||
|
<h4>The active project</h4>
|
||||||
|
<p>The <strong>active project</strong> is the one you're currently working in. All SOP and Work Package data is scoped (namespaced) to it, so switching projects loads that project's own configuration and packages — nothing leaks between jobs. Use the <em>change</em> link next to the active project name to switch.</p>
|
||||||
|
<div class="ui-help-callout">Projects are stored centrally via the API and mirrored to your browser, so the suite still works offline; it re-syncs when the connection returns.</div>` },
|
||||||
|
|
||||||
|
{ id: 'sop', title: 'SOP Configuration', body: `
|
||||||
|
<h3>SOP Configuration (10 steps)</h3>
|
||||||
|
<p>The SOP is the project baseline. Walk the 10 steps with <strong>← Back</strong> / <strong>Next →</strong>, or jump using the step indicators. The final step is <strong>✓ SOP Complete</strong> — saving it unlocks the Work Package Creator and turns the home-page card green.</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Project Basics</strong> — name, number, client, division/sector, site. Inherited by every WP.</li>
|
||||||
|
<li><strong>Project Team Leadership</strong> — PM, APM, CM, QM, plus any additional members (<em>+ Add Team Member</em>).</li>
|
||||||
|
<li><strong>Required Sign-Off Roles</strong> — Superintendent and Foreman are always required; add optional roles (HSE, Quality Rep, Planner, etc.) with <em>+ Add Role</em>.</li>
|
||||||
|
<li><strong>Work Package Types</strong> — enable the install types this project uses (Conduit Install, Wire Pull, Terminations, …). Enabled types populate the WP type picker.</li>
|
||||||
|
<li><strong>Governance & WP Numbering</strong> — the WP <strong>number format</strong> (e.g. <code>WP##-[Sector]-[TYPE]</code>), issuance strategy, the project's <strong>disciplines</strong>, the <strong>discipline strategy</strong>, and <strong>WP sizing</strong> (see <a data-help-jump="sizing">Sizing</a> and <a data-help-jump="disciplines">Disciplines</a>).</li>
|
||||||
|
<li><strong>Quality & Inspection Strategy</strong> — QC requirement, photo/documentation standard, and hold/witness points.</li>
|
||||||
|
<li><strong>Tracking & Commissioning Platforms</strong> — e.g. CxAlloy, Procore, ACC.</li>
|
||||||
|
<li><strong>Construction Sequence</strong> — the install flow; reorder by dragging (⠿), edit labels, add <em>◆ QC Hold</em> gates or custom steps. These feed the WP "predecessor" picker.</li>
|
||||||
|
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||||
|
<li><strong>Engineering Sources & References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings & Attachments</em>.</li>
|
||||||
|
</ol>
|
||||||
|
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` },
|
||||||
|
|
||||||
|
{ id: 'wps', title: 'Work Packages', body: `
|
||||||
|
<h3>Creating Work Packages</h3>
|
||||||
|
<p>In the Creator, start a package with <strong>+ New</strong> (blank, auto-numbered) or <strong>⧉ Duplicate</strong> (copies a saved package and increments the number). The <strong>WP Number</strong> is built automatically from the SOP number format plus your scope fields, the WP type, and a counter — it's read-only.</p>
|
||||||
|
<h4>Key fields</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
||||||
|
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
|
||||||
|
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
||||||
|
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
||||||
|
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
||||||
|
<li><strong>Material List</strong> — the bill of materials; import from CSV/Excel or add lines manually.</li>
|
||||||
|
<li><strong>Drawings & Attachments</strong> — documents and SOP source-folder links.</li>
|
||||||
|
<li><strong>Kitting & Material Movement (MIMO)</strong> — kitting status, warehouse owner, move date/location.</li>
|
||||||
|
<li><strong>Constraints</strong> — the release gate (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||||
|
<li><strong>Quality / Hold Points</strong>, <strong>Approvals & Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Saving</h4>
|
||||||
|
<p><strong>Save Draft</strong> stores the package; <strong>⚡ Save & View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
|
||||||
|
|
||||||
|
{ id: 'statuses', title: 'Statuses', body: `
|
||||||
|
<h3>Work Package statuses</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Status</th><th>Meaning</th></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-draft">Draft</span></td><td>Work in progress; not yet released.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-sched">Scheduled</span></td><td>Planned and scheduled; upcoming.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-issued">Issued</span></td><td>Released to the field. Requires <em>all constraints Cleared or N/A</em>.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-prog">In Progress</span></td><td>Actively being worked.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-qc">QC</span></td><td>In quality check / inspection.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-closed">Closed</span></td><td>Completed.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-hold">Issue (Hold)</span></td><td>A constraint reopened after release — work is paused until it's resolved.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="ui-help-callout">A package <strong>cannot move to Issued</strong> while any constraint is Open. If a constraint reopens after a package is Issued, its status automatically drops to <strong>Issue (Hold)</strong> and the suite makes you log what happened.</div>
|
||||||
|
<p>On a multi-discipline package, each discipline carries its own status and the overall status <strong>rolls up to the least-advanced discipline</strong> — so a package is never "Closed" while one trade still lags.</p>` },
|
||||||
|
|
||||||
|
{ id: 'constraints', title: 'Constraints & release', body: `
|
||||||
|
<h3>Constraints & release readiness</h3>
|
||||||
|
<p>Constraints are the readiness checklist that gates a package's release to the field. They follow Advanced Work Packaging (AWP Vol II §2.3.2). The standard set:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Safety & Permitting</li><li>Quality Control / Inspection</li><li>IFC Drawings & Specs</li>
|
||||||
|
<li>Schedule</li><li>Materials (on site, bagged & tagged)</li><li>Prefabrication</li>
|
||||||
|
<li>Work Access & Laydown</li><li>Craft Availability</li><li>Construction Equipment & Tools</li>
|
||||||
|
<li>Scaffolding / Access Equipment</li>
|
||||||
|
</ol>
|
||||||
|
<p>Pick which apply (and add custom ones) in <strong>SOP Step 9</strong>. Each constraint on a package has one of three states:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>State</th><th>Effect</th></tr>
|
||||||
|
<tr><td><strong>Open</strong></td><td>Not yet cleared — <em>blocks release</em>.</td></tr>
|
||||||
|
<tr><td><strong>Cleared</strong></td><td>Requirement met — counts toward release-ready.</td></tr>
|
||||||
|
<tr><td><strong>N/A</strong></td><td>Not applicable to this package — counts as cleared.</td></tr>
|
||||||
|
</table>
|
||||||
|
<h4>The release gate</h4>
|
||||||
|
<ul>
|
||||||
|
<li>A package is <strong>release-ready</strong> when every constraint is Cleared or N/A. The sticky banner shows green when ready, amber when constraints are still open, and red when on hold.</li>
|
||||||
|
<li>When the last open constraint clears, the suite offers to mark the package <strong>Issued</strong>.</li>
|
||||||
|
<li>If a constraint reopens after the package is Issued, you log the hold (what reopened, details, optional doc link & photo) and the status drops to <strong>Issue (Hold)</strong>.</li>
|
||||||
|
</ul>` },
|
||||||
|
|
||||||
|
{ id: 'disciplines', title: 'Disciplines & Split', body: `
|
||||||
|
<h3>Disciplines & Split by Discipline</h3>
|
||||||
|
<p>Disciplines are trades (Mechanical, Electrical, Tech, …) set in <strong>SOP Step 5</strong>. The <strong>discipline strategy</strong> controls how packages handle them:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Let the planner choose per package</strong> (recommended) — pick one discipline (flat scope) or several (per-discipline scope + the <em>Split</em> option).</li>
|
||||||
|
<li><strong>One discipline per package</strong> — each WP is single-discipline.</li>
|
||||||
|
<li><strong>Multiple disciplines per package</strong> — scope is always split by discipline.</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Split by Discipline</h4>
|
||||||
|
<p>When a package covers 2+ disciplines, the <strong>⎘ Split by Discipline</strong> button breaks it into one numbered instance per discipline — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> (or <code>_MECH</code>/<code>_ELEC</code> suffixes, set in the SOP). The original is kept as a <strong>master / roll-up</strong>; each instance:</p>
|
||||||
|
<ul>
|
||||||
|
<li>becomes its own single-discipline package, issued independently;</li>
|
||||||
|
<li>receives only the <strong>scope steps</strong> and <strong>materials tagged to that discipline</strong>;</li>
|
||||||
|
<li>stays linked back to the master.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="ui-help-callout">Tag material rows to a discipline <em>before</em> splitting. <strong>Untagged rows stay on the master only</strong> and won't be routed to any instance. Masters are excluded from dashboard counts so hours aren't double-counted.</div>` },
|
||||||
|
|
||||||
|
{ id: 'sizing', title: 'Sizing', body: `
|
||||||
|
<h3>Work Package sizing</h3>
|
||||||
|
<p>In <strong>SOP Step 5</strong> you set a typical WP <strong>size band</strong>, which sets a <strong>split threshold</strong> (max labor hours):</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Size band</th><th>Split threshold</th></tr>
|
||||||
|
<tr><td>Small — 1–2 days (≈8–24 hrs)</td><td>24 hrs</td></tr>
|
||||||
|
<tr><td>Standard — 3–5 days (≈40–80 hrs)</td><td>80 hrs</td></tr>
|
||||||
|
<tr><td>Large — 1–2 weeks (≈80–160 hrs)</td><td>160 hrs</td></tr>
|
||||||
|
<tr><td>Custom…</td><td>you set it</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>In the Creator, the <strong>Est. Hrs.</strong> field is checked live against the threshold. Within range you see the target band; over it you get an amber warning — <em>"⚠ … exceeds the …-hr split threshold — consider breaking this package down"</em> — and a nudge to split by discipline where that applies. It's a guide, not a hard block: you can proceed if it's intentional.</p>` },
|
||||||
|
|
||||||
|
{ id: 'dashboard', title: 'Dashboard', body: `
|
||||||
|
<h3>Dashboard & metrics</h3>
|
||||||
|
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p>
|
||||||
|
<h4>Metric cards (click to filter)</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
|
||||||
|
<li><strong>Est. hrs</strong> and <strong>Actual hrs</strong> (summed)</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Breakdowns & gates</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
|
||||||
|
<li><strong>⛔ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
|
||||||
|
</ul>
|
||||||
|
<h4>The table</h4>
|
||||||
|
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
|
||||||
|
<div class="ui-help-callout">Split <strong>masters</strong> are labelled and excluded from the counts; you issue their instances one at a time as each becomes release-ready.</div>` },
|
||||||
|
|
||||||
|
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||||
|
<h3>Samples, import / export & comments</h3>
|
||||||
|
<h4>Load Sample</h4>
|
||||||
|
<p><strong>⭐ Load Sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
|
||||||
|
<h4>Import / Export</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
|
||||||
|
<li><strong>SOP</strong> — the Creator can import a SOP <code>.json</code> (via <em>⤒ Import SOP</em>) or load the sample SOP.</li>
|
||||||
|
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Comments & feedback</h4>
|
||||||
|
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
|
||||||
|
<h4>Usage logs</h4>
|
||||||
|
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
|
||||||
|
|
||||||
|
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
|
||||||
|
<h3>Tips & keyboard shortcuts</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Enter</strong> in a sequence, constraint, or material input adds/saves that row.</li>
|
||||||
|
<li><strong>Esc</strong> closes any modal — this help center, comments, the constraint library, and the hold-log dialog.</li>
|
||||||
|
<li>Hover any <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icon for an inline hint.</li>
|
||||||
|
<li>Data is kept <strong>per project</strong> — switch projects from the home page.</li>
|
||||||
|
<li>Your work <strong>auto-saves</strong> to the browser as you type; <em>Save & View</em> produces the print-ready output.</li>
|
||||||
|
<li>Click a metric card or status chip on the <strong>Dashboard</strong> to filter the table.</li>
|
||||||
|
</ul>` },
|
||||||
|
|
||||||
|
{ id: 'glossary', title: 'Glossary', body: `
|
||||||
|
<h3>Glossary</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Term</th><th>Meaning</th></tr>
|
||||||
|
<tr><td><strong>IWP</strong></td><td>Installation Work Package — the field-level package this tool produces.</td></tr>
|
||||||
|
<tr><td><strong>AWP</strong></td><td>Advanced Work Packaging — the methodology behind the constraint set and release gate.</td></tr>
|
||||||
|
<tr><td><strong>SOP</strong></td><td>Standard Operating Procedure — the project baseline every WP inherits.</td></tr>
|
||||||
|
<tr><td><strong>Constraint</strong></td><td>A readiness item (Open / Cleared / N/A) that gates release.</td></tr>
|
||||||
|
<tr><td><strong>Release-ready</strong></td><td>All constraints Cleared or N/A — the package can be Issued.</td></tr>
|
||||||
|
<tr><td><strong>Issued</strong></td><td>Released to the field.</td></tr>
|
||||||
|
<tr><td><strong>Issue (Hold)</strong></td><td>A released package paused because a constraint reopened.</td></tr>
|
||||||
|
<tr><td><strong>Discipline</strong></td><td>A trade (Mechanical, Electrical, Tech, …).</td></tr>
|
||||||
|
<tr><td><strong>Split / Master / Instance</strong></td><td>Breaking a multi-discipline package (master/roll-up) into single-discipline instances (WP01A/B/C).</td></tr>
|
||||||
|
<tr><td><strong>Scope</strong></td><td>The sequenced steps the crew performs.</td></tr>
|
||||||
|
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
|
||||||
|
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
||||||
|
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
||||||
|
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
|
||||||
|
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
||||||
|
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||||
|
</table>` },
|
||||||
|
|
||||||
|
{ id: 'faq', title: 'FAQ', body: `
|
||||||
|
<h3>Frequently asked questions</h3>
|
||||||
|
<h4>The Work Package Creator is locked — why?</h4>
|
||||||
|
<p>The SOP for the active project isn't complete yet. Finish SOP Configuration and click <strong>✓ SOP Complete</strong> on the last step; the Creator unlocks and the home card turns green.</p>
|
||||||
|
<h4>Why can't I set a package to Issued?</h4>
|
||||||
|
<p>At least one constraint is still <strong>Open</strong>. Clear or mark N/A every constraint — the release banner turns green — and the suite will offer to issue it.</p>
|
||||||
|
<h4>My package's materials didn't all carry over when I split it.</h4>
|
||||||
|
<p>Only material rows <strong>tagged to a discipline</strong> are routed to that instance. Untagged rows stay on the master. Tag them before splitting.</p>
|
||||||
|
<h4>Why don't split masters show in the dashboard totals?</h4>
|
||||||
|
<p>Masters are roll-ups; counting them would double-count their hours and packages. The individual instances are counted instead.</p>
|
||||||
|
<h4>Will I lose my work if I close the browser?</h4>
|
||||||
|
<p>No — drafts auto-save locally per project and reload next time. Use <em>Export (JSON)</em> for a backup or to share with a teammate.</p>
|
||||||
|
<h4>Does each project keep its own data?</h4>
|
||||||
|
<p>Yes. SOP and Work Packages are scoped to the active project; switching projects loads that project's own set.</p>
|
||||||
|
<h4>How do I report a problem or suggestion?</h4>
|
||||||
|
<p>Use the feedback / comments features (home page, SOP <em>Step Comments</em>, or the Creator's <em>Comments</em> drawer).</p>` }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── build ─────────────────────────────────────────────────────────────────
|
||||||
|
function buildModal() {
|
||||||
|
if (document.getElementById('ui-help-overlay')) return;
|
||||||
|
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.className = 'ui-help-overlay';
|
||||||
|
overlay.id = 'ui-help-overlay';
|
||||||
|
|
||||||
|
var nav = TOPICS.map(function (t) {
|
||||||
|
return '<a data-help-target="' + t.id + '">' + t.title + '</a>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var sections = TOPICS.map(function (t) {
|
||||||
|
return '<section class="ui-help-sec" id="ui-help-sec-' + t.id + '">' + t.body + '</section>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help center">' +
|
||||||
|
'<div class="ui-help-head">' +
|
||||||
|
'<span class="ui-help-title">Help — Work Package Suite</span>' +
|
||||||
|
'<span class="ui-help-search"><input id="ui-help-q" type="search" placeholder="Search help…" aria-label="Search help"></span>' +
|
||||||
|
'<button type="button" class="ui-help-x" onclick="closeHelp()" aria-label="Close help">✕</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ui-help-wrap">' +
|
||||||
|
'<nav class="ui-help-nav" id="ui-help-nav">' + nav + '</nav>' +
|
||||||
|
'<div class="ui-help-content" id="ui-help-content">' +
|
||||||
|
'<p class="ui-help-noresult" id="ui-help-noresult">No matches. Try another word.</p>' +
|
||||||
|
sections +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// nav clicks + in-content cross-links jump to a section
|
||||||
|
overlay.addEventListener('click', function (e) {
|
||||||
|
var t = e.target.closest('[data-help-target],[data-help-jump]');
|
||||||
|
if (!t) return;
|
||||||
|
e.preventDefault();
|
||||||
|
jumpTo(t.getAttribute('data-help-target') || t.getAttribute('data-help-jump'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// search
|
||||||
|
var q = overlay.querySelector('#ui-help-q');
|
||||||
|
q.addEventListener('input', function () { runSearch(q.value); });
|
||||||
|
|
||||||
|
// highlight nav as you scroll
|
||||||
|
var content = overlay.querySelector('#ui-help-content');
|
||||||
|
content.addEventListener('scroll', syncActiveNav, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpTo(id) {
|
||||||
|
var sec = document.getElementById('ui-help-sec-' + id);
|
||||||
|
if (!sec) return;
|
||||||
|
// Clear any active search filter so the target is visible.
|
||||||
|
var q = document.getElementById('ui-help-q');
|
||||||
|
if (q && q.value) { q.value = ''; runSearch(''); }
|
||||||
|
sec.scrollIntoView({ block: 'start' });
|
||||||
|
setActiveNav(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveNav(id) {
|
||||||
|
var nav = document.getElementById('ui-help-nav');
|
||||||
|
if (!nav) return;
|
||||||
|
nav.querySelectorAll('a').forEach(function (a) {
|
||||||
|
a.classList.toggle('active', a.getAttribute('data-help-target') === id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveNav() {
|
||||||
|
var content = document.getElementById('ui-help-content');
|
||||||
|
if (!content) return;
|
||||||
|
var top = content.scrollTop, best = null, bestDist = Infinity;
|
||||||
|
TOPICS.forEach(function (t) {
|
||||||
|
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||||
|
if (!sec || sec.classList.contains('hide')) return;
|
||||||
|
var d = Math.abs(sec.offsetTop - top);
|
||||||
|
if (sec.offsetTop - top <= 40 && d < bestDist) { bestDist = d; best = t.id; }
|
||||||
|
});
|
||||||
|
if (best) setActiveNav(best);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── search: filter sections + highlight matches ───────────────────────────
|
||||||
|
function clearMarks(root) {
|
||||||
|
root.querySelectorAll('mark').forEach(function (m) {
|
||||||
|
var txt = document.createTextNode(m.textContent);
|
||||||
|
m.parentNode.replaceChild(txt, m);
|
||||||
|
});
|
||||||
|
root.normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markMatches(el, query) {
|
||||||
|
var lower = query.toLowerCase();
|
||||||
|
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: function (node) {
|
||||||
|
if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||||||
|
var p = node.parentNode.nodeName;
|
||||||
|
if (p === 'MARK' || p === 'STYLE' || p === 'SCRIPT') return NodeFilter.FILTER_REJECT;
|
||||||
|
return node.nodeValue.toLowerCase().indexOf(lower) >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var nodes = [], n;
|
||||||
|
while ((n = walker.nextNode())) nodes.push(n);
|
||||||
|
nodes.forEach(function (node) {
|
||||||
|
var val = node.nodeValue, low = val.toLowerCase(), frag = document.createDocumentFragment(), i = 0, idx;
|
||||||
|
while ((idx = low.indexOf(lower, i)) >= 0) {
|
||||||
|
if (idx > i) frag.appendChild(document.createTextNode(val.slice(i, idx)));
|
||||||
|
var mk = document.createElement('mark');
|
||||||
|
mk.textContent = val.slice(idx, idx + query.length);
|
||||||
|
frag.appendChild(mk);
|
||||||
|
i = idx + query.length;
|
||||||
|
}
|
||||||
|
if (i < val.length) frag.appendChild(document.createTextNode(val.slice(i)));
|
||||||
|
node.parentNode.replaceChild(frag, node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSearch(query) {
|
||||||
|
var content = document.getElementById('ui-help-content');
|
||||||
|
var nav = document.getElementById('ui-help-nav');
|
||||||
|
var noresult = document.getElementById('ui-help-noresult');
|
||||||
|
if (!content) return;
|
||||||
|
query = (query || '').trim();
|
||||||
|
var hits = 0;
|
||||||
|
|
||||||
|
TOPICS.forEach(function (t) {
|
||||||
|
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||||
|
var navItem = nav.querySelector('[data-help-target="' + t.id + '"]');
|
||||||
|
clearMarks(sec);
|
||||||
|
var match = !query || sec.textContent.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||||
|
sec.classList.toggle('hide', !match);
|
||||||
|
if (navItem) navItem.classList.toggle('nohit', !!query && !match);
|
||||||
|
if (match) {
|
||||||
|
hits++;
|
||||||
|
if (query) markMatches(sec, query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
noresult.style.display = (query && hits === 0) ? 'block' : 'none';
|
||||||
|
if (query) { content.scrollTop = 0; }
|
||||||
|
else { syncActiveNav(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public API ──────────────────────────────────────────────────────────────
|
||||||
|
global.openHelp = function (topicId) {
|
||||||
|
buildModal();
|
||||||
|
document.getElementById('ui-help-overlay').classList.add('open');
|
||||||
|
var q = document.getElementById('ui-help-q');
|
||||||
|
if (topicId && typeof topicId === 'string') jumpTo(topicId);
|
||||||
|
else { setActiveNav(TOPICS[0].id); if (q) setTimeout(function () { q.focus(); }, 30); }
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── floating launcher on pages without their own Help button ────────────────
|
||||||
|
function maybeAddFab() {
|
||||||
|
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||||
|
if (inIframe || global.WP_HELP_NO_FAB) return; // suite shows the parent's button
|
||||||
|
if (document.querySelector('[onclick*="openHelp"]')) return; // page already has a Help trigger
|
||||||
|
if (document.getElementById('ui-help-fab')) return;
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.id = 'ui-help-fab'; b.className = 'ui-help-fab'; b.type = 'button';
|
||||||
|
b.title = 'Help'; b.setAttribute('aria-label', 'Open help'); b.textContent = '?';
|
||||||
|
b.addEventListener('click', function () { global.openHelp(); });
|
||||||
|
document.body.appendChild(b);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', maybeAddFab);
|
||||||
|
else maybeAddFab();
|
||||||
|
})(window);
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Work Package Suite — Prime Controls</title>
|
<title>Work Package Suite — Prime Controls</title>
|
||||||
|
<script src="auth-guard.js"></script>
|
||||||
<link rel="icon" href="favicon.ico" sizes="any">
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
<style>
|
<style>
|
||||||
@@ -348,6 +349,22 @@
|
|||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PROJECT PICKER */
|
||||||
|
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||||
|
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||||
|
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
|
||||||
|
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||||
|
border-radius: 6px; padding: 1.25rem; }
|
||||||
|
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||||
|
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
|
||||||
|
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
||||||
|
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||||
|
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
|
||||||
|
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
||||||
|
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
||||||
|
|
||||||
/* RESPONSIVE */
|
/* RESPONSIVE */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-content { flex-direction: column; text-align: center; }
|
.header-content { flex-direction: column; text-align: center; }
|
||||||
@@ -370,6 +387,7 @@
|
|||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="#overview">Overview</a>
|
<a href="#overview">Overview</a>
|
||||||
<a href="#comments">Feedback</a>
|
<a href="#comments">Feedback</a>
|
||||||
|
<a href="#" onclick="openHelp();return false;">Help</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -379,12 +397,19 @@
|
|||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Work Package Suite</h1>
|
<h1 id="hero-title">Work Package Suite</h1>
|
||||||
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p>
|
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TOOL CARDS -->
|
<!-- PROJECT SELECTION -->
|
||||||
<div class="cards-grid" id="overview">
|
<div class="section" id="project-section">
|
||||||
|
<h2>Project</h2>
|
||||||
|
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
||||||
|
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOOL CARDS (shown once a project is active) -->
|
||||||
|
<div class="cards-grid" id="overview" style="display:none">
|
||||||
|
|
||||||
<!-- SOP CONFIG -->
|
<!-- SOP CONFIG -->
|
||||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||||
@@ -443,40 +468,148 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SUPPORT SECTION -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>About This Suite</h2>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
|
|
||||||
<div>
|
|
||||||
<h3>Two-Step Workflow</h3>
|
|
||||||
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Leave Feedback</h3>
|
|
||||||
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Offline & Collaborative</h3>
|
|
||||||
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER -->
|
<!-- FOOTER -->
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>Work Package Suite v1.0 | Prime Controls | All files work offline with local browser storage</p>
|
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Reflect SOP completion on the tool cards.
|
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||||
(function reflectSOPStatus(){
|
const esc = ProjectData.esc;
|
||||||
|
let _projects = [];
|
||||||
|
|
||||||
|
function initProjects(){
|
||||||
|
ProjectData.list().then(list => {
|
||||||
|
_projects = list || [];
|
||||||
|
// Reconcile the active project against the list; clear if it's gone.
|
||||||
|
const active = ProjectData.getActive();
|
||||||
|
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
|
||||||
|
renderProjectPicker();
|
||||||
|
applyActiveProject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFormHtml(){
|
||||||
|
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||||
|
<div class="proj-form-grid">
|
||||||
|
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
||||||
|
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||||
|
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||||
|
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||||
|
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||||
|
</div>
|
||||||
|
<div class="proj-actions">
|
||||||
|
<button class="card-button" onclick="saveNewProject()">Create & Select</button>
|
||||||
|
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectPicker(){
|
||||||
|
const box = document.getElementById('project-picker');
|
||||||
|
const activeId = ProjectData.getActiveId();
|
||||||
|
if(!_projects.length){
|
||||||
|
box.innerHTML = `<div class="proj-empty">
|
||||||
|
<p>No projects yet. Create your first project, or start from a sample.</p>
|
||||||
|
<div class="proj-actions">
|
||||||
|
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
|
||||||
|
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
|
||||||
|
</div>
|
||||||
|
</div>` + createFormHtml();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = _projects.map(p =>
|
||||||
|
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
||||||
|
).join('');
|
||||||
|
box.innerHTML = `<div class="proj-row">
|
||||||
|
<select id="project-select" onchange="selectProject(this.value)">
|
||||||
|
<option value="">Select a project…</option>${opts}
|
||||||
|
</select>
|
||||||
|
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
||||||
|
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
||||||
|
</div>
|
||||||
|
<div id="active-project-info"></div>` + createFormHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
||||||
|
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
||||||
|
|
||||||
|
function saveNewProject(){
|
||||||
|
const v = id => (document.getElementById(id)?.value || '').trim();
|
||||||
|
const name = v('np_name');
|
||||||
|
if(!name){ alert('Project name is required.'); return; }
|
||||||
|
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
||||||
|
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSampleProject(){
|
||||||
|
const existing = _projects.find(p => p.sample);
|
||||||
|
if(existing){ afterProjectChosen(existing); return; }
|
||||||
|
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProject(id){
|
||||||
|
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||||
|
const p = _projects.find(x => x.id === id);
|
||||||
|
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterProjectChosen(p){
|
||||||
|
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
||||||
|
ProjectData.setActive(p);
|
||||||
|
renderProjectPicker();
|
||||||
|
applyActiveProject();
|
||||||
|
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the tool cards and stamp the active project into their links.
|
||||||
|
function applyActiveProject(){
|
||||||
|
const active = ProjectData.getActive();
|
||||||
|
const cards = document.getElementById('overview');
|
||||||
|
const heroTitle = document.getElementById('hero-title');
|
||||||
|
const heroSub = document.getElementById('hero-sub');
|
||||||
|
const info = document.getElementById('active-project-info');
|
||||||
|
|
||||||
|
if(!active){
|
||||||
|
cards.style.display = 'none';
|
||||||
|
heroTitle.textContent = 'Work Package Suite';
|
||||||
|
heroSub.textContent = 'Select a project to begin — or create one.';
|
||||||
|
if(info) info.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = '&project=' + encodeURIComponent(active.id);
|
||||||
|
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
|
||||||
|
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
||||||
|
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
||||||
|
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
||||||
|
|
||||||
|
cards.style.display = '';
|
||||||
|
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||||
|
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
||||||
|
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||||
|
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||||
|
|
||||||
|
// Pull the project's shared SOP from the server into the local cache first,
|
||||||
|
// so the SOP "Complete / Review" status reflects what other users have done.
|
||||||
|
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
|
||||||
|
else reflectSOPStatus(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||||
|
|
||||||
|
// Reflect SOP completion on the tool cards (scoped to the active project).
|
||||||
|
function reflectSOPStatus(active){
|
||||||
let complete = false, projName = '';
|
let complete = false, projName = '';
|
||||||
try {
|
try {
|
||||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
// Storage is namespaced per project, so these already scope to `active`.
|
||||||
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
|
||||||
|
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
|
||||||
projName = sop && sop.project && sop.project.name || '';
|
projName = sop && sop.project && sop.project.name || '';
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
|
|
||||||
@@ -484,6 +617,12 @@
|
|||||||
const sopBtn = document.getElementById('card-sop-btn');
|
const sopBtn = document.getElementById('card-sop-btn');
|
||||||
const wpCard = document.getElementById('card-wp');
|
const wpCard = document.getElementById('card-wp');
|
||||||
const wpBtn = document.getElementById('card-wp-btn');
|
const wpBtn = document.getElementById('card-wp-btn');
|
||||||
|
if(!sopCard) return;
|
||||||
|
|
||||||
|
// reset (re-render can run multiple times)
|
||||||
|
sopCard.classList.remove('complete');
|
||||||
|
wpCard && wpCard.classList.remove('disabled');
|
||||||
|
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
||||||
|
|
||||||
if(complete){
|
if(complete){
|
||||||
sopCard.classList.add('complete');
|
sopCard.classList.add('complete');
|
||||||
@@ -497,7 +636,9 @@
|
|||||||
if(wpCard) wpCard.classList.add('disabled');
|
if(wpCard) wpCard.classList.add('disabled');
|
||||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
initProjects();
|
||||||
|
|
||||||
let allComments = [];
|
let allComments = [];
|
||||||
|
|
||||||
@@ -527,9 +668,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() {
|
||||||
@@ -568,22 +707,36 @@
|
|||||||
r.readAsText(f);
|
r.readAsText(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadComments() {
|
function renderComments() {
|
||||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
|
||||||
if (saved) allComments = JSON.parse(saved);
|
|
||||||
|
|
||||||
const list = document.getElementById('comments-list');
|
const list = document.getElementById('comments-list');
|
||||||
if (allComments.length === 0) {
|
if (allComments.length === 0) {
|
||||||
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
|
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
|
||||||
} else {
|
} else {
|
||||||
list.innerHTML = allComments.map(c => `
|
list.innerHTML = allComments.map(c => `
|
||||||
<div class="comment-item">
|
<div class="comment-item">
|
||||||
<div class="comment-meta"><strong>${c.name}</strong> • ${c.timestamp}</div>
|
<div class="comment-meta"><strong>${(c.name||'Anonymous').replace(/</g,'<')}</strong> • ${c.timestamp||''}</div>
|
||||||
<div class="comment-text">${c.text.replace(/</g,'<').replace(/>/g,'>')}</div>
|
<div class="comment-text">${(c.text||'').replace(/</g,'<').replace(/>/g,'>')}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadComments() {
|
||||||
|
// Server is authoritative (so feedback is shared across users); fall back to
|
||||||
|
// the local cache if the API is unreachable.
|
||||||
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||||
|
if (saved) { try { allComments = JSON.parse(saved) || []; } catch(e) { allComments = []; } }
|
||||||
|
renderComments();
|
||||||
|
fetch('/api/comments?source=home_feedback', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(rows => {
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
allComments = rows.map(c => ({ name: c.author, text: c.text, timestamp: c.created_at ? new Date(c.created_at).toLocaleString() : '' }));
|
||||||
|
renderComments();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
109
html/login.html
Normal file
109
html/login.html
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in — Work Package Suite</title>
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--cds-background);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--cds-layer);
|
||||||
|
border: 1px solid var(--cds-border-subtle);
|
||||||
|
box-shadow: 0 2px 6px var(--cds-shadow);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.brand img { height: 36px; width: auto; }
|
||||||
|
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||||
|
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
||||||
|
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
|
||||||
|
.field { margin-bottom: 1.25rem; }
|
||||||
|
input[type=text], input[type=password] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--cds-field);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--cds-border-strong);
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cds-text-on-color);
|
||||||
|
background: var(--cds-button-primary);
|
||||||
|
border: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
||||||
|
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||||
|
.error {
|
||||||
|
display: none;
|
||||||
|
background: #fff1f1;
|
||||||
|
border-left: 3px solid var(--cds-support-error);
|
||||||
|
color: var(--cds-text-error);
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.error.show { display: block; }
|
||||||
|
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
|
||||||
|
</div>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="sub">Work Package Suite</p>
|
||||||
|
|
||||||
|
<div id="error" class="error" role="alert"></div>
|
||||||
|
|
||||||
|
<form id="login-form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button id="submit" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
|
||||||
|
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
|
||||||
|
</p>
|
||||||
|
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem; border-radius:0 6px 6px 0;">
|
||||||
|
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
html/login.js
Normal file
68
html/login.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/* Login page logic for the Work Package Suite.
|
||||||
|
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
|
||||||
|
session cookie (not readable here — that's the point) and we redirect to the
|
||||||
|
page the user was trying to reach, or the home page. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var form = document.getElementById('login-form');
|
||||||
|
var errorBox = document.getElementById('error');
|
||||||
|
var submitBtn = document.getElementById('submit');
|
||||||
|
|
||||||
|
// Where to go after signing in: the ?next= param if it's a safe same-site
|
||||||
|
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
||||||
|
// open-redirect.)
|
||||||
|
function nextTarget() {
|
||||||
|
try {
|
||||||
|
var next = new URLSearchParams(location.search).get('next') || '';
|
||||||
|
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||||
|
} catch (e) {}
|
||||||
|
return 'index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
errorBox.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
var forgot = document.getElementById('forgot-link');
|
||||||
|
if (forgot) {
|
||||||
|
forgot.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var m = document.getElementById('forgot-msg');
|
||||||
|
if (m) m.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
errorBox.classList.remove('show');
|
||||||
|
var username = document.getElementById('username').value.trim();
|
||||||
|
var password = document.getElementById('password').value;
|
||||||
|
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Signing in…';
|
||||||
|
|
||||||
|
fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: username, password: password })
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok) { location.replace(nextTarget()); return null; }
|
||||||
|
return r.json().catch(function () { return null; }).then(function (j) {
|
||||||
|
if (r.status === 401) showError('Invalid username or password.');
|
||||||
|
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
|
||||||
|
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Sign in';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Could not reach the server. Check your connection and try again.');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Sign in';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
197
html/project-data.js
Normal file
197
html/project-data.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/* Shared project layer for the Work Package Suite.
|
||||||
|
Projects are the top-level container — every SOP and Work Package belongs to
|
||||||
|
one. Project records live in the SQL database (via /api/projects); this
|
||||||
|
adapter is API-first and falls back to a localStorage mirror so the suite
|
||||||
|
still works in local dev / offline. Included by the home page and the suite. */
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '/api';
|
||||||
|
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
|
||||||
|
var LS_ACTIVE = 'wp_active_project'; // active project id
|
||||||
|
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
||||||
|
|
||||||
|
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||||
|
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
|
||||||
|
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
||||||
|
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
||||||
|
function cacheUpsert(p) {
|
||||||
|
var list = readLocal();
|
||||||
|
var ix = list.findIndex(function (x) { return x.id === p.id; });
|
||||||
|
if (ix >= 0) list[ix] = p; else list.unshift(p);
|
||||||
|
writeLocal(list);
|
||||||
|
}
|
||||||
|
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||||
|
|
||||||
|
var SAMPLE_PROJECT = {
|
||||||
|
name: 'Micron FMCS Install (sample)', number: '26-67-008',
|
||||||
|
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||||
|
site: 'Boise, ID — Fab', sample: true
|
||||||
|
};
|
||||||
|
|
||||||
|
var ProjectData = {
|
||||||
|
SAMPLE: SAMPLE_PROJECT,
|
||||||
|
esc: esc,
|
||||||
|
|
||||||
|
// Returns the project list. Tries the API; falls back to the local mirror.
|
||||||
|
list: function () {
|
||||||
|
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function (rows) { writeLocal(rows); return rows; })
|
||||||
|
.catch(function () { return readLocal(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
get: function (id) {
|
||||||
|
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
|
||||||
|
save: function (p) {
|
||||||
|
if (!p.id) p.id = uid();
|
||||||
|
return fetch(API + '/projects', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function (saved) { cacheUpsert(saved); return saved; })
|
||||||
|
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: function (id) {
|
||||||
|
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
|
.then(function () { cacheRemove(id); })
|
||||||
|
.catch(function () { cacheRemove(id); });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── active project context ────────────────────────────────────────────────
|
||||||
|
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
||||||
|
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||||
|
setActive: function (p) {
|
||||||
|
try {
|
||||||
|
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
||||||
|
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
||||||
|
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
|
||||||
|
// Falls back to the bare key when no project is active.
|
||||||
|
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Server sync for SOPs and Work Packages ─────────────────────────────────
|
||||||
|
// SOPs and WPs are authoritative on the server (so every user of a project sees
|
||||||
|
// the same data). To avoid rewriting the two apps, we keep their existing
|
||||||
|
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
|
||||||
|
// keys from the API on page load, and the push* helpers write through to the
|
||||||
|
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
|
||||||
|
function nsKey(base, id) { return id ? base + '__' + id : base; }
|
||||||
|
function currentUser() {
|
||||||
|
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A saved Work Package is a flat object in the browser; the API splits it into
|
||||||
|
// promoted columns + a `data` blob. We store the whole flat object in `data`
|
||||||
|
// for perfect round-tripping, and mirror the few fields the API promotes.
|
||||||
|
function pkgToServer(p, projectId) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
project_id: p.projectId || projectId || null,
|
||||||
|
parent_id: p.instanceOf || null,
|
||||||
|
number: p.number || '',
|
||||||
|
subject: p.subject || '',
|
||||||
|
type: p.type || '',
|
||||||
|
status: p.status || 'Draft',
|
||||||
|
created_by: p.createdBy || currentUser(),
|
||||||
|
data: p
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function serverToPkg(row) {
|
||||||
|
var p = Object.assign({}, row.data || {}); // full flat object lives in data
|
||||||
|
p.id = row.id;
|
||||||
|
p.projectId = row.project_id || p.projectId || '';
|
||||||
|
if (row.number) p.number = row.number;
|
||||||
|
if (row.subject != null) p.subject = row.subject;
|
||||||
|
if (row.type != null) p.type = row.type;
|
||||||
|
if (row.status) p.status = row.status; // honor server-side status changes
|
||||||
|
if (row.parent_id) p.instanceOf = row.parent_id;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull this project's SOP + WPs from the API into the localStorage keys the
|
||||||
|
// apps read. Resolves even on failure (offline / no API) so boot continues.
|
||||||
|
ProjectData.pullProject = function (projectId) {
|
||||||
|
if (!projectId) return Promise.resolve();
|
||||||
|
var jobs = [];
|
||||||
|
jobs.push(
|
||||||
|
fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (sopRow) {
|
||||||
|
if (sopRow && sopRow.data) {
|
||||||
|
var d = sopRow.data; // { sop, state } as written by pushSOP
|
||||||
|
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
|
||||||
|
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
|
||||||
|
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||||
|
}
|
||||||
|
}).catch(function () {})
|
||||||
|
);
|
||||||
|
jobs.push(
|
||||||
|
fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (rows) {
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg)));
|
||||||
|
}
|
||||||
|
}).catch(function () {})
|
||||||
|
);
|
||||||
|
return Promise.all(jobs).then(function () {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
|
||||||
|
// deterministic id per project so re-completing updates the same row.
|
||||||
|
ProjectData.pushSOP = function (projectId, sop, state) {
|
||||||
|
if (!projectId) return Promise.resolve(null);
|
||||||
|
var body = {
|
||||||
|
id: 'sop__' + projectId,
|
||||||
|
project_id: projectId,
|
||||||
|
name: (sop && sop.project && sop.project.name) || 'SOP',
|
||||||
|
number: (sop && sop.project && sop.project.number) || '',
|
||||||
|
complete: true,
|
||||||
|
created_by: currentUser(),
|
||||||
|
data: { sop: sop, state: state }
|
||||||
|
};
|
||||||
|
return fetch(API + '/sops', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert a single Work Package to the API (fire-and-forget from the caller's
|
||||||
|
// perspective; the local cache is the source of truth for immediate rendering).
|
||||||
|
ProjectData.pushWP = function (p, projectId) {
|
||||||
|
if (!p || !p.id) return Promise.resolve(null);
|
||||||
|
return fetch(API + '/wps', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectData.removeWP = function (id) {
|
||||||
|
if (!id) return Promise.resolve();
|
||||||
|
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
|
.then(function () {}).catch(function () {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||||
|
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem('wp_ns_migrated_v1')) {
|
||||||
|
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
|
||||||
|
try { localStorage.removeItem(k); } catch (e) {}
|
||||||
|
});
|
||||||
|
localStorage.setItem('wp_ns_migrated_v1', '1');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
global.ProjectData = ProjectData;
|
||||||
|
})(window);
|
||||||
@@ -4,6 +4,25 @@ let currentStep = 1;
|
|||||||
let sopComplete = false;
|
let sopComplete = false;
|
||||||
let allComments = [];
|
let allComments = [];
|
||||||
|
|
||||||
|
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' when a
|
||||||
|
// project is active. Keeps each project's SOP separate in the browser.
|
||||||
|
function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } }
|
||||||
|
|
||||||
|
// WP size presets — the dropdown label maps to a default split-threshold (max
|
||||||
|
// labor hours). The label is exported as governance.woSize (human-readable
|
||||||
|
// guidance); the number drives the Creator's "consider splitting" warning.
|
||||||
|
const WP_SIZE_PRESETS = {
|
||||||
|
'Small — 1–2 days (≈8–24 hrs)': 24,
|
||||||
|
'Standard — 3–5 days (≈40–80 hrs)': 80,
|
||||||
|
'Large — 1–2 weeks (≈80–160 hrs)': 160
|
||||||
|
};
|
||||||
|
function onSizePresetChange(){
|
||||||
|
const label = document.getElementById('gov_wosize').value;
|
||||||
|
const max = WP_SIZE_PRESETS[label];
|
||||||
|
if(max != null){ document.getElementById('gov_size_hours_max').value = max; }
|
||||||
|
// 'Custom…' / '' leave the threshold for manual entry.
|
||||||
|
}
|
||||||
|
|
||||||
let state = {
|
let state = {
|
||||||
project: {name:'', number:'', client:'', division:'', site:''},
|
project: {name:'', number:'', client:'', division:'', site:''},
|
||||||
team: {pm:'', apm:'', cm:'', qm:''},
|
team: {pm:'', apm:'', cm:'', qm:''},
|
||||||
@@ -126,17 +145,36 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
renderStandardConstraints();
|
renderStandardConstraints();
|
||||||
renderSequenceSteps();
|
renderSequenceSteps();
|
||||||
renderSources();
|
renderSources();
|
||||||
|
// Resolve the active project FIRST so per-project storage keys are correct
|
||||||
|
// before we restore this project's SOP.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const projId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||||
|
applyProjectContext(params.get('project'));
|
||||||
|
|
||||||
|
// Pull the project's shared SOP from the server into the local cache, THEN
|
||||||
|
// restore it. Falls back to the local cache if offline.
|
||||||
|
function afterPull(){
|
||||||
restoreSavedSOP();
|
restoreSavedSOP();
|
||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
|
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
||||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page cards.
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const tab = params.get('tab');
|
const tab = params.get('tab');
|
||||||
if(params.get('view') === 'dashboard') switchTool('wp');
|
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
||||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||||
|
}
|
||||||
|
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||||
|
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||||||
|
} else {
|
||||||
|
afterPull();
|
||||||
|
}
|
||||||
|
|
||||||
track('app_open');
|
track('app_open');
|
||||||
|
|
||||||
|
// Feedback author auto-populates from the signed-in user (auth-guard sets
|
||||||
|
// window.WP_USER and fires 'wp-auth-ready'); the field is read-only.
|
||||||
|
setCommenterName();
|
||||||
|
document.addEventListener('wp-auth-ready', setCommenterName);
|
||||||
|
|
||||||
let _fieldTimer;
|
let _fieldTimer;
|
||||||
document.addEventListener('input', e=>{
|
document.addEventListener('input', e=>{
|
||||||
const t = e.target;
|
const t = e.target;
|
||||||
@@ -154,7 +192,15 @@ function initializeWPTypes(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
||||||
|
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard
|
||||||
|
// tab it loads the example Work Package inside the embedded creator.
|
||||||
function loadSampleData(){
|
function loadSampleData(){
|
||||||
|
if(currentTool && currentTool !== 'sop'){
|
||||||
|
const f = document.getElementById('wp-frame');
|
||||||
|
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
|
||||||
|
else { alert('Open the Work Package Creation tab first, then load the sample.'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Populate Step 1
|
// Populate Step 1
|
||||||
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
||||||
document.getElementById('proj_number').value = '26-67-008';
|
document.getElementById('proj_number').value = '26-67-008';
|
||||||
@@ -174,10 +220,10 @@ function loadSampleData(){
|
|||||||
|
|
||||||
// Populate Step 5
|
// Populate Step 5
|
||||||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
||||||
document.getElementById('gov_wosize').value = '3–5 days / 40–80 hours';
|
document.getElementById('gov_wosize').value = 'Standard — 3–5 days (≈40–80 hrs)';
|
||||||
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
|
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
|
||||||
document.getElementById('gov_discmode').value = 'choice';
|
document.getElementById('gov_discmode').value = 'choice';
|
||||||
document.getElementById('gov_size_hours_max').value = '120';
|
document.getElementById('gov_size_hours_max').value = '80';
|
||||||
|
|
||||||
// Populate Step 6
|
// Populate Step 6
|
||||||
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
||||||
@@ -202,9 +248,9 @@ function loadSampleData(){
|
|||||||
function restoreSavedSOP(){
|
function restoreSavedSOP(){
|
||||||
let savedState = null, savedSop = null, complete = false;
|
let savedState = null, savedSop = null, complete = false;
|
||||||
try {
|
try {
|
||||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
|
||||||
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null');
|
savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
|
||||||
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
if(!complete || !savedState) return;
|
if(!complete || !savedState) return;
|
||||||
|
|
||||||
@@ -239,6 +285,12 @@ function repopulateForm(){
|
|||||||
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
||||||
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
||||||
set('gov_woformat', state.governance.woformat);
|
set('gov_woformat', state.governance.woformat);
|
||||||
|
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
||||||
|
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
||||||
|
const wsEl = document.getElementById('gov_wosize');
|
||||||
|
if(wsEl && state.governance.wosize && !Array.from(wsEl.options).some(o=>o.value===state.governance.wosize)){
|
||||||
|
wsEl.add(new Option(state.governance.wosize, state.governance.wosize));
|
||||||
|
}
|
||||||
set('gov_wosize', state.governance.wosize);
|
set('gov_wosize', state.governance.wosize);
|
||||||
set('gov_disciplines', (state.governance.disciplines||[]).join(', '));
|
set('gov_disciplines', (state.governance.disciplines||[]).join(', '));
|
||||||
set('gov_discmode', state.governance.discMode);
|
set('gov_discmode', state.governance.discMode);
|
||||||
@@ -253,30 +305,32 @@ function repopulateForm(){
|
|||||||
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
|
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
|
||||||
function switchTool(tool){
|
function switchTool(tool){
|
||||||
currentTool = tool;
|
currentTool = tool;
|
||||||
|
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
||||||
|
// creator) but opens it straight to the dashboard view.
|
||||||
|
const isDash = (tool === 'dashboard');
|
||||||
|
const contentTool = isDash ? 'wp' : tool;
|
||||||
|
|
||||||
// Update nav tabs
|
// Update nav tabs
|
||||||
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
||||||
document.querySelector(`[data-tab="${tool}"]`).classList.add('active');
|
const tabBtn = document.querySelector(`[data-tab="${tool}"]`);
|
||||||
|
if(tabBtn) tabBtn.classList.add('active');
|
||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
||||||
document.getElementById(`tool-${tool}`).classList.add('active');
|
document.getElementById(`tool-${contentTool}`).classList.add('active');
|
||||||
|
|
||||||
// Reset step counter
|
// Reset step counter
|
||||||
if(tool === 'sop'){
|
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||||||
document.getElementById('total-steps').textContent = '10';
|
|
||||||
}else{
|
|
||||||
document.getElementById('total-steps').textContent = '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
if(tool === 'wp') renderWPTab();
|
if(contentTool === 'wp') renderWPTab(isDash);
|
||||||
|
|
||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
||||||
function renderWPTab(){
|
// wantDash=true opens the creator straight to the dashboard view.
|
||||||
|
function renderWPTab(wantDash){
|
||||||
const gate = document.getElementById('wp-gate');
|
const gate = document.getElementById('wp-gate');
|
||||||
const frame = document.getElementById('wp-frame');
|
const frame = document.getElementById('wp-frame');
|
||||||
if(!gate || !frame) return;
|
if(!gate || !frame) return;
|
||||||
@@ -284,8 +338,11 @@ function renderWPTab(){
|
|||||||
gate.style.display = 'none';
|
gate.style.display = 'none';
|
||||||
frame.style.display = 'block';
|
frame.style.display = 'block';
|
||||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
// Reload each time so the creator picks up the latest SOP from localStorage.
|
||||||
const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard';
|
const sp = new URLSearchParams(window.location.search);
|
||||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now();
|
const dash = wantDash || sp.get('view') === 'dashboard';
|
||||||
|
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||||
|
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
|
||||||
|
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||||||
}else{
|
}else{
|
||||||
gate.style.display = 'block';
|
gate.style.display = 'block';
|
||||||
frame.style.display = 'none';
|
frame.style.display = 'none';
|
||||||
@@ -297,8 +354,43 @@ function onSOPReady(){
|
|||||||
if(currentTool === 'wp') renderWPTab();
|
if(currentTool === 'wp') renderWPTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active project comes from the home page (?project=<id> + ProjectData.getActive()).
|
||||||
|
// When the SOP's project fields are still empty, prefill them from the project
|
||||||
|
// record so the SOP is authored against the chosen project.
|
||||||
|
let activeProject = null;
|
||||||
|
function applyProjectContext(projectId){
|
||||||
|
try {
|
||||||
|
if(typeof ProjectData !== 'undefined'){
|
||||||
|
if(projectId && ProjectData.getActiveId() !== projectId){
|
||||||
|
// Deep-linked to a project that isn't the cached active one. Seed the id
|
||||||
|
// immediately so namespaced storage keys resolve, then fetch the full record.
|
||||||
|
const cached = ProjectData.getActive();
|
||||||
|
ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId });
|
||||||
|
ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } });
|
||||||
|
}
|
||||||
|
activeProject = ProjectData.getActive();
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
prefillProjectFields();
|
||||||
|
}
|
||||||
|
function prefillProjectFields(){
|
||||||
|
if(!activeProject) return;
|
||||||
|
const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; };
|
||||||
|
set('proj_name', activeProject.name);
|
||||||
|
set('proj_number', activeProject.number);
|
||||||
|
set('proj_client', activeProject.client);
|
||||||
|
set('proj_division', activeProject.division);
|
||||||
|
set('proj_site', activeProject.site);
|
||||||
|
if(typeof state !== 'undefined' && state.project){
|
||||||
|
state.project.name = state.project.name || activeProject.name || '';
|
||||||
|
state.project.number = state.project.number || activeProject.number || '';
|
||||||
|
state.project.client = state.project.client || activeProject.client || '';
|
||||||
|
state.project.division = state.project.division || activeProject.division || '';
|
||||||
|
state.project.site = state.project.site || activeProject.site || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
function updateProjectDisplay(){
|
function updateProjectDisplay(){
|
||||||
const projName = document.getElementById('proj_name')?.value || 'Project';
|
const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project';
|
||||||
const display = document.getElementById('project-display');
|
const display = document.getElementById('project-display');
|
||||||
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
||||||
}
|
}
|
||||||
@@ -315,14 +407,24 @@ function renderWPTypes(){
|
|||||||
state.wpTypes.forEach((t,i)=>{
|
state.wpTypes.forEach((t,i)=>{
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'wp-type-row';
|
row.className = 'wp-type-row';
|
||||||
|
const nameCell = t.custom
|
||||||
|
? `<div style="display:flex; gap:6px; align-items:center;">
|
||||||
|
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
|
||||||
|
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
|
||||||
|
</div>`
|
||||||
|
: `<div style="font-weight:600;">${t.name}</div>`;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div style="font-weight:600;">${t.name}</div>
|
${nameCell}
|
||||||
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
||||||
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||||
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||||
`;
|
`;
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
|
const addRow = document.createElement('div');
|
||||||
|
addRow.style.cssText = 'margin-top:0.85rem;';
|
||||||
|
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add Custom Type</button>`;
|
||||||
|
container.appendChild(addRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleWPType(i){
|
function toggleWPType(i){
|
||||||
@@ -330,6 +432,21 @@ function toggleWPType(i){
|
|||||||
renderWPTypes();
|
renderWPTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addCustomWPType(){
|
||||||
|
state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true});
|
||||||
|
renderWPTypes();
|
||||||
|
// Focus the new custom row's name input.
|
||||||
|
const rows = document.querySelectorAll('#wp-types-table .wp-type-row');
|
||||||
|
const last = rows[rows.length-1];
|
||||||
|
const nameInput = last && last.querySelector('input[type="text"]');
|
||||||
|
if(nameInput) nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWPType(i){
|
||||||
|
state.wpTypes.splice(i,1);
|
||||||
|
renderWPTypes();
|
||||||
|
}
|
||||||
|
|
||||||
function renderTeamMembers(){
|
function renderTeamMembers(){
|
||||||
const container = document.getElementById('team-members-list');
|
const container = document.getElementById('team-members-list');
|
||||||
if(!container) return;
|
if(!container) return;
|
||||||
@@ -376,18 +493,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){
|
||||||
@@ -413,13 +557,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;
|
||||||
@@ -499,20 +654,30 @@ const DEFAULT_SOURCES = [
|
|||||||
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,8 +816,8 @@ function completeSOP(){
|
|||||||
instanceSuffix: state.governance.instanceSuffix || 'letter',
|
instanceSuffix: state.governance.instanceSuffix || 'letter',
|
||||||
sizeHoursMax: state.governance.sizeHoursMax || ''
|
sizeHoursMax: state.governance.sizeHoursMax || ''
|
||||||
},
|
},
|
||||||
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
|
woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({
|
||||||
name: t.name,
|
name: t.name.trim(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notes: t.notes || '',
|
notes: t.notes || '',
|
||||||
approval: t.approval || ''
|
approval: t.approval || ''
|
||||||
@@ -675,21 +840,32 @@ function completeSOP(){
|
|||||||
};
|
};
|
||||||
|
|
||||||
sopComplete = true;
|
sopComplete = true;
|
||||||
|
// Stamp the active project onto the SOP so it's unambiguously tied to it.
|
||||||
|
try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){}
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
|
|
||||||
// Persist for the home page (green / "Review") and for the WP Creator tab.
|
// Persist for the home page (green / "Review") and for the WP Creator tab,
|
||||||
|
// namespaced to the active project so each project keeps its own SOP.
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('wp_suite_sop', JSON.stringify(sop));
|
localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
|
||||||
localStorage.setItem('wp_suite_state', JSON.stringify(state));
|
localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
|
||||||
localStorage.setItem('wp_suite_sop_complete', '1');
|
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
|
||||||
|
} catch(e){}
|
||||||
|
|
||||||
|
// Share the SOP to the server so every user of this project gets it.
|
||||||
|
try {
|
||||||
|
const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || '';
|
||||||
|
if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state);
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
|
|
||||||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
||||||
|
|
||||||
alert('✓ SOP Configuration Complete!\n\nSwitch to "Work Package Creation" to start creating Work Packages.');
|
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
|
||||||
|
// the user stays), then return to the project home page per the requested flow.
|
||||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab.
|
|
||||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||||||
|
|
||||||
|
alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.');
|
||||||
|
window.location.href = 'index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||||||
@@ -699,8 +875,17 @@ function toggleComments(){
|
|||||||
if(panel.style.display === 'block') loadStepComments();
|
if(panel.style.display === 'block') loadStepComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentUserName(){
|
||||||
|
const u = window.WP_USER;
|
||||||
|
return (u && (u.full_name || u.username)) || '';
|
||||||
|
}
|
||||||
|
function setCommenterName(){
|
||||||
|
const el = document.getElementById('commenter-name');
|
||||||
|
if(el) el.value = currentUserName();
|
||||||
|
}
|
||||||
|
|
||||||
function submitComment(){
|
function submitComment(){
|
||||||
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
|
||||||
const text = document.getElementById('comment-text').value.trim();
|
const text = document.getElementById('comment-text').value.trim();
|
||||||
|
|
||||||
if(!text){ alert('Please enter a comment.'); return; }
|
if(!text){ alert('Please enter a comment.'); return; }
|
||||||
@@ -717,9 +902,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(){
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Work Package Suite</title>
|
<title>Work Package Suite</title>
|
||||||
|
<script src="auth-guard.js"></script>
|
||||||
<link rel="icon" href="favicon.ico" sizes="any">
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||||
@@ -22,9 +23,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load example SOP data">⭐ Load Sample</button>
|
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
|
||||||
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
|
<button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</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>
|
||||||
@@ -32,10 +33,13 @@
|
|||||||
<!-- MAIN NAVIGATION -->
|
<!-- MAIN NAVIGATION -->
|
||||||
<div class="main-nav">
|
<div class="main-nav">
|
||||||
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
|
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
|
||||||
<span class="tab-icon">⚙️</span> SOP Configuration
|
SOP Configuration
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
||||||
<span class="tab-icon">📋</span> Work Package Creation
|
Work Package Creation
|
||||||
|
</button>
|
||||||
|
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
||||||
|
Dashboard
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,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>
|
||||||
|
|
||||||
@@ -188,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>
|
||||||
@@ -202,13 +215,20 @@
|
|||||||
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 1–2 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
|
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 1–2 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Typical WP Size (guidance)</label>
|
<label>Typical WP Size</label>
|
||||||
<input type="text" id="gov_wosize" placeholder="e.g., 3–5 days or 40–80 hours">
|
<select id="gov_wosize" onchange="onSizePresetChange()">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="Small — 1–2 days (≈8–24 hrs)">Small — 1–2 days (≈8–24 hrs)</option>
|
||||||
|
<option value="Standard — 3–5 days (≈40–80 hrs)">Standard — 3–5 days (≈40–80 hrs)</option>
|
||||||
|
<option value="Large — 1–2 weeks (≈80–160 hrs)">Large — 1–2 weeks (≈80–160 hrs)</option>
|
||||||
|
<option value="Custom…">Custom…</option>
|
||||||
|
</select>
|
||||||
|
<small>Sets the split threshold automatically; choose Custom to enter your own.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Split threshold — max labor hours</label>
|
<label>Split threshold — max labor hours<span class="help-tip" data-tip="The Work Package Creator flags any package whose estimated hours exceed this so the planner can break it down. Auto-set by the size band; override if needed.">i</span></label>
|
||||||
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 120">
|
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
|
||||||
<small>The Creator flags packages above this so they can be split (by discipline or scope).</small>
|
<small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +297,7 @@
|
|||||||
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
|
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
|
||||||
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||||
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
|
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
|
||||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">◆ Add QC Hold</button>
|
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,9 +328,9 @@
|
|||||||
|
|
||||||
<!-- SOP NAVIGATION -->
|
<!-- SOP NAVIGATION -->
|
||||||
<div class="step-navigation">
|
<div class="step-navigation">
|
||||||
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">← Back</button>
|
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
|
||||||
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next →</button>
|
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
|
||||||
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">✓ SOP Complete</button>
|
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -321,9 +341,9 @@
|
|||||||
<div id="tool-wp" class="tool">
|
<div id="tool-wp" class="tool">
|
||||||
<!-- Shown until the SOP is complete -->
|
<!-- Shown until the SOP is complete -->
|
||||||
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
|
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
|
||||||
<h2>📋 Work Package Creation</h2>
|
<h2>Work Package Creation</h2>
|
||||||
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
|
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
|
||||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">← Go to SOP Configuration</button>
|
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
||||||
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
|
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
|
||||||
@@ -336,12 +356,12 @@
|
|||||||
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
|
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
|
||||||
<div id="comments-panel" class="comments-dropdown" style="display: none;">
|
<div id="comments-panel" class="comments-dropdown" style="display: none;">
|
||||||
<div class="comments-dropdown-header">
|
<div class="comments-dropdown-header">
|
||||||
<strong>💬 Step Comments</strong>
|
<strong>Feedback</strong>
|
||||||
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
|
<label style="font-weight: 600; font-size: 13px;">Your Name</label>
|
||||||
<input type="text" id="commenter-name" placeholder="e.g., Bill Clarida" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
||||||
@@ -349,8 +369,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||||
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
||||||
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤓ Export</button>
|
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
|
||||||
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤒ Import</button>
|
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
|
||||||
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
|
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
|
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
|
||||||
@@ -363,12 +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="help.js"></script>
|
||||||
<script src="work-package-suite-app.js"></script>
|
<script src="work-package-suite-app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,7 +8,7 @@ const SAMPLE_SOP = {
|
|||||||
meta:{tool:'Work Package Configuration', sample:true},
|
meta:{tool:'Work Package Configuration', sample:true},
|
||||||
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
||||||
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
||||||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' },
|
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 3–5 days (≈40–80 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' },
|
||||||
woTypes:[
|
woTypes:[
|
||||||
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
||||||
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
||||||
@@ -34,15 +34,16 @@ const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
|
|||||||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
||||||
|
|
||||||
// Acumatica cost codes (comment 10) — code|description
|
// Acumatica cost codes (comment 10) — code|description
|
||||||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract','5010|Instrument Material','5020|Network & Computers Material','5030|Software Material','5040|PLC Material','5050|Panel Material','5060|Electrical Material','5070|Mechanical Material','5080|Security Material','5090|Radio Material','6000|Production','7000|Quality','7100|Panel Quality Control','7200|Factory Acceptance Testing','7300|Site Acceptance Testing','8000|Safety','9000|Administration','9100|Warranty','9200|Freight','9300|Travel','9350|Jobsite Costs/Consumable/Other Direct Costs','9400|Contingency','9450|Other','9500|Accrued Incentive Compensation','9600|Sales Tax','9650|Job Cost Labor Burden','9700|Bonding','9800|Non-Billable Compensation'];
|
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract'];
|
||||||
// Acumatica allowed units of measure (comment 15) — common first
|
// Acumatica allowed units of measure (comment 15) — common first
|
||||||
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
||||||
|
|
||||||
// Example built work package (comment 4 / "Load Example") — WP02 export
|
// Example built work package (comment 4 / "Load Example") — WP02 export
|
||||||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Bill Clarida (Prime Controls), Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
||||||
|
|
||||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||||
let SOP=null, editingId=null, numberDirty=false;
|
let SOP=null, editingId=null, numberDirty=false;
|
||||||
|
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
||||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||||
@@ -135,7 +136,12 @@ function editQuality(id){
|
|||||||
}
|
}
|
||||||
function renderCtxBar(){
|
function renderCtxBar(){
|
||||||
const bar=document.getElementById('ctx-bar');
|
const bar=document.getElementById('ctx-bar');
|
||||||
if(!SOP){ bar.innerHTML=`<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; return; }
|
if(!SOP){
|
||||||
|
bar.innerHTML = activeProjectId
|
||||||
|
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
||||||
|
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const p=SOP.project||{}, g=SOP.governance||{};
|
const p=SOP.project||{}, g=SOP.governance||{};
|
||||||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
||||||
@@ -371,12 +377,15 @@ function rollupDisciplineStatus(){
|
|||||||
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
||||||
function onHoursChange(){
|
function onHoursChange(){
|
||||||
const el=document.getElementById('size-check'); if(!el) return;
|
const el=document.getElementById('size-check'); if(!el) return;
|
||||||
const max=parseFloat((SOP&&SOP.governance&&SOP.governance.sizeHoursMax)||'');
|
const g=(SOP&&SOP.governance)||{};
|
||||||
|
const band=g.woSize?('Target: '+g.woSize+'. '):'';
|
||||||
|
const max=parseFloat(g.sizeHoursMax||'');
|
||||||
const hrs=parseFloat(gv('wp_hours'));
|
const hrs=parseFloat(gv('wp_hours'));
|
||||||
if(max && hrs && hrs>max){
|
if(max && hrs && hrs>max){
|
||||||
el.innerHTML=`<span style="color:var(--accent-amber)">⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
el.innerHTML=`<span style="color:var(--accent-amber)">${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||||||
} else if(max){ el.textContent=`Split threshold: ${max} hrs (from SOP).`; }
|
} else if(band || max){
|
||||||
else { el.textContent=''; }
|
el.innerHTML=`<span>${esc(band)}${max?'Split threshold: '+max+' hrs.':''}</span>`;
|
||||||
|
} else { el.textContent=''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
|
// ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
|
||||||
@@ -520,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}; }
|
||||||
@@ -530,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);
|
||||||
@@ -631,6 +655,7 @@ function collectPackage(){
|
|||||||
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
||||||
return {
|
return {
|
||||||
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
||||||
|
projectId: (prev&&prev.projectId) || activeProjectId || '',
|
||||||
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
||||||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||||
@@ -662,6 +687,7 @@ function savePackage(view){
|
|||||||
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
||||||
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
||||||
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
|
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
|
||||||
|
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server
|
||||||
document.getElementById('loadingOverlay').classList.add('active');
|
document.getElementById('loadingOverlay').classList.add('active');
|
||||||
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
|
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
|
||||||
}
|
}
|
||||||
@@ -751,13 +777,61 @@ 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';
|
||||||
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} }
|
// Per-project namespaced key so each project keeps its own packages in the browser.
|
||||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} }
|
function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } }
|
||||||
|
function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} }
|
||||||
|
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
|
||||||
function renderSavedList(){
|
function renderSavedList(){
|
||||||
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
||||||
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
||||||
@@ -768,13 +842,13 @@ 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('');
|
||||||
}
|
}
|
||||||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); }
|
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
||||||
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages on this device?')) return; savedPackages=[]; saveStore(); renderSavedList(); }
|
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
||||||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
|
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
|
||||||
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
||||||
function loadPackageIntoForm(p){
|
function loadPackageIntoForm(p){
|
||||||
@@ -818,6 +892,37 @@ function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; bu
|
|||||||
buildConstraints(); }
|
buildConstraints(); }
|
||||||
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
||||||
|
|
||||||
|
// Duplicate the current work package N times (asks how many). Each copy is a
|
||||||
|
// fresh Draft with a unique number/subject and approvals/closeout cleared.
|
||||||
|
function duplicateWP(){
|
||||||
|
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; }
|
||||||
|
const ans=prompt('How many copies of this work package do you want to create?','1');
|
||||||
|
if(ans===null) return;
|
||||||
|
const n=parseInt(ans,10);
|
||||||
|
if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; }
|
||||||
|
const base=collectPackage();
|
||||||
|
const baseNum = base.number || ('WP'+pad2(editingSeq()));
|
||||||
|
const made=[];
|
||||||
|
for(let i=1;i<=n;i++){
|
||||||
|
const c=JSON.parse(JSON.stringify(base));
|
||||||
|
c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i;
|
||||||
|
c.number = baseNum + '-C' + i;
|
||||||
|
c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)');
|
||||||
|
c.status='Draft';
|
||||||
|
c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined;
|
||||||
|
if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''}));
|
||||||
|
c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons='';
|
||||||
|
c.projectId = base.projectId || activeProjectId || '';
|
||||||
|
c.updatedAt=new Date().toISOString();
|
||||||
|
savedPackages.push(c); made.push(c);
|
||||||
|
}
|
||||||
|
editingId=null; saveStore(); renderSavedList();
|
||||||
|
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
|
||||||
|
toast('Created '+n+' duplicate'+(n>1?'s':''));
|
||||||
|
track('wp_duplicated',{count:n});
|
||||||
|
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
|
||||||
|
}
|
||||||
|
|
||||||
function newPackage(){
|
function newPackage(){
|
||||||
editingId=null;
|
editingId=null;
|
||||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||||
@@ -848,21 +953,36 @@ function exportPackages(){
|
|||||||
// Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus()
|
// Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus()
|
||||||
// bodies for fetch() calls to /api/wps — the dashboard UI doesn't change.
|
// bodies for fetch() calls to /api/wps — the dashboard UI doesn't change.
|
||||||
const WPData = {
|
const WPData = {
|
||||||
list(){ return savedPackages.slice(); }, // → GET /api/wps
|
list(){ return savedPackages.slice(); }, // hydrated from GET /api/wps on boot
|
||||||
get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id}
|
get(id){ return savedPackages.find(p=>p.id===id); },
|
||||||
issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||||||
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); return true; }, // → POST /api/wps/{id}/issue
|
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore();
|
||||||
|
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||||||
setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||||||
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();
|
||||||
|
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||||||
};
|
};
|
||||||
|
|
||||||
let dashFilter={status:'',discipline:'',q:''};
|
let dashFilter={status:'',discipline:'',q:'',flag:''};
|
||||||
|
function dashToggleFlag(f){
|
||||||
|
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
|
||||||
|
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
|
||||||
|
// Consistent colored status pill, reused by the dashboard board and the saved list.
|
||||||
|
function statusPill(s){
|
||||||
|
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
|
||||||
|
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
|
||||||
|
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
|
||||||
|
}
|
||||||
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
||||||
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
||||||
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
||||||
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
||||||
|
|
||||||
function showDashboard(){
|
function showDashboard(){
|
||||||
|
setFormChrome(false);
|
||||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
||||||
document.getElementById('pkg-output').style.display='none';
|
document.getElementById('pkg-output').style.display='none';
|
||||||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||||||
@@ -881,19 +1001,25 @@ function renderDashboard(){
|
|||||||
if(isOverdue(p)) overdue++;
|
if(isOverdue(p)) overdue++;
|
||||||
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
||||||
});
|
});
|
||||||
const card=(label,val,cls)=>`<div class="dash-metric ${cls||''}"><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
// Clickable metric cards filter the board (flag-based); a card with no flag is static.
|
||||||
|
const card=(label,val,cls,flag)=>{
|
||||||
|
const active = flag && dashFilter.flag===flag ? ' dm-active' : '';
|
||||||
|
const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : '';
|
||||||
|
return `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||||
|
};
|
||||||
let h=`<div class="dash-metrics">
|
let h=`<div class="dash-metrics">
|
||||||
${card('Total WPs', all.length)}
|
${card('Total WPs', all.length, '', 'all')}
|
||||||
${card('Release-ready', ready, ready?'dm-green':'')}
|
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||||||
${card('On hold', hold, hold?'dm-red':'')}
|
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||||||
${card('Overdue', overdue, overdue?'dm-red':'')}
|
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||||
${card('Est. hrs', Math.round(estH))}
|
${card('Est. hrs', Math.round(estH))}
|
||||||
${card('Actual hrs', Math.round(actH))}
|
${card('Actual hrs', Math.round(actH))}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// status + discipline breakdown chips
|
// status + discipline breakdown chips (status chips also filter the board)
|
||||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('')
|
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||||||
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:'');
|
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||||||
|
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||||||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||||||
@@ -923,6 +1049,9 @@ function renderDashboard(){
|
|||||||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||||||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||||||
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
||||||
|
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
|
||||||
|
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
|
||||||
|
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||||||
@@ -938,7 +1067,7 @@ function renderDashboard(){
|
|||||||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||||||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||||||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||||||
<td>${esc(p.status||'')}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||||||
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
||||||
});
|
});
|
||||||
h+=`</tbody></table></div>`;
|
h+=`</tbody></table></div>`;
|
||||||
@@ -1010,25 +1139,49 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
||||||
loadStore();
|
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
|
||||||
(function bootSOP(){
|
(function seedProject(){
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||||
|
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){
|
||||||
|
const cached = ProjectData.getActive && ProjectData.getActive();
|
||||||
|
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
function bootSOP(){
|
||||||
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
||||||
// and prefer the SOP the Suite just completed (persisted to localStorage).
|
// and prefer the SOP the Suite just completed (persisted to localStorage, which
|
||||||
|
// we've already hydrated from the server for this project).
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
if(params.get('embedded')) document.body.classList.add('embedded');
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('wp_suite_sop');
|
const raw = localStorage.getItem(wpKey('wp_suite_sop'));
|
||||||
if(raw){
|
if(raw){
|
||||||
const d = JSON.parse(raw);
|
const d = JSON.parse(raw);
|
||||||
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
||||||
}
|
}
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
loadSampleSOP();
|
// No SOP found. Only show the Micron SAMPLE for a standalone preview (no project
|
||||||
})();
|
// context). For a real project, never substitute sample data — show the empty
|
||||||
setRadio('status','Draft');
|
// state so it's clear the project's SOP must be completed first.
|
||||||
renderSavedList();
|
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||||||
cmtInit();
|
else { loadSampleSOP(); }
|
||||||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
}
|
||||||
(function(){ const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } })();
|
function bootData(){
|
||||||
|
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||||||
|
bootSOP();
|
||||||
|
setRadio('status','Draft');
|
||||||
|
renderSavedList();
|
||||||
|
cmtInit();
|
||||||
|
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
||||||
|
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
||||||
|
track('app_open');
|
||||||
|
}
|
||||||
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
||||||
track('app_open');
|
// Pull this project's shared SOP + Work Packages from the server first, then boot
|
||||||
|
// off the refreshed cache. Falls back to whatever is cached locally if offline.
|
||||||
|
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||||
|
ProjectData.pullProject(activeProjectId).then(bootData).catch(bootData);
|
||||||
|
} else {
|
||||||
|
bootData();
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Work Package (IWP) — Prime Controls</title>
|
<title>Work Package (IWP) — Prime Controls</title>
|
||||||
|
<script src="auth-guard.js"></script>
|
||||||
<link rel="icon" href="favicon.ico" sizes="any">
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
<link rel="stylesheet" href="wp-creation-styles.css">
|
<link rel="stylesheet" href="wp-creation-styles.css">
|
||||||
@@ -13,21 +14,22 @@
|
|||||||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo-wrap">
|
<div class="logo-wrap embed-hide">
|
||||||
<div class="header-logo">Prime Controls</div>
|
<div class="header-logo">Prime Controls</div>
|
||||||
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-sep">|</div>
|
<div class="header-sep embed-hide">|</div>
|
||||||
<div class="header-title">Work Package (IWP)</div>
|
<div class="header-title">Work Package (IWP)</div>
|
||||||
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
||||||
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
|
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
|
||||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
||||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||||
<button class="btn btn-ghost" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||||
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
|
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
|
||||||
@@ -37,6 +39,9 @@
|
|||||||
<!-- RELEASE READINESS BANNER -->
|
<!-- RELEASE READINESS BANNER -->
|
||||||
<div class="release-banner" id="release-banner"></div>
|
<div class="release-banner" id="release-banner"></div>
|
||||||
|
|
||||||
|
<!-- SECTION NAV (jump links, built from the form cards) -->
|
||||||
|
<div class="section-nav-bar" id="section-nav"></div>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
<!-- GENERAL INFORMATION -->
|
<!-- GENERAL INFORMATION -->
|
||||||
@@ -44,7 +49,7 @@
|
|||||||
<div class="section-header"><div class="section-title">General Information</div>
|
<div class="section-header"><div class="section-title">General Information</div>
|
||||||
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
<div class="field"><label>WP Number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||||
<div class="field"><label>Status</label>
|
<div class="field"><label>Status</label>
|
||||||
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
||||||
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
||||||
@@ -87,14 +92,14 @@
|
|||||||
|
|
||||||
<!-- DISCIPLINES -->
|
<!-- DISCIPLINES -->
|
||||||
<div class="card" id="discipline-card" style="display:none">
|
<div class="card" id="discipline-card" style="display:none">
|
||||||
<div class="sub-heading">Disciplines</div>
|
<div class="sub-heading">Disciplines<span class="help-tip" data-tip="Pick every discipline this package covers. Choosing two or more turns Scope into per-discipline sections and enables Split by Discipline.">i</span></div>
|
||||||
<div class="notice" id="discipline-note"></div>
|
<div class="notice" id="discipline-note"></div>
|
||||||
<div class="disc-picker" id="discipline-picker"></div>
|
<div class="disc-picker" id="discipline-picker"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SCOPE & WORK -->
|
<!-- SCOPE & WORK -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="sub-heading">Scope & Work</div>
|
<div class="sub-heading">Scope & Work<span class="help-tip" data-tip="Ordered steps the crew performs. With multiple disciplines selected, each gets its own scope section and status. Use Split by Discipline to break a large package into WP01A / WP01B / WP01C instances.">i</span></div>
|
||||||
<div id="flat-scope">
|
<div id="flat-scope">
|
||||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||||
@@ -112,7 +117,7 @@
|
|||||||
|
|
||||||
<!-- MATERIAL LIST -->
|
<!-- MATERIAL LIST -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="sub-heading">Material List</div>
|
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
|
||||||
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
||||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
||||||
<div class="material-actions">
|
<div class="material-actions">
|
||||||
@@ -156,7 +161,7 @@
|
|||||||
|
|
||||||
<!-- CONSTRAINTS / RELEASE READINESS -->
|
<!-- CONSTRAINTS / RELEASE READINESS -->
|
||||||
<div class="card" id="constraint-card">
|
<div class="card" id="constraint-card">
|
||||||
<div class="sub-heading">Constraints — Release Readiness</div>
|
<div class="sub-heading">Constraints — Release Readiness<span class="help-tip" data-tip="A package can't be Issued until every constraint is Cleared or N/A. If one reopens after release, the package drops to Issue (Hold).">i</span></div>
|
||||||
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
|
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +280,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 & View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
<script src="wp-creation-app.js"></script>
|
<script src="wp-creation-app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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; }
|
||||||
4
nginx/Dockerfile
Normal file
4
nginx/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx/conf.d/wp-suite.conf /etc/nginx/conf.d/wp-suite.conf
|
||||||
|
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY html/ /usr/share/nginx/html/
|
||||||
25
nginx/conf.d/wp-suite.conf
Normal file
25
nginx/conf.d/wp-suite.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Work Package Suite — NGINX site config
|
||||||
|
# This container sits behind an external reverse proxy that handles SSL.
|
||||||
|
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name wp.controls.dev;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
nginx/nginx.conf
Normal file
17
nginx/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
@@ -10,3 +10,13 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
|||||||
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
||||||
# production the site is same-origin via NGINX, so leave this unset.
|
# production the site is same-origin via NGINX, so leave this unset.
|
||||||
# CORS_ORIGINS=http://localhost:5500
|
# CORS_ORIGINS=http://localhost:5500
|
||||||
|
|
||||||
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
|
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
|
||||||
|
# the API falls back to a random per-process key, so logins reset on every
|
||||||
|
# restart and break across multiple gunicorn workers. Generate a strong one:
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||||
|
|
||||||
|
# How long a login lasts before re-authentication (hours). Default 12.
|
||||||
|
# AUTH_SESSION_HOURS=12
|
||||||
|
|||||||
294
server/README.md
294
server/README.md
@@ -6,14 +6,21 @@ to this service.
|
|||||||
|
|
||||||
```
|
```
|
||||||
browser → NGINX ──serves──> static site (index.html, …)
|
browser → NGINX ──serves──> static site (index.html, …)
|
||||||
└─proxy /api/─> this API (uvicorn/gunicorn :8000) → PostgreSQL
|
└─proxy /api/─> api container (:8000) → db container (postgres)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| GET | `/api/health` | liveness check |
|
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||||
|
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||||
|
| POST | `/api/auth/logout` | clear the session cookie |
|
||||||
|
| GET | `/api/auth/me` | the logged-in user |
|
||||||
|
| POST | `/api/auth/password` | change your own password |
|
||||||
|
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||||
|
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||||
|
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||||
| GET | `/api/sops` | list SOP summaries |
|
| GET | `/api/sops` | list SOP summaries |
|
||||||
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
||||||
@@ -31,6 +38,55 @@ Interactive docs once running: **`/api/docs`**.
|
|||||||
The full client document is stored in each row's `data` (JSON) column; common
|
The full client document is stored in each row's `data` (JSON) column; common
|
||||||
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login portal (user accounts)
|
||||||
|
|
||||||
|
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||||
|
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||||
|
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||||
|
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||||
|
request is validated by checking the cookie's signature and expiry.
|
||||||
|
|
||||||
|
**The real security boundary is the API:** every `/api/` data route is refused
|
||||||
|
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||||
|
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||||
|
to `login.html` when there's no session — that's for UX, not protection.
|
||||||
|
|
||||||
|
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||||
|
`admin` (may manage users) and `user`.
|
||||||
|
|
||||||
|
### Set the signing secret
|
||||||
|
|
||||||
|
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
|
||||||
|
without it the API uses a random per-process key, so logins reset on restart.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the first admin
|
||||||
|
|
||||||
|
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||||
|
shell (run from the **project root**, like uvicorn):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||||
|
# prompts for a password (min 8 chars)
|
||||||
|
```
|
||||||
|
|
||||||
|
In Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||||
|
```
|
||||||
|
|
||||||
|
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||||
|
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||||
|
API (or you can keep using the CLI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,49 +101,215 @@ Then open http://localhost:8000/api/docs.
|
|||||||
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
||||||
> `server/` directory), because the import path is `server.app:app`.
|
> `server/` directory), because the import path is `server.app:app`.
|
||||||
|
|
||||||
## PostgreSQL setup (production)
|
---
|
||||||
|
|
||||||
|
## Production — Docker Compose
|
||||||
|
|
||||||
|
This is the recommended production setup. Three containers run in an isolated
|
||||||
|
internal network; only NGINX is exposed to the outside via the external `proxy`
|
||||||
|
network.
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE wpsuite;
|
|
||||||
CREATE USER wpsuite WITH PASSWORD 'CHANGE_ME';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE wpsuite TO wpsuite;
|
|
||||||
```
|
```
|
||||||
Tables are created automatically on first startup. (For future schema changes,
|
[external proxy network]
|
||||||
introduce Alembic migrations rather than editing tables by hand.)
|
│
|
||||||
|
┌────▼────┐ internal network ┌──────────┐ ┌────────┐
|
||||||
## Run in production (gunicorn + systemd)
|
│ nginx │ ───────────────────> │ api │ → │ db │
|
||||||
|
└─────────┘ └──────────┘ └────────┘
|
||||||
`/etc/systemd/system/wp-suite-api.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Work Package Suite API
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=www-data
|
|
||||||
WorkingDirectory=/opt/wp-suite
|
|
||||||
Environment="DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite"
|
|
||||||
ExecStart=/opt/wp-suite/.venv/bin/gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 1. Create the credentials file
|
||||||
|
|
||||||
|
Create `.env` in the **project root** (same directory as `docker-compose.yml`).
|
||||||
|
This file is never committed — add it to `.gitignore`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
# .env — project root
|
||||||
sudo systemctl enable --now wp-suite-api
|
POSTGRES_DB=wpsuite
|
||||||
|
POSTGRES_USER=wpsuite
|
||||||
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
|
|
||||||
|
# Must match POSTGRES_* above; hostname is the compose service name "db"
|
||||||
|
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||||
```
|
```
|
||||||
|
|
||||||
NGINX already proxies `/api/` to `127.0.0.1:8000` (see `nginx-wp-suite.conf`).
|
Generate a strong password:
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add the Dockerfile
|
||||||
|
|
||||||
|
Create `Dockerfile` in the **project root**:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/requirements.txt ./server/
|
||||||
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
|
COPY server/ ./server/
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||||
|
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update the NGINX site config
|
||||||
|
|
||||||
|
The API is no longer at `127.0.0.1:8000` — it is the `api` container.
|
||||||
|
Update the `/api/` proxy block in your nginx conf (e.g. `nginx/conf.d/wp-suite.conf`):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000; # ← service name, not localhost
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. docker-compose.yml
|
||||||
|
|
||||||
|
Replace your existing `docker-compose.yml` with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: nginx_webserver
|
||||||
|
volumes:
|
||||||
|
- ./html:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./logs:/var/log/nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- proxy # external — reachable by your reverse proxy / traefik
|
||||||
|
- internal # needs a path to the api container
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: wp_api
|
||||||
|
env_file: .env # loads DATABASE_URL
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wp_db
|
||||||
|
env_file: .env # loads POSTGRES_DB / USER / PASSWORD
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
internal: true # no outbound internet access from api/db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. First-time startup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the api image and start all containers
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Confirm all three containers are running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Tail logs (Ctrl-C to stop following)
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables are created automatically on first API startup — no manual `CREATE TABLE`
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Authentication notes
|
||||||
|
|
||||||
|
**Postgres → API authentication** is handled entirely through `DATABASE_URL` in
|
||||||
|
`.env`. The `db` container uses `POSTGRES_USER` / `POSTGRES_PASSWORD` to
|
||||||
|
initialise the database on first run; the `api` container uses the matching
|
||||||
|
credentials in `DATABASE_URL` to connect. Neither credential ever appears in the
|
||||||
|
compose file itself.
|
||||||
|
|
||||||
|
**Network isolation**: the `db` container is on the `internal` network only —
|
||||||
|
it has no port exposed to the host and is unreachable from outside the compose
|
||||||
|
stack. Only the `api` container can open a connection to it.
|
||||||
|
|
||||||
|
**Changing the password**: update both `POSTGRES_PASSWORD` and the password
|
||||||
|
in `DATABASE_URL` in `.env`, then:
|
||||||
|
```bash
|
||||||
|
# Stop api first (db must keep running to accept the ALTER USER command)
|
||||||
|
docker compose stop api
|
||||||
|
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
|
||||||
|
docker compose start api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Day-to-day operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild api after a code change
|
||||||
|
docker compose up -d --build api
|
||||||
|
|
||||||
|
# View postgres data directly
|
||||||
|
docker compose exec db psql -U wpsuite -d wpsuite
|
||||||
|
|
||||||
|
# Take a database backup
|
||||||
|
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql
|
||||||
|
|
||||||
|
# Stop everything (data volume is preserved)
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop everything AND delete all data
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick test
|
## Quick test
|
||||||
|
|
||||||
```bash
|
`/api/health` is open; data routes now require a session, so log in first and
|
||||||
curl -X POST http://127.0.0.1:8000/api/comments \
|
reuse the cookie jar:
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
|
|
||||||
|
|
||||||
curl http://127.0.0.1:8000/api/comments
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||||
|
|
||||||
|
# Sign in, saving the session cookie to a jar
|
||||||
|
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"alice","password":"<password>"}'
|
||||||
|
|
||||||
|
# Reuse the cookie on protected routes
|
||||||
|
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||||
|
|
||||||
|
Or via the nginx proxy (replace with your hostname):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://wp-suite.company.local/api/health
|
||||||
```
|
```
|
||||||
|
|||||||
380
server/app.py
380
server/app.py
@@ -12,14 +12,16 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Query
|
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .db import Base, engine, get_db
|
from .db import Base, engine, get_db
|
||||||
from . import models
|
from . import models, auth
|
||||||
|
|
||||||
# Create tables on startup. (For schema changes later, switch to Alembic.)
|
# Create tables on startup. (For schema changes later, switch to Alembic.)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -28,21 +30,96 @@ app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url=
|
|||||||
|
|
||||||
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
|
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
|
||||||
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
|
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
|
||||||
|
# allow_credentials is required so the browser sends the session cookie.
|
||||||
_origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
_origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
||||||
if _origins:
|
if _origins:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware, allow_origins=_origins,
|
CORSMiddleware, allow_origins=_origins, allow_credentials=True,
|
||||||
allow_methods=["*"], allow_headers=["*"],
|
allow_methods=["*"], allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Authentication gate ────────────────────────────────────────────────────────
|
||||||
|
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
||||||
|
# docs are exempt (see auth._needs_auth). This is the real security boundary —
|
||||||
|
# the static pages are only client-side guarded for UX. OPTIONS (CORS preflight)
|
||||||
|
# is always allowed so the browser can negotiate before sending credentials.
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_gate(request: Request, call_next):
|
||||||
|
if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
|
||||||
|
if not auth.is_request_authenticated(request):
|
||||||
|
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
def gen_id(prefix: str) -> str:
|
def gen_id(prefix: str) -> str:
|
||||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-project access control ─────────────────────────────────────────────────
|
||||||
|
# A non-admin user may only touch projects they're a member of (project_members).
|
||||||
|
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
|
||||||
|
# gated. List endpoints are scoped to accessible projects; single-resource and
|
||||||
|
# mutating endpoints raise 403 on no access.
|
||||||
|
def accessible_project_ids(db: Session, user: "models.User"):
|
||||||
|
"""Return the set of project ids the user may access, or None for 'all' (admin)."""
|
||||||
|
if user.role == "admin":
|
||||||
|
return None
|
||||||
|
rows = db.scalars(
|
||||||
|
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
|
||||||
|
).all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
|
||||||
|
if user.role == "admin" or project_id is None:
|
||||||
|
return
|
||||||
|
ok = db.scalar(
|
||||||
|
select(models.ProjectMember.id).where(
|
||||||
|
(models.ProjectMember.user_id == user.id)
|
||||||
|
& (models.ProjectMember.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
||||||
|
|
||||||
|
|
||||||
|
def scope_to_access(stmt, column, db: Session, user: "models.User"):
|
||||||
|
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
|
||||||
|
ids = accessible_project_ids(db, user)
|
||||||
|
if ids is None:
|
||||||
|
return stmt
|
||||||
|
return stmt.where(column.in_(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
|
||||||
|
"""Add a (user, project) membership if it isn't already there."""
|
||||||
|
exists = db.scalar(
|
||||||
|
select(models.ProjectMember.id).where(
|
||||||
|
(models.ProjectMember.user_id == user_id)
|
||||||
|
& (models.ProjectMember.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
|
||||||
|
|
||||||
|
|
||||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||||
|
class ProjectIn(BaseModel):
|
||||||
|
id: Optional[str] = None
|
||||||
|
name: str = ""
|
||||||
|
number: str = ""
|
||||||
|
client: str = ""
|
||||||
|
division: str = ""
|
||||||
|
site: str = ""
|
||||||
|
sample: bool = False
|
||||||
|
created_by: str = ""
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class SopIn(BaseModel):
|
class SopIn(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
name: str = ""
|
name: str = ""
|
||||||
number: str = ""
|
number: str = ""
|
||||||
complete: bool = False
|
complete: bool = False
|
||||||
@@ -52,6 +129,7 @@ class SopIn(BaseModel):
|
|||||||
|
|
||||||
class WpIn(BaseModel):
|
class WpIn(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
sop_id: Optional[str] = None
|
sop_id: Optional[str] = None
|
||||||
parent_id: Optional[str] = None
|
parent_id: Optional[str] = None
|
||||||
number: str = ""
|
number: str = ""
|
||||||
@@ -86,13 +164,238 @@ def health():
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Authentication ─────────────────────────────────────────────────────────────
|
||||||
|
class LoginIn(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class NewUserIn(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
full_name: str = ""
|
||||||
|
email: str = ""
|
||||||
|
role: str = "user" # 'admin' | 'user'
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeIn(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPasswordIn(BaseModel):
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveIn(BaseModel):
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAssignIn(BaseModel):
|
||||||
|
project_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||||
|
"""Verify credentials and, on success, set the HttpOnly session cookie."""
|
||||||
|
user = auth.find_user(db, body.username)
|
||||||
|
# Always run a hash comparison to avoid leaking which usernames exist via
|
||||||
|
# response timing; verify_password tolerates an empty hash.
|
||||||
|
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||||
|
if not user or not valid:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||||
|
user.last_login_at = models.utcnow()
|
||||||
|
db.commit()
|
||||||
|
token = auth.create_token(user)
|
||||||
|
auth.set_session_cookie(response, request, token)
|
||||||
|
return {"user": user.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout")
|
||||||
|
def logout(response: Response):
|
||||||
|
auth.clear_session_cookie(response)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/me")
|
||||||
|
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||||
|
"""Who is logged in. The frontend guard calls this on every page load."""
|
||||||
|
return {"user": user.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/password")
|
||||||
|
def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
if not auth.verify_password(body.current_password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
if len(body.new_password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||||
|
user.password_hash = auth.hash_password(body.new_password)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── User administration (admin only) ────────────────────────────────────────────
|
||||||
|
@app.get("/api/auth/users")
|
||||||
|
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
|
||||||
|
return [u.to_dict() for u in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/users")
|
||||||
|
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
if len(body.password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||||
|
if body.role not in ("admin", "user"):
|
||||||
|
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||||
|
if auth.find_user(db, body.username):
|
||||||
|
raise HTTPException(status_code=409, detail="A user with that username already exists")
|
||||||
|
u = models.User(
|
||||||
|
id=gen_id("user"),
|
||||||
|
username=body.username.strip(),
|
||||||
|
email=body.email.strip(),
|
||||||
|
full_name=body.full_name.strip(),
|
||||||
|
password_hash=auth.hash_password(body.password),
|
||||||
|
role=body.role,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
return u.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/users/{user_id}/password")
|
||||||
|
def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
u = db.get(models.User, user_id)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if len(body.new_password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||||
|
u.password_hash = auth.hash_password(body.new_password)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/users/{user_id}/active")
|
||||||
|
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
u = db.get(models.User, user_id)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if u.id == admin.id and not body.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
||||||
|
u.is_active = body.is_active
|
||||||
|
db.commit()
|
||||||
|
return u.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/auth/users/{user_id}")
|
||||||
|
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
u = db.get(models.User, user_id)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if u.id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||||
|
db.delete(u)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/users/{user_id}/projects")
|
||||||
|
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
"""Which projects a user is assigned to, plus the full project list for the
|
||||||
|
assignment UI. (Admins implicitly access every project regardless.)"""
|
||||||
|
u = db.get(models.User, user_id)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
|
||||||
|
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||||
|
return {
|
||||||
|
"user": u.to_dict(),
|
||||||
|
"assigned": list(assigned),
|
||||||
|
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/auth/users/{user_id}/projects")
|
||||||
|
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
"""Replace a user's project assignments with the given set."""
|
||||||
|
u = db.get(models.User, user_id)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
||||||
|
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
||||||
|
for pid in valid:
|
||||||
|
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
|
||||||
|
db.commit()
|
||||||
|
return {"assigned": sorted(valid)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||||
|
@app.post("/api/projects")
|
||||||
|
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, body.id) if body.id else None
|
||||||
|
is_new = proj is None
|
||||||
|
if not is_new:
|
||||||
|
require_project_access(db, user, proj.id)
|
||||||
|
if proj is None:
|
||||||
|
proj = models.Project(id=body.id or gen_id("proj"))
|
||||||
|
db.add(proj)
|
||||||
|
proj.name = body.name
|
||||||
|
proj.number = body.number
|
||||||
|
proj.client = body.client
|
||||||
|
proj.division = body.division
|
||||||
|
proj.site = body.site
|
||||||
|
proj.sample = body.sample
|
||||||
|
proj.created_by = body.created_by or proj.created_by
|
||||||
|
proj.data = body.data
|
||||||
|
db.commit()
|
||||||
|
# A project created by a non-admin auto-grants its creator access.
|
||||||
|
if is_new and user.role != "admin":
|
||||||
|
grant_project_access(db, user.id, proj.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(proj)
|
||||||
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects")
|
||||||
|
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
|
||||||
|
rows = db.scalars(stmt).all()
|
||||||
|
return [p.summary() for p in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects/{project_id}")
|
||||||
|
def get_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if not proj:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
require_project_access(db, user, proj.id)
|
||||||
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/projects/{project_id}")
|
||||||
|
def delete_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if not proj:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
require_project_access(db, user, proj.id)
|
||||||
|
db.delete(proj)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": project_id}
|
||||||
|
|
||||||
|
|
||||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||||
@app.post("/api/sops")
|
@app.post("/api/sops")
|
||||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
require_project_access(db, user, body.project_id)
|
||||||
sop = db.get(models.Sop, body.id) if body.id else None
|
sop = db.get(models.Sop, body.id) if body.id else None
|
||||||
|
if sop is not None:
|
||||||
|
require_project_access(db, user, sop.project_id)
|
||||||
if sop is None:
|
if sop is None:
|
||||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||||
db.add(sop)
|
db.add(sop)
|
||||||
|
sop.project_id = body.project_id
|
||||||
sop.name = body.name
|
sop.name = body.name
|
||||||
sop.number = body.number
|
sop.number = body.number
|
||||||
sop.complete = body.complete
|
sop.complete = body.complete
|
||||||
@@ -104,16 +407,25 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/sops")
|
@app.get("/api/sops")
|
||||||
def list_sops(db: Session = Depends(get_db)):
|
def list_sops(project_id: Optional[str] = Query(None), full: bool = Query(False), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all()
|
stmt = select(models.Sop)
|
||||||
return [s.summary() for s in rows]
|
if project_id:
|
||||||
|
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||||
|
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
||||||
|
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
|
||||||
|
# full=true includes the data JSON (the whole SOP document) for hydration;
|
||||||
|
# the default summary view stays lean for listing.
|
||||||
|
return [(s.to_dict() if full else s.summary()) for s in rows]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/sops/latest")
|
@app.get("/api/sops/latest")
|
||||||
def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)):
|
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
stmt = select(models.Sop)
|
stmt = select(models.Sop)
|
||||||
if complete is not None:
|
if complete is not None:
|
||||||
stmt = stmt.where(models.Sop.complete == complete)
|
stmt = stmt.where(models.Sop.complete == complete)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||||
|
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
||||||
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
||||||
if not sop:
|
if not sop:
|
||||||
raise HTTPException(status_code=404, detail="No SOP found")
|
raise HTTPException(status_code=404, detail="No SOP found")
|
||||||
@@ -121,18 +433,20 @@ def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/sops/{sop_id}")
|
@app.get("/api/sops/{sop_id}")
|
||||||
def get_sop(sop_id: str, db: Session = Depends(get_db)):
|
def get_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
sop = db.get(models.Sop, sop_id)
|
sop = db.get(models.Sop, sop_id)
|
||||||
if not sop:
|
if not sop:
|
||||||
raise HTTPException(status_code=404, detail="SOP not found")
|
raise HTTPException(status_code=404, detail="SOP not found")
|
||||||
|
require_project_access(db, user, sop.project_id)
|
||||||
return sop.to_dict()
|
return sop.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/sops/{sop_id}")
|
@app.delete("/api/sops/{sop_id}")
|
||||||
def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
sop = db.get(models.Sop, sop_id)
|
sop = db.get(models.Sop, sop_id)
|
||||||
if not sop:
|
if not sop:
|
||||||
raise HTTPException(status_code=404, detail="SOP not found")
|
raise HTTPException(status_code=404, detail="SOP not found")
|
||||||
|
require_project_access(db, user, sop.project_id)
|
||||||
db.delete(sop)
|
db.delete(sop)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": sop_id}
|
return {"deleted": sop_id}
|
||||||
@@ -140,11 +454,15 @@ def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||||
@app.post("/api/wps")
|
@app.post("/api/wps")
|
||||||
def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
require_project_access(db, user, body.project_id)
|
||||||
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
||||||
|
if wp is not None:
|
||||||
|
require_project_access(db, user, wp.project_id)
|
||||||
if wp is None:
|
if wp is None:
|
||||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||||
db.add(wp)
|
db.add(wp)
|
||||||
|
wp.project_id = body.project_id
|
||||||
wp.sop_id = body.sop_id
|
wp.sop_id = body.sop_id
|
||||||
wp.parent_id = body.parent_id
|
wp.parent_id = body.parent_id
|
||||||
wp.number = body.number
|
wp.number = body.number
|
||||||
@@ -160,30 +478,41 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@app.get("/api/wps")
|
@app.get("/api/wps")
|
||||||
def list_wps(
|
def list_wps(
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
sop_id: Optional[str] = Query(None),
|
sop_id: Optional[str] = Query(None),
|
||||||
parent_id: Optional[str] = Query(None),
|
parent_id: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
|
full: bool = Query(False),
|
||||||
|
user: models.User = Depends(auth.get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(models.WorkPackage)
|
stmt = select(models.WorkPackage)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||||
if sop_id:
|
if sop_id:
|
||||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||||
if parent_id:
|
if parent_id:
|
||||||
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(models.WorkPackage.status == status)
|
stmt = stmt.where(models.WorkPackage.status == status)
|
||||||
|
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||||
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
||||||
return [w.summary() for w in rows]
|
# full=true includes the data JSON (full package document) so the creator can
|
||||||
|
# rehydrate everything in one request; default stays lean for listing.
|
||||||
|
return [(w.to_dict() if full else w.summary()) for w in rows]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/wps/metrics")
|
@app.get("/api/wps/metrics")
|
||||||
def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||||
from counts so a split package's hours aren't double-counted with its
|
from counts so a split package's hours aren't double-counted with its
|
||||||
instances."""
|
instances."""
|
||||||
stmt = select(models.WorkPackage)
|
stmt = select(models.WorkPackage)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||||
if sop_id:
|
if sop_id:
|
||||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||||
|
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||||
rows = db.scalars(stmt).all()
|
rows = db.scalars(stmt).all()
|
||||||
|
|
||||||
by_status: dict[str, int] = {}
|
by_status: dict[str, int] = {}
|
||||||
@@ -217,30 +546,33 @@ def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/wps/{wp_id}")
|
@app.get("/api/wps/{wp_id}")
|
||||||
def get_wp(wp_id: str, db: Session = Depends(get_db)):
|
def get_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
wp = db.get(models.WorkPackage, wp_id)
|
wp = db.get(models.WorkPackage, wp_id)
|
||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
|
require_project_access(db, user, wp.project_id)
|
||||||
return wp.to_dict()
|
return wp.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/wps/{wp_id}")
|
@app.delete("/api/wps/{wp_id}")
|
||||||
def delete_wp(wp_id: str, db: Session = Depends(get_db)):
|
def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
wp = db.get(models.WorkPackage, wp_id)
|
wp = db.get(models.WorkPackage, wp_id)
|
||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
|
require_project_access(db, user, wp.project_id)
|
||||||
db.delete(wp)
|
db.delete(wp)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": wp_id}
|
return {"deleted": wp_id}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/wps/{wp_id}/issue")
|
@app.post("/api/wps/{wp_id}/issue")
|
||||||
def issue_wp(wp_id: str, db: Session = Depends(get_db)):
|
def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
"""Release a Work Package to the field. Refuses if any constraint is still
|
"""Release a Work Package to the field. Refuses if any constraint is still
|
||||||
open (the AWP release gate)."""
|
open (the AWP release gate)."""
|
||||||
wp = db.get(models.WorkPackage, wp_id)
|
wp = db.get(models.WorkPackage, wp_id)
|
||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
|
require_project_access(db, user, wp.project_id)
|
||||||
constraints = (wp.data or {}).get("constraints") or []
|
constraints = (wp.data or {}).get("constraints") or []
|
||||||
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
|
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
|
||||||
if open_names:
|
if open_names:
|
||||||
@@ -253,10 +585,11 @@ def issue_wp(wp_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/wps/{wp_id}/status")
|
@app.post("/api/wps/{wp_id}/status")
|
||||||
def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)):
|
def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
wp = db.get(models.WorkPackage, wp_id)
|
wp = db.get(models.WorkPackage, wp_id)
|
||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
|
require_project_access(db, user, wp.project_id)
|
||||||
wp.status = body.status
|
wp.status = body.status
|
||||||
if body.status == "Issued" and wp.issued_at is None:
|
if body.status == "Issued" and wp.issued_at is None:
|
||||||
wp.issued_at = models.utcnow()
|
wp.issued_at = models.utcnow()
|
||||||
@@ -315,3 +648,16 @@ def list_comments(
|
|||||||
stmt = stmt.where(models.Comment.step == step)
|
stmt = stmt.where(models.Comment.step == step)
|
||||||
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
|
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
|
||||||
return [c.to_dict() for c in rows]
|
return [c.to_dict() for c in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local dev convenience: serve the static site from this app ──────────────────
|
||||||
|
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
||||||
|
# receives "/" requests, and the api Docker image doesn't even include html/ — so
|
||||||
|
# this mount stays inactive there. Locally (plain uvicorn, no NGINX) it lets you
|
||||||
|
# open the whole suite at http://localhost:8000/ with the API on the SAME origin,
|
||||||
|
# so the session cookie just works (no CORS, no Secure-cookie headache).
|
||||||
|
#
|
||||||
|
# Mounted LAST so the /api/* routes above always match first.
|
||||||
|
_html_dir = os.path.join(os.path.dirname(__file__), "..", "html")
|
||||||
|
if os.path.isdir(_html_dir):
|
||||||
|
app.mount("/", StaticFiles(directory=_html_dir, html=True), name="site")
|
||||||
|
|||||||
186
server/auth.py
Normal file
186
server/auth.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""Authentication for the Work Package Suite.
|
||||||
|
|
||||||
|
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||||
|
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||||
|
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||||
|
server-side session store — every request is checked by verifying the cookie's
|
||||||
|
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||||
|
|
||||||
|
Security model:
|
||||||
|
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
||||||
|
route is refused with 401 unless a valid session cookie is present.
|
||||||
|
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
||||||
|
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
||||||
|
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
||||||
|
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
||||||
|
set; if it is missing we fall back to a random per-process key (which logs a
|
||||||
|
warning and invalidates every session on restart) so dev still works.
|
||||||
|
|
||||||
|
Roles: 'admin' (may manage users) and 'user'.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import jwt
|
||||||
|
from fastapi import Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .db import get_db
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
log = logging.getLogger("wpsuite.auth")
|
||||||
|
|
||||||
|
COOKIE_NAME = "wp_session"
|
||||||
|
JWT_ALG = "HS256"
|
||||||
|
# How long a login lasts before the user must sign in again.
|
||||||
|
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||||
|
|
||||||
|
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||||
|
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||||
|
_EXEMPT_EXACT = {
|
||||||
|
"/api/health",
|
||||||
|
"/api/docs",
|
||||||
|
"/api/openapi.json",
|
||||||
|
"/api/docs/oauth2-redirect",
|
||||||
|
"/api/redoc",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_secret() -> str:
|
||||||
|
s = os.getenv("AUTH_SECRET_KEY")
|
||||||
|
if s:
|
||||||
|
return s
|
||||||
|
# No secret configured: generate an ephemeral one so the app still runs in
|
||||||
|
# dev. Sessions won't survive a restart, and this is unsafe across multiple
|
||||||
|
# workers — production must set AUTH_SECRET_KEY.
|
||||||
|
log.warning(
|
||||||
|
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
|
||||||
|
"Logins will reset on restart and break across multiple workers. "
|
||||||
|
"Set AUTH_SECRET_KEY in the environment for production."
|
||||||
|
)
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = _load_secret()
|
||||||
|
|
||||||
|
|
||||||
|
# ── password hashing ──────────────────────────────────────────────────────────
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||||
|
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||||
|
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
if not hashed:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||||
|
def create_token(user: "models.User") -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
|
"""Return the token claims if the signature and expiry are valid, else None."""
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||||
|
def _is_https(request: Request) -> bool:
|
||||||
|
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||||
|
# so trust X-Forwarded-Proto (set in nginx-wp-suite.conf) when present.
|
||||||
|
xfp = request.headers.get("x-forwarded-proto", "")
|
||||||
|
if xfp:
|
||||||
|
return xfp.split(",")[0].strip().lower() == "https"
|
||||||
|
return request.url.scheme == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_cookie(response: Response, request: Request, token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=SESSION_HOURS * 3600,
|
||||||
|
httponly=True,
|
||||||
|
secure=_is_https(request),
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
# ── request gate (used as middleware in app.py) ─────────────────────────────────
|
||||||
|
def _needs_auth(path: str) -> bool:
|
||||||
|
if not path.startswith("/api/"):
|
||||||
|
return False # static assets are served by NGINX, not this app
|
||||||
|
if path in _EXEMPT_EXACT:
|
||||||
|
return False
|
||||||
|
return not any(path.startswith(p) for p in _EXEMPT_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_request_authenticated(request: Request) -> Optional[dict]:
|
||||||
|
"""Validate the session cookie on a raw request. Returns claims or None.
|
||||||
|
Used by the middleware gate, which has no dependency-injection context."""
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return decode_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
# ── dependencies (used inside route handlers) ───────────────────────────────────
|
||||||
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models.User":
|
||||||
|
"""Resolve the logged-in user from the session cookie, or raise 401.
|
||||||
|
|
||||||
|
Unlike the middleware gate (which only checks the token signature), this also
|
||||||
|
confirms the account still exists and is active — so disabling a user takes
|
||||||
|
effect on their next request."""
|
||||||
|
claims = is_request_authenticated(request)
|
||||||
|
if not claims:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
user = db.get(models.User, claims.get("sub"))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
||||||
|
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
||||||
|
"""Look up by username, case-insensitively (also matches on email)."""
|
||||||
|
uname = (username or "").strip().lower()
|
||||||
|
if not uname:
|
||||||
|
return None
|
||||||
|
return db.scalars(
|
||||||
|
select(models.User).where(
|
||||||
|
(func.lower(models.User.username) == uname)
|
||||||
|
| (func.lower(models.User.email) == uname)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
43
server/db.py
43
server/db.py
@@ -1,28 +1,49 @@
|
|||||||
"""Database engine and session setup.
|
"""Database engine and session setup.
|
||||||
|
|
||||||
The connection string comes from the DATABASE_URL environment variable, e.g.
|
Connection precedence:
|
||||||
postgresql+psycopg://wpsuite:secret@db-host:5432/wpsuite
|
1. POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB (preferred) — the URL is
|
||||||
|
built with SQLAlchemy's URL.create(), which encodes the password for you,
|
||||||
|
so passwords with special characters (@ ! # : / …) need NO manual escaping.
|
||||||
|
Host/port default to POSTGRES_HOST=db / POSTGRES_PORT=5432.
|
||||||
|
2. DATABASE_URL — a full SQLAlchemy URL, if you'd rather supply one directly
|
||||||
|
(you must URL-encode any special characters in the password yourself).
|
||||||
|
3. Neither set → a local SQLite file, so the API runs anywhere without Postgres.
|
||||||
|
|
||||||
If unset, it falls back to a local SQLite file so the API can be run and tested
|
The schema is identical either way (SQLAlchemy handles dialect differences).
|
||||||
on any machine without Postgres. The schema is identical either way (SQLAlchemy
|
|
||||||
handles the dialect differences).
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, URL
|
||||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
|
|
||||||
# Load a local .env if present (dev convenience). In production the DATABASE_URL
|
# Load a local .env if present (dev convenience).
|
||||||
# normally comes from the systemd unit's Environment / EnvironmentFile instead.
|
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./wpsuite.db")
|
|
||||||
|
|
||||||
# SQLite needs this flag to be used from FastAPI's threadpool; Postgres ignores it.
|
def _resolve_url():
|
||||||
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
user = os.getenv("POSTGRES_USER")
|
||||||
|
pw = os.getenv("POSTGRES_PASSWORD")
|
||||||
|
dbname = os.getenv("POSTGRES_DB")
|
||||||
|
if user and pw and dbname:
|
||||||
|
# Build from components — password is encoded automatically.
|
||||||
|
return URL.create(
|
||||||
|
"postgresql+psycopg",
|
||||||
|
username=user, password=pw,
|
||||||
|
host=os.getenv("POSTGRES_HOST", "db"),
|
||||||
|
port=int(os.getenv("POSTGRES_PORT", "5432")),
|
||||||
|
database=dbname,
|
||||||
|
)
|
||||||
|
return os.getenv("DATABASE_URL") or "sqlite:///./wpsuite.db"
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE_URL = _resolve_url()
|
||||||
|
|
||||||
|
# SQLite needs this flag from FastAPI's threadpool; Postgres ignores it.
|
||||||
|
_is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
|
||||||
|
connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|||||||
144
server/manage_users.py
Normal file
144
server/manage_users.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Command-line user management for the Work Package Suite.
|
||||||
|
|
||||||
|
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||||
|
existing admin, so you have to bootstrap one here), and for occasional account
|
||||||
|
maintenance from a shell on the server.
|
||||||
|
|
||||||
|
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
||||||
|
and .env resolve the same way the API does:
|
||||||
|
|
||||||
|
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||||
|
python -m server.manage_users create bob --role user --name "Bob Jones"
|
||||||
|
python -m server.manage_users list
|
||||||
|
python -m server.manage_users reset-password alice
|
||||||
|
python -m server.manage_users disable bob
|
||||||
|
python -m server.manage_users enable bob
|
||||||
|
|
||||||
|
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||||
|
at least 8 characters.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .db import SessionLocal, Base, engine
|
||||||
|
from . import models, auth
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_id() -> str:
|
||||||
|
return f"user_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_password(provided: str | None) -> str:
|
||||||
|
pw = provided
|
||||||
|
if not pw:
|
||||||
|
pw = getpass.getpass("New password: ")
|
||||||
|
confirm = getpass.getpass("Confirm password: ")
|
||||||
|
if pw != confirm:
|
||||||
|
sys.exit("Passwords do not match.")
|
||||||
|
if len(pw) < 8:
|
||||||
|
sys.exit("Password must be at least 8 characters.")
|
||||||
|
return pw
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create(args, role: str | None = None) -> None:
|
||||||
|
role = role or args.role
|
||||||
|
if role not in ("admin", "user"):
|
||||||
|
sys.exit("role must be 'admin' or 'user'")
|
||||||
|
pw = _prompt_password(getattr(args, "password", None))
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if auth.find_user(db, args.username):
|
||||||
|
sys.exit(f"A user named '{args.username}' already exists.")
|
||||||
|
u = models.User(
|
||||||
|
id=_gen_id(),
|
||||||
|
username=args.username.strip(),
|
||||||
|
full_name=(args.name or "").strip(),
|
||||||
|
email=(args.email or "").strip(),
|
||||||
|
password_hash=auth.hash_password(pw),
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
db.commit()
|
||||||
|
print(f"Created {role}: {u.username} (id={u.id})")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(args) -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
rows = db.query(models.User).order_by(models.User.username).all()
|
||||||
|
if not rows:
|
||||||
|
print("No users yet. Create one with: create-admin <username>")
|
||||||
|
return
|
||||||
|
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
||||||
|
for u in rows:
|
||||||
|
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_reset_password(args) -> None:
|
||||||
|
pw = _prompt_password(getattr(args, "password", None))
|
||||||
|
with SessionLocal() as db:
|
||||||
|
u = auth.find_user(db, args.username)
|
||||||
|
if not u:
|
||||||
|
sys.exit(f"No user named '{args.username}'.")
|
||||||
|
u.password_hash = auth.hash_password(pw)
|
||||||
|
db.commit()
|
||||||
|
print(f"Password reset for {u.username}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_active(username: str, active: bool) -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
u = auth.find_user(db, username)
|
||||||
|
if not u:
|
||||||
|
sys.exit(f"No user named '{username}'.")
|
||||||
|
u.is_active = active
|
||||||
|
db.commit()
|
||||||
|
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Ensure the users table exists even on a fresh database.
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
def add_create(name, help_):
|
||||||
|
sp = sub.add_parser(name, help=help_)
|
||||||
|
sp.add_argument("username")
|
||||||
|
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||||
|
sp.add_argument("--name", default="", help="full name")
|
||||||
|
sp.add_argument("--email", default="")
|
||||||
|
return sp
|
||||||
|
|
||||||
|
add_create("create-admin", "create an admin account")
|
||||||
|
c = add_create("create", "create an account")
|
||||||
|
c.add_argument("--role", choices=["admin", "user"], default="user")
|
||||||
|
|
||||||
|
sub.add_parser("list", help="list all accounts")
|
||||||
|
|
||||||
|
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||||
|
rp.add_argument("username")
|
||||||
|
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||||
|
|
||||||
|
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
||||||
|
dp.add_argument("username")
|
||||||
|
ep = sub.add_parser("enable", help="re-enable an account")
|
||||||
|
ep.add_argument("username")
|
||||||
|
|
||||||
|
args = p.parse_args()
|
||||||
|
if args.cmd == "create-admin":
|
||||||
|
cmd_create(args, role="admin")
|
||||||
|
elif args.cmd == "create":
|
||||||
|
cmd_create(args)
|
||||||
|
elif args.cmd == "list":
|
||||||
|
cmd_list(args)
|
||||||
|
elif args.cmd == "reset-password":
|
||||||
|
cmd_reset_password(args)
|
||||||
|
elif args.cmd == "disable":
|
||||||
|
_set_active(args.username, False)
|
||||||
|
elif args.cmd == "enable":
|
||||||
|
_set_active(args.username, True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON
|
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from .db import Base
|
from .db import Base
|
||||||
|
|
||||||
@@ -21,10 +21,42 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
"""A construction project — the top-level container. SOPs and Work Packages
|
||||||
|
belong to a project so the suite can be used for many jobs at once."""
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
number: Mapped[str] = mapped_column(String(100), default="", index=True)
|
||||||
|
client: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
division: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
site: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
sample: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "name": self.name, "number": self.number,
|
||||||
|
"client": self.client, "division": self.division, "site": self.site,
|
||||||
|
"sample": self.sample, "created_by": self.created_by,
|
||||||
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {**self.summary(), "data": self.data or {}}
|
||||||
|
|
||||||
|
|
||||||
class Sop(Base):
|
class Sop(Base):
|
||||||
__tablename__ = "sops"
|
__tablename__ = "sops"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
name: Mapped[str] = mapped_column(String(300), default="")
|
name: Mapped[str] = mapped_column(String(300), default="")
|
||||||
number: Mapped[str] = mapped_column(String(100), default="")
|
number: Mapped[str] = mapped_column(String(100), default="")
|
||||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -35,8 +67,8 @@ class Sop(Base):
|
|||||||
|
|
||||||
def summary(self) -> dict:
|
def summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id, "name": self.name, "number": self.number,
|
"id": self.id, "project_id": self.project_id, "name": self.name,
|
||||||
"complete": self.complete, "created_by": self.created_by,
|
"number": self.number, "complete": self.complete, "created_by": self.created_by,
|
||||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +80,9 @@ class WorkPackage(Base):
|
|||||||
__tablename__ = "work_packages"
|
__tablename__ = "work_packages"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
sop_id: Mapped[Optional[str]] = mapped_column(
|
sop_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
@@ -65,9 +100,9 @@ class WorkPackage(Base):
|
|||||||
|
|
||||||
def summary(self) -> dict:
|
def summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id,
|
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||||
"number": self.number, "subject": self.subject, "type": self.type,
|
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||||
"status": self.status, "issued_at": _iso(self.issued_at),
|
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
|
||||||
"created_by": self.created_by,
|
"created_by": self.created_by,
|
||||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
}
|
}
|
||||||
@@ -76,6 +111,49 @@ class WorkPackage(Base):
|
|||||||
return {**self.summary(), "data": self.data or {}}
|
return {**self.summary(), "data": self.data or {}}
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||||
|
hash (see server/auth.py). `username` is what people sign in with; `role` is
|
||||||
|
either 'admin' (can manage users) or 'user'."""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user'
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Public view of a user — NEVER includes the password hash."""
|
||||||
|
return {
|
||||||
|
"id": self.id, "username": self.username, "email": self.email,
|
||||||
|
"full_name": self.full_name, "role": self.role, "is_active": self.is_active,
|
||||||
|
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMember(Base):
|
||||||
|
"""Which users may access which projects. A user sees/operates on a project
|
||||||
|
only if a row links them to it (admins bypass this entirely). One row per
|
||||||
|
(user, project) pair."""
|
||||||
|
__tablename__ = "project_members"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
project_id: Mapped[str] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ sqlalchemy>=2.0
|
|||||||
psycopg[binary]>=3.1
|
psycopg[binary]>=3.1
|
||||||
pydantic>=2.6
|
pydantic>=2.6
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
|
bcrypt>=4.1 # password hashing
|
||||||
|
PyJWT>=2.8 # signed session tokens
|
||||||
|
|||||||
175
server/seed_demo.py
Normal file
175
server/seed_demo.py
Normal 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 — 3–5 days (≈40–80 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
196
server/smoketest.py
Normal 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())
|
||||||
Reference in New Issue
Block a user