Compare commits
42 Commits
savepoint-
...
docs/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
| 153fe97a31 | |||
| 928ab8c900 | |||
| e5977758c0 | |||
| a9b22f2add | |||
| e3527a6e1d | |||
| 917a728399 | |||
| 40bd19b6cf | |||
| 6c3098922f | |||
| fcba74b584 | |||
| b38348e6ae | |||
| 61d1cf4bff | |||
| 79b0e955b4 | |||
| 1d004cab75 | |||
| 39b48055ff | |||
| dd37f1f551 | |||
| afdc815fb4 | |||
| ca4176ac00 | |||
| 0e698438a2 | |||
| 566ace9969 | |||
| eefa76e460 | |||
| 5ad3ffa58e | |||
| bdb798efdd | |||
| 151ccea0ac | |||
| 1f8c23c9bb | |||
| 20afb0e565 | |||
| deaf13c724 | |||
| a02f7ec511 | |||
| c010bc22a0 | |||
| 66da5b708a | |||
| 1e31aa535e | |||
| ca8c36a889 | |||
| 39230adf07 | |||
| 2bdb65e580 | |||
| e3ef3b0023 | |||
| c64b5c8b49 | |||
| a32c275f76 | |||
| e5f77846ad | |||
| 561d4f2408 | |||
| a18ae487f6 | |||
| 68c1c803d6 | |||
| e5c450597a | |||
| 3c40b58ff8 |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -14,3 +14,17 @@ wpsuite.db
|
|||||||
|
|
||||||
# Runtime directories (created by containers)
|
# Runtime directories (created by containers)
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Database backup dumps (large + sensitive) — keep the folder, ignore contents
|
||||||
|
/backups/*
|
||||||
|
!/backups/.gitkeep
|
||||||
|
|
||||||
|
# Local server logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local scratch / test artifacts (curl cookie jars hold live session tokens)
|
||||||
|
_*.txt
|
||||||
|
cookies.txt
|
||||||
|
|
||||||
|
# Claude Code local workspace (agent memory, session data)
|
||||||
|
.claude/
|
||||||
|
|||||||
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).
|
||||||
284
DEPLOY-runbook-2026-08-04.md
Normal file
284
DEPLOY-runbook-2026-08-04.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Deploy runbook — WP Suite
|
||||||
|
|
||||||
|
**For:** IT / whoever administers the Docker host and Portainer
|
||||||
|
**From:** n.siegfried@prime-controls.com
|
||||||
|
**Revised:** 2026-08-05 — **this replaces the 2026-08-04 version.** Same procedure,
|
||||||
|
but the deploy now carries a second database migration and a new admin screen. If
|
||||||
|
you already have the earlier copy, work from this one instead.
|
||||||
|
**Expected duration:** 10–15 minutes, including the backup
|
||||||
|
**Expected downtime:** under a minute, while containers are recreated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fill these in before handing this over
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|---|---|
|
||||||
|
| Docker host (SSH target) | `________________` |
|
||||||
|
| Stack name in Portainer | `________________` |
|
||||||
|
| Site URL | `https://________________` |
|
||||||
|
| Stack directory on the host (holds `docker-compose.yml` / `backups/`) | `________________` |
|
||||||
|
|
||||||
|
Container names are fixed by the compose file and are the same on every host:
|
||||||
|
`nginx_webserver`, `wp_api`, `wp_db`, `wp_db_backup`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this deploy changes
|
||||||
|
|
||||||
|
Front-end and nginx changes, a new admin screen, plus pending database migrations
|
||||||
|
that run automatically. Three things make it more than a routine restart:
|
||||||
|
|
||||||
|
1. **The nginx config and the entire `html/` directory are baked into the
|
||||||
|
container image at build time.** A plain restart deploys nothing — the stack
|
||||||
|
must be re-pulled and re-built.
|
||||||
|
2. **A pending migration rewrites existing rows** in the `users.role` column
|
||||||
|
(`b41c7ae90d52`, values `user` → `project_user`). That is why step 1 is a backup
|
||||||
|
and not optional. If a previous deploy already applied it, it will not run again —
|
||||||
|
step 0 tells you which of these you are actually about to run.
|
||||||
|
3. **A second migration adds new columns** (`a7c31f9e5b02`: an archive timestamp on
|
||||||
|
projects, and two default-membership fields on users). This one is additive and
|
||||||
|
has database defaults for existing rows, so it does not rewrite anything.
|
||||||
|
|
||||||
|
For the people using the app, the visible changes are: projects can now be
|
||||||
|
**archived** from the Admin Console (they disappear from the pickers and go
|
||||||
|
read-only, and can be brought back), certain users can be set to join **every new
|
||||||
|
project automatically**, and the Admin Console has been rebuilt so the user table
|
||||||
|
fits on screen.
|
||||||
|
|
||||||
|
Migrations run themselves when the `wp_api` container starts. There is nothing
|
||||||
|
to type and **no new environment variables** — do not change the stack's
|
||||||
|
environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0 — Record the current state (needed for rollback)
|
||||||
|
|
||||||
|
SSH to the Docker host and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wp_api alembic -c server/alembic.ini current
|
||||||
|
docker inspect nginx_webserver --format 'nginx image: {{.Image}}'
|
||||||
|
docker inspect wp_api --format 'api image: {{.Image}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy the output into your ticket.** Also note the Git commit the Portainer
|
||||||
|
stack is currently on (Portainer → the stack → the Git reference / last-updated
|
||||||
|
commit). Without these, rollback is guesswork.
|
||||||
|
|
||||||
|
The first command prints the migration the database is currently on. Use it to see
|
||||||
|
which migrations this deploy will actually run:
|
||||||
|
|
||||||
|
| `alembic current` shows | What will run | What that means |
|
||||||
|
|---|---|---|
|
||||||
|
| `c93f2b1d7e04` or earlier | both migrations | The `users.role` rewrite is included — the backup in step 1 matters most in this case. |
|
||||||
|
| `d15b8c4ef207` | only `a7c31f9e5b02` | The `users.role` rewrite already happened on an earlier deploy. This one is additive only. |
|
||||||
|
| `a7c31f9e5b02` | nothing | The database is already up to date; this is a code-only deploy. |
|
||||||
|
|
||||||
|
Take the backup either way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Back up the database
|
||||||
|
|
||||||
|
On the Docker host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wp_db_backup /scripts/db-backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This triggers the stack's existing backup sidecar once, on demand. Expected
|
||||||
|
output ends with a line like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[db-backup] wrote 1.4M /backups/wpsuite-20260804-141233Z.sql.gz.enc
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the file is on the host (substitute the stack directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lt <stack-dir>/backups | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Record that filename.** Do not continue until you have seen the `wrote …`
|
||||||
|
line and the file in that listing.
|
||||||
|
|
||||||
|
- A `.sql.gz.enc` extension means backups are encrypted — expected and correct.
|
||||||
|
- A `.sql.gz` extension plus a `WARNING: BACKUP_ENC_PASSPHRASE not set` line
|
||||||
|
means backups are unencrypted. Not a blocker for this deploy; report it back.
|
||||||
|
- **No SSH access?** Portainer → **Containers** → `wp_db_backup` → **Console** →
|
||||||
|
connect with `/bin/sh`, then run `/scripts/db-backup.sh`. Same result: the
|
||||||
|
dump lands on the host, because `/backups` is a bind mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Redeploy the stack in Portainer
|
||||||
|
|
||||||
|
1. Portainer → **Stacks** → select the stack.
|
||||||
|
2. **Pull and redeploy** — with re-pull / re-build **enabled**.
|
||||||
|
3. Wait for it to report success.
|
||||||
|
|
||||||
|
A plain "restart" or "stop/start" will **not** deploy this change. See "What
|
||||||
|
this deploy changes" above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Confirm the containers came up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --filter name=nginx_webserver --filter name=wp_api --filter name=wp_db
|
||||||
|
```
|
||||||
|
|
||||||
|
All three must be `Up`, and `wp_db` should show `(healthy)`. Then check the API
|
||||||
|
applied its migrations cleanly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs wp_api --tail 40
|
||||||
|
```
|
||||||
|
|
||||||
|
You are looking for Alembic `Running upgrade …` lines followed by gunicorn
|
||||||
|
starting up, and **no** traceback. The last one should end at `a7c31f9e5b02`. The
|
||||||
|
API deliberately refuses to start if a migration fails, so a restarting `wp_api`
|
||||||
|
container means the migration failed — go to Rollback.
|
||||||
|
|
||||||
|
Confirm the database landed on the new revision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wp_api alembic -c server/alembic.ini current
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `a7c31f9e5b02 (head)`.
|
||||||
|
|
||||||
|
Then verify nginx's own view of its config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx_webserver nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `syntax is ok` / `test is successful`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Confirm the response headers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://<site-url>/work-package-suite.html | grep -Ei 'cache-control|content-security-policy'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-k` if the site uses an internal or self-signed certificate.
|
||||||
|
|
||||||
|
**Both lines must come back.** Expected, approximately:
|
||||||
|
|
||||||
|
```
|
||||||
|
cache-control: no-cache, must-revalidate
|
||||||
|
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; ...
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `content-security-policy` line is **missing** while `cache-control` is
|
||||||
|
present, the deploy is bad — go to Rollback and send me the nginx log. (This is
|
||||||
|
the specific regression this deploy fixes; the two headers must coexist.)
|
||||||
|
|
||||||
|
Also confirm the API is reachable through the proxy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://<site-url>/api/health # → {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Hard-reload once in a browser
|
||||||
|
|
||||||
|
Open the site and press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app
|
||||||
|
uses a service worker; a normal reload can serve the previous version and make a
|
||||||
|
good deploy look broken.
|
||||||
|
|
||||||
|
Sanity checks — all four should take under a minute:
|
||||||
|
|
||||||
|
1. Log in. The home page offers to select or create a project.
|
||||||
|
2. Open **Admin Console** (the link is on the home page; you need an admin account).
|
||||||
|
The user table should read as **one line per user** — if rows are three lines tall
|
||||||
|
and the table spills outside its white card, you are still on the old cached
|
||||||
|
files: hard-reload again.
|
||||||
|
3. Two new cards are present and load: **Projects**, and **Default members on new
|
||||||
|
projects**. Both should list rows, not an error.
|
||||||
|
4. In the **Projects** card, click **Archive** on a project you don't mind hiding
|
||||||
|
(a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived** —
|
||||||
|
it should reappear marked `archived`. Click **Unarchive** to put it back. That
|
||||||
|
round trip proves the new migration and the new endpoint are both live.
|
||||||
|
|
||||||
|
**Deploy complete.** Please report back: the step 0 output (including which
|
||||||
|
migrations ran), the backup filename, and the two header lines from step 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Pick the case that matches.
|
||||||
|
|
||||||
|
### Case A — nginx won't start, or the CSP header is missing
|
||||||
|
|
||||||
|
The database is untouched by this, so this is a code-only rollback. In Portainer,
|
||||||
|
redeploy the stack pinned to the **previous Git commit** recorded in step 0
|
||||||
|
(Portainer → the stack → change the Git reference to that commit → Pull and
|
||||||
|
redeploy). Then re-run step 3 and step 4.
|
||||||
|
|
||||||
|
**Before you do:** grab the log, because it is what I need to fix this.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs nginx_webserver --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Send me that output. If the container is in a restart loop the log still works.
|
||||||
|
|
||||||
|
### Case B — `wp_api` is restarting / a migration failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs wp_api --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Send me that output. **Do not restore the database and do not roll the API back
|
||||||
|
without contacting me first.** Which migration got as far as committing decides what
|
||||||
|
is safe, and they are not the same:
|
||||||
|
|
||||||
|
- **`a7c31f9e5b02`** (the new columns) is additive. If only this one ran, rolling
|
||||||
|
the API back to the previous image is safe on its own — the old code simply
|
||||||
|
ignores the extra columns. Nothing needs converting.
|
||||||
|
- **`b41c7ae90d52`** (the `users.role` rewrite) is not. If that one committed,
|
||||||
|
rolling the API back without converting those values back **will break logins**.
|
||||||
|
That conversion is a one-line command, but it has to match what actually ran.
|
||||||
|
|
||||||
|
The `alembic current` output from step 0, plus the `Running upgrade …` lines in the
|
||||||
|
log above, are exactly what tells us which case you are in — please include both.
|
||||||
|
|
||||||
|
Reach me at n.siegfried@prime-controls.com.
|
||||||
|
|
||||||
|
### Case C — restoring the backup (only if I ask for it)
|
||||||
|
|
||||||
|
Destructive: this drops and recreates the current schema and data. For an
|
||||||
|
encrypted dump, on the Docker host, in the `backups` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BACKUP_ENC_PASSPHRASE='<the passphrase — from the stack env vars>'
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE \
|
||||||
|
-in wpsuite-<timestamp>.sql.gz.enc \
|
||||||
|
| gunzip \
|
||||||
|
| docker exec -i wp_db psql -U wpsuite -d wpsuite
|
||||||
|
unset BACKUP_ENC_PASSPHRASE
|
||||||
|
```
|
||||||
|
|
||||||
|
For an unencrypted dump, drop the `openssl` stage and pipe `gunzip` straight
|
||||||
|
into `psql`. Substitute the real values if `POSTGRES_USER` / `POSTGRES_DB` are
|
||||||
|
not `wpsuite`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Do not add or change environment variables for this deploy.
|
||||||
|
- Do not run `docker compose down -v` — the `-v` flag deletes the `pgdata`
|
||||||
|
volume and with it the entire database.
|
||||||
|
- `docker compose …` commands are avoided throughout this runbook on purpose:
|
||||||
|
for a Portainer-managed Git stack the compose project lives under Portainer's
|
||||||
|
own data directory, so `docker compose` from an SSH session usually can't find
|
||||||
|
it. The `docker exec <container-name>` form used here works from any directory.
|
||||||
|
- Full background documentation: `DEPLOYMENT.md` in the repository.
|
||||||
588
DEPLOYMENT.md
588
DEPLOYMENT.md
@@ -1,81 +1,561 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
The Work Package Suite has two parts:
|
Audience: the IT admin standing this up inside the firewall. This covers the
|
||||||
|
**SQL-backed deployment** — NGINX serving the static front end and a Python API
|
||||||
|
backed by **PostgreSQL**.
|
||||||
|
|
||||||
- a **static front end** (plain HTML/CSS/JS — no build step), and
|
The repo already contains everything needed to run it as a Docker stack:
|
||||||
- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project
|
`Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in
|
||||||
SOPs, Work Packages, and comments so they are shared across users instead of
|
`html/`, and the API in `server/`. The detailed container reference (endpoints,
|
||||||
living in each person's browser.
|
password rotation, day-to-day commands) lives in
|
||||||
|
[`server/README.md`](server/README.md) — this doc is the start-to-finish guide.
|
||||||
|
|
||||||
```
|
```
|
||||||
browser → NGINX ──serves──> static site (index.html, …)
|
[ your TLS reverse proxy / traefik ] ← HTTPS terminates here
|
||||||
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL
|
│ (external "proxy" network)
|
||||||
|
┌────▼────┐ internal network ┌──────────┐ ┌────────────┐
|
||||||
|
browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │
|
||||||
|
│ (html/) │ │ FastAPI │ │ (db) │
|
||||||
|
└─────────┘ └──────────┘ └────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything runs inside your firewall; the app makes **no outbound internet
|
Everything runs inside your firewall; the app makes **no outbound internet
|
||||||
calls** (the logo and scripts are local and the old Google-Fonts dependency was
|
calls** (logo and scripts are local).
|
||||||
removed).
|
|
||||||
|
|
||||||
## 1. Front end (NGINX)
|
> **Architecture note:** all static files live under **`html/`** and are *baked
|
||||||
|
> into the nginx image* at build time (not bind-mounted). So after any front-end
|
||||||
|
> change you rebuild the `webserver` image (see *Updating* below). The API image
|
||||||
|
> is built from the root `Dockerfile`.
|
||||||
|
|
||||||
Copy the project files to a web root and serve them over HTTPS. The provided
|
---
|
||||||
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
|
|
||||||
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
|
|
||||||
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
|
|
||||||
|
|
||||||
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package
|
## 1. Prerequisites
|
||||||
Creator (`<iframe>`) and any browser-side caching behave reliably.
|
|
||||||
|
|
||||||
## 2. API + database
|
- A Linux host with **Docker** and **Docker Compose v2** (`docker compose …`).
|
||||||
|
- An external Docker network named `proxy` that your TLS-terminating reverse
|
||||||
|
proxy also sits on (the compose file marks it `external: true`):
|
||||||
|
```bash
|
||||||
|
docker network create proxy
|
||||||
|
```
|
||||||
|
If you don't run a separate reverse proxy, you can instead publish the nginx
|
||||||
|
container's port 80 directly (see the note in step 4) and terminate TLS there.
|
||||||
|
- The repository checked out on the host.
|
||||||
|
|
||||||
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in
|
## 2. Create the database credentials (`.env`)
|
||||||
[`server/README.md`](server/README.md). In short:
|
|
||||||
|
|
||||||
1. Create the `wpsuite` Postgres database/user.
|
Create a file named `.env` in the **project root** (same folder as
|
||||||
2. `pip install -r server/requirements.txt` into a venv.
|
`docker-compose.yml`). It is git-ignored and must never be committed.
|
||||||
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
|
|
||||||
4. Tables are created automatically on first start.
|
|
||||||
|
|
||||||
Interactive API docs are at `/api/docs` once it's running.
|
```bash
|
||||||
|
# .env — project root
|
||||||
|
POSTGRES_DB=wpsuite
|
||||||
|
POSTGRES_USER=wpsuite
|
||||||
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
|
|
||||||
## 3. Comments / feedback
|
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
|
||||||
|
# out and the API refuses to start. Generate once and keep it stable:
|
||||||
|
# openssl rand -base64 48
|
||||||
|
AUTH_SECRET_KEY=<strong-random-secret>
|
||||||
|
|
||||||
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
|
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
|
||||||
posts to `/api/feedback`, which the API stores in the `comments` table. The
|
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
|
||||||
**Export / Import** buttons remain as an offline fallback — a reviewer can export
|
# unrecoverable: openssl rand -base64 32
|
||||||
a JSON file and someone can import/merge it — but with the API running, comments
|
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
|
||||||
are collected centrally with no manual steps.
|
|
||||||
|
|
||||||
> The earlier Power Automate route is **no longer needed** — comments go straight
|
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
|
||||||
> to Postgres. If you still want a Power App view, point a Power App at the
|
# is OFF by default and enabled from the Admin console; the host/port/from-address
|
||||||
> Postgres `comments` table via the on-prem data gateway, or have a flow read the
|
# are configured there, but the password is only ever read from this variable
|
||||||
> table; no change to this app is required.
|
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP
|
||||||
|
# details.
|
||||||
|
# SMTP_PASSWORD=<smtp-app-password>
|
||||||
|
|
||||||
### Comment payload shape
|
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
|
||||||
|
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
|
||||||
```json
|
# AUTH_RESET_MINUTES=60
|
||||||
{
|
# AUTH_RESET_COOLDOWN_SECONDS=120
|
||||||
"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
|
The API builds its own DB connection string from the `POSTGRES_*`
|
||||||
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` / `AUTH_SECRET_KEY` /
|
||||||
|
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) 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.
|
||||||
|
|
||||||
|
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
|
||||||
|
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
|
||||||
|
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
|
||||||
|
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
|
||||||
|
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
|
||||||
|
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
|
||||||
|
> reached over HTTPS.
|
||||||
|
|
||||||
|
## 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**, its **SOP**, and its **Work
|
||||||
|
> Packages** are all API/SQL-backed, so they appear in the home-page project
|
||||||
|
> picker and render in the Creator/Dashboard as soon as any user opens the
|
||||||
|
> project. 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
|
||||||
|
|
||||||
|
The API + Postgres are the system of record. Everything below is server-stored
|
||||||
|
and shared across every user who opens the project:
|
||||||
|
|
||||||
|
| 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** | **Yes** — pulled from `/api/sops` on load and written through on every save. |
|
||||||
|
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). |
|
||||||
|
|
||||||
|
Saves go through a **durable client-side sync outbox**: edits are written to the
|
||||||
|
API immediately, and if the device is offline they queue and retry when it
|
||||||
|
reconnects (4xx rejections are dropped rather than retried forever). The browser
|
||||||
|
cache is only an offline fallback that reconciles through that outbox — so two
|
||||||
|
users on the same project see the same server-stored SOP and Work Packages.
|
||||||
|
|
||||||
## 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`, `archived_at`, `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`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
||||||
|
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||||
|
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
|
||||||
|
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
||||||
|
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
|
||||||
|
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||||
|
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
|
||||||
|
|
||||||
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}`,
|
||||||
|
`POST /api/projects/{id}/archive` ·
|
||||||
|
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`, `POST /api/wps/{id}/archive`,
|
||||||
|
`GET /api/wps/metrics` ·
|
||||||
|
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
||||||
|
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
||||||
|
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
|
||||||
|
Admin-only `GET/PUT /api/settings`,
|
||||||
|
`POST /api/settings/test-email`, `GET /api/notifications`,
|
||||||
|
`GET /api/projects/{id}/members`.
|
||||||
|
List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects`
|
||||||
|
and `GET /api/wps` both take `archived=exclude|only|all` and **default to
|
||||||
|
`exclude`** — anything that needs to see archived rows (the admin console, the demo
|
||||||
|
cleanup) must ask for them. 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
|
||||||
|
|
||||||
|
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
|
||||||
|
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
|
||||||
|
stack — no cron to set up.
|
||||||
|
|
||||||
|
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env`
|
||||||
|
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many
|
||||||
|
to keep).
|
||||||
|
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
|
||||||
|
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
|
||||||
|
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
|
||||||
|
the passphrase somewhere other than this host; if you lose it the backups can't
|
||||||
|
be restored.
|
||||||
|
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
|
||||||
|
- **Restore (destructive — overwrites current data):**
|
||||||
|
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
|
||||||
|
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
|
||||||
|
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
|
||||||
|
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
|
||||||
|
`internal` network on purpose, so offsite must be pushed from the host.
|
||||||
|
- **Test restores quarterly:** load the latest dump into a throwaway database and
|
||||||
|
confirm it applies. An untested backup is not a backup.
|
||||||
|
|
||||||
|
## Field devices & data at rest
|
||||||
|
|
||||||
|
The field view (PWA) caches a project's Work Packages/SOP in the browser's
|
||||||
|
localStorage so it works offline — i.e. **customer IP sits on the device**.
|
||||||
|
localStorage is not encrypted and is not a security boundary. Signing out clears
|
||||||
|
the cached project data, but for any tablet/phone that opens customer-IP projects:
|
||||||
|
|
||||||
|
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is
|
||||||
|
encrypted by default) and a device passcode.
|
||||||
|
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep
|
||||||
|
the browser profile per-user on shared devices.
|
||||||
|
- Users should **sign out** when handing off a shared device (clears the cache).
|
||||||
|
|
||||||
|
## Email notifications (optional)
|
||||||
|
|
||||||
|
Work-package **owner assignment** works out of the box (in-app only). Optional
|
||||||
|
**email** on assignment is **OFF by default** and is turned on from the **Admin
|
||||||
|
console → Notifications & email** card, where an admin sets the SMTP host / port /
|
||||||
|
TLS / From address and flips the master toggle.
|
||||||
|
|
||||||
|
- The **SMTP password is never stored in the database.** It is read only from the
|
||||||
|
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
|
||||||
|
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
|
||||||
|
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
|
||||||
|
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
|
||||||
|
still recorded in-app (status `skipped`); nothing is sent.
|
||||||
|
- Notification emails carry only a **WP number and a deep link** — never the work
|
||||||
|
package contents — so customer IP stays behind the login.
|
||||||
|
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
||||||
|
|
||||||
|
### Self-service password reset
|
||||||
|
|
||||||
|
Turning email on also enables **Forgot password** on the login page. Until then the
|
||||||
|
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
|
||||||
|
console's **Reset password** button).
|
||||||
|
|
||||||
|
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
|
||||||
|
(default 60). It is **single-use**: completing a reset bumps the account's
|
||||||
|
`token_version`, which both burns the link and signs out that user's other
|
||||||
|
sessions. A completed reset also clears any login lockout.
|
||||||
|
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
|
||||||
|
can't be used to discover usernames. Misses are recorded in the audit log
|
||||||
|
(`password_reset_miss`) instead.
|
||||||
|
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
|
||||||
|
so the form can't be used to flood someone's inbox. The throttle is per worker
|
||||||
|
and in-memory; the token expiry is the real control.
|
||||||
|
- Reset mails are sent **immediately, not through the notifications outbox** — a
|
||||||
|
reset link must never be persisted where an admin could read it and take over an
|
||||||
|
account.
|
||||||
|
- Set `app_base_url` in the admin card, or the emailed link will be relative and
|
||||||
|
therefore useless.
|
||||||
|
|
||||||
|
## Permissions roles
|
||||||
|
|
||||||
|
`User.role` is the **permissions** role; `User.project_role` is the person's **job
|
||||||
|
function** on the project (Project Manager, Superintendent, …) and grants nothing.
|
||||||
|
Both are set in the Admin console's user table.
|
||||||
|
|
||||||
|
| Role | May do |
|
||||||
|
|---|---|
|
||||||
|
| `admin` | User administration, app settings, and every project |
|
||||||
|
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
|
||||||
|
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
|
||||||
|
|
||||||
|
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
|
||||||
|
only hides controls to avoid dead-end clicks. Accounts created before this change
|
||||||
|
carried the role `user`, which the migration rewrites to `project_user`.
|
||||||
|
|
||||||
|
## Feature flags
|
||||||
|
|
||||||
|
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
|
||||||
|
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
|
||||||
|
already has BIM enabled keeps its data — it just stops being offered — so turning
|
||||||
|
the flag off never deletes BIM types, gates, or sequence steps.
|
||||||
|
|
||||||
|
## Release gates (constraints + predecessors)
|
||||||
|
|
||||||
|
A work package reaches **Issued** only when both gates are met:
|
||||||
|
|
||||||
|
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
|
||||||
|
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
|
||||||
|
**Closed**.
|
||||||
|
|
||||||
|
Enforced by `enforce_release_gates()` on **every** path that can set a status —
|
||||||
|
`/api/wps` (the browser and the offline outbox both save through it),
|
||||||
|
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
|
||||||
|
|
||||||
|
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
|
||||||
|
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
|
||||||
|
A blank reason is not an override. The server writes a `gate_overridden` audit
|
||||||
|
event naming the reason and what was skipped, and the reason prints on the
|
||||||
|
package. Changing the predecessor set clears the override.
|
||||||
|
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
|
||||||
|
with a 400 explaining which package already waits on this one.
|
||||||
|
- **A deleted predecessor does not block.** It would otherwise freeze everything
|
||||||
|
downstream of a package someone removed.
|
||||||
|
- The Creator's picker hides itself and any package that already waits on it, so a
|
||||||
|
cycle is hard to build in the first place; the dashboard refuses to issue a
|
||||||
|
blocked package and points at the form for the logged override.
|
||||||
|
|
||||||
|
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
|
||||||
|
descriptive — it gates nothing.
|
||||||
|
|
||||||
|
## Critical constraints reopened after release
|
||||||
|
|
||||||
|
A constraint marked **Critical** on the SOP that reopens **after** the package was
|
||||||
|
released emails the **owner, PM, CM and everyone on the package's distribution
|
||||||
|
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
|
||||||
|
|
||||||
|
Detected by comparing incoming constraints against the stored ones inside the
|
||||||
|
normal upsert — *not* a separate endpoint, because the browser saves through the
|
||||||
|
sync outbox, which only replays `POST /api/wps`; anything hung off another route
|
||||||
|
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
|
||||||
|
re-saving an already-open constraint doesn't re-announce, and never for a package
|
||||||
|
that was never released or a non-critical constraint. Bodies carry the constraint
|
||||||
|
name, WP number and a link — never the package contents.
|
||||||
|
|
||||||
|
## Localization (dates, times, numbers)
|
||||||
|
|
||||||
|
Three levels, most specific first — resolved in `html/wp-format.js`:
|
||||||
|
|
||||||
|
1. **the user's own preference** — *Language & time* in the top-right menu
|
||||||
|
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
|
||||||
|
2. **the app default** — Admin console → Features → *Localization defaults*
|
||||||
|
(`default_locale` / `default_timezone`)
|
||||||
|
3. **the browser**, as before
|
||||||
|
|
||||||
|
Timezone names are validated against the server's own `zoneinfo` database, and the
|
||||||
|
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
|
||||||
|
Calendar dates (a due date, a kitting date) are formatted from their parts and are
|
||||||
|
**never** shifted by a timezone — only real instants (MIMO windows, history,
|
||||||
|
notifications) are converted. Use the shared helpers (`wpFormatDate`,
|
||||||
|
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
|
||||||
|
`toLocaleString()`, or a page will quietly ignore the preference.
|
||||||
|
|
||||||
|
## Top-bar chrome (project switcher + search)
|
||||||
|
|
||||||
|
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
|
||||||
|
global search into whichever top bar a page has — the dark `.wp-appbar` or the
|
||||||
|
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
|
||||||
|
not get a second bar.
|
||||||
|
|
||||||
|
- Switching project reloads the current page with `?project=<id>`; every page
|
||||||
|
already resolves its project from that parameter.
|
||||||
|
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
|
||||||
|
(`scope_to_access`) and hides archived work packages, archived projects, and
|
||||||
|
anything belonging to an archived project. LIKE wildcards in the query are escaped,
|
||||||
|
so searching `100%` matches a literal `100%`. Two-character minimum.
|
||||||
|
- Ctrl/Cmd-K focuses the field from anywhere.
|
||||||
|
|
||||||
|
## Schema migrations (Alembic)
|
||||||
|
|
||||||
|
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
|
||||||
|
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
|
||||||
|
pending migrations automatically**.
|
||||||
|
|
||||||
|
- The **baseline** migration is idempotent: on a fresh database it creates every
|
||||||
|
table; on a database whose tables already exist (made by the old `create_all`)
|
||||||
|
it adopts the schema as-is — no manual `alembic stamp` needed.
|
||||||
|
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
|
||||||
|
migrations-only.
|
||||||
|
- **To change the schema:** edit `server/models.py`, then generate and review a
|
||||||
|
migration before committing:
|
||||||
|
```bash
|
||||||
|
# from the project root (against your dev SQLite or a staging DB)
|
||||||
|
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
|
||||||
|
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
|
||||||
|
```
|
||||||
|
The next `docker compose up -d --build api` applies it in production on startup.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## Per-project permissions
|
||||||
|
|
||||||
|
`users.role` is the account's **default** permissions role. A membership row can
|
||||||
|
override it **per project** (`project_members.role`), so someone can be Project
|
||||||
|
Admin on one job and a plain Project User on another. Empty means "inherit the
|
||||||
|
account's role", which is how every pre-existing membership behaves.
|
||||||
|
|
||||||
|
Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
|
||||||
|
so deleting a work package, changing a completed SOP and deleting a project are all
|
||||||
|
judged **on that project**. An app `admin` is admin everywhere and bypasses
|
||||||
|
membership entirely.
|
||||||
|
|
||||||
|
Set it in **Admin console → User administration → Project access** (its own column,
|
||||||
|
showing how many projects each account can reach). The dialog ticks project access
|
||||||
|
and picks the role on each; `/api/auth/users/{id}/projects` takes
|
||||||
|
`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
|
||||||
|
project-scoped roles. Changes are audit-logged as `project_access_changed`.
|
||||||
|
|
||||||
|
**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
|
||||||
|
the project's members plus app admins, each with their effective role on that
|
||||||
|
project. A project with nobody assigned shows only the admins, which is why
|
||||||
|
assigning people is the first step on a new job.
|
||||||
|
|
||||||
|
### Default members on new projects
|
||||||
|
|
||||||
|
Memberships are also created automatically. **Admin console → Default members on
|
||||||
|
new projects** flags accounts (`users.auto_add_projects`) that belong on every job —
|
||||||
|
the PM who runs them all, the QC lead — with the role they should hold there
|
||||||
|
(`users.auto_add_role`, sharing `project_members.role`'s value space, `''` =
|
||||||
|
inherit the account's own).
|
||||||
|
|
||||||
|
- It applies **only to projects created after the flag is set**. Nothing is
|
||||||
|
back-filled onto existing jobs; use **Project access** for those.
|
||||||
|
- App admins are skipped (they already reach every project) and the flag is cleared
|
||||||
|
if an account is promoted to admin. Inactive accounts are skipped.
|
||||||
|
- Runs in `add_default_members()` on the `is_new` branch of `upsert_project`, so it
|
||||||
|
covers every route into project creation — the home page, the sample project, the
|
||||||
|
demo seeder. An update never re-runs it.
|
||||||
|
- If the creator is themselves a flagged member, the membership created for them as
|
||||||
|
creator carries their `auto_add_role`, so they aren't silently downgraded on the
|
||||||
|
one job they started.
|
||||||
|
- Audit-logged once per project as `project_access_granted` with
|
||||||
|
`detail.reason = "auto_add_projects"`.
|
||||||
|
|
||||||
|
## Archiving a project
|
||||||
|
|
||||||
|
A finished job is archived rather than deleted: `projects.archived_at`, set from
|
||||||
|
**Admin console → Projects** (or `POST /api/projects/{id}/archive`, which needs
|
||||||
|
Project Admin **on that project**, same bar as deleting it).
|
||||||
|
|
||||||
|
An archived project is **hidden and frozen**:
|
||||||
|
|
||||||
|
- It leaves the home picker, the app-bar switcher and global search, because
|
||||||
|
`GET /api/projects` defaults to `archived=exclude`.
|
||||||
|
- It is still readable by id, so a deep link renders it — with a read-only banner
|
||||||
|
from `wp-chrome.js` — and the admin console still lists it under
|
||||||
|
`?archived=all`.
|
||||||
|
- Every write that lands on it is refused with **409** by
|
||||||
|
`require_project_writable()`: saving a project, SOP or work package, deleting
|
||||||
|
either, issuing, status changes, WP archiving, and comments on its WPs/SOPs.
|
||||||
|
Moving a work package *into* or *out of* an archived project is refused too.
|
||||||
|
409 rather than 403 is deliberate — nobody lacks a permission, the project's state
|
||||||
|
is the objection, and the browser outbox (`html/project-data.js`) retires 4xx ops
|
||||||
|
instead of retrying them forever.
|
||||||
|
- Unarchiving and **deleting** stay allowed: unarchive is the one write an archived
|
||||||
|
project must accept, and archive-then-delete is a normal sequence.
|
||||||
|
|
||||||
|
Nothing is removed, and unarchiving restores all of it. `server/smoketest.py`
|
||||||
|
asserts the whole round trip.
|
||||||
|
|
||||||
|
## Asset freshness (why the app can't run half-updated)
|
||||||
|
|
||||||
|
A page must never run against a stylesheet or script from a previous deploy. Three
|
||||||
|
things enforce that, and all three are needed:
|
||||||
|
|
||||||
|
1. **`Cache-Control: no-cache` on HTML/CSS/JS** — set by NGINX
|
||||||
|
(`nginx/conf.d/wp-suite.conf`) and by the dev server (`_NoCacheCode` in
|
||||||
|
`server/app.py`). With no header at all the browser applies *heuristic* freshness,
|
||||||
|
roughly 10% of each file's age, so the least recently changed file gets the longest
|
||||||
|
lifetime — which is exactly how HTML and CSS drift apart. ETag/Last-Modified still
|
||||||
|
make each revalidation a cheap 304.
|
||||||
|
2. **The service worker fetches code with `cache: 'no-cache'`** (`html/sw.js`) and
|
||||||
|
precaches with `cache: 'reload'`. A plain `fetch(req)` inherits the request's
|
||||||
|
default cache mode and consults the browser HTTP cache, so "network-first" alone
|
||||||
|
was not enough. Non-`ok` responses fall back to the cache rather than replacing a
|
||||||
|
page the cache could still serve, and cache keys drop the query string so in-app
|
||||||
|
links (`?project=…&tab=…`) still resolve offline.
|
||||||
|
3. **Components whose CSS-missing state is *broken* carry their own critical layout.**
|
||||||
|
The embedded creator's iframe keeps its sizing inline (and `sizeWPFrame()` re-applies
|
||||||
|
it), and the work-package panel injects a floor of positioning rules from
|
||||||
|
`wp-creation-app.js`. Both had failure modes — a 300×150 iframe, and panel controls
|
||||||
|
dumped loose into the form — that a missing rule turned into a broken page rather
|
||||||
|
than a plain one.
|
||||||
|
|
||||||
|
If you change the shell file list in `sw.js`, bump `CACHE`.
|
||||||
|
|
||||||
|
> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control`
|
||||||
|
> at http level, applied with a single server-level `add_header`. Do **not** move it
|
||||||
|
> into a `location` block: nginx does not inherit `add_header` into a block that
|
||||||
|
> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control`
|
||||||
|
> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those
|
||||||
|
> files. After deploying, confirm both are present on one response:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy'
|
||||||
|
> ```
|
||||||
|
|||||||
@@ -4,5 +4,8 @@ COPY server/requirements.txt ./server/
|
|||||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is
|
||||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
# safe on both fresh and existing databases (the baseline migration adopts an
|
||||||
|
# existing schema, so no manual stamp is needed). `exec` hands PID 1 to gunicorn
|
||||||
|
# for correct signal handling; --preload imports the app once before forking.
|
||||||
|
CMD ["sh", "-c", "alembic -c server/alembic.ini upgrade head && exec gunicorn -k uvicorn.workers.UvicornWorker --preload -b 0.0.0.0:8000 --workers 2 server.app:app"]
|
||||||
148
KNOWN-ISSUES.md
Normal file
148
KNOWN-ISSUES.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Known issues — Work Package Suite
|
||||||
|
|
||||||
|
Defects and limitations we know about and have decided not to fix yet. An entry
|
||||||
|
here is a commitment to a decision, not a bug tracker: it says what is wrong, what
|
||||||
|
it costs, why it is still open, and what closing it takes.
|
||||||
|
|
||||||
|
Anything genuinely urgent does not belong here — it belongs in the next deploy.
|
||||||
|
|
||||||
|
Close an entry by deleting it in the same commit that fixes it.
|
||||||
|
|
||||||
|
| # | Issue | Severity | Raised | Status |
|
||||||
|
|---|-------|----------|--------|--------|
|
||||||
|
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
|
||||||
|
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. XSS via SOP discipline names in the WP creator
|
||||||
|
|
||||||
|
**Files:** `html/wp-creation-app.js` lines 684, 723, 727, 729 · escaping helper at
|
||||||
|
line 63
|
||||||
|
**Predates:** the 2026-08-05 archive/admin-console work. Not introduced by it.
|
||||||
|
|
||||||
|
### What is wrong
|
||||||
|
|
||||||
|
Discipline names are rendered into inline event handlers escaped with `esc()`,
|
||||||
|
which maps `'` to `'`. That is correct for text and wrong here. The browser
|
||||||
|
decodes entities in an attribute value **before** the JavaScript parser sees it, so
|
||||||
|
`'` becomes a bare `'` inside the handler's string literal and closes it early.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// html/wp-creation-app.js:684 — esc() is not sufficient for a handler argument
|
||||||
|
onchange="toggleDiscipline('${esc(d)}',this.checked)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Escaping for an inline handler has to happen in this order: **backslash, then
|
||||||
|
quote** (for the JS string literal), **then HTML** (for the attribute carrying it).
|
||||||
|
`esc()` only does the last part.
|
||||||
|
|
||||||
|
### How it is reached
|
||||||
|
|
||||||
|
1. `gov_disciplines` (`html/work-package-suite.html:219`) is a free-text field. Its
|
||||||
|
value is comma-split with no validation at `work-package-suite-app.js:1224`.
|
||||||
|
2. It is saved into `sops.data` and syncs to the server via `ProjectData.pushSOP`.
|
||||||
|
3. Every other member of that project pulls it with `pullProject()` and renders it
|
||||||
|
in the WP creator — so this is **stored** and **cross-user**, and it fires on
|
||||||
|
page load rather than needing the victim to click anything.
|
||||||
|
|
||||||
|
Any **project_user** on the job can set it while the SOP is a draft (after the SOP
|
||||||
|
is marked complete it takes project_admin). The victim is anyone who opens the WP
|
||||||
|
creator for that project, which includes administrators.
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
**The likely cost is a broken screen, not an attack.** A discipline named
|
||||||
|
`Owner's Equipment` — an ordinary thing to type — produces a syntax error in the
|
||||||
|
handler, so the discipline pill and its scope-step buttons silently stop
|
||||||
|
responding. No error message, nothing a field user can diagnose.
|
||||||
|
|
||||||
|
**The security ceiling is project_user → admin.** The session cookie is HttpOnly so
|
||||||
|
the token cannot be read, but the injected code does not need it: it runs in the
|
||||||
|
victim's page and can call any API the victim can, including
|
||||||
|
`POST /api/auth/users/{id}/role`.
|
||||||
|
|
||||||
|
Two controls that look like they would contain this do not:
|
||||||
|
|
||||||
|
- **CSP does not mitigate it.** `nginx-wp-suite.conf:58` serves
|
||||||
|
`script-src 'self' 'unsafe-inline'`, and `'unsafe-inline'` is what permits inline
|
||||||
|
event handlers in the first place.
|
||||||
|
- **The CSRF gate does not mitigate it.** `_csrf_ok` (`server/app.py:67`) only
|
||||||
|
requires a same-origin `Origin`, and code running inside our own page is
|
||||||
|
same-origin.
|
||||||
|
|
||||||
|
### Why it is still open
|
||||||
|
|
||||||
|
The suite is internal, behind a login, on the corporate network, with a small set
|
||||||
|
of named employee accounts and no anonymous input path. Exploiting it means an
|
||||||
|
employee deliberately attacking colleagues, and the audit log carries their name on
|
||||||
|
the SOP edit. The accidental-breakage case is far more likely to be met than the
|
||||||
|
malicious one.
|
||||||
|
|
||||||
|
**Re-rate this as High and fix it immediately if any of these become true:** the
|
||||||
|
suite is exposed outside the corporate network, accounts are issued to
|
||||||
|
subcontractors or clients, or self-registration is added.
|
||||||
|
|
||||||
|
### What closing it takes
|
||||||
|
|
||||||
|
Small — roughly half an hour. The helper already exists; it was added to the SOP
|
||||||
|
builder on 2026-08-05 for the same bug in custom constraint names:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// html/work-package-suite-app.js:1120
|
||||||
|
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Add the same helper to `html/wp-creation-app.js` alongside `esc()`.
|
||||||
|
2. Use it at lines 684, 723, 727 and 729 in place of `esc(d)`.
|
||||||
|
3. Sweep the other inline handlers in that file for the same pattern. The remaining
|
||||||
|
ones interpolate server-generated ids that `check_id()` already constrains to a
|
||||||
|
safe charset, or hardcoded enum values, so they are not currently reachable —
|
||||||
|
converting them anyway keeps the pattern from coming back.
|
||||||
|
4. Confirm with a discipline named `Owner's Equipment`: the pill must respond to
|
||||||
|
clicks and the name must display intact.
|
||||||
|
|
||||||
|
The equivalent fix on the admin side is `jsq()` in `html/admin.js` — same ordering,
|
||||||
|
same reasoning, worth reading before starting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Archived projects: the two big apps don't grey out their own controls
|
||||||
|
|
||||||
|
**Files:** `html/wp-creation-app.js`, `html/work-package-suite-app.js`
|
||||||
|
**Raised:** 2026-08-05, with the project-archiving work.
|
||||||
|
|
||||||
|
### What is wrong
|
||||||
|
|
||||||
|
Archiving a project freezes it server-side — every write returns 409 (see
|
||||||
|
`require_project_writable` in `server/app.py`, and the *Archiving a project*
|
||||||
|
section of `DEPLOYMENT.md`). The front end tells the user, but does not stop them:
|
||||||
|
`wp-chrome.js` shows a read-only banner and sets `data-wp-archived="1"` on the
|
||||||
|
document element, and nothing reads that attribute yet. So on an archived project
|
||||||
|
the WP creator and the SOP builder still present working Save and Issue buttons.
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
Low, and it fails safe — the server refuses the write, so nothing is corrupted and
|
||||||
|
no data is lost. The cost is wasted effort and a confusing moment: someone deep-
|
||||||
|
linked to an archived job can fill in a form and only learn it was refused when the
|
||||||
|
sync indicator reports the change did not save.
|
||||||
|
|
||||||
|
Reaching an archived project at all takes a deep link or a stale tab, since it is
|
||||||
|
gone from every picker, switcher and search — which is why this is a rough edge
|
||||||
|
rather than a defect.
|
||||||
|
|
||||||
|
### Why it is still open
|
||||||
|
|
||||||
|
Gating every control in two large single-page apps is materially bigger than the
|
||||||
|
archive feature itself, and the server is the real enforcement boundary either way.
|
||||||
|
The banner plus the sync indicator were judged enough for a first release.
|
||||||
|
|
||||||
|
### What closing it takes
|
||||||
|
|
||||||
|
`data-wp-archived` is already on the document element for exactly this purpose.
|
||||||
|
Either add `[data-wp-archived]` rules in `wp-chrome.css` that disable and dim the
|
||||||
|
save/issue controls, or add a boot check in each app that disables them and shows a
|
||||||
|
read-only notice inline. Decide separately how the embedded creator
|
||||||
|
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
|
||||||
|
app bar — and therefore the banner — is deliberately skipped.
|
||||||
@@ -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`
|
||||||
|
|||||||
0
backups/.gitkeep
Normal file
0
backups/.gitkeep
Normal file
@@ -19,7 +19,22 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: wp_api
|
container_name: wp_api
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
# 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. REQUIRED — compose fails fast if it's unset,
|
||||||
|
# and the API refuses to start in production without it (see server/auth.py).
|
||||||
|
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
||||||
|
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||||
|
# Optional — SMTP password for WP-assignment emails. Email is off by
|
||||||
|
# default and enabled from the Admin console; this is the only email
|
||||||
|
# secret and it is never stored in the DB. Leave unset until configured.
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -45,6 +60,36 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
|
# Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on
|
||||||
|
# the host (sync that folder offsite from the host — this container has no
|
||||||
|
# internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups.
|
||||||
|
backup:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: scripts/backup.Dockerfile # postgres client + openssl
|
||||||
|
container_name: wp_db_backup
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
PGHOST: db
|
||||||
|
BACKUP_DIR: /backups
|
||||||
|
BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps
|
||||||
|
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily
|
||||||
|
# Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256).
|
||||||
|
# Required once the DB holds customer IP. Keep the passphrase off this host.
|
||||||
|
BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-}
|
||||||
|
volumes:
|
||||||
|
- ./scripts:/scripts:ro
|
||||||
|
- ./backups:/backups
|
||||||
|
entrypoint: ["/bin/sh", "/scripts/backup-cron.sh"]
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
nginx_logs:
|
nginx_logs:
|
||||||
|
|||||||
383
html/admin.html
Normal file
383
html/admin.html
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
<!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>
|
||||||
|
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||||
|
timestamps during their own boot. -->
|
||||||
|
<script src="wp-format.js"></script>
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#161616">
|
||||||
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
|
<link rel="stylesheet" href="wp-chrome.css">
|
||||||
|
<style>
|
||||||
|
/* ══ TOKENS ══════════════════════════════════════════════════════════════
|
||||||
|
The console is the only page in the suite that is mostly dense tables, so
|
||||||
|
it carries its own sheet. The palette, the square corners and the type are
|
||||||
|
Carbon's — the same ones theme-light.css sets — so it still reads as one
|
||||||
|
product with the rest of the suite. Two scales do all the spacing and all
|
||||||
|
the control sizing; nothing in here should invent its own. */
|
||||||
|
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
|
||||||
|
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
|
||||||
|
--green:#198038; --green-bg:#defbe6;
|
||||||
|
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd;
|
||||||
|
--head-bg:#f4f4f4; --zebra:#fafafa; --row-hover:#eef0f2;
|
||||||
|
--mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace;
|
||||||
|
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:28px;
|
||||||
|
--ctl:32px; /* every button / input / select that sits in a form row */
|
||||||
|
--ctl-sm:26px; } /* every control that sits inside a table cell */
|
||||||
|
*{ box-sizing:border-box; }
|
||||||
|
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
|
||||||
|
|
||||||
|
/* ══ PAGE ════════════════════════════════════════════════════════════════
|
||||||
|
1240px, not 860: the user table is nine columns wide and at 860 it spilled
|
||||||
|
straight out of its own white card. Wide enough for that table, still a
|
||||||
|
readable measure for the prose, which is capped separately. */
|
||||||
|
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
|
||||||
|
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
|
||||||
|
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
|
||||||
|
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
|
||||||
|
a.home:hover{ text-decoration:underline; }
|
||||||
|
|
||||||
|
/* ══ CARDS ═══════════════════════════════════════════════════════════════ */
|
||||||
|
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
|
||||||
|
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
|
||||||
|
/* One card header everywhere: small uppercase accent label on a hairline.
|
||||||
|
admin.js also emits h2 for sub-sections inside a card (Localization
|
||||||
|
defaults, Step views, Actions) with an inline margin-top — the same
|
||||||
|
treatment reads correctly as a divider there, so both get it. */
|
||||||
|
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
|
||||||
|
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
|
||||||
|
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
|
||||||
|
|
||||||
|
/* ══ CONTROLS ════════════════════════════════════════════════════════════
|
||||||
|
Every button, input and select in a form row is exactly --ctl tall, so a
|
||||||
|
toolbar is one clean band instead of a ragged one. */
|
||||||
|
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
|
||||||
|
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
|
||||||
|
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
|
||||||
|
button:hover{ border-color:var(--accent); color:var(--accent); }
|
||||||
|
button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
|
||||||
|
button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:#fff; cursor:default; }
|
||||||
|
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
|
button.primary:hover{ background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
|
||||||
|
button.danger{ border-color:var(--red); color:var(--red); }
|
||||||
|
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
|
||||||
|
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||||
|
/* The filter / search / button strip at the top of a card. */
|
||||||
|
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
|
||||||
|
.toolbar + .banner{ margin-top:0; }
|
||||||
|
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||||
|
/* Checkboxes are excluded: they are drawn by the platform and want none of a
|
||||||
|
text field's height, padding or border. */
|
||||||
|
.toolbar input:not([type=checkbox]), .toolbar select,
|
||||||
|
.urow input:not([type=checkbox]), .urow select{
|
||||||
|
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
|
||||||
|
border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); }
|
||||||
|
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
|
||||||
|
.toolbar input:focus-visible, .toolbar select:focus-visible,
|
||||||
|
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
|
||||||
|
.toolbar > input{ flex:1 1 240px; min-width:150px; }
|
||||||
|
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
|
||||||
|
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
|
||||||
|
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
|
||||||
|
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
|
||||||
|
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
|
||||||
|
|
||||||
|
/* ══ FEEDBACK: banners, notes, console output, key/value ═════════════════ */
|
||||||
|
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
|
||||||
|
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
|
||||||
|
background:var(--surface); color:var(--text); }
|
||||||
|
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:#a7f0ba; border-left-color:var(--green); }
|
||||||
|
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:#ffd7d9; border-left-color:var(--red); }
|
||||||
|
/* These three start life as empty divs that admin.js fills on demand, so they
|
||||||
|
only earn their gap once they are actually saying something. */
|
||||||
|
#users-banner:not(:empty), #projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||||
|
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
|
||||||
|
and #features-box / #settings-box are themselves .note — their primary toggle
|
||||||
|
labels inherit this colour. */
|
||||||
|
.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
|
||||||
|
.note strong, .note em{ color:var(--text); }
|
||||||
|
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
|
||||||
|
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 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:var(--s2); }
|
||||||
|
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
|
||||||
|
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
|
||||||
|
|
||||||
|
/* ══ DATA TABLES ═════════════════════════════════════════════════════════
|
||||||
|
table.users is the name admin.js already emits; table.grid is the same
|
||||||
|
object under the shared name. One rule set serves both, so existing markup
|
||||||
|
picks up the dense styling without being rewritten. border-collapse is
|
||||||
|
separate rather than collapse because a collapsed border does not travel
|
||||||
|
with a sticky header. */
|
||||||
|
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
|
||||||
|
font-size:13px; color:var(--text); background:var(--surface); }
|
||||||
|
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
|
||||||
|
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
|
||||||
|
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
|
||||||
|
box-shadow:inset 0 -1px 0 var(--border); }
|
||||||
|
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and
|
||||||
|
the table stops reading as rows. Anything genuinely long truncates (.ell)
|
||||||
|
or is exempted by name further down. */
|
||||||
|
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
|
||||||
|
vertical-align:middle; white-space:nowrap; }
|
||||||
|
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
|
||||||
|
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
|
||||||
|
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
|
||||||
|
projects" pill sitting on its own colour disappears the moment you hover it. */
|
||||||
|
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
|
||||||
|
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
|
||||||
|
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
|
||||||
|
never appears, which is the usual reason this trick looks like it works in the
|
||||||
|
stylesheet and doesn't on the page. admin.js emits <td class="ell"><span>. */
|
||||||
|
.ell{ max-width:240px; }
|
||||||
|
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
|
||||||
|
white-space:nowrap; }
|
||||||
|
/* Every action cell admin.js renders is a .cellactions, and it must not wrap:
|
||||||
|
unwrapped, the three buttons stack and the row grows fourfold — which is what
|
||||||
|
the console looked like before this pass. */
|
||||||
|
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
|
||||||
|
/* Controls that live in a cell are one step smaller, which is what keeps a
|
||||||
|
row at ~34px instead of ~100px. .chk is form-row sized by default, so it needs
|
||||||
|
saying again here or the default-members rows stand 6px taller than the rest. */
|
||||||
|
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
|
||||||
|
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
|
||||||
|
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
|
||||||
|
font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0;
|
||||||
|
background:#fff; color:var(--text); cursor:pointer; }
|
||||||
|
select.role-select:hover{ border-color:var(--accent); }
|
||||||
|
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
|
||||||
|
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
|
||||||
|
line-height:1.55; white-space:nowrap; vertical-align:middle; }
|
||||||
|
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
|
||||||
|
.tag.user{ background:#e8e8e8; color:var(--muted); }
|
||||||
|
.tag.on{ background:var(--green-bg); color:var(--green); }
|
||||||
|
.tag.off{ background:var(--red-bg); color:var(--red); }
|
||||||
|
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
|
||||||
|
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
|
||||||
|
|
||||||
|
/* A wide table scrolls inside its own box so the page never scrolls sideways,
|
||||||
|
and the capped height is what gives the sticky header something to do. Every
|
||||||
|
container admin.js paints a table into is one, so a sticky header always has
|
||||||
|
a scrollport of its own rather than sliding up behind the app bar. */
|
||||||
|
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
|
||||||
|
#projects-table, #defmem-table{
|
||||||
|
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||||
|
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside
|
||||||
|
so one table never ends up with two scrollbars. */
|
||||||
|
#users-table:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll),
|
||||||
|
#notif-box:has(.tscroll), #usage-admin:has(.tscroll), #projects-table:has(.tscroll),
|
||||||
|
#defmem-table:has(.tscroll){
|
||||||
|
overflow:visible; max-height:none; }
|
||||||
|
|
||||||
|
/* Column exceptions, addressed by card because admin.js emits these tables
|
||||||
|
without per-cell classes. Email is the one user cell long enough to stretch
|
||||||
|
a row, so it truncates; comment text and audit detail are the two columns
|
||||||
|
you are actually here to read, so they wrap inside a sane width instead. */
|
||||||
|
#users-table table.users td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
#comments-admin table.users td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
|
||||||
|
#audit-admin table.users td:nth-child(6){ white-space:normal; max-width:420px; }
|
||||||
|
|
||||||
|
/* ══ GATES & WARNINGS ════════════════════════════════════════════════════ */
|
||||||
|
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
|
||||||
|
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
|
||||||
|
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
|
||||||
|
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
|
||||||
|
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||||
|
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
|
||||||
|
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
|
||||||
|
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
|
||||||
|
#admin-denied .card{ max-width:560px; }
|
||||||
|
|
||||||
|
/* ══ NARROW SCREENS ══════════════════════════════════════════════════════
|
||||||
|
The page itself must never scroll sideways; the wide tables scroll inside
|
||||||
|
their own box instead, and there they get the full page height to do it. */
|
||||||
|
@media (max-width:900px){
|
||||||
|
.wrap{ padding:var(--s4) var(--s3) 60px; }
|
||||||
|
.card{ padding:var(--s3) var(--s4) var(--s4); }
|
||||||
|
.toolbar > input{ flex:1 1 100%; }
|
||||||
|
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
|
||||||
|
#projects-table, #defmem-table{ max-height:none; }
|
||||||
|
}
|
||||||
|
@media (max-width:620px){
|
||||||
|
.urow input, .urow select, .urow button{ flex:1 1 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- SHARED DARK APP BAR -->
|
||||||
|
<header class="wp-appbar">
|
||||||
|
<a href="index.html" class="wp-appbar-brand" title="Back to site">
|
||||||
|
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||||
|
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 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">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; margin-bottom:var(--s5)">
|
||||||
|
<div><h1>Admin Console</h1><div class="sub" style="margin:0">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="toolbar"><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">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||||
|
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
|
||||||
|
<div id="users-banner"></div>
|
||||||
|
<div id="users-table"></div>
|
||||||
|
|
||||||
|
<h2 style="margin-top:var(--s6)">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" title="Permissions — what this account may do">
|
||||||
|
<option value="project_user">Project User</option>
|
||||||
|
<option value="project_admin">Project Admin</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
</select>
|
||||||
|
<select id="nu-project-role" title="Job function on the project"></select>
|
||||||
|
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||||
|
<button class="primary" onclick="createUser()">Create user</button>
|
||||||
|
</div>
|
||||||
|
<div id="users-create-msg" class="note"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Projects</h2>
|
||||||
|
<div class="sub">Archiving a project hides it from every picker, switcher and search, and freezes it
|
||||||
|
read-only — nothing is deleted and every work package, SOP and comment is kept exactly as it is.
|
||||||
|
Unarchive here to bring it back; the project returns unchanged.</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button onclick="loadProjects()">Refresh projects</button>
|
||||||
|
<label class="chk"><input type="checkbox" id="proj-show-archived" onchange="renderProjects()"> Show archived</label>
|
||||||
|
<input id="proj-search" placeholder="Search name / number / client…" oninput="renderProjects()">
|
||||||
|
</div>
|
||||||
|
<div id="projects-banner"></div>
|
||||||
|
<div id="projects-table"><div class="note">Click “Refresh projects” to load.</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DEFAULT MEMBERS ON NEW PROJECTS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Default members on new projects</h2>
|
||||||
|
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
|
||||||
|
with the role chosen here. It does not touch projects that already exist — for those, use
|
||||||
|
<strong>Project access</strong> in the user table above. Administrators are listed with nothing
|
||||||
|
to set: they already reach every project.</div>
|
||||||
|
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
|
||||||
|
<div id="defmem-banner"></div>
|
||||||
|
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FEATURE FLAGS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<div class="sub">Switches that change what the suite offers on every project.</div>
|
||||||
|
<div id="features-box" class="note">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NOTIFICATIONS / EMAIL -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Notifications & email</h2>
|
||||||
|
<div class="sub">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
|
||||||
|
<div id="settings-box" class="note">Loading…</div>
|
||||||
|
<div id="notif-box" class="note" style="margin-top:14px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ALL FEEDBACK / COMMENTS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>All feedback & comments</h2>
|
||||||
|
<div class="sub">Every comment submitted across the suite — who wrote it, what they said, and where they were (page & step) when they commented.</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<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()">
|
||||||
|
</div>
|
||||||
|
<div id="comments-admin" class="note">Click refresh to load.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Activity log</h2>
|
||||||
|
<div class="sub">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button onclick="loadAudit()">Refresh</button>
|
||||||
|
<select id="audit-type" onchange="renderAudit()">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="wp">Work packages</option>
|
||||||
|
<option value="sop">SOPs</option>
|
||||||
|
<option value="project">Projects</option>
|
||||||
|
<option value="user">User accounts</option>
|
||||||
|
</select>
|
||||||
|
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()">
|
||||||
|
</div>
|
||||||
|
<div id="audit-admin" class="note">Click refresh to load.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USAGE LOGS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Usage logs</h2>
|
||||||
|
<div class="sub">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="toolbar">
|
||||||
|
<button onclick="loadUsage()">Refresh</button>
|
||||||
|
<button onclick="downloadUsage()">Download JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="usage-admin" class="note">Click refresh to load.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DB SNAPSHOT -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Database snapshot</h2>
|
||||||
|
<div class="toolbar"><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">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
|
||||||
|
<div class="toolbar"><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">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="toolbar">
|
||||||
|
<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>
|
||||||
|
<script src="wp-chrome.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1014
html/admin.js
Normal file
1014
html/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
260
html/auth-guard.js
Normal file
260
html/auth-guard.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/* 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; } })();
|
||||||
|
|
||||||
|
// Register the PWA service worker (caches the app shell for offline use). Only
|
||||||
|
// from the top window; the API and writes are never cached (see sw.js).
|
||||||
|
if (!inIframe && 'serviceWorker' in navigator) {
|
||||||
|
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 () {
|
||||||
|
try {
|
||||||
|
// Clear the auth cache AND all cached project data (customer IP) from this
|
||||||
|
// device on sign-out — important on shared/field tablets. The outbox
|
||||||
|
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
|
||||||
|
// (localStorage is not a security boundary; field devices still need
|
||||||
|
// full-disk encryption / MDM — see DEPLOYMENT.md.)
|
||||||
|
localStorage.removeItem('wp_auth_cache');
|
||||||
|
Object.keys(localStorage).forEach(function (k) {
|
||||||
|
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
|
||||||
|
localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
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 12 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 < 12) { msg('New password must be at least 12 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); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── permissions helpers ────────────────────────────────────────────────────
|
||||||
|
// The server enforces all of this; these are for hiding controls the signed-in
|
||||||
|
// user can't use, so nobody clicks a button just to get a 403.
|
||||||
|
// 'user' is the legacy value for what is now 'project_user'.
|
||||||
|
window.wpRole = function () {
|
||||||
|
var r = (window.WP_USER && window.WP_USER.role) || '';
|
||||||
|
return r === 'user' ? 'project_user' : r;
|
||||||
|
};
|
||||||
|
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
|
||||||
|
window.wpIsProjectAdmin = function () {
|
||||||
|
var r = window.wpRole();
|
||||||
|
return r === 'admin' || r === 'project_admin';
|
||||||
|
};
|
||||||
|
// Deleting a work package, deleting a project, and editing a completed SOP are
|
||||||
|
// all Project Admin actions (see server require_project_admin).
|
||||||
|
window.wpCanDeleteWP = window.wpIsProjectAdmin;
|
||||||
|
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
|
||||||
|
|
||||||
|
// ── app feature flags ──────────────────────────────────────────────────────
|
||||||
|
// Cached per page load. Pages that must know before rendering should await
|
||||||
|
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
|
||||||
|
window.WP_FLAGS = null;
|
||||||
|
var _flagsPromise = null;
|
||||||
|
window.wpFlags = function () {
|
||||||
|
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
|
||||||
|
if (_flagsPromise) return _flagsPromise;
|
||||||
|
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : {}; })
|
||||||
|
.catch(function () { return {}; }) // offline: fall through to defaults
|
||||||
|
.then(function (f) {
|
||||||
|
window.WP_FLAGS = f || {};
|
||||||
|
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
|
||||||
|
return window.WP_FLAGS;
|
||||||
|
});
|
||||||
|
return _flagsPromise;
|
||||||
|
};
|
||||||
|
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
|
||||||
|
// stale cache errs toward hiding the unfinished tooling rather than showing it.
|
||||||
|
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
|
||||||
|
|
||||||
|
function isDarkBg(el) {
|
||||||
|
try {
|
||||||
|
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||||
|
if (!m) return true;
|
||||||
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||||
|
} catch (e) { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
|
||||||
|
// bar it sits in (light links on a dark bar, blue links on a light bar).
|
||||||
|
function buildUserMenu(user, dark) {
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.id = 'wp-usermenu';
|
||||||
|
var linkColor = dark ? '#ffffff' : '#0f62fe';
|
||||||
|
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
|
||||||
|
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
|
||||||
|
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
|
||||||
|
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
|
||||||
|
function link(text, onClick, href) {
|
||||||
|
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
|
||||||
|
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
|
||||||
|
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
var who = document.createElement('span');
|
||||||
|
who.textContent = user.full_name || user.username;
|
||||||
|
who.style.color = dark ? '#ffffff' : '#161616';
|
||||||
|
wrap.appendChild(who);
|
||||||
|
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||||
|
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
||||||
|
// Always offered; wp-format.js may still be parsing when the menu is built, so
|
||||||
|
// the check happens at click time rather than once, up front.
|
||||||
|
wrap.appendChild(sep());
|
||||||
|
wrap.appendChild(link('Language & time', function () {
|
||||||
|
if (typeof window.wpPreferences === 'function') window.wpPreferences();
|
||||||
|
}));
|
||||||
|
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
|
||||||
|
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLogoutPill(user) {
|
||||||
|
if (inIframe) return; // the parent page already shows it
|
||||||
|
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
|
||||||
|
|
||||||
|
// Preferred: drop the menu INTO the top bar so it never floats over the
|
||||||
|
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
|
||||||
|
// the older .header bars alike.
|
||||||
|
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
||||||
|
if (host) {
|
||||||
|
var menu = buildUserMenu(user, isDarkBg(host));
|
||||||
|
// The older .header bars already right-align their own toolbar (via flex:1
|
||||||
|
// or a button's margin-left:auto). A second auto-margin would split the free
|
||||||
|
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
|
||||||
|
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
|
||||||
|
host.appendChild(menu);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any page with no header bar: a floating pill (as before).
|
||||||
|
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;background:#fff;border:1px solid #e0e0e0;' +
|
||||||
|
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
|
||||||
|
pill.appendChild(buildUserMenu(user, false));
|
||||||
|
document.body.appendChild(pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
function proceed(user) {
|
||||||
|
clearTimeout(safety);
|
||||||
|
window.WP_USER = user;
|
||||||
|
reveal();
|
||||||
|
if (window.WP_USER) {
|
||||||
|
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
|
||||||
|
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); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
|
||||||
|
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
||||||
|
return r.json().then(function (data) {
|
||||||
|
var user = data && data.user;
|
||||||
|
// Remember the last good auth so the PWA can open offline. The server is
|
||||||
|
// still the real gate; offline writes queue in the outbox until reconnect.
|
||||||
|
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
|
||||||
|
proceed(user);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Offline / API unreachable: fall back to a recent cached auth if present,
|
||||||
|
// so the app (and the field view) still open without a network.
|
||||||
|
try {
|
||||||
|
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
|
||||||
|
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
|
||||||
|
} catch (e) {}
|
||||||
|
goToLogin();
|
||||||
|
});
|
||||||
|
})();
|
||||||
91
html/field.html
Normal file
91
html/field.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Field View — Work Package Suite</title>
|
||||||
|
<script src="auth-guard.js"></script>
|
||||||
|
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||||
|
timestamps during their own boot. -->
|
||||||
|
<script src="wp-format.js"></script>
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#161616">
|
||||||
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
|
<link rel="stylesheet" href="wp-chrome.css">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { -webkit-text-size-adjust: 100%; }
|
||||||
|
.field-wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 40px; }
|
||||||
|
.fld-ctx { font-size: 13px; color: var(--cds-text-secondary); margin-bottom: 12px; }
|
||||||
|
.fld-ctx b { color: var(--cds-text-primary); }
|
||||||
|
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: #fff; margin-bottom: 14px; }
|
||||||
|
.fld-search:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||||
|
.wp-card { display: block; width: 100%; text-align: left; background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); border-left: 4px solid var(--cds-border-strong); padding: 14px 16px; margin-bottom: 10px; cursor: pointer; font-family: inherit; }
|
||||||
|
.wp-card:active { background: var(--cds-layer-hover); }
|
||||||
|
.wp-card.ready { border-left-color: var(--cds-support-success); }
|
||||||
|
.wp-card.hold { border-left-color: var(--cds-support-error); }
|
||||||
|
.wp-card .num { font-weight: 600; font-size: 16px; color: var(--cds-text-primary); }
|
||||||
|
.wp-card .subj { color: var(--cds-text-secondary); font-size: 13px; margin-top: 2px; }
|
||||||
|
.wp-card .meta { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
|
||||||
|
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
|
||||||
|
.pill.ok { background: #defbe6; color: #0e6027; }
|
||||||
|
.pill.warn { background: #fdf6dd; color: #8a6d00; }
|
||||||
|
.pill.bad { background: #fff1f1; color: #da1e28; }
|
||||||
|
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: #fff; }
|
||||||
|
.fld-empty a { color: var(--cds-link-primary); }
|
||||||
|
.fld-back { background: none; border: none; color: var(--cds-link-primary); font-size: 15px; padding: 8px 0; cursor: pointer; font-family: inherit; }
|
||||||
|
.fld-h1 { font-size: 20px; font-weight: 600; margin: 4px 0 2px; }
|
||||||
|
.fld-sub { color: var(--cds-text-secondary); font-size: 14px; margin-bottom: 16px; }
|
||||||
|
.fld-sec { background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); padding: 14px 16px; margin-bottom: 14px; }
|
||||||
|
.fld-sec h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--cds-text-helper); margin-bottom: 10px; }
|
||||||
|
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
|
||||||
|
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
|
||||||
|
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
|
||||||
|
.st-btn.hold.on { background: var(--cds-support-error); border-color: var(--cds-support-error); }
|
||||||
|
.cx-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--cds-border-subtle); }
|
||||||
|
.cx-row:last-child { border-bottom: none; }
|
||||||
|
.cx-name { flex: 1; font-size: 15px; }
|
||||||
|
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; text-align: center; font-family: inherit; }
|
||||||
|
.cx-state.cleared { background: #defbe6; color: #0e6027; border-color: #a7f0ba; }
|
||||||
|
.cx-state.na { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
|
||||||
|
.cx-state.open { background: #fff1f1; color: #da1e28; border-color: #ffd7d9; }
|
||||||
|
.fld-note { width: 100%; padding: 12px; font-size: 16px; border: 1px solid var(--cds-border-strong); min-height: 84px; font-family: inherit; resize: vertical; }
|
||||||
|
.fld-photo-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
|
||||||
|
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; font-family: inherit; }
|
||||||
|
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
|
||||||
|
.log-item { border: 1px solid var(--cds-border-subtle); padding: 10px 12px; margin-bottom: 8px; font-size: 14px; color: var(--cds-text-primary); white-space: pre-wrap; }
|
||||||
|
.log-item .lm { color: var(--cds-text-helper); font-size: 11px; margin-bottom: 4px; }
|
||||||
|
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
|
||||||
|
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: #161616; color: #fff; padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
|
||||||
|
.fld-toast.show { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="wp-appbar">
|
||||||
|
<a href="index.html" class="wp-appbar-brand" title="Home">
|
||||||
|
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||||
|
<span class="wp-appbar-title">Field View</span>
|
||||||
|
</a>
|
||||||
|
<div class="wp-appbar-spacer"></div>
|
||||||
|
<a class="wp-appbar-link" href="index.html">Home</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="field-wrap">
|
||||||
|
<div class="fld-ctx" id="fld-ctx"></div>
|
||||||
|
<section id="screen-list">
|
||||||
|
<input class="fld-search" id="fld-search" type="search" placeholder="Search work packages…" oninput="renderList()" aria-label="Search work packages">
|
||||||
|
<div id="wp-list"></div>
|
||||||
|
</section>
|
||||||
|
<section id="screen-detail" style="display:none"></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="fld-toast"></div>
|
||||||
|
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
|
<script src="field.js"></script>
|
||||||
|
<script src="wp-chrome.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
178
html/field.js
Normal file
178
html/field.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/* Field view — a touch-optimized screen for updating a Work Package's status,
|
||||||
|
constraints, and a photo/note log from the work face. Reads the same shared
|
||||||
|
data as the desktop creator (via project-data.js) and saves through the sync
|
||||||
|
outbox, so it works offline and syncs when the network returns. */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||||||
|
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
|
||||||
|
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||||
|
|
||||||
|
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||||
|
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||||
|
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||||||
|
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||||||
|
// Predecessor packages that aren't Closed yet. A package waiting on upstream work
|
||||||
|
// is not release-ready either, so the field list must not call it Ready — the
|
||||||
|
// server would refuse to issue it (see enforce_release_gates).
|
||||||
|
function waitingCount(p, all) {
|
||||||
|
var preds = (p && p.predecessors) || [];
|
||||||
|
if (!preds.length) return 0;
|
||||||
|
var byId = {};
|
||||||
|
(all || []).forEach(function (x) { byId[x.id] = x; });
|
||||||
|
return preds.filter(function (id) { var q = byId[id]; return q && q.status !== 'Closed'; }).length;
|
||||||
|
}
|
||||||
|
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
|
||||||
|
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
|
||||||
|
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
|
||||||
|
|
||||||
|
// ── boot / data ──────────────────────────────────────────────────────────────
|
||||||
|
function boot() {
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||||
|
if (!PID) { showNoProject(); return; }
|
||||||
|
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
|
||||||
|
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; renderCtx(); }).catch(function () {}); }
|
||||||
|
loadWPs();
|
||||||
|
}
|
||||||
|
function renderCtx() {
|
||||||
|
var el = document.getElementById('fld-ctx'); if (!el) return;
|
||||||
|
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
|
||||||
|
else el.textContent = 'Project: ' + PID;
|
||||||
|
}
|
||||||
|
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
|
||||||
|
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
|
||||||
|
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
|
||||||
|
|
||||||
|
function loadWPs() {
|
||||||
|
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
|
||||||
|
renderList();
|
||||||
|
if (ProjectData.pullProject) {
|
||||||
|
ProjectData.pullProject(PID).then(function () {
|
||||||
|
WPS = activePkgs(readCache());
|
||||||
|
if (!curId) renderList(); else renderDetail();
|
||||||
|
}).catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showNoProject() {
|
||||||
|
var s = document.getElementById('screen-list');
|
||||||
|
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── list ───────────────────────────────────────────────────────────────────
|
||||||
|
function renderList() {
|
||||||
|
var box = document.getElementById('wp-list'); if (!box) return;
|
||||||
|
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
|
||||||
|
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
|
||||||
|
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
|
||||||
|
box.innerHTML = rows.map(function (p) {
|
||||||
|
var open = openCount(p);
|
||||||
|
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
|
||||||
|
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
|
||||||
|
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
|
||||||
|
: (open ? '<span class="pill warn">' + open + ' open</span>'
|
||||||
|
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
|
||||||
|
: '<span class="pill ok">Ready</span>'));
|
||||||
|
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
||||||
|
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
||||||
|
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
||||||
|
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
|
||||||
|
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── detail ─────────────────────────────────────────────────────────────────
|
||||||
|
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
|
||||||
|
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
|
||||||
|
function backToList() {
|
||||||
|
curId = null; pendingPhoto = ''; draftNote = '';
|
||||||
|
document.getElementById('screen-detail').style.display = 'none';
|
||||||
|
document.getElementById('screen-list').style.display = '';
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
function renderDetail() {
|
||||||
|
var p = curWP(); if (!p) { backToList(); return; }
|
||||||
|
document.getElementById('screen-list').style.display = 'none';
|
||||||
|
var d = document.getElementById('screen-detail'); d.style.display = '';
|
||||||
|
|
||||||
|
var stBtns = STATUSES.map(function (s) {
|
||||||
|
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var cx = (p.constraints) || [];
|
||||||
|
var cxRows = cx.length ? cx.map(function (c, i) {
|
||||||
|
var st = c.status || 'open';
|
||||||
|
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
|
||||||
|
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
|
||||||
|
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
|
||||||
|
|
||||||
|
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
|
||||||
|
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
|
||||||
|
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
|
||||||
|
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
|
||||||
|
|
||||||
|
d.innerHTML =
|
||||||
|
'<button class="fld-back" onclick="backToList()">‹ All packages</button>' +
|
||||||
|
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
|
||||||
|
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
|
||||||
|
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
|
||||||
|
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
|
||||||
|
'<div class="fld-sec"><h3>Add field update</h3>' +
|
||||||
|
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
|
||||||
|
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
|
||||||
|
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
|
||||||
|
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
|
||||||
|
function saveWP(p) {
|
||||||
|
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
|
||||||
|
if (ix >= 0) WPS[ix] = p;
|
||||||
|
writeCache();
|
||||||
|
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
|
||||||
|
}
|
||||||
|
function setStatus(s) {
|
||||||
|
var p = curWP(); if (!p) return;
|
||||||
|
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
|
||||||
|
if (p.status === s) return;
|
||||||
|
p.status = s;
|
||||||
|
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
|
||||||
|
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
|
||||||
|
}
|
||||||
|
function cycleConstraint(i) {
|
||||||
|
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
|
||||||
|
var order = ['open', 'cleared', 'na'];
|
||||||
|
var cur = p.constraints[i].status || 'open';
|
||||||
|
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
|
||||||
|
saveWP(p); renderDetail();
|
||||||
|
}
|
||||||
|
function onPhoto(ev) {
|
||||||
|
var f = ev.target.files && ev.target.files[0]; if (!f) return;
|
||||||
|
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
|
||||||
|
var url = URL.createObjectURL(f);
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
|
||||||
|
var cv = document.createElement('canvas');
|
||||||
|
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
|
||||||
|
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
|
||||||
|
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
|
||||||
|
};
|
||||||
|
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
function addUpdate() {
|
||||||
|
var p = curWP(); if (!p) return;
|
||||||
|
var note = (draftNote || '').trim();
|
||||||
|
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
|
||||||
|
if (!p.fieldLog) p.fieldLog = [];
|
||||||
|
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
|
||||||
|
pendingPhoto = ''; draftNote = '';
|
||||||
|
saveWP(p); renderDetail(); toast('Update added to log');
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
481
html/help.js
Normal file
481
html/help.js
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
/* 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:#525252; 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:#161616; color:#fff; padding:7px 10px; border-radius:0; 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:#161616; 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:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||||
|
border-radius:0; 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 #e0e0e0; 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 #8d8d8d; border-radius:0;
|
||||||
|
font-size:13px; outline:none; background:#f7f8fa; }
|
||||||
|
.ui-help-search input:focus{ border-color:#0f62fe; 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:#525252; line-height:1; }
|
||||||
|
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||||
|
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
||||||
|
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; 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:#edf5ff; color:#0353e9; 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:#161616; scroll-margin-top:10px; }
|
||||||
|
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; }
|
||||||
|
.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 #e0e0e0; 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:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
|
||||||
|
.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 #0f62fe; padding:10px 14px; border-radius:0;
|
||||||
|
font-size:13px; line-height:1.55; margin:10px 0; }
|
||||||
|
.ui-help-noresult{ display:none; color:#525252; 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:#0f62fe; 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:#0353e9; }
|
||||||
|
@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 #e0e0e0; }
|
||||||
|
.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>
|
||||||
|
<h4>Quick start</h4>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
|
||||||
|
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
|
||||||
|
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
|
||||||
|
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
|
||||||
|
</ol>
|
||||||
|
<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);
|
||||||
BIN
html/icon-192.png
Normal file
BIN
html/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
html/icon-512.png
Normal file
BIN
html/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
439
html/index.html
439
html/index.html
@@ -4,115 +4,64 @@
|
|||||||
<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>
|
||||||
|
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||||
|
timestamps during their own boot. -->
|
||||||
|
<script src="wp-format.js"></script>
|
||||||
<link rel="icon" href="favicon.ico" sizes="any">
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#161616">
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
|
<link rel="stylesheet" href="wp-chrome.css">
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: var(--cds-background);
|
background: var(--cds-background);
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* HEADER */
|
|
||||||
.header {
|
|
||||||
background: var(--cds-layer);
|
|
||||||
padding: 1.5rem 2rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
border-bottom: 1px solid var(--cds-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-content {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
background: white;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo img {
|
|
||||||
height: 32px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo:hover { opacity: 0.9; }
|
|
||||||
|
|
||||||
.header-spacer { flex: 1; }
|
|
||||||
|
|
||||||
.header-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-nav a {
|
|
||||||
color: var(--cds-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-nav a:hover { color: var(--cds-text-primary); }
|
|
||||||
|
|
||||||
/* CONTAINER */
|
/* CONTAINER */
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 3rem 2rem;
|
padding: 2.5rem 2rem 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* HERO */
|
/* HERO */
|
||||||
.hero {
|
.hero {
|
||||||
text-align: center;
|
margin-bottom: 2.5rem;
|
||||||
margin-bottom: 4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-size: 2.625rem;
|
font-size: 2.25rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
margin-bottom: 1rem;
|
letter-spacing: -0.01em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero p {
|
.hero p {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
color: var(--cds-text-secondary);
|
color: var(--cds-text-secondary);
|
||||||
margin-bottom: 2rem;
|
max-width: 760px;
|
||||||
max-width: 700px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CARDS */
|
/* CARDS */
|
||||||
.cards-grid {
|
.cards-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--cds-layer);
|
background: var(--cds-layer);
|
||||||
border: 1px solid var(--cds-border-subtle);
|
border: 1px solid var(--cds-border-subtle);
|
||||||
border-radius: 4px;
|
border-left: 4px solid var(--cds-border-strong);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
transition: border-color 0.15s, background 0.15s;
|
||||||
transition: all 0.2s;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -120,9 +69,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
|
border-left-color: var(--cds-interactive-01);
|
||||||
transform: translateY(-2px);
|
background: var(--cds-layer-hover);
|
||||||
border-color: var(--cds-button-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-badge {
|
.card-badge {
|
||||||
@@ -152,10 +100,10 @@
|
|||||||
|
|
||||||
.card-button {
|
.card-button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
align-self: flex-start;
|
||||||
background: var(--cds-button-primary);
|
background: var(--cds-button-primary);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.7rem 1.25rem;
|
||||||
border-radius: 3px;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -171,16 +119,15 @@
|
|||||||
|
|
||||||
/* COMPLETE STATE (SOP done) */
|
/* COMPLETE STATE (SOP done) */
|
||||||
.card.complete {
|
.card.complete {
|
||||||
background: #ecfdf5;
|
border-left-color: var(--cds-support-success);
|
||||||
border-color: #16a34a;
|
|
||||||
}
|
}
|
||||||
.card.complete .card-button { background: #16a34a; }
|
.card.complete .card-button { background: var(--cds-support-success); }
|
||||||
.card.complete .card-button:hover { background: #15803d; }
|
.card.complete .card-button:hover { background: #0e6027; }
|
||||||
.card-status {
|
.card-status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #16a34a;
|
color: var(--cds-support-success);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.card.disabled {
|
.card.disabled {
|
||||||
@@ -191,9 +138,8 @@
|
|||||||
/* SECTION */
|
/* SECTION */
|
||||||
.section {
|
.section {
|
||||||
background: var(--cds-layer);
|
background: var(--cds-layer);
|
||||||
border-radius: 4px;
|
padding: 1.75rem;
|
||||||
padding: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
border: 1px solid var(--cds-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,16 +162,6 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-start {
|
|
||||||
background: var(--cds-button-primary);
|
|
||||||
color: white;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-start h2 { color: white; }
|
|
||||||
.quick-start ol { margin-left: 1.5rem; line-height: 2; }
|
|
||||||
.quick-start li { margin-bottom: 0.5rem; }
|
|
||||||
|
|
||||||
/* FOOTER */
|
/* FOOTER */
|
||||||
.footer {
|
.footer {
|
||||||
background: var(--cds-ui-01);
|
background: var(--cds-ui-01);
|
||||||
@@ -246,18 +182,16 @@
|
|||||||
/* COMMENTS SECTION */
|
/* COMMENTS SECTION */
|
||||||
.comments-section {
|
.comments-section {
|
||||||
background: var(--cds-layer);
|
background: var(--cds-layer);
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
border: 1px solid var(--cds-border-subtle);
|
border: 1px solid var(--cds-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-toggle {
|
.comments-toggle {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.7rem 1.25rem;
|
||||||
background: var(--cds-button-primary);
|
background: var(--cds-button-primary);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -270,8 +204,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--cds-ui-01);
|
background: var(--cds-layer-accent);
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
border: 1px solid var(--cds-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,15 +213,17 @@
|
|||||||
.comments-panel input,
|
.comments-panel input,
|
||||||
.comments-panel textarea {
|
.comments-panel textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.7rem;
|
||||||
border: 1px solid var(--cds-border-subtle);
|
border: 1px solid var(--cds-border-strong);
|
||||||
border-radius: 3px;
|
background: var(--cds-field);
|
||||||
background: var(--cds-ui-02);
|
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comments-panel input:focus,
|
||||||
|
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||||
|
|
||||||
.comments-panel textarea {
|
.comments-panel textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
@@ -297,12 +232,12 @@
|
|||||||
.comment-buttons {
|
.comment-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-buttons button {
|
.comment-buttons button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -317,11 +252,12 @@
|
|||||||
.submit-btn:hover { background: var(--cds-hover-primary); }
|
.submit-btn:hover { background: var(--cds-hover-primary); }
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: var(--cds-border-subtle);
|
background: var(--cds-layer-selected);
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
|
border: 1px solid var(--cds-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover { background: var(--cds-hover-ui); }
|
.close-btn:hover { background: var(--cds-layer-selected-hover); }
|
||||||
|
|
||||||
.comments-list {
|
.comments-list {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -333,7 +269,6 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--cds-background);
|
background: var(--cds-background);
|
||||||
border: 1px solid var(--cds-border-subtle);
|
border: 1px solid var(--cds-border-subtle);
|
||||||
border-radius: 3px;
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -348,10 +283,28 @@
|
|||||||
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); background: #fff; }
|
||||||
|
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||||
|
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; }
|
||||||
|
/* Shown once when the project someone had open turns out to have been archived
|
||||||
|
rather than deleted — otherwise the picker just silently resets on them. */
|
||||||
|
.proj-archived-note { background: #fdf6dd; border: 1px solid #f1c21b; color: #8e6a00;
|
||||||
|
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
|
||||||
|
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); 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); }
|
||||||
|
.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-spacer { display: none; }
|
|
||||||
.hero h1 { font-size: 1.75rem; }
|
.hero h1 { font-size: 1.75rem; }
|
||||||
.cards-grid { grid-template-columns: 1fr; }
|
.cards-grid { grid-template-columns: 1fr; }
|
||||||
.container { padding: 1.5rem; }
|
.container { padding: 1.5rem; }
|
||||||
@@ -360,18 +313,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- HEADER -->
|
<!-- HEADER -->
|
||||||
<header class="header">
|
<header class="wp-appbar">
|
||||||
<div class="header-content">
|
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
|
||||||
<a href="index.html" class="logo">
|
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||||
<img src="prime-controls-logo.jpg" alt="Prime Controls">
|
<span class="wp-appbar-title">Work Package Suite</span>
|
||||||
<div>Work Package Suite</div>
|
|
||||||
</a>
|
</a>
|
||||||
<div class="header-spacer"></div>
|
<div class="wp-appbar-spacer"></div>
|
||||||
<nav class="header-nav">
|
<nav class="wp-appbar-actions">
|
||||||
<a href="#overview">Overview</a>
|
<a class="wp-appbar-link" href="#overview">Overview</a>
|
||||||
<a href="#comments">Feedback</a>
|
<a class="wp-appbar-link" href="#comments">Feedback</a>
|
||||||
|
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- MAIN CONTENT -->
|
<!-- MAIN CONTENT -->
|
||||||
@@ -379,12 +331,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">
|
||||||
@@ -407,17 +366,13 @@
|
|||||||
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</div>
|
<!-- FIELD VIEW -->
|
||||||
|
<a href="field.html" class="card" id="card-field">
|
||||||
|
<h3>Field View</h3>
|
||||||
|
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
|
||||||
|
<button class="card-button" id="card-field-btn">Open Field View</button>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- QUICK START -->
|
|
||||||
<div class="section quick-start">
|
|
||||||
<h2>Getting Started</h2>
|
|
||||||
<ol>
|
|
||||||
<li><strong>Open "SOP Configuration"</strong> and complete the 10 steps for your project (~15 minutes)</li>
|
|
||||||
<li><strong>Finish the SOP</strong> — this card turns green and unlocks the Work Package Creator</li>
|
|
||||||
<li><strong>Open "Work Package Creator"</strong> to author Work Packages with your SOP defaults pre-populated</li>
|
|
||||||
<li><strong>Leave feedback</strong> on any page using the feedback button below</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- COMMENTS SECTION -->
|
<!-- COMMENTS SECTION -->
|
||||||
@@ -443,40 +398,171 @@
|
|||||||
</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();
|
||||||
|
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
|
||||||
|
if(dropped) ProjectData.setActive(null);
|
||||||
|
renderProjectPicker();
|
||||||
|
applyActiveProject();
|
||||||
|
// "No longer in the list" used to mean one thing — deleted. Now it also
|
||||||
|
// means archived, and resetting someone to "Select a project" with no word
|
||||||
|
// about it sends them hunting for a job that is merely finished.
|
||||||
|
if(dropped) explainDroppedProject(dropped);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainDroppedProject(p){
|
||||||
|
// The project is still readable when it's archived; a delete (or access being
|
||||||
|
// taken away) fails here, and that case genuinely has nothing to say.
|
||||||
|
ProjectData.get(p.id).then(full => {
|
||||||
|
if(!full || !full.archived) return;
|
||||||
|
const box = document.getElementById('project-picker');
|
||||||
|
if(!box || document.getElementById('proj-archived-note')) return;
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.id = 'proj-archived-note';
|
||||||
|
note.className = 'proj-archived-note';
|
||||||
|
note.innerHTML = `<strong>${esc(p.name || 'The project you had open')}</strong> has been
|
||||||
|
archived — it is read-only and no longer listed here. An administrator can unarchive it
|
||||||
|
from the Admin Console.`;
|
||||||
|
box.insertBefore(note, box.firstChild);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
setHref('card-field', 'field.html?src=home');
|
||||||
|
|
||||||
|
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/WPs from the server into the local cache
|
||||||
|
// first, so the SOP "Complete / Review" status reflects what other users did.
|
||||||
|
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 +570,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 +589,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 +621,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 +660,37 @@
|
|||||||
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>
|
||||||
|
<script src="wp-chrome.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
163
html/login.html
Normal file
163
html/login.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!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);
|
||||||
|
border-top: 3px solid var(--cds-interactive-01);
|
||||||
|
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; }
|
||||||
|
.ok {
|
||||||
|
display: none;
|
||||||
|
background: #defbe6;
|
||||||
|
border-left: 3px solid var(--cds-support-success);
|
||||||
|
color: #0e6027;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.ok.show { display: block; }
|
||||||
|
.note {
|
||||||
|
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; margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
|
||||||
|
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
|
||||||
|
a.link:hover { text-decoration: underline; }
|
||||||
|
.center { text-align: center; margin-top: 1.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
|
||||||
|
</div>
|
||||||
|
<div id="error" class="error" role="alert"></div>
|
||||||
|
<div id="ok" class="ok" role="status"></div>
|
||||||
|
|
||||||
|
<!-- SIGN IN -->
|
||||||
|
<section id="view-login">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="sub">Work Package Suite</p>
|
||||||
|
<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 class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FORGOT PASSWORD (email reset) -->
|
||||||
|
<section id="view-forgot" style="display:none">
|
||||||
|
<h1>Reset password</h1>
|
||||||
|
<p class="sub">We'll email you a link to set a new one.</p>
|
||||||
|
<div id="forgot-unavailable" class="note" style="display:none">
|
||||||
|
Password reset by email isn't switched on yet. Contact your project admin and
|
||||||
|
they'll set a new password for you. Once you're signed in you can change it
|
||||||
|
yourself from the menu in the top-right corner.
|
||||||
|
</div>
|
||||||
|
<form id="forgot-form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="forgot-username">Username or email</label>
|
||||||
|
<input id="forgot-username" type="text" autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
<button id="forgot-submit" type="submit">Email me a reset link</button>
|
||||||
|
</form>
|
||||||
|
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
|
||||||
|
<section id="view-reset" style="display:none">
|
||||||
|
<h1>Set a new password</h1>
|
||||||
|
<p class="sub">Choose a password you don't use anywhere else.</p>
|
||||||
|
<form id="reset-form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-password">New password</label>
|
||||||
|
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="hint">At least 12 characters.</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-password2">Confirm new password</label>
|
||||||
|
<input id="new-password2" type="password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<button id="reset-submit" type="submit">Set password & sign in</button>
|
||||||
|
</form>
|
||||||
|
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
215
html/login.js
Normal file
215
html/login.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/* Login page logic for the Work Package Suite.
|
||||||
|
|
||||||
|
Three views on one page:
|
||||||
|
• sign in posts to /api/auth/login. On success the server sets an
|
||||||
|
HttpOnly session cookie (not readable here — that's the
|
||||||
|
point) and we redirect to ?next= or the home page.
|
||||||
|
• forgot password posts to /api/auth/forgot-password, which emails a
|
||||||
|
single-use link. Only offered when the server reports
|
||||||
|
email is actually configured (/api/auth/reset-available);
|
||||||
|
otherwise we say to ask an admin.
|
||||||
|
• set a new password shown when the page is opened as login.html?reset=<token>
|
||||||
|
from that email. Posts to /api/auth/reset-password.
|
||||||
|
|
||||||
|
The reset token stays in the URL only until it's used; on success we strip it
|
||||||
|
from the address bar so it isn't left in history or copied out of the bar. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var errorBox = document.getElementById('error');
|
||||||
|
var okBox = document.getElementById('ok');
|
||||||
|
|
||||||
|
function show(el) { if (el) el.style.display = ''; }
|
||||||
|
function hide(el) { if (el) el.style.display = 'none'; }
|
||||||
|
function byId(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
okBox.classList.remove('show');
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
errorBox.classList.add('show');
|
||||||
|
}
|
||||||
|
function showOk(msg) {
|
||||||
|
errorBox.classList.remove('show');
|
||||||
|
okBox.textContent = msg;
|
||||||
|
okBox.classList.add('show');
|
||||||
|
}
|
||||||
|
function clearBanners() {
|
||||||
|
errorBox.classList.remove('show');
|
||||||
|
okBox.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 resetToken() {
|
||||||
|
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJson(url, payload) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().catch(function () { return null; }).then(function (j) {
|
||||||
|
return { status: r.status, ok: r.ok, json: j };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function detail(res, fallback) {
|
||||||
|
var d = res && res.json && res.json.detail;
|
||||||
|
return (typeof d === 'string' && d) ? d : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function view(which) {
|
||||||
|
clearBanners();
|
||||||
|
['login', 'forgot', 'reset'].forEach(function (v) {
|
||||||
|
(which === v ? show : hide)(byId('view-' + v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sign in ────────────────────────────────────────────────────────────────
|
||||||
|
var form = byId('login-form');
|
||||||
|
var submitBtn = byId('submit');
|
||||||
|
// Guarded because a cached older login.html may not have the reset views; an
|
||||||
|
// unguarded addEventListener on null would break sign-in itself.
|
||||||
|
if (!form || !submitBtn) return;
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearBanners();
|
||||||
|
var username = byId('username').value.trim();
|
||||||
|
var password = byId('password').value;
|
||||||
|
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Signing in…';
|
||||||
|
postJson('/api/auth/login', { username: username, password: password })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) { location.replace(nextTarget()); return; }
|
||||||
|
if (res.status === 401) showError('Invalid username or password.');
|
||||||
|
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
|
||||||
|
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
|
||||||
|
else showError(detail(res, 'Sign-in failed (HTTP ' + res.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';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── forgot password ────────────────────────────────────────────────────────
|
||||||
|
var resetAvailable = null; // null = not checked yet
|
||||||
|
|
||||||
|
function checkResetAvailable() {
|
||||||
|
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
|
||||||
|
return fetch('/api/auth/reset-available')
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
|
||||||
|
.catch(function () { resetAvailable = false; return false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
view('forgot');
|
||||||
|
// Prefill from the sign-in box so nobody types their username twice.
|
||||||
|
var u = byId('username').value.trim();
|
||||||
|
if (u) byId('forgot-username').value = u;
|
||||||
|
checkResetAvailable().then(function (enabled) {
|
||||||
|
// With email off there's nothing to submit — say so and hide the form.
|
||||||
|
(enabled ? hide : show)(byId('forgot-unavailable'));
|
||||||
|
(enabled ? show : hide)(byId('forgot-form'));
|
||||||
|
if (enabled) byId('forgot-username').focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
view('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
var forgotForm = byId('forgot-form') || document.createElement('form');
|
||||||
|
var forgotBtn = byId('forgot-submit') || document.createElement('button');
|
||||||
|
forgotForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearBanners();
|
||||||
|
var who = byId('forgot-username').value.trim();
|
||||||
|
if (!who) { showError('Enter your username or email.'); return; }
|
||||||
|
forgotBtn.disabled = true;
|
||||||
|
forgotBtn.textContent = 'Sending…';
|
||||||
|
postJson('/api/auth/forgot-password', { username: who })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.status === 503) {
|
||||||
|
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
|
||||||
|
} else if (res.ok) {
|
||||||
|
// Deliberately the same message whether or not the account exists.
|
||||||
|
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
|
||||||
|
hide(forgotForm);
|
||||||
|
} else {
|
||||||
|
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
|
||||||
|
}
|
||||||
|
forgotBtn.disabled = false;
|
||||||
|
forgotBtn.textContent = 'Email me a reset link';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Could not reach the server. Check your connection and try again.');
|
||||||
|
forgotBtn.disabled = false;
|
||||||
|
forgotBtn.textContent = 'Email me a reset link';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── set a new password (from the emailed link) ──────────────────────────────
|
||||||
|
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
view('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
var resetForm = byId('reset-form') || document.createElement('form');
|
||||||
|
var resetBtn = byId('reset-submit') || document.createElement('button');
|
||||||
|
resetForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearBanners();
|
||||||
|
var token = resetToken();
|
||||||
|
var pw = byId('new-password').value;
|
||||||
|
var pw2 = byId('new-password2').value;
|
||||||
|
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
|
||||||
|
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
|
||||||
|
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
|
||||||
|
|
||||||
|
resetBtn.disabled = true;
|
||||||
|
resetBtn.textContent = 'Saving…';
|
||||||
|
postJson('/api/auth/reset-password', { token: token, new_password: pw })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
// Take the token out of the URL before anything else — it's spent.
|
||||||
|
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
|
||||||
|
view('login');
|
||||||
|
showOk('Password updated. Sign in with your new password.');
|
||||||
|
byId('username').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
resetBtn.textContent = 'Set password & sign in';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Could not reach the server. Check your connection and try again.');
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
resetBtn.textContent = 'Set password & sign in';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Arriving from the reset email opens straight into the new-password view.
|
||||||
|
if (resetToken()) view('reset');
|
||||||
|
})();
|
||||||
18
html/manifest.webmanifest
Normal file
18
html/manifest.webmanifest
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Prime Work Package Suite",
|
||||||
|
"short_name": "WP Suite",
|
||||||
|
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
|
||||||
|
"start_url": "/index.html",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#f4f4f4",
|
||||||
|
"theme_color": "#161616",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
|
||||||
|
]
|
||||||
|
}
|
||||||
403
html/project-data.js
Normal file
403
html/project-data.js
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
/* 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, '>').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
|
||||||
|
},
|
||||||
|
|
||||||
|
// Deleting a project cascades its SOPs and work packages, and the server
|
||||||
|
// allows it only for a Project Admin. Drop it from the local cache ONLY if
|
||||||
|
// the server actually deleted it (or it was already gone) — removing it on a
|
||||||
|
// 403 would hide a project that still exists for everyone else.
|
||||||
|
remove: function (id) {
|
||||||
|
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok || r.status === 404) { cacheRemove(id); return true; }
|
||||||
|
return r.json().catch(function () { return null; }).then(function (j) {
|
||||||
|
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Archiving a project lives in the admin console (html/admin.js), which doesn't
|
||||||
|
// load this file — deliberately not mirrored here, so there's only one
|
||||||
|
// implementation of it rather than two that can disagree.
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
// (Original author: C-West8, "storing data in DB instead of client only";
|
||||||
|
// reintegrated on top of the BIM/per-package work.)
|
||||||
|
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 (so BIM fields, kind, projectLinks, etc. all
|
||||||
|
// survive), and mirror the few fields the API promotes to columns.
|
||||||
|
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',
|
||||||
|
assignee_id: p.assigneeId || null,
|
||||||
|
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;
|
||||||
|
p.archived = !!row.archived_at;
|
||||||
|
p.assigneeId = row.assignee_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 () {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Durable write-through outbox ───────────────────────────────────────────
|
||||||
|
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
|
||||||
|
// silently-failed POST leaves the browser and server divergent. Instead of a
|
||||||
|
// fire-and-forget request, each mutation is appended to a localStorage-backed
|
||||||
|
// queue and flushed to the API with retry + backoff. The API upserts by id and
|
||||||
|
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
|
||||||
|
// local cache still updates immediately, so rendering never waits on the network.
|
||||||
|
var OUTBOX_KEY = 'wp_sync_outbox_v1';
|
||||||
|
var _flushTimer = null, _backoff = 0, _flushing = false;
|
||||||
|
|
||||||
|
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
|
||||||
|
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
|
||||||
|
|
||||||
|
// Append an op, coalescing by (kind,key) so only the latest write per entity is
|
||||||
|
// queued. A delete supersedes any pending upsert for the same id.
|
||||||
|
function enqueue(op) {
|
||||||
|
var q = qRead();
|
||||||
|
if (op.kind === 'wp-del') {
|
||||||
|
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
|
||||||
|
} else {
|
||||||
|
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
|
||||||
|
}
|
||||||
|
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||||
|
op.tries = 0;
|
||||||
|
q.push(op);
|
||||||
|
qWrite(q);
|
||||||
|
notifySync();
|
||||||
|
scheduleFlush(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function opRequest(op) {
|
||||||
|
if (op.kind === 'wp-del') {
|
||||||
|
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpTries(opId, err) {
|
||||||
|
var q = qRead();
|
||||||
|
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
|
||||||
|
qWrite(q);
|
||||||
|
}
|
||||||
|
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
|
||||||
|
// retrying, so a rejected write can't loop forever. `err` is the server's own
|
||||||
|
// explanation when it sent one; it is what the sync badge shows the user.
|
||||||
|
function markDead(opId, err) {
|
||||||
|
var q = qRead();
|
||||||
|
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
|
||||||
|
qWrite(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt every live op; successes are removed, 4xx client errors are marked
|
||||||
|
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
|
||||||
|
function flush() {
|
||||||
|
if (_flushing) return Promise.resolve();
|
||||||
|
var q = qRead().filter(function (o) { return !o.dead; });
|
||||||
|
if (!q.length) { notifySync(); return Promise.resolve(); }
|
||||||
|
_flushing = true; notifySync();
|
||||||
|
var chain = Promise.resolve(), anyFail = false;
|
||||||
|
q.forEach(function (op) {
|
||||||
|
chain = chain.then(function () {
|
||||||
|
return opRequest(op).then(function (r) {
|
||||||
|
var status = r ? r.status : 0;
|
||||||
|
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
|
||||||
|
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
|
||||||
|
else if (status >= 400 && status < 500 && status !== 429) {
|
||||||
|
// Refused once, refused forever — so the only useful thing left is the
|
||||||
|
// reason. A 409 here is the archived-project gate, whose detail tells
|
||||||
|
// the user the project is read-only and how to get it unarchived; a
|
||||||
|
// bare "HTTP 409" would leave them staring at a change that vanished.
|
||||||
|
return r.json().catch(function () { return null; }).then(function (j) {
|
||||||
|
var why = (j && typeof j.detail === 'string' && j.detail) || ('HTTP ' + status);
|
||||||
|
markDead(op.opId, why);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
|
||||||
|
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return chain.then(function () {
|
||||||
|
_flushing = false;
|
||||||
|
notifySync();
|
||||||
|
if (qRead().filter(function (o) { return !o.dead; }).length) {
|
||||||
|
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
|
||||||
|
scheduleFlush(_backoff || 15000);
|
||||||
|
} else { _backoff = 0; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(delay) {
|
||||||
|
if (_flushTimer) return; // one pending flush at a time
|
||||||
|
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sync status (drives the indicator + any listeners) ──────────────────────
|
||||||
|
// `failed` stays the total not-getting-through count (what listeners already
|
||||||
|
// read); `dead` splits out the ops the server has permanently refused, with the
|
||||||
|
// first reason it gave, because those two states need different words.
|
||||||
|
function syncCounts() {
|
||||||
|
var q = qRead(), pending = 0, failed = 0, dead = 0, reason = '';
|
||||||
|
for (var i = 0; i < q.length; i++) {
|
||||||
|
if (q[i].dead) { dead++; if (!reason && q[i].lastErr) reason = String(q[i].lastErr); }
|
||||||
|
else if ((q[i].tries || 0) >= 3) failed++;
|
||||||
|
else pending++;
|
||||||
|
}
|
||||||
|
return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing };
|
||||||
|
}
|
||||||
|
ProjectData.syncStatus = syncCounts;
|
||||||
|
function notifySync() {
|
||||||
|
var c = syncCounts();
|
||||||
|
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
|
||||||
|
renderSyncBadge(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
|
||||||
|
// isn't duplicated inside the embedded creator iframe; the top window still sees
|
||||||
|
// the iframe's queue changes via the 'storage' event below.
|
||||||
|
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
|
||||||
|
var _badgeHideTimer = null;
|
||||||
|
function renderSyncBadge(c) {
|
||||||
|
if (!_isTop || !document.body) return;
|
||||||
|
var el = document.getElementById('wp-sync-badge');
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = 'wp-sync-badge';
|
||||||
|
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
|
||||||
|
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
|
||||||
|
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
|
||||||
|
// A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the
|
||||||
|
// reason is the only thing that tells the user what to do (e.g. the project is
|
||||||
|
// archived). Stack it under the headline; the badge never hides in this state.
|
||||||
|
el.style.flexDirection = c.dead ? 'column' : 'row';
|
||||||
|
el.style.alignItems = c.dead ? 'flex-start' : 'center';
|
||||||
|
el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none';
|
||||||
|
if (c.dead) {
|
||||||
|
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved</span>' +
|
||||||
|
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
|
||||||
|
el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex';
|
||||||
|
} else if (c.failed) {
|
||||||
|
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
|
||||||
|
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
|
||||||
|
} else if (c.pending) {
|
||||||
|
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
|
||||||
|
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
|
||||||
|
} else {
|
||||||
|
el.textContent = '✓ All changes saved';
|
||||||
|
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
|
||||||
|
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
|
||||||
|
// a periodic backstop. Anything left from a previous session flushes on load.
|
||||||
|
try {
|
||||||
|
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
|
||||||
|
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
|
||||||
|
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
|
||||||
|
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
|
||||||
|
} catch (e) {}
|
||||||
|
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
|
||||||
|
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
|
||||||
|
|
||||||
|
// ── public write API (now durable via the outbox) ───────────────────────────
|
||||||
|
// Write a completed SOP (plus the builder's raw state). Deterministic id per
|
||||||
|
// project so re-completing updates the same row.
|
||||||
|
ProjectData.pushSOP = function (projectId, sop, state) {
|
||||||
|
if (!projectId) return Promise.resolve(null);
|
||||||
|
enqueue({
|
||||||
|
kind: 'sop', key: 'sop__' + projectId,
|
||||||
|
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 Promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert a single Work Package. The local cache stays the source of truth for
|
||||||
|
// immediate rendering; the outbox guarantees the write reaches the server.
|
||||||
|
ProjectData.pushWP = function (p, projectId) {
|
||||||
|
if (!p || !p.id) return Promise.resolve(null);
|
||||||
|
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectData.removeWP = function (id) {
|
||||||
|
if (!id) return Promise.resolve();
|
||||||
|
enqueue({ kind: 'wp-del', key: id });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Force a flush now and resolve when the queue drains (or a round-trip is done).
|
||||||
|
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
|
||||||
|
|
||||||
|
// Archive / unarchive a Work Package (hide from active lists without deleting).
|
||||||
|
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
|
||||||
|
// the caller updates the view on the returned result.
|
||||||
|
ProjectData.archiveWP = function (id, archived) {
|
||||||
|
if (!id) return Promise.resolve(null);
|
||||||
|
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
|
||||||
|
// "show archived" view. Returns app-shaped package objects (p.archived === true).
|
||||||
|
ProjectData.listArchived = function (projectId) {
|
||||||
|
if (!projectId) return Promise.resolve([]);
|
||||||
|
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
|
||||||
|
.catch(function () { return []; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
107
html/sw.js
Normal file
107
html/sw.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/* Service worker for the Work Package Suite PWA.
|
||||||
|
|
||||||
|
Goal: let the app (and especially the field view) load and run offline. Data
|
||||||
|
durability is already handled by the sync outbox in project-data.js — this
|
||||||
|
worker only caches the static app shell so the pages open without a network.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
• /api/* and non-GET → never touched (pass straight to the network; offline
|
||||||
|
reads fall back to the app's localStorage cache, writes queue in the outbox).
|
||||||
|
• HTML / CSS / JS → network-first, cache as fallback. These reference each
|
||||||
|
other, so a page must never run against a stale sibling.
|
||||||
|
• images / icons / manifest → stale-while-revalidate (instant from cache).
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||||
|
// instead of serving a half-old shell from the previous cache.
|
||||||
|
const CACHE = 'wp-suite-shell-v5';
|
||||||
|
const SHELL = [
|
||||||
|
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||||
|
'/field.html', '/login.html', '/admin.html',
|
||||||
|
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
||||||
|
'/wp-chrome.css',
|
||||||
|
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
||||||
|
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
||||||
|
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
|
||||||
|
'/prime-controls-logo.jpg', '/favicon.ico',
|
||||||
|
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
// Cache each shell asset individually so one missing file doesn't abort install.
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE)
|
||||||
|
// cache:'reload' bypasses the browser HTTP cache. Without it the precache
|
||||||
|
// can be filled from stale HTTP entries, freezing a mismatched shell.
|
||||||
|
.then((c) => Promise.all(SHELL.map(
|
||||||
|
(u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {}))))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when
|
||||||
|
// offline. Everything else (images, icons, the manifest) stays cache-first, which is
|
||||||
|
// where offline speed actually comes from.
|
||||||
|
//
|
||||||
|
// Why not cache-first for code: these files reference each other, and the cache
|
||||||
|
// stores them as independent entries. Cache-first served whichever copy of each file
|
||||||
|
// happened to be stored, so a browser could run new HTML against old CSS — which is
|
||||||
|
// exactly how the embedded creator once collapsed to a 300x150 iframe. A page must
|
||||||
|
// only ever run against the stylesheet and scripts it shipped with.
|
||||||
|
const CODE_RE = /\.(html|css|js)$|\/$/i;
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const req = e.request;
|
||||||
|
if (req.method !== 'GET') return; // outbox owns writes
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return; // third-party: default
|
||||||
|
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
||||||
|
|
||||||
|
const isCode = CODE_RE.test(url.pathname);
|
||||||
|
|
||||||
|
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
|
||||||
|
// and the embedded creator used to carry a cache-busting timestamp, so keying on the
|
||||||
|
// full URL both missed every offline navigation and grew the cache without bound.
|
||||||
|
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
|
||||||
|
const fromCache = () => caches.match(key).then((c) => c || caches.match(req));
|
||||||
|
|
||||||
|
const store = (res) => {
|
||||||
|
if (res && res.ok && res.type !== 'opaque') {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCode) {
|
||||||
|
e.respondWith(
|
||||||
|
// cache:'no-cache' forces revalidation with the server. Plain fetch() inherits
|
||||||
|
// the request's default cache mode, which consults the browser HTTP cache — so
|
||||||
|
// "network-first" alone still let a page run against a stale sibling file.
|
||||||
|
fetch(req, { cache: 'no-cache' })
|
||||||
|
.then((res) => {
|
||||||
|
// A 502/404 must not replace a page the cache could still serve.
|
||||||
|
if (!res || !res.ok) return fromCache().then((c) => c || res);
|
||||||
|
return store(res);
|
||||||
|
})
|
||||||
|
.catch(() => fromCache()) // offline → last good copy
|
||||||
|
.then((res) => res || Response.error()) // never resolve to undefined
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(key).then((cached) => {
|
||||||
|
const network = fetch(req).then(store).catch(() => cached);
|
||||||
|
return cached || network.then((res) => res || Response.error());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -157,3 +157,93 @@ input, textarea, select {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
App shell — shared "UI Shell" chrome (Prime Controls, IBM Carbon styling)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
One dark top bar across every page so the suite reads as a single product.
|
||||||
|
The Prime Controls logo is a white-background wordmark, so it sits inside a
|
||||||
|
white "chip" on the near-black bar (reads as intentional, not a stray box).
|
||||||
|
Flip --wp-appbar-bg to a light value if a light header is ever preferred.
|
||||||
|
============================================================================ */
|
||||||
|
:root {
|
||||||
|
--wp-appbar-bg: #161616; /* near-black UI Shell bar */
|
||||||
|
--wp-appbar-fg: #ffffff;
|
||||||
|
--wp-appbar-fg-dim: #c6c6c6;
|
||||||
|
--wp-appbar-border: #6f6f6f; /* outline for ghost buttons on the bar */
|
||||||
|
--wp-appbar-hover: #353535;
|
||||||
|
--wp-appbar-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp-appbar {
|
||||||
|
background: var(--wp-appbar-bg);
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
height: var(--wp-appbar-height);
|
||||||
|
padding: 0 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.wp-appbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
}
|
||||||
|
.wp-appbar-brand:hover { text-decoration: none; opacity: .92; }
|
||||||
|
.wp-logo-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.wp-logo-chip img { height: 24px; width: auto; display: block; }
|
||||||
|
.wp-appbar-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
.wp-appbar-title .wp-appbar-sub { font-weight: 400; color: var(--wp-appbar-fg-dim); }
|
||||||
|
.wp-appbar-spacer { flex: 1 1 auto; }
|
||||||
|
.wp-appbar-meta { font-size: 13px; color: var(--wp-appbar-fg-dim); white-space: nowrap; }
|
||||||
|
.wp-appbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* Buttons and links that live on the dark bar */
|
||||||
|
.wp-appbar-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
border: 1px solid var(--wp-appbar-border);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.wp-appbar-btn:hover { background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); text-decoration: none; }
|
||||||
|
.wp-appbar-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); }
|
||||||
|
.wp-appbar-btn.primary:hover { background: var(--cds-hover-primary); border-color: var(--cds-hover-primary); }
|
||||||
|
.wp-appbar-btn:focus-visible { outline: 2px solid var(--wp-appbar-fg); outline-offset: 1px; }
|
||||||
|
.wp-appbar-count { font-size: 13px; color: var(--wp-appbar-fg-dim); padding: 0 2px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Plain text links on the dark bar (Overview / Feedback / Help, Admin, etc.) */
|
||||||
|
.wp-appbar-link { color: var(--wp-appbar-fg-dim); text-decoration: none; font-size: 14px; white-space: nowrap; }
|
||||||
|
.wp-appbar-link:hover { color: var(--wp-appbar-fg); text-decoration: none; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.wp-appbar { height: auto; flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
|
||||||
|
.wp-appbar-actions { flex-wrap: wrap; }
|
||||||
|
.wp-appbar-meta { width: 100%; order: 5; }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,25 @@
|
|||||||
:root {
|
:root {
|
||||||
--primary: #2563eb;
|
--primary: #0f62fe;
|
||||||
--primary-light: #dbeafe;
|
--primary-light: #edf5ff;
|
||||||
--success: #16a34a;
|
--success: #198038;
|
||||||
--warning: #ea580c;
|
--warning: #8e6a00;
|
||||||
--danger: #dc2626;
|
--warning-bg: #fdf6dd;
|
||||||
--text: #1f2937;
|
--danger: #da1e28;
|
||||||
--text-light: #6b7280;
|
--text: #161616;
|
||||||
--text-dim: #9ca3af;
|
--text-light: #525252;
|
||||||
--border: #e5e7eb;
|
--text-dim: #8d8d8d;
|
||||||
--bg: #f9fafb;
|
--border: #e0e0e0;
|
||||||
|
--border-strong: #8d8d8d;
|
||||||
|
--bg: #f4f4f4;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
--appbar: #161616;
|
||||||
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
|
--shadow: none;
|
||||||
|
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -28,43 +31,46 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* HEADER */
|
/* HEADER — dark UI Shell bar */
|
||||||
.header {
|
.header {
|
||||||
background: #ffffff;
|
background: var(--appbar);
|
||||||
color: var(--text);
|
color: #fff;
|
||||||
padding: 1.5rem 2rem;
|
padding: 0 16px;
|
||||||
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: var(--shadow);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
|
||||||
.logo {
|
.logo {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo:hover { opacity: 0.7; }
|
.logo:hover { opacity: 0.92; }
|
||||||
|
.logo img { height: 24px; width: auto; display: block; }
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background: var(--primary-light);
|
background: var(--primary-light);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -73,97 +79,142 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 24px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
color: var(--text);
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-subtitle {
|
.header-subtitle {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--text-light);
|
color: #c6c6c6;
|
||||||
min-height: 20px;
|
min-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-button {
|
.header-button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 7px 14px;
|
||||||
background: var(--bg);
|
background: transparent;
|
||||||
color: var(--primary);
|
color: #fff;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid #6f6f6f;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
transition: all 0.2s;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-button:hover {
|
.header-button:hover {
|
||||||
background: var(--primary-light);
|
background: #353535;
|
||||||
border-color: var(--primary);
|
border-color: #6f6f6f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-counter {
|
.step-counter {
|
||||||
background: var(--bg);
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid #6f6f6f;
|
||||||
color: var(--text-light);
|
color: #c6c6c6;
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 4px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* MAIN NAVIGATION */
|
/* MAIN NAVIGATION — underline tabs */
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0;
|
||||||
padding: 1rem 2rem;
|
padding: 0 16px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tab {
|
.nav-tab {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 13px 18px;
|
||||||
background: var(--bg);
|
background: none;
|
||||||
border: 2px solid var(--border);
|
border: none;
|
||||||
border-radius: 6px;
|
border-bottom: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
|
color: var(--text-light);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
transition: all 0.2s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tab:hover {
|
.nav-tab:hover {
|
||||||
border-color: var(--primary);
|
background: var(--bg);
|
||||||
color: var(--primary);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tab.active {
|
.nav-tab.active {
|
||||||
background: var(--primary);
|
background: none;
|
||||||
color: white;
|
color: var(--text);
|
||||||
border-color: var(--primary);
|
border-bottom-color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-icon { font-size: 16px; }
|
.tab-icon { font-size: 16px; }
|
||||||
|
|
||||||
/* CONTENT AREA */
|
/* CONTENT AREA
|
||||||
|
The SOP wizard reads better with a bound on line length, but 1000px on a 1920
|
||||||
|
screen wasted half the display — and it also squeezed the embedded Work Package
|
||||||
|
Creator (an iframe living in here) into a ~930px column with its own scrollbar
|
||||||
|
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
|
||||||
|
(see .content-area.embed-full below). */
|
||||||
.content-area {
|
.content-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1000px;
|
max-width: 1700px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Work Package Creation / Dashboard: the iframe fills the window below the app
|
||||||
|
chrome and owns the only scrollbar, so the creator's sticky save bar and
|
||||||
|
navigator drawer position against a real viewport instead of scrolling away. */
|
||||||
|
.content-area.embed-full {
|
||||||
|
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
|
||||||
|
flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
|
||||||
|
child's `height:100%` resolves to auto and the iframe collapses to its 150px
|
||||||
|
default. Opting out of flex sizing makes the height definite. */
|
||||||
|
flex: none;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
height: calc(100vh - var(--wp-chrome-h, 96px));
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.content-area.embed-full > .tool.active {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0; /* let it shrink instead of overflowing the shell */
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#wp-frame {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
#wp-frame.fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
|
||||||
|
body.embed-full { overflow: hidden; }
|
||||||
|
|
||||||
.tool {
|
.tool {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -186,35 +237,37 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-item {
|
.step-item {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.6rem 0.9rem;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
background: var(--bg);
|
background: var(--bg-card);
|
||||||
border: 2px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: all 0.2s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-item:hover { background: var(--primary-light); border-color: var(--primary); }
|
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); }
|
||||||
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
|
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; }
|
||||||
|
|
||||||
/* STEP CONTENT */
|
/* STEP CONTENT */
|
||||||
.step-content {
|
.step-content {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow);
|
border-radius: 0;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step { display: none; }
|
.step { display: none; }
|
||||||
|
|
||||||
.step h2 {
|
.step h2 {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 400;
|
||||||
margin-bottom: 0.5rem;
|
letter-spacing: -0.01em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,15 +276,20 @@ body {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
background: var(--primary-light);
|
background: var(--primary-light);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
border-left: 4px solid var(--primary);
|
border-left: 4px solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FIELDS */
|
/* FIELDS
|
||||||
|
The wizard's fields were one per row, which looked right in a 1000px column but
|
||||||
|
stretches a text input across the screen now that the content area is wide. Flow
|
||||||
|
them into as many ~340px columns as fit; `.col1` still forces a single column for
|
||||||
|
the fields that genuinely want the width (long text, textareas). */
|
||||||
.field-grid {
|
.field-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-grid.col1 { grid-template-columns: 1fr; }
|
.field-grid.col1 { grid-template-columns: 1fr; }
|
||||||
@@ -253,7 +311,7 @@ body {
|
|||||||
.field textarea {
|
.field textarea {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -275,6 +333,19 @@ body {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Small helper text under a field. It's used on this page (step 2's CM hint, the
|
||||||
|
team-member notices) but its only rule used to live in wp-creation-styles.css,
|
||||||
|
which this page does not link — so it rendered as unstyled body text. */
|
||||||
|
.field-hint { font-size: 12px; color: var(--text-dim); margin-top: 0.25rem; }
|
||||||
|
.field-hint strong { color: var(--text-light); }
|
||||||
|
|
||||||
|
/* The sign-off name pickers sit outside .field, so they got no form styling at all. */
|
||||||
|
.user-pick {
|
||||||
|
padding: 0.75rem; border: 1px solid var(--border); border-radius: 0;
|
||||||
|
font-size: 14px; font-family: inherit; color: var(--text); background: var(--bg);
|
||||||
|
}
|
||||||
|
.user-pick:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
|
||||||
|
|
||||||
/* ROLES */
|
/* ROLES */
|
||||||
.required-roles {
|
.required-roles {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -287,7 +358,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,20 +385,20 @@ body {
|
|||||||
|
|
||||||
.wp-type-row {
|
.wp-type-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
|
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
|
||||||
gap: 1rem;
|
gap: 0.85rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wp-types-header {
|
.wp-types-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
|
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
|
||||||
gap: 1rem;
|
gap: 0.85rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -339,31 +410,32 @@ body {
|
|||||||
|
|
||||||
/* BUTTONS */
|
/* BUTTONS */
|
||||||
.add-btn {
|
.add-btn {
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.7rem 1.25rem;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-btn:hover { background: #1d4ed8; }
|
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); }
|
||||||
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.7rem 1.4rem;
|
||||||
background: var(--bg);
|
background: var(--bg-card);
|
||||||
border: 2px solid var(--border);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
|
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); }
|
||||||
|
|
||||||
.nav-btn.primary {
|
.nav-btn.primary {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
@@ -371,7 +443,7 @@ body {
|
|||||||
border-color: var(--success);
|
border-color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
|
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; }
|
||||||
|
|
||||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
@@ -382,7 +454,7 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +470,7 @@ body {
|
|||||||
background: var(--primary-light);
|
background: var(--primary-light);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
border: 1px solid var(--primary);
|
border: 1px solid var(--primary);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -411,7 +483,7 @@ body {
|
|||||||
#sequence-list { display: flex; flex-direction: column; gap: 8px; }
|
#sequence-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.seq-step {
|
.seq-step {
|
||||||
display: flex; align-items: center; gap: 12px; padding: 11px 14px;
|
display: flex; align-items: center; gap: 12px; padding: 11px 14px;
|
||||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
|
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0;
|
||||||
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
|
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
|
||||||
}
|
}
|
||||||
.seq-step:hover { border-color: var(--primary); }
|
.seq-step:hover { border-color: var(--primary); }
|
||||||
@@ -432,7 +504,7 @@ body {
|
|||||||
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
|
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
|
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
|
||||||
.seq-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
|
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; }
|
||||||
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
|
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
|
||||||
.seq-gate-badge {
|
.seq-gate-badge {
|
||||||
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
|
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
|
||||||
@@ -448,7 +520,7 @@ body {
|
|||||||
max-width: calc(100vw - 2rem);
|
max-width: calc(100vw - 2rem);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
@@ -488,7 +560,7 @@ body {
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -528,7 +600,7 @@ body {
|
|||||||
|
|
||||||
/* RESPONSIVE */
|
/* RESPONSIVE */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header { flex-direction: column; text-align: center; gap: 1rem; }
|
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
|
||||||
.main-nav { flex-wrap: wrap; }
|
.main-nav { flex-wrap: wrap; }
|
||||||
.content-area { padding: 1rem; }
|
.content-area { padding: 1rem; }
|
||||||
.step-content { padding: 1rem; }
|
.step-content { padding: 1rem; }
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
<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>
|
||||||
|
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||||
|
timestamps during their own boot. -->
|
||||||
|
<script src="wp-format.js"></script>
|
||||||
<link rel="icon" href="favicon.ico" sizes="any">
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#161616">
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
|
<link rel="stylesheet" href="wp-chrome.css">
|
||||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -14,17 +21,17 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a href="index.html" class="logo" title="Back to Home">
|
<a href="index.html" class="logo" title="Back to Home">
|
||||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
|
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;">
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<div style="min-width:0;overflow:hidden">
|
||||||
<div class="header-title">Work Package Suite</div>
|
<div class="header-title">Work Package Suite</div>
|
||||||
<div class="header-subtitle" id="project-display"></div>
|
<div class="header-subtitle" id="project-display"></div>
|
||||||
</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 +39,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>
|
||||||
|
|
||||||
@@ -97,23 +107,28 @@
|
|||||||
<!-- STEP 2: PROJECT TEAM -->
|
<!-- STEP 2: PROJECT TEAM -->
|
||||||
<div class="step" id="sop-step-2" style="display: none;">
|
<div class="step" id="sop-step-2" style="display: none;">
|
||||||
<h2>2. Project Team Leadership</h2>
|
<h2>2. Project Team Leadership</h2>
|
||||||
<div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div>
|
<div class="notice">Pick the key project leaders from the people assigned to this project. Choosing a
|
||||||
|
<strong>user account</strong> (rather than typing a name) is what lets the Work Package Creator offer them
|
||||||
|
as an owner and lets the suite email them — so add anyone missing to the project first, in the
|
||||||
|
<a href="admin.html" target="_blank" rel="noopener">Admin Console</a>.</div>
|
||||||
|
<div id="team-accounts-warn" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Project Manager (PM)</label>
|
<label>Project Manager (PM)</label>
|
||||||
<input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez">
|
<select id="proj_pm" class="team-pick" data-team="pm"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Assistant Project Manager (APM)</label>
|
<label>Assistant Project Manager (APM)</label>
|
||||||
<input type="text" id="proj_apm" placeholder="e.g., Assistant PM name">
|
<select id="proj_apm" class="team-pick" data-team="apm"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Construction Manager (CM)</label>
|
<label>Construction Manager (CM)</label>
|
||||||
<input type="text" id="proj_cm" placeholder="e.g., K. Boyd">
|
<select id="proj_cm" class="team-pick" data-team="cm"></select>
|
||||||
|
<div class="field-hint">Kept on the distribution list of every work package by default.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Quality Manager (QM)</label>
|
<label>Quality Manager (QM)</label>
|
||||||
<input type="text" id="proj_qm" placeholder="e.g., D. Nguyen">
|
<select id="proj_qm" class="team-pick" data-team="qm"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||||
@@ -126,21 +141,21 @@
|
|||||||
<!-- STEP 3: SIGN-OFF ROLES -->
|
<!-- STEP 3: SIGN-OFF ROLES -->
|
||||||
<div class="step" id="sop-step-3" style="display: none;">
|
<div class="step" id="sop-step-3" style="display: none;">
|
||||||
<h2>3. Required Sign-Off Roles</h2>
|
<h2>3. Required Sign-Off Roles</h2>
|
||||||
<div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
|
<div class="notice">Two roles are required on every package. They default to <strong>Superintendent</strong> and <strong>Foreman</strong> — rename either to fit your project (e.g. a BIM SOP uses <em>BIM Coordinator</em> and <em>Construction Lead</em>). Add more below.</div>
|
||||||
<div class="required-roles">
|
<div class="required-roles">
|
||||||
<div class="role-required">
|
<div class="role-required">
|
||||||
<div class="role-checkbox">
|
<div class="role-checkbox">
|
||||||
<input type="checkbox" id="role_super" checked disabled>
|
<input type="checkbox" id="role_super" checked disabled>
|
||||||
<label>Superintendent *</label>
|
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
<select id="role_super_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="role-required">
|
<div class="role-required">
|
||||||
<div class="role-checkbox">
|
<div class="role-checkbox">
|
||||||
<input type="checkbox" id="role_foreman" checked disabled>
|
<input type="checkbox" id="role_foreman" checked disabled>
|
||||||
<label>Foreman *</label>
|
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
<select id="role_foreman_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||||
@@ -153,7 +168,15 @@
|
|||||||
<!-- STEP 4: WORK PACKAGE TYPES -->
|
<!-- STEP 4: WORK PACKAGE TYPES -->
|
||||||
<div class="step" id="sop-step-4" style="display: none;">
|
<div class="step" id="sop-step-4" style="display: none;">
|
||||||
<h2>4. Work Package Types</h2>
|
<h2>4. Work Package Types</h2>
|
||||||
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
|
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.
|
||||||
|
<strong>Spec Section</strong> is filled onto every work package of that type automatically, so nobody types it per package.</div>
|
||||||
|
<label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
|
||||||
|
<span><strong>Include BIM / VDC work packages on this project</strong><br>
|
||||||
|
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types & release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
|
||||||
|
</label>
|
||||||
|
<!-- Shown instead of the toggle when an admin has the BIM tooling switched off app-wide. -->
|
||||||
|
<div id="bim-disabled-note" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||||
<div id="wp-types-table" style="margin-top: 1.5rem;"></div>
|
<div id="wp-types-table" style="margin-top: 1.5rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,14 +191,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 +220,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 +234,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>
|
||||||
@@ -267,6 +306,16 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" style="margin-top:1rem;">
|
||||||
|
<label>Tracking platform — project homepage link</label>
|
||||||
|
<input type="url" id="plat_tracking_url" placeholder="Paste the project's URL in the tracking platform (e.g. its Procore / CxAlloy project home)">
|
||||||
|
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:0.75rem;">
|
||||||
|
<label>Commissioning tool — project homepage link</label>
|
||||||
|
<input type="url" id="plat_commissioning_url" placeholder="Paste the project's URL in the commissioning tool">
|
||||||
|
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- STEP 8: SEQUENCE -->
|
<!-- STEP 8: SEQUENCE -->
|
||||||
@@ -277,7 +326,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 +357,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,12 +370,18 @@
|
|||||||
<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>
|
<!-- Sizing stays INLINE on purpose. An iframe with no width/height falls back
|
||||||
|
to the HTML default 300x150 box, and the service worker caches this page
|
||||||
|
and the stylesheet separately — so a browser can hold new HTML with old
|
||||||
|
CSS and collapse the creator to a tiny scrolling box. Inline attributes
|
||||||
|
survive any cache mismatch; the CSS below only refines them. -->
|
||||||
|
<iframe id="wp-frame" title="Work Package Creator"
|
||||||
|
style="display:none; width:100%; border:0; min-height:calc(100vh - 200px)"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -336,12 +391,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 +404,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 +418,20 @@
|
|||||||
<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>
|
||||||
|
<script src="wp-chrome.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
237
html/wp-chrome.css
Normal file
237
html/wp-chrome.css
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SHARED APP CHROME — project switcher + global search
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Injected by wp-chrome.js into whichever top bar a page has: the dark UI-shell
|
||||||
|
bar (.wp-appbar on index / admin / field) or the older light bars (.header on
|
||||||
|
the SOP suite and the WP creator). The two live side by side, so every colour
|
||||||
|
here comes from a variable that wp-chrome.js sets per host bar — the markup and
|
||||||
|
behaviour are identical on both.
|
||||||
|
============================================================================ */
|
||||||
|
.wp-chrome {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0; /* lets the search shrink instead of overflowing */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
/* Light host bar (the two tool pages) */
|
||||||
|
.wp-chrome {
|
||||||
|
--wpc-fg: #161616;
|
||||||
|
--wpc-fg-dim: #525252;
|
||||||
|
--wpc-bg: #ffffff;
|
||||||
|
--wpc-bg-soft: #f4f4f4;
|
||||||
|
--wpc-border: #c6c6c6;
|
||||||
|
--wpc-hover: #e8e8e8;
|
||||||
|
--wpc-accent: #0f62fe;
|
||||||
|
}
|
||||||
|
/* Dark host bar (the UI-shell appbar) */
|
||||||
|
.wp-chrome[data-bar="dark"] {
|
||||||
|
--wpc-fg: #ffffff;
|
||||||
|
--wpc-fg-dim: #c6c6c6;
|
||||||
|
--wpc-bg: #262626;
|
||||||
|
--wpc-bg-soft: #393939;
|
||||||
|
--wpc-border: #6f6f6f;
|
||||||
|
--wpc-hover: #353535;
|
||||||
|
--wpc-accent: #78a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── archived-project banner ──────────────────────────────────────────────── */
|
||||||
|
/* Inserted by wp-chrome.js as the top bar's next sibling, so it sits directly
|
||||||
|
under the bar in normal flow and can never overlap it or eat its height (the
|
||||||
|
bar is sticky; this strip scrolls away under it). `flex: 0 0 auto` is for the
|
||||||
|
suite page, whose shell is a flex column — without it the strip would squash.
|
||||||
|
Amber tokens are the suite's warning set, same as the sync badge. */
|
||||||
|
.wpc-archived {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: #fdf6dd;
|
||||||
|
color: #8e6a00;
|
||||||
|
border-bottom: 1px solid #f1c21b;
|
||||||
|
border-radius: 0;
|
||||||
|
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.wpc-archived-ico { flex: 0 0 auto; font-size: 14px; }
|
||||||
|
.wpc-archived-text { min-width: 0; } /* wraps instead of forcing a scrollbar */
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.wpc-archived { padding: 8px 12px; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── project switcher ─────────────────────────────────────────────────────── */
|
||||||
|
.wpc-proj { position: relative; flex: 0 0 auto; }
|
||||||
|
.wpc-proj-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--wpc-fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wpc-proj-btn:hover { background: var(--wpc-hover); border-color: var(--wpc-border); }
|
||||||
|
.wpc-proj-btn[aria-expanded="true"] { background: var(--wpc-hover); border-color: var(--wpc-border); }
|
||||||
|
.wpc-proj-labels { min-width: 0; }
|
||||||
|
.wpc-proj-kicker {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--wpc-fg-dim);
|
||||||
|
}
|
||||||
|
.wpc-proj-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
.wpc-caret { flex: 0 0 auto; align-self: flex-end; margin-bottom: 3px; font-size: 10px;
|
||||||
|
line-height: 1; color: var(--wpc-fg-dim); }
|
||||||
|
|
||||||
|
/* ── dropdown / results panel (shared shell) ──────────────────────────────── */
|
||||||
|
.wpc-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: min(460px, 92vw);
|
||||||
|
max-height: min(70vh, 560px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
color: #161616;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
box-shadow: 0 8px 28px rgba(20, 30, 50, .22);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.wpc-pop[hidden] { display: none; }
|
||||||
|
.wpc-pop-head {
|
||||||
|
padding: 9px 12px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6f6f6f;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.wpc-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #161616;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.wpc-item:hover, .wpc-item.is-active { background: #f4f4f4; }
|
||||||
|
.wpc-item.is-current { border-left-color: #0f62fe; background: #edf5ff; }
|
||||||
|
.wpc-item-title { display: block; font-weight: 600; }
|
||||||
|
.wpc-item-sub { display: block; font-size: 11.5px; color: #6f6f6f; }
|
||||||
|
.wpc-item-mono { font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; font-size: 12px; color: #0f62fe; }
|
||||||
|
.wpc-empty { padding: 14px 12px; font-size: 13px; color: #6f6f6f; }
|
||||||
|
.wpc-pop-foot {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wpc-foot-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #c6c6c6;
|
||||||
|
background: #fff;
|
||||||
|
color: #161616;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.wpc-foot-btn:hover { border-color: #0f62fe; color: #0f62fe; }
|
||||||
|
|
||||||
|
/* ── global search ────────────────────────────────────────────────────────── */
|
||||||
|
/* Centered in the bar: the wrapper takes the free space and centres a capped box,
|
||||||
|
which keeps the field mid-screen without absolute positioning (so it can never
|
||||||
|
sit on top of the bar's own buttons). */
|
||||||
|
.wpc-search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wpc-search-box {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
height: 34px;
|
||||||
|
background: var(--wpc-bg);
|
||||||
|
border: 1px solid var(--wpc-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; }
|
||||||
|
.wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; }
|
||||||
|
.wpc-search-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
color: var(--wpc-fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.wpc-search-input::placeholder { color: var(--wpc-fg-dim); }
|
||||||
|
.wpc-kbd {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--wpc-fg-dim);
|
||||||
|
border: 1px solid var(--wpc-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wpc-search .wpc-pop { left: 50%; transform: translateX(-50%); min-width: min(560px, 92vw); }
|
||||||
|
.wpc-clear {
|
||||||
|
flex: 0 0 auto; background: transparent; border: 0; cursor: pointer;
|
||||||
|
color: var(--wpc-fg-dim); font: inherit; font-size: 14px; line-height: 1; padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.wpc-clear:hover { color: var(--wpc-fg); }
|
||||||
|
|
||||||
|
/* ── narrow screens ───────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.wpc-search-box { max-width: none; }
|
||||||
|
.wpc-kbd { display: none; }
|
||||||
|
.wpc-proj-btn { max-width: 190px; }
|
||||||
|
.wpc-proj-name { max-width: 150px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
/* Keep the switcher (you must be able to change project) and let the search
|
||||||
|
collapse to an icon-width field rather than pushing the bar out of shape. */
|
||||||
|
.wpc-proj-kicker { display: none; }
|
||||||
|
.wpc-search { flex: 1 1 120px; }
|
||||||
|
}
|
||||||
388
html/wp-chrome.js
Normal file
388
html/wp-chrome.js
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
/* Shared app chrome for the Work Package Suite: a project switcher beside the
|
||||||
|
Prime logo and a global search centered in the top bar.
|
||||||
|
|
||||||
|
One script for every page because there are two generations of top bar — the
|
||||||
|
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
|
||||||
|
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
|
||||||
|
flip a colour set based on how dark the host bar is.
|
||||||
|
|
||||||
|
Search hits GET /api/search, which scopes results to the projects the signed-in
|
||||||
|
user may access — so this is a convenience, never a way to see another job.
|
||||||
|
|
||||||
|
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
|
||||||
|
second bar inside the frame would be nonsense. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||||
|
if (inIframe) return;
|
||||||
|
|
||||||
|
var SEARCH_MIN = 2; // characters before we ask the server
|
||||||
|
var DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
function el(tag, cls, html) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (html != null) n.innerHTML = html;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
function esc(v) {
|
||||||
|
return String(v == null ? '' : v)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
function isDark(node) {
|
||||||
|
try {
|
||||||
|
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||||
|
if (!m) return false;
|
||||||
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── where to put the chrome ────────────────────────────────────────────────
|
||||||
|
// Returns {host, insertBefore} or null. The insertion point matters: on the
|
||||||
|
// dark bar we sit before the spacer (so search takes the middle); on the light
|
||||||
|
// bars we sit between the left block and the right-hand buttons.
|
||||||
|
function findMount() {
|
||||||
|
var appbar = document.querySelector('.wp-appbar');
|
||||||
|
if (appbar) {
|
||||||
|
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
|
||||||
|
}
|
||||||
|
var header = document.querySelector('.header');
|
||||||
|
if (header) {
|
||||||
|
// The suite page wraps its own left/right groups; the creator's bar is a
|
||||||
|
// flat row of buttons whose first button carries margin-left:auto.
|
||||||
|
var right = header.querySelector('.header-right');
|
||||||
|
if (right) return { host: header, before: right };
|
||||||
|
var firstBtn = header.querySelector('.btn, button');
|
||||||
|
return { host: header, before: firstBtn };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── project switcher ───────────────────────────────────────────────────────
|
||||||
|
var projects = [];
|
||||||
|
|
||||||
|
function activeProject() {
|
||||||
|
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectLabel(p) {
|
||||||
|
if (!p) return 'Select a project';
|
||||||
|
var n = p.name || '(unnamed)';
|
||||||
|
return p.number ? (p.number + ' — ' + n) : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching project reloads the current page with ?project=<id>. Every page
|
||||||
|
// already resolves its project from that param (falling back to the stored
|
||||||
|
// active id), so a reload is both the simplest and the safest route — no page
|
||||||
|
// has to re-hydrate half its state in place.
|
||||||
|
function switchProject(p) {
|
||||||
|
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
|
||||||
|
var url = new URL(location.href);
|
||||||
|
url.searchParams.set('project', p.id);
|
||||||
|
url.hash = '';
|
||||||
|
location.assign(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectSwitcher() {
|
||||||
|
var wrap = el('div', 'wpc-proj');
|
||||||
|
var btn = el('button', 'wpc-proj-btn');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.title = 'Switch project';
|
||||||
|
var cur = activeProject();
|
||||||
|
btn.innerHTML =
|
||||||
|
'<span class="wpc-proj-labels">' +
|
||||||
|
'<span class="wpc-proj-kicker">Project</span>' +
|
||||||
|
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
|
||||||
|
'</span><span class="wpc-caret">▾</span>';
|
||||||
|
var pop = el('div', 'wpc-pop');
|
||||||
|
pop.hidden = true;
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
wrap.appendChild(pop);
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var curId = (activeProject() || {}).id || '';
|
||||||
|
var rows = projects.map(function (p) {
|
||||||
|
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
|
||||||
|
'" data-pid="' + esc(p.id) + '">' +
|
||||||
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
|
||||||
|
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
|
pop.innerHTML =
|
||||||
|
'<div class="wpc-pop-head">Switch project</div>' +
|
||||||
|
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
|
||||||
|
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
|
||||||
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
|
||||||
|
if (p) switchProject(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
render();
|
||||||
|
pop.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
pop.hidden = true;
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (pop.hidden) open(); else close();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
||||||
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
||||||
|
|
||||||
|
// Refresh the label once the project list (and any active project) is known.
|
||||||
|
wrap.wpcRefresh = function () {
|
||||||
|
var c = activeProject();
|
||||||
|
var nameEl = btn.querySelector('.wpc-proj-name');
|
||||||
|
if (nameEl) nameEl.textContent = projectLabel(c);
|
||||||
|
if (!pop.hidden) render();
|
||||||
|
};
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProjects(switcher) {
|
||||||
|
// ProjectData.list() already hits the API and falls back to its local cache
|
||||||
|
// when offline, so there's no second request to make here.
|
||||||
|
var p;
|
||||||
|
try {
|
||||||
|
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
|
||||||
|
} catch (e) { p = null; }
|
||||||
|
if (!p) {
|
||||||
|
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; });
|
||||||
|
}
|
||||||
|
Promise.resolve(p)
|
||||||
|
.then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── archived-project banner ────────────────────────────────────────────────
|
||||||
|
// An archived project is still readable and still deep-linkable (?project=<id>),
|
||||||
|
// but every write now 409s. With nothing on the page to say so, that reads as a
|
||||||
|
// silent failure — so the bar, the one thing every page has, carries the warning.
|
||||||
|
//
|
||||||
|
// The state comes from GET /api/projects/<id>, never from "it's missing from the
|
||||||
|
// switcher": absence also means "you have no access to it", which is a different
|
||||||
|
// message. Fails closed and silent — an error means no banner, not a broken page.
|
||||||
|
function activeProjectId() {
|
||||||
|
try {
|
||||||
|
var q = new URLSearchParams(location.search).get('project');
|
||||||
|
if (q) return q;
|
||||||
|
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArchivedBanner() {
|
||||||
|
var bar = el('div', 'wpc-archived');
|
||||||
|
bar.setAttribute('role', 'status');
|
||||||
|
bar.innerHTML =
|
||||||
|
'<span class="wpc-archived-ico" aria-hidden="true">⚠</span>' +
|
||||||
|
'<span class="wpc-archived-text"><strong>Archived project — read-only.</strong> ' +
|
||||||
|
'Unarchive it from the Admin Console to make changes.</span>';
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkArchived(host) {
|
||||||
|
var id = activeProjectId();
|
||||||
|
if (!id || !host || !host.parentNode) return;
|
||||||
|
var pinned = false;
|
||||||
|
try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {}
|
||||||
|
fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (p) {
|
||||||
|
if (!p || !p.archived) return;
|
||||||
|
// The home page reconciles the stored active project against the (now
|
||||||
|
// archive-filtered) list while this request is in flight, and drops it. If
|
||||||
|
// that happened, the id we asked about is nobody's context any more —
|
||||||
|
// banner-ing it would contradict the picker one line below. A ?project=
|
||||||
|
// deep link is pinned to this page and can't be cleared out from under us.
|
||||||
|
if (!pinned && window.ProjectData && ProjectData.getActiveId &&
|
||||||
|
ProjectData.getActiveId() !== id) return;
|
||||||
|
// Also on the document element, so the two big apps can gate their own UI
|
||||||
|
// from CSS or a boot check without a second round trip. The server stays
|
||||||
|
// the real gate; this is only there so the UI can agree with it.
|
||||||
|
try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {}
|
||||||
|
if (document.querySelector('.wpc-archived')) return;
|
||||||
|
host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling);
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── global search ──────────────────────────────────────────────────────────
|
||||||
|
function buildSearch() {
|
||||||
|
var wrap = el('div', 'wpc-search');
|
||||||
|
var box = el('div', 'wpc-search-box');
|
||||||
|
box.innerHTML =
|
||||||
|
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
|
||||||
|
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
|
||||||
|
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
|
||||||
|
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
|
||||||
|
'<span class="wpc-kbd">Ctrl K</span>';
|
||||||
|
var pop = el('div', 'wpc-pop');
|
||||||
|
pop.hidden = true;
|
||||||
|
wrap.appendChild(box);
|
||||||
|
wrap.appendChild(pop);
|
||||||
|
|
||||||
|
var input = box.querySelector('.wpc-search-input');
|
||||||
|
var clear = box.querySelector('.wpc-clear');
|
||||||
|
var timer = null, seq = 0, items = [], activeIx = -1;
|
||||||
|
|
||||||
|
function close() { pop.hidden = true; activeIx = -1; }
|
||||||
|
|
||||||
|
function highlight() {
|
||||||
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
|
||||||
|
n.classList.toggle('is-active', i === activeIx);
|
||||||
|
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A work package lives inside the suite's Creator tab, so open the suite on
|
||||||
|
// that project with the package requested; a SOP opens the SOP tab.
|
||||||
|
function hrefFor(hit) {
|
||||||
|
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
|
||||||
|
if (hit.kind === 'wp') {
|
||||||
|
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
|
||||||
|
'&wp=' + encodeURIComponent(hit.id);
|
||||||
|
}
|
||||||
|
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(hit) {
|
||||||
|
if (!hit) return;
|
||||||
|
if (hit.kind === 'project') {
|
||||||
|
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
|
||||||
|
if (p) { switchProject(p); return; }
|
||||||
|
}
|
||||||
|
// Set the active project only from a full record — writing a stub would
|
||||||
|
// clobber the cached project (name, number, client) other pages read. The
|
||||||
|
// ?project= param in the URL is what actually switches context.
|
||||||
|
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
|
||||||
|
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
|
||||||
|
location.assign(hrefFor(hit));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(data) {
|
||||||
|
items = [];
|
||||||
|
var html = '';
|
||||||
|
function group(title, rows) {
|
||||||
|
if (!rows.length) return;
|
||||||
|
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
|
||||||
|
}
|
||||||
|
group('Work packages', (data.wps || []).map(function (w) {
|
||||||
|
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
|
||||||
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||||
|
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
|
||||||
|
esc(w.subject || '') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}));
|
||||||
|
group('Projects', (data.projects || []).map(function (p) {
|
||||||
|
items.push({ kind: 'project', id: p.id });
|
||||||
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||||
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}));
|
||||||
|
group('SOPs', (data.sops || []).map(function (s) {
|
||||||
|
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
|
||||||
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||||
|
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}));
|
||||||
|
if (!items.length) {
|
||||||
|
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
|
||||||
|
}
|
||||||
|
pop.innerHTML = html;
|
||||||
|
pop.hidden = false;
|
||||||
|
activeIx = items.length ? 0 : -1;
|
||||||
|
highlight();
|
||||||
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
|
||||||
|
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
|
||||||
|
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(q) {
|
||||||
|
var mine = ++seq;
|
||||||
|
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (mine !== seq) return; // a newer keystroke already won
|
||||||
|
if (!data) { close(); return; }
|
||||||
|
renderResults(data);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (mine !== seq) return;
|
||||||
|
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
|
||||||
|
pop.hidden = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
clear.hidden = !q;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (q.length < SEARCH_MIN) { close(); return; }
|
||||||
|
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { close(); input.blur(); return; }
|
||||||
|
if (pop.hidden || !items.length) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', function () {
|
||||||
|
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
|
||||||
|
});
|
||||||
|
clear.addEventListener('click', function () {
|
||||||
|
input.value = ''; clear.hidden = true; close(); input.focus();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
||||||
|
|
||||||
|
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
|
||||||
|
// use). Ignored while typing in another field so it can't steal a shortcut.
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mount ──────────────────────────────────────────────────────────────────
|
||||||
|
function mount() {
|
||||||
|
if (document.querySelector('.wp-chrome')) return;
|
||||||
|
var m = findMount();
|
||||||
|
if (!m) return;
|
||||||
|
var chrome = el('div', 'wp-chrome');
|
||||||
|
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
|
||||||
|
var switcher = buildProjectSwitcher();
|
||||||
|
chrome.appendChild(switcher);
|
||||||
|
chrome.appendChild(buildSearch());
|
||||||
|
if (m.before) m.host.insertBefore(chrome, m.before);
|
||||||
|
else m.host.appendChild(chrome);
|
||||||
|
loadProjects(switcher);
|
||||||
|
checkArchived(m.host);
|
||||||
|
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the auth guard: an unauthenticated page is about to redirect, and
|
||||||
|
// /api/search would 401 anyway.
|
||||||
|
if (window.WP_USER) mount();
|
||||||
|
else document.addEventListener('wp-auth-ready', mount);
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,13 @@
|
|||||||
<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>
|
||||||
|
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||||
|
timestamps during their own boot. -->
|
||||||
|
<script src="wp-format.js"></script>
|
||||||
<link rel="icon" href="favicon.ico" sizes="any">
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#161616">
|
||||||
<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">
|
||||||
</head>
|
</head>
|
||||||
@@ -13,21 +19,23 @@
|
|||||||
<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" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</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,14 +45,87 @@
|
|||||||
<!-- 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="wp-layout">
|
||||||
|
|
||||||
|
<!-- WORK PACKAGE NAVIGATOR
|
||||||
|
A persistent side panel (not a hover drawer): collapse toggle, a primary action,
|
||||||
|
icon nav, then the project's packages as rows with colour-coded initial badges.
|
||||||
|
Collapsing leaves a narrow icon rail so you can still see and switch packages. -->
|
||||||
|
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
|
||||||
|
<div class="wp-nav-top">
|
||||||
|
<button class="wp-nav-toggle" id="wp-nav-toggle" onclick="toggleWpNav()"
|
||||||
|
title="Collapse the panel" aria-label="Collapse the panel" aria-expanded="true">
|
||||||
|
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
|
||||||
|
<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<line x1="7.5" y1="3.5" x2="7.5" y2="16.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path class="wp-nav-toggle-arrow" d="M14 10 H10 M11.6 8.2 L9.8 10 L11.6 11.8"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wp-nav-primary">
|
||||||
|
<button class="wp-nav-cta" onclick="newPackage()" title="Start a new work package">
|
||||||
|
<span class="wp-nav-cta-plus" aria-hidden="true">+</span><span class="wp-nav-cta-label">New work package</span>
|
||||||
|
</button>
|
||||||
|
<button class="wp-nav-cta-more" id="wp-nav-more-btn" onclick="toggleWpNavMore(event)"
|
||||||
|
title="More actions" aria-label="More actions" aria-haspopup="true" aria-expanded="false">▾</button>
|
||||||
|
<div class="wp-nav-menu" id="wp-nav-more" hidden>
|
||||||
|
<button type="button" onclick="wpNavAction('duplicate')">Duplicate this package</button>
|
||||||
|
<button type="button" onclick="wpNavAction('split')">Split by discipline</button>
|
||||||
|
<button type="button" onclick="wpNavAction('export')">Export all (JSON)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="wp-nav-links" aria-label="Views">
|
||||||
|
<button type="button" class="wp-nav-link" data-view="mine" onclick="setWpNavView('mine')" title="Packages you own">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">◔</span><span class="wp-nav-link-label">My packages</span>
|
||||||
|
<span class="wp-nav-link-n" id="wp-nav-n-mine"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="wp-nav-link is-current" data-view="all" onclick="setWpNavView('all')" title="Every package on this project">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">▤</span><span class="wp-nav-link-label">All packages</span>
|
||||||
|
<span class="wp-nav-link-n" id="wp-nav-n-all"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="wp-nav-link" data-view="open" onclick="setWpNavView('open')" title="Not release-ready yet">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">⚠</span><span class="wp-nav-link-label">Needs attention</span>
|
||||||
|
<span class="wp-nav-link-n" id="wp-nav-n-open"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="wp-nav-link" onclick="showDashboard()" title="Status and gating across the project">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">▦</span><span class="wp-nav-link-label">Dashboard</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="wp-nav-sect">
|
||||||
|
<span class="wp-nav-sect-label" id="wp-nav-sect-label">Work packages</span>
|
||||||
|
<span class="wp-nav-count" id="wp-nav-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="wp-nav-filter">
|
||||||
|
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter packages…" oninput="renderWpNav()">
|
||||||
|
</div>
|
||||||
|
<div class="wp-nav-list" id="wp-nav-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
|
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
|
||||||
|
<div class="card" id="kind-row" style="display:none">
|
||||||
|
<div class="sub-heading">Package Type</div>
|
||||||
|
<div class="notice">This project includes BIM/VDC packages. Choose what this one is — it tailors the fields below and the WP types / release gates offered.</div>
|
||||||
|
<div class="radio-group" id="kind-group" style="margin-bottom:0">
|
||||||
|
<label class="radio-pill" data-val="iwp"><input type="radio" name="pkgkind" onclick="setKind('iwp')"><span class="dot"></span>Install package (IWP)</label>
|
||||||
|
<label class="radio-pill" data-val="ewp"><input type="radio" name="pkgkind" onclick="setKind('ewp')"><span class="dot"></span>BIM package (EWP)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- GENERAL INFORMATION -->
|
<!-- GENERAL INFORMATION -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<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>
|
||||||
@@ -69,16 +150,39 @@
|
|||||||
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
|
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
|
||||||
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
|
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
|
||||||
|
<div class="people-pick" id="pick_assignees"></div>
|
||||||
|
<input type="hidden" id="wp_assignees"></div>
|
||||||
|
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
|
||||||
|
<div class="people-pick" id="pick_distribution"></div>
|
||||||
|
<input type="hidden" id="wp_distribution"></div>
|
||||||
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
|
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
|
||||||
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
|
<div class="field"><label>Specification Section</label>
|
||||||
|
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
|
||||||
|
<div class="field-hint" id="spec-folder-link"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||||
|
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
|
||||||
|
<div class="card" id="bim-card" style="display:none">
|
||||||
|
<div class="sub-heading">BIM / Model Details</div>
|
||||||
|
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
|
||||||
|
<div class="field"><label>Clash / Coordination Status</label>
|
||||||
|
<select id="wp_clash" onchange="onClashChange()"><option value="">—</option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
|
||||||
|
<div class="field"><label>IFF #<span class="help-tip" data-tip="Issued-For-Fabrication/Field number — the GC sign-off reference for this model package. Required once the coordination status is Signed off (IFF).">i</span></label>
|
||||||
|
<input type="text" id="wp_iff" placeholder="e.g. IFF-2026-0142" oninput="onClashChange()">
|
||||||
|
<div class="field-hint" id="iff-hint"></div></div>
|
||||||
|
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ASSETS (controls.dev) -->
|
<!-- ASSETS (controls.dev) -->
|
||||||
<div class="card">
|
<div class="card" id="asset-card">
|
||||||
<div class="sub-heading">Assets</div>
|
<div class="sub-heading">Assets</div>
|
||||||
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
|
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
|
||||||
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
||||||
@@ -87,14 +191,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>
|
||||||
@@ -106,13 +210,17 @@
|
|||||||
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
|
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
|
||||||
<div class="field-grid" style="margin-top:14px">
|
<div class="field-grid" style="margin-top:14px">
|
||||||
<div class="field"><label>Labor – Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
|
<div class="field"><label>Labor – Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
|
||||||
<div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div>
|
<div class="field"><label>Predecessor work packages<span class="help-tip" data-tip="The packages that must be Closed before this one can be released. A package with an open predecessor is not release-ready — you can still release it, but the override is logged.">i</span></label>
|
||||||
|
<div class="people-pick" id="pick_predecessors"></div>
|
||||||
|
<div class="field-hint" id="pred-hint"></div></div>
|
||||||
|
<div class="field"><label>Sequence phase <span class="help-tip" data-tip="Which phase of the SOP's construction sequence this package belongs to. Descriptive — it does not gate release; predecessor packages do.">i</span></label>
|
||||||
|
<select id="wp_seq"></select><div class="field-hint sop-hint">from the SOP construction sequence</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MATERIAL LIST -->
|
<!-- MATERIAL LIST -->
|
||||||
<div class="card">
|
<div class="card" id="material-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">
|
||||||
@@ -142,7 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KITTING & MIMO -->
|
<!-- KITTING & MIMO -->
|
||||||
<div class="card">
|
<div class="card" id="mimo-card">
|
||||||
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label>Kitting Status</label>
|
<div class="field"><label>Kitting Status</label>
|
||||||
@@ -156,7 +264,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>
|
||||||
@@ -234,6 +342,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- HOLD LOG MODAL (comment 7) -->
|
<!-- HOLD LOG MODAL (comment 7) -->
|
||||||
<div class="modal-overlay" id="hold-modal">
|
<div class="modal-overlay" id="hold-modal">
|
||||||
@@ -275,7 +384,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>
|
||||||
|
|||||||
@@ -7,25 +7,25 @@
|
|||||||
body.embedded .embed-first { margin-left: auto; }
|
body.embedded .embed-first { margin-left: auto; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4f5f7;
|
--bg: #f4f4f4;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface2: #f7f8fa;
|
--surface2: #f4f4f4;
|
||||||
--border: #e3e6ec;
|
--border: #e0e0e0;
|
||||||
--border-strong: #d0d5de;
|
--border-strong: #8d8d8d;
|
||||||
--text: #1a2230;
|
--text: #161616;
|
||||||
--text-muted: #5a6675;
|
--text-muted: #525252;
|
||||||
--text-dim: #9aa3b2;
|
--text-dim: #8d8d8d;
|
||||||
--accent: #2563d6;
|
--accent: #0f62fe;
|
||||||
--accent-dim: #e8f0fe;
|
--accent-dim: #edf5ff;
|
||||||
--accent-green: #15924f;
|
--accent-green: #198038;
|
||||||
--accent-green-dim: #e4f6ec;
|
--accent-green-dim: #defbe6;
|
||||||
--accent-amber: #b87100;
|
--accent-amber: #8e6a00;
|
||||||
--accent-amber-dim: #fdf2e0;
|
--accent-amber-dim: #fdf6dd;
|
||||||
--red: #cf3b3b;
|
--red: #da1e28;
|
||||||
--red-dim: #fbeaea;
|
--red-dim: #fff1f1;
|
||||||
--radius: 5px;
|
--radius: 0;
|
||||||
--shadow: 0 1px 2px rgba(20,30,50,.04), 0 1px 3px rgba(20,30,50,.06);
|
--shadow: none;
|
||||||
--shadow-lg: 0 4px 16px rgba(20,30,50,.08);
|
--shadow-lg: 0 4px 16px rgba(20,30,50,.12);
|
||||||
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
|
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
|
||||||
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,15 @@
|
|||||||
.step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); }
|
.step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); }
|
||||||
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
|
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
|
||||||
|
|
||||||
/* ── MAIN ── */
|
/* ── MAIN ──
|
||||||
.main { max-width: 1000px; margin: 0 auto; padding: 28px 32px 64px; }
|
The form uses the full width it's given. The work-package navigator is an
|
||||||
|
auto-hiding overlay drawer (see below) rather than a column, so it never takes
|
||||||
|
width away from the form — which matters most when this page is embedded in the
|
||||||
|
suite's tab and every pixel is shared with the app chrome. */
|
||||||
|
.wp-layout { display: block; width: 100%; margin: 0; }
|
||||||
|
.main { min-width: 0; max-width: none; margin: 0;
|
||||||
|
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
|
||||||
|
transition: padding-left .18s ease; }
|
||||||
|
|
||||||
.section { display: none; }
|
.section { display: none; }
|
||||||
.section.active { display: block; animation: fade .25s ease; }
|
.section.active { display: block; animation: fade .25s ease; }
|
||||||
@@ -126,6 +133,11 @@
|
|||||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
|
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
|
||||||
.field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; }
|
.field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||||
.field-grid.col1 { grid-template-columns: 1fr; }
|
.field-grid.col1 { grid-template-columns: 1fr; }
|
||||||
|
/* On a wide screen let the two-up grids flow into 3–4 columns instead of
|
||||||
|
stretching two fields across the whole card. */
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.field-grid:not(.col1):not(.col3) { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
|
||||||
|
}
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.field.span2 { grid-column: span 2; }
|
.field.span2 { grid-column: span 2; }
|
||||||
|
|
||||||
@@ -213,7 +225,7 @@
|
|||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
|
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
|
||||||
padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
|
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── DELIVERABLES ── */
|
/* ── DELIVERABLES ── */
|
||||||
@@ -245,9 +257,9 @@
|
|||||||
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
|
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
|
||||||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
|
||||||
.btn-primary:hover { background: #1d52b8; }
|
.btn-primary:hover { background: #0353e9; }
|
||||||
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
|
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
|
||||||
.btn-generate:hover { background: #117a42; }
|
.btn-generate:hover { background: #0e6027; }
|
||||||
|
|
||||||
/* ── OUTPUT ── */
|
/* ── OUTPUT ── */
|
||||||
#output-section { display: none; }
|
#output-section { display: none; }
|
||||||
@@ -419,7 +431,7 @@
|
|||||||
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
|
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
|
||||||
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
|
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
|
||||||
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
|
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
|
||||||
.use-btn:hover { background:#0f7a40; }
|
.use-btn:hover { background:#0e6027; }
|
||||||
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
||||||
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
|
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
|
||||||
padding:3px 10px; font-family:var(--mono); font-size:10px; }
|
padding:3px 10px; font-family:var(--mono); font-size:10px; }
|
||||||
@@ -427,7 +439,7 @@
|
|||||||
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
|
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
|
||||||
|
|
||||||
/* ── CREATION TOOL ───────────────────────────────────────────────── */
|
/* ── CREATION TOOL ───────────────────────────────────────────────── */
|
||||||
.ctx-bar { max-width:1080px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
|
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
|
||||||
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
|
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
|
||||||
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
||||||
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
|
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
|
||||||
@@ -439,7 +451,7 @@
|
|||||||
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
|
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
|
||||||
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
|
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
|
||||||
|
|
||||||
.mode-wrap { max-width:1080px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
|
.mode-wrap { max-width:none; margin:0; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
|
||||||
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
|
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
|
||||||
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
|
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
|
||||||
color:var(--text-muted); cursor:pointer; }
|
color:var(--text-muted); cursor:pointer; }
|
||||||
@@ -463,7 +475,7 @@
|
|||||||
|
|
||||||
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
||||||
.sop-hint { color:var(--accent) !important; }
|
.sop-hint { color:var(--accent) !important; }
|
||||||
.release-banner { max-width:1080px; margin:0 auto; padding:0 28px; }
|
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
|
||||||
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
|
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
|
||||||
display:flex; align-items:center; gap:10px; }
|
display:flex; align-items:center; gap:10px; }
|
||||||
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
|
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
|
||||||
@@ -495,7 +507,7 @@
|
|||||||
|
|
||||||
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
|
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
|
||||||
.modal-overlay.open { display:flex; }
|
.modal-overlay.open { display:flex; }
|
||||||
.modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
||||||
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
|
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
|
||||||
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
|
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
|
||||||
.modal-body { padding:18px 20px; overflow-y:auto; }
|
.modal-body { padding:18px 20px; overflow-y:auto; }
|
||||||
@@ -564,6 +576,247 @@
|
|||||||
.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 8px calc(var(--nav-w,288px) + 28px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
|
||||||
|
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
|
||||||
|
transition:transform .22s ease; }
|
||||||
|
.section-nav-bar:empty{ display:none; }
|
||||||
|
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* ── SOP-inherited marker ───────────────────────────────────────────────────
|
||||||
|
The "from SOP types" subtext used to sit under the field. It's now a small
|
||||||
|
chip on the label with the detail in a hover tooltip (site comment 8/3).
|
||||||
|
The chip stays VISIBLE rather than hover-only: on a field tablet there is no
|
||||||
|
hover, and "this value came from the SOP" is the part people need to see. */
|
||||||
|
.field-hint.sop-hint { display: none; }
|
||||||
|
.sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px;
|
||||||
|
background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb;
|
||||||
|
font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
|
||||||
|
vertical-align:middle; cursor:help; position:relative; }
|
||||||
|
.sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%;
|
||||||
|
transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px;
|
||||||
|
font-weight:400; letter-spacing:0; text-transform:none; 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); }
|
||||||
|
.sop-chip::before { content:''; position:absolute; bottom:135%; left:50%;
|
||||||
|
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616;
|
||||||
|
opacity:0; transition:opacity .12s; z-index:9999; }
|
||||||
|
.sop-chip:hover::after, .sop-chip:hover::before,
|
||||||
|
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
|
||||||
|
|
||||||
|
/* ── people picker (Assignees / Distribution / Predecessors) ─────────────────
|
||||||
|
Multi-select over the SOP project team instead of a free-text list. */
|
||||||
|
.people-pick { border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
|
||||||
|
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
|
||||||
|
.people-pick:focus-within { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||||
|
.pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px;
|
||||||
|
background:var(--surface2); border:1px solid var(--border); border-radius:12px;
|
||||||
|
font-size:12px; max-width:100%; }
|
||||||
|
.pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); }
|
||||||
|
.pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted);
|
||||||
|
font-size:12px; line-height:1; padding:0 1px; }
|
||||||
|
.pp-chip .pp-x:hover { color:var(--red); }
|
||||||
|
.pp-add { position:relative; }
|
||||||
|
.pp-add-btn { background:none; border:1px dashed var(--border-strong); border-radius:12px;
|
||||||
|
color:var(--text-muted); font:inherit; font-size:12px; padding:2px 9px; cursor:pointer; }
|
||||||
|
.pp-add-btn:hover { border-color:var(--accent); color:var(--accent); }
|
||||||
|
.pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px;
|
||||||
|
max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong);
|
||||||
|
box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; }
|
||||||
|
.pp-menu[hidden] { display:none; }
|
||||||
|
.pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
|
||||||
|
color:var(--text-dim); padding:7px 10px 3px; }
|
||||||
|
.pp-opt { display:flex; align-items:center; gap:8px; padding:5px 10px; font-size:13px; cursor:pointer; }
|
||||||
|
.pp-opt:hover { background:var(--surface2); }
|
||||||
|
.pp-opt input { width:15px; height:15px; cursor:pointer; }
|
||||||
|
.pp-opt .pp-role { color:var(--text-dim); font-size:11.5px; }
|
||||||
|
.pp-free { border-top:1px solid var(--border); margin-top:5px; padding:7px 10px 3px; }
|
||||||
|
.pp-free input { width:100%; padding:5px 7px; font:inherit; font-size:12.5px;
|
||||||
|
border:1px solid var(--border); border-radius:3px; }
|
||||||
|
.pp-free .field-hint { margin-top:4px; }
|
||||||
|
|
||||||
|
/* Critical constraint marker (from the SOP) */
|
||||||
|
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
|
||||||
|
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
||||||
|
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
|
||||||
|
|
||||||
|
/* -- WORK PACKAGE NAVIGATOR ------------------------------------------------
|
||||||
|
A persistent side panel in the spirit of MS Planner: collapse toggle, one
|
||||||
|
primary action, icon nav, then the packages as rows with colour-coded initial
|
||||||
|
badges and a highlighted current row. It sits IN the layout (the form shifts
|
||||||
|
across) rather than hovering over the content, and collapses to a 56px icon
|
||||||
|
rail so you can still see and switch packages with it closed. */
|
||||||
|
.wp-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--rail-top, 48px);
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--nav-w, 288px);
|
||||||
|
z-index: 120;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fbfbfc;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width .16s ease;
|
||||||
|
}
|
||||||
|
body { --nav-w: 288px; }
|
||||||
|
body.wp-nav-collapsed { --nav-w: 56px; }
|
||||||
|
|
||||||
|
/* -- collapse toggle -- */
|
||||||
|
.wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; }
|
||||||
|
.wp-nav-toggle {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 34px; height: 34px; padding: 0;
|
||||||
|
background: transparent; border: 1px solid transparent; border-radius: 5px;
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.wp-nav-toggle:hover { background: #eef0f3; color: var(--text); }
|
||||||
|
/* Arrow flips to point right when the panel is closed. */
|
||||||
|
body.wp-nav-collapsed .wp-nav-toggle-arrow { transform: rotate(180deg); transform-origin: 11px 10px; }
|
||||||
|
|
||||||
|
/* -- primary action -- */
|
||||||
|
.wp-nav-primary { position: relative; display: flex; gap: 2px; padding: 6px 10px 12px; }
|
||||||
|
.wp-nav-cta {
|
||||||
|
flex: 1 1 auto; min-width: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: flex-start; gap: 9px;
|
||||||
|
height: 40px; padding: 0 14px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
border: 0; border-radius: 6px 0 0 6px;
|
||||||
|
font: inherit; font-size: 14px; font-weight: 600;
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wp-nav-cta:hover { background: #0353e9; }
|
||||||
|
.wp-nav-cta-plus { font-size: 17px; font-weight: 400; line-height: 1; }
|
||||||
|
.wp-nav-cta-more {
|
||||||
|
flex: 0 0 auto; width: 30px; height: 40px;
|
||||||
|
background: var(--accent); color: #fff; border: 0; border-left: 1px solid rgba(255,255,255,.28);
|
||||||
|
border-radius: 0 6px 6px 0; font: inherit; font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.wp-nav-cta-more:hover { background: #0353e9; }
|
||||||
|
.wp-nav-menu {
|
||||||
|
position: absolute; top: calc(100% - 6px); left: 10px; right: 10px; z-index: 10;
|
||||||
|
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px;
|
||||||
|
box-shadow: 0 10px 26px rgba(20,30,50,.18); padding: 5px 0;
|
||||||
|
}
|
||||||
|
.wp-nav-menu[hidden] { display: none; }
|
||||||
|
.wp-nav-menu button {
|
||||||
|
display: block; width: 100%; text-align: left; background: none; border: 0;
|
||||||
|
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.wp-nav-menu button:hover { background: var(--surface2); }
|
||||||
|
|
||||||
|
/* -- icon nav -- */
|
||||||
|
.wp-nav-links { padding: 2px 8px 10px; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.wp-nav-link {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
width: 100%; padding: 9px 10px;
|
||||||
|
background: none; border: 0; border-radius: 6px;
|
||||||
|
font: inherit; font-size: 14px; color: var(--text);
|
||||||
|
cursor: pointer; text-align: left; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wp-nav-link:hover { background: #eef0f3; }
|
||||||
|
.wp-nav-link.is-current { background: #e8eaed; font-weight: 600; }
|
||||||
|
.wp-nav-ico { flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--text-muted); }
|
||||||
|
.wp-nav-link-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wp-nav-link-n { flex: 0 0 auto; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* -- section header + filter -- */
|
||||||
|
.wp-nav-sect {
|
||||||
|
display: flex; align-items: baseline; gap: 6px;
|
||||||
|
padding: 6px 18px 4px; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.wp-nav-sect-label { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.wp-nav-count { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
.wp-nav-filter { padding: 4px 12px 8px; }
|
||||||
|
.wp-nav-search {
|
||||||
|
width: 100%; padding: 7px 10px; font: inherit; font-size: 13px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
|
||||||
|
}
|
||||||
|
.wp-nav-search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* -- package rows -- */
|
||||||
|
.wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; }
|
||||||
|
.wp-nav-group {
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: .02em;
|
||||||
|
color: var(--text-dim); padding: 12px 10px 4px;
|
||||||
|
}
|
||||||
|
.wp-nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; gap: 11px;
|
||||||
|
width: 100%; padding: 7px 10px; margin-bottom: 1px;
|
||||||
|
background: none; border: 0; border-radius: 6px;
|
||||||
|
font: inherit; color: var(--text); text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.wp-nav-item:hover { background: #eef0f3; }
|
||||||
|
.wp-nav-item.active { background: #e8eaed; }
|
||||||
|
/* Left accent bar on the current package, like Planner's selected plan. */
|
||||||
|
.wp-nav-item.active::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 6px; bottom: 6px;
|
||||||
|
width: 3px; border-radius: 2px; background: var(--accent);
|
||||||
|
}
|
||||||
|
.wp-nav-badge {
|
||||||
|
flex: 0 0 28px; width: 28px; height: 28px; border-radius: 5px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .02em;
|
||||||
|
color: #fff; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.wp-nav-body { min-width: 0; flex: 1 1 auto; }
|
||||||
|
.wp-nav-num { display: block; font-size: 13.5px; font-weight: 600; color: var(--text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wp-nav-subj { display: block; font-size: 12px; color: var(--text-muted); line-height: 1.35;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wp-nav-state { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
font-size: 10.5px; color: var(--text-dim); white-space: nowrap; }
|
||||||
|
.wp-nav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); flex: 0 0 auto; }
|
||||||
|
.wp-nav-dot.ok { background: var(--accent-green); }
|
||||||
|
.wp-nav-dot.open { background: var(--accent-amber); }
|
||||||
|
.wp-nav-dot.hold { background: var(--red); }
|
||||||
|
.wp-nav-empty { padding: 14px 10px; font-size: 12.5px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* -- collapsed rail: badges and icons only -- */
|
||||||
|
body.wp-nav-collapsed .wp-nav-cta-label,
|
||||||
|
body.wp-nav-collapsed .wp-nav-cta-more,
|
||||||
|
body.wp-nav-collapsed .wp-nav-link-label,
|
||||||
|
body.wp-nav-collapsed .wp-nav-link-n,
|
||||||
|
body.wp-nav-collapsed .wp-nav-sect,
|
||||||
|
body.wp-nav-collapsed .wp-nav-filter,
|
||||||
|
body.wp-nav-collapsed .wp-nav-body,
|
||||||
|
body.wp-nav-collapsed .wp-nav-state,
|
||||||
|
body.wp-nav-collapsed .wp-nav-group { display: none; }
|
||||||
|
body.wp-nav-collapsed .wp-nav-primary { padding: 6px 10px 10px; }
|
||||||
|
body.wp-nav-collapsed .wp-nav-cta { justify-content: center; padding: 0; border-radius: 6px; }
|
||||||
|
body.wp-nav-collapsed .wp-nav-link { justify-content: center; padding: 9px 0; }
|
||||||
|
body.wp-nav-collapsed .wp-nav-item { justify-content: center; padding: 6px 0; }
|
||||||
|
body.wp-nav-collapsed .wp-nav-list { padding: 6px 6px 14px; }
|
||||||
|
|
||||||
|
/* Narrow screens: keep the rail collapsed-width so the form still has room. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
body { --nav-w: 56px; }
|
||||||
|
body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: 6px 0 22px rgba(20,30,50,.16); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky save bar */
|
||||||
|
.sticky-save{ position:fixed; left:var(--nav-w,288px); 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);
|
||||||
@@ -581,16 +834,22 @@
|
|||||||
|
|
||||||
/* Dashboard */
|
/* Dashboard */
|
||||||
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
|
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
|
||||||
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
|
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; }
|
||||||
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
|
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
|
||||||
.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.dm-blue .dm-val { color:var(--accent, #0f62fe); }
|
||||||
|
.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; }
|
||||||
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
|
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
|
||||||
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
|
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; }
|
||||||
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
|
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
|
||||||
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||||
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
|
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
|
||||||
@@ -599,3 +858,27 @@
|
|||||||
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
|
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
|
||||||
.dash-filters input[type=search] { flex:1; min-width:200px; }
|
.dash-filters input[type=search] { flex:1; min-width:200px; }
|
||||||
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
|
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
/* ── WP history (audit trail) modal ──────────────────────────────────────── */
|
||||||
|
.hist-list { display:flex; flex-direction:column; }
|
||||||
|
.hist-item { display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:baseline;
|
||||||
|
padding:9px 2px; border-bottom:1px solid var(--border); }
|
||||||
|
.hist-item:last-child { border-bottom:none; }
|
||||||
|
.hist-when { font-family:var(--mono); font-size:11px; color:var(--text-muted); white-space:nowrap; }
|
||||||
|
.hist-action { font-weight:600; color:var(--text); }
|
||||||
|
.hist-detail { color:var(--accent); font-size:13px; }
|
||||||
|
.hist-actor { font-size:12px; color:var(--text-muted); white-space:nowrap; }
|
||||||
|
@media (max-width:560px){ .hist-item { grid-template-columns:1fr; gap:2px; } }
|
||||||
|
|
||||||
|
/* ── Dashboard progress bars + pager + archived toggle (Phase 2) ──────────── */
|
||||||
|
.prog-row { display:grid; grid-template-columns:150px 1fr 96px; gap:10px; align-items:center; margin-bottom:7px; }
|
||||||
|
.prog-name { font-size:12.5px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.prog-bar { height:10px; background:var(--surface2); border:1px solid var(--border); overflow:hidden; }
|
||||||
|
.prog-fill { height:100%; background:var(--accent); transition:width .3s ease; }
|
||||||
|
.prog-pct { font-size:12px; font-weight:600; color:var(--text); text-align:right; white-space:nowrap; }
|
||||||
|
.prog-sub { font-weight:400; color:var(--text-muted); font-size:11px; }
|
||||||
|
.dash-arch-toggle { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--text-muted); white-space:nowrap; cursor:pointer; }
|
||||||
|
.dash-pager { display:flex; align-items:center; gap:12px; margin-top:12px; font-size:12.5px; color:var(--text-muted); }
|
||||||
|
.dash-pager-btns { margin-left:auto; display:flex; gap:8px; }
|
||||||
|
.dash-pager .btn { padding:5px 12px; }
|
||||||
|
@media (max-width:560px){ .prog-row { grid-template-columns:110px 1fr 74px; } }
|
||||||
|
|||||||
232
html/wp-format.js
Normal file
232
html/wp-format.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/* Localization + time formatting for the Work Package Suite.
|
||||||
|
|
||||||
|
Every date the app shows should agree, wherever it's rendered. Three sources,
|
||||||
|
most specific first:
|
||||||
|
1. the signed-in user's own preference (users.locale / users.timezone)
|
||||||
|
2. the app default set by an admin (Admin console → Localization)
|
||||||
|
3. the browser's own locale / timezone (the previous behaviour)
|
||||||
|
|
||||||
|
Why store it server-side: on a shared field tablet the browser's locale isn't
|
||||||
|
the person's, and a package due date that reads a day early because the device
|
||||||
|
sits in another zone is a real scheduling problem — not a cosmetic one.
|
||||||
|
|
||||||
|
Exposes:
|
||||||
|
wpFormatDate(v) → 3 Aug 2026 (date only)
|
||||||
|
wpFormatDateTime(v) → 3 Aug 2026, 14:07 (date + time)
|
||||||
|
wpFormatTime(v) → 14:07
|
||||||
|
wpFormatNumber(v) → locale-grouped number
|
||||||
|
wpTimeZoneLabel() → the zone in effect, for a UI hint
|
||||||
|
wpPreferences() → opens the preferences dialog
|
||||||
|
All formatters take an ISO string, Date, or epoch ms, and return '' for empty
|
||||||
|
input (never 'Invalid Date'), so they're safe to drop into a template. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function prefs() {
|
||||||
|
var u = window.WP_USER || {};
|
||||||
|
var f = window.WP_FLAGS || {};
|
||||||
|
return {
|
||||||
|
locale: (u.locale || f.default_locale || '') || undefined,
|
||||||
|
timezone: (u.timezone || f.default_timezone || '') || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A date-only value ('2026-08-03') is a calendar date, not an instant. Parsed as
|
||||||
|
// UTC midnight by the platform, it can render as the previous day in a western
|
||||||
|
// zone — so format these from their parts and never apply a timezone.
|
||||||
|
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function toDate(v) {
|
||||||
|
if (v == null || v === '') return null;
|
||||||
|
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
||||||
|
if (typeof v === 'number') { var n = new Date(v); return isNaN(n.getTime()) ? null : n; }
|
||||||
|
var s = String(v).trim();
|
||||||
|
if (!s) return null;
|
||||||
|
var d = new Date(s);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v, opts, forceNoTz) {
|
||||||
|
var s = (typeof v === 'string') ? v.trim() : v;
|
||||||
|
var dateOnly = (typeof s === 'string') && DATE_ONLY.test(s);
|
||||||
|
var d = dateOnly ? new Date(s + 'T12:00:00') : toDate(s); // noon: immune to ±12h shifts
|
||||||
|
if (!d) return '';
|
||||||
|
var p = prefs();
|
||||||
|
var o = {};
|
||||||
|
for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k];
|
||||||
|
if (p.timezone && !dateOnly && !forceNoTz) o.timeZone = p.timezone;
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(p.locale, o).format(d);
|
||||||
|
} catch (e) {
|
||||||
|
// Bad locale/zone (e.g. a preference set before tzdata was available):
|
||||||
|
// fall back to the platform default rather than showing nothing.
|
||||||
|
try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e2) { return String(v); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.wpFormatDate = function (v) {
|
||||||
|
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
window.wpFormatDateTime = function (v) {
|
||||||
|
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
window.wpFormatTime = function (v) {
|
||||||
|
return fmt(v, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
window.wpFormatNumber = function (v, opts) {
|
||||||
|
if (v == null || v === '' || isNaN(+v)) return '';
|
||||||
|
try { return new Intl.NumberFormat(prefs().locale, opts || {}).format(+v); }
|
||||||
|
catch (e) { return String(v); }
|
||||||
|
};
|
||||||
|
window.wpTimeZoneLabel = function () {
|
||||||
|
var p = prefs();
|
||||||
|
if (p.timezone) return p.timezone;
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'browser default'; }
|
||||||
|
catch (e) { return 'browser default'; }
|
||||||
|
};
|
||||||
|
window.wpLocaleLabel = function () {
|
||||||
|
var p = prefs();
|
||||||
|
if (p.locale) return p.locale;
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().locale || 'browser default'; }
|
||||||
|
catch (e) { return 'browser default'; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── preferences dialog ─────────────────────────────────────────────────────
|
||||||
|
var COMMON_LOCALES = [
|
||||||
|
['', 'Browser default'],
|
||||||
|
['en-US', 'English (United States) — 8/3/2026, 2:07 PM'],
|
||||||
|
['en-GB', 'English (United Kingdom) — 03/08/2026, 14:07'],
|
||||||
|
['en-CA', 'English (Canada)'],
|
||||||
|
['es-MX', 'Español (México)'],
|
||||||
|
['es-US', 'Español (Estados Unidos)'],
|
||||||
|
['fr-CA', 'Français (Canada)'],
|
||||||
|
['de-DE', 'Deutsch (Deutschland)'],
|
||||||
|
['ja-JP', '日本語 (日本)'],
|
||||||
|
['ko-KR', '한국어 (대한민국)'],
|
||||||
|
['zh-TW', '中文 (台灣)']
|
||||||
|
];
|
||||||
|
// Zones the fabs and offices actually sit in, offered before the full list.
|
||||||
|
var COMMON_ZONES = [
|
||||||
|
'America/Chicago', 'America/New_York', 'America/Denver', 'America/Phoenix',
|
||||||
|
'America/Los_Angeles', 'America/Boise', 'Asia/Tokyo', 'Asia/Taipei',
|
||||||
|
'Asia/Seoul', 'Asia/Singapore', 'Europe/Dublin', 'Europe/London', 'UTC'
|
||||||
|
];
|
||||||
|
|
||||||
|
window.wpPreferences = function () {
|
||||||
|
if (document.getElementById('wp-prefs-modal')) return;
|
||||||
|
var u = window.WP_USER || {};
|
||||||
|
var ov = document.createElement('div');
|
||||||
|
ov.id = 'wp-prefs-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.45 "IBM Plex Sans",-apple-system,' +
|
||||||
|
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||||
|
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;background:#fff;';
|
||||||
|
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
|
||||||
|
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
|
||||||
|
ov.innerHTML =
|
||||||
|
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:460px;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;">Language & time</div>' +
|
||||||
|
'<div style="padding:4px 18px 16px;">' +
|
||||||
|
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
|
||||||
|
'<label style="' + lbl + '">Language & number format</label>' +
|
||||||
|
'<select id="wp-prefs-locale" style="' + fld + '"></select>' +
|
||||||
|
'<div style="' + hint + '">Sets how dates and numbers are written. It does not translate the app.</div>' +
|
||||||
|
'<label style="' + lbl + '">Time zone</label>' +
|
||||||
|
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
|
||||||
|
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
|
||||||
|
'Calendar dates like a due date are never shifted.</div>' +
|
||||||
|
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
||||||
|
'<button type="button" id="wp-prefs-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-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
|
||||||
|
function msg(text, ok) {
|
||||||
|
var e = document.getElementById('wp-prefs-msg');
|
||||||
|
e.style.display = 'block'; e.textContent = text;
|
||||||
|
e.style.background = ok ? '#defbe6' : '#fff1f1';
|
||||||
|
e.style.color = ok ? '#0e6027' : '#da1e28';
|
||||||
|
}
|
||||||
|
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
|
||||||
|
var locSel = document.getElementById('wp-prefs-locale');
|
||||||
|
var tzSel = document.getElementById('wp-prefs-tz');
|
||||||
|
var preview = document.getElementById('wp-prefs-preview');
|
||||||
|
|
||||||
|
locSel.innerHTML = COMMON_LOCALES.map(function (p) {
|
||||||
|
return '<option value="' + p[0] + '"' + (p[0] === (u.locale || '') ? ' selected' : '') + '>' + p[1] + '</option>';
|
||||||
|
}).join('');
|
||||||
|
// A stored locale that isn't in the shortlist stays selectable.
|
||||||
|
if (u.locale && !COMMON_LOCALES.some(function (p) { return p[0] === u.locale; })) {
|
||||||
|
locSel.add(new Option(u.locale, u.locale, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillZones(all) {
|
||||||
|
var cur = u.timezone || '';
|
||||||
|
var browser = '';
|
||||||
|
try { browser = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
|
||||||
|
var html = '<option value=""' + (cur ? '' : ' selected') + '>Browser default' +
|
||||||
|
(browser ? ' (' + browser + ')' : '') + '</option>';
|
||||||
|
html += '<optgroup label="Common">' + COMMON_ZONES.map(function (z) {
|
||||||
|
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
||||||
|
}).join('') + '</optgroup>';
|
||||||
|
var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; });
|
||||||
|
if (rest.length) {
|
||||||
|
html += '<optgroup label="All time zones">' + rest.map(function (z) {
|
||||||
|
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
||||||
|
}).join('') + '</optgroup>';
|
||||||
|
} else if (cur && COMMON_ZONES.indexOf(cur) < 0) {
|
||||||
|
html += '<option value="' + cur + '" selected>' + cur + '</option>';
|
||||||
|
}
|
||||||
|
tzSel.innerHTML = html;
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview uses the picked values, not the saved ones, so the effect is visible
|
||||||
|
// before committing.
|
||||||
|
function updatePreview() {
|
||||||
|
var l = locSel.value || undefined, z = tzSel.value || undefined;
|
||||||
|
var now = new Date();
|
||||||
|
var out;
|
||||||
|
try {
|
||||||
|
out = new Intl.DateTimeFormat(l, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', timeZone: z
|
||||||
|
}).format(now);
|
||||||
|
} catch (e) { out = 'Not supported by this browser'; }
|
||||||
|
preview.innerHTML = '<strong>Preview</strong><br>Right now: ' +
|
||||||
|
String(out).replace(/[<>]/g, '') +
|
||||||
|
'<br>A due date (2026-08-03) always reads: ' + window.wpFormatDate('2026-08-03');
|
||||||
|
}
|
||||||
|
locSel.addEventListener('change', updatePreview);
|
||||||
|
tzSel.addEventListener('change', updatePreview);
|
||||||
|
|
||||||
|
// The picker offers exactly what the server will accept.
|
||||||
|
fetch('/api/timezones', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(fillZones)
|
||||||
|
.catch(function () { fillZones([]); });
|
||||||
|
|
||||||
|
document.getElementById('wp-prefs-cancel').onclick = close;
|
||||||
|
document.getElementById('wp-prefs-save').onclick = function () {
|
||||||
|
var body = { locale: locSel.value || '', timezone: tzSel.value || '' };
|
||||||
|
fetch('/api/auth/preferences', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.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((res.j && res.j.detail) || ('Could not save (HTTP ' + res.status + ').'), false); return; }
|
||||||
|
if (res.j && res.j.user) window.WP_USER = res.j.user;
|
||||||
|
try { localStorage.setItem('wp_auth_cache', JSON.stringify({ user: window.WP_USER, at: Date.now() })); } catch (e) {}
|
||||||
|
msg('Saved. Reloading so every date on the page agrees…', true);
|
||||||
|
// Dates are formatted at render time all over the app; a reload is the
|
||||||
|
// honest way to apply the change everywhere at once.
|
||||||
|
setTimeout(function () { location.reload(); }, 700);
|
||||||
|
})
|
||||||
|
.catch(function () { msg('Could not reach the server.', false); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -14,6 +14,22 @@
|
|||||||
# 5. sudo nginx -t && sudo systemctl reload nginx
|
# 5. sudo nginx -t && sudo systemctl reload nginx
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Cache-Control per file type. Computed in a map rather than a nested location
|
||||||
|
# because nginx's add_header is NOT inherited into a block that declares its own —
|
||||||
|
# a `location ~* \.(html|css|js)$` setting only Cache-Control would silently drop the
|
||||||
|
# CSP / HSTS / X-Frame-Options / nosniff headers below for exactly those files. An
|
||||||
|
# empty value makes nginx omit the header, so images and fonts stay cacheable.
|
||||||
|
#
|
||||||
|
# Code must revalidate on every load: with no Cache-Control the browser applies
|
||||||
|
# HEURISTIC freshness (~10% of the file's age), so the least recently changed file
|
||||||
|
# gets the LONGEST lifetime — which is how a page ends up running against a
|
||||||
|
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a 304.
|
||||||
|
map $uri $wp_cache_control {
|
||||||
|
default "";
|
||||||
|
~*\.(?:html|css|js|webmanifest)$ "no-cache";
|
||||||
|
~*/$ "no-cache"; # directory index -> index.html
|
||||||
|
}
|
||||||
|
|
||||||
# Redirect plain HTTP to HTTPS
|
# Redirect plain HTTP to HTTPS
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -34,6 +50,15 @@ server {
|
|||||||
root /var/www/wp-suite; # <-- web root
|
root /var/www/wp-suite; # <-- web root
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# ── Security response headers (defense-in-depth) ─────────────────────────
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
|
||||||
|
# Empty for anything that isn't code, in which case nginx omits the header.
|
||||||
|
add_header Cache-Control $wp_cache_control always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,43 @@
|
|||||||
# This container sits behind an external reverse proxy that handles SSL.
|
# This container sits behind an external reverse proxy that handles SSL.
|
||||||
# It listens on port 80 (plain HTTP on the internal Docker network).
|
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||||
|
|
||||||
|
# Cache-Control per file type, computed here rather than in a nested location.
|
||||||
|
# WHY A MAP: nginx's add_header is not inherited into a block that declares its own
|
||||||
|
# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have
|
||||||
|
# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly
|
||||||
|
# those files. Computing the value here keeps every header in ONE scope. An empty value
|
||||||
|
# means nginx omits the header entirely, so images and fonts stay freely cacheable.
|
||||||
|
#
|
||||||
|
# Code assets must revalidate on every load: with no Cache-Control at all the browser
|
||||||
|
# applies HEURISTIC freshness (~10% of the file's age), so the least recently changed
|
||||||
|
# file gets the LONGEST lifetime — which is how a page ends up running against a
|
||||||
|
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a cheap 304.
|
||||||
|
map $uri $wp_cache_control {
|
||||||
|
default "";
|
||||||
|
~*\.(?:html|css|js|webmanifest)$ "no-cache";
|
||||||
|
~*/$ "no-cache"; # directory index → index.html
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name wp.controls.dev;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# ── Security response headers (defense-in-depth) ─────────────────────────
|
||||||
|
# CSP keeps 'unsafe-inline' for now because the app uses inline handlers/styles
|
||||||
|
# heavily; even so, connect-src/img-src/object-src/base-uri/frame-ancestors
|
||||||
|
# sharply limit what injected script could load or exfiltrate. Tighten toward
|
||||||
|
# nonce-based scripts once inline handlers are refactored.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
|
||||||
|
# Empty for anything that isn't code, in which case nginx omits the header.
|
||||||
|
add_header Cache-Control $wp_cache_control always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
@@ -19,7 +49,10 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# This container is only ever reached via the TLS-terminating external
|
||||||
|
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
|
||||||
|
# here is always "http") so the API marks the session cookie Secure.
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
client_max_body_size 5m;
|
client_max_body_size 5m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
scripts/backup-cron.sh
Normal file
16
scripts/backup-cron.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed
|
||||||
|
# interval (default: daily). Kept deliberately simple — a sleep loop instead of a
|
||||||
|
# cron daemon — so it works in a bare postgres:16-alpine image.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
|
||||||
|
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
|
||||||
|
|
||||||
|
# Take one backup shortly after start so a freshly-deployed stack has an
|
||||||
|
# immediate restore point instead of waiting a whole interval.
|
||||||
|
sleep 20
|
||||||
|
while true; do
|
||||||
|
sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
5
scripts/backup.Dockerfile
Normal file
5
scripts/backup.Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
|
||||||
|
# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime
|
||||||
|
# (see the `backup` service in docker-compose.yml), so they're not COPYed here.
|
||||||
|
FROM postgres:16-alpine
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
58
scripts/db-backup.sh
Normal file
58
scripts/db-backup.sh
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
|
||||||
|
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
|
||||||
|
#
|
||||||
|
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
|
||||||
|
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
|
||||||
|
# the database holds customer IP — otherwise the dump (and every offsite copy) is
|
||||||
|
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
|
||||||
|
# possible); losing it means the backups are unrecoverable.
|
||||||
|
#
|
||||||
|
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||||
|
KEEP="${BACKUP_KEEP:-14}"
|
||||||
|
PGHOST="${PGHOST:-db}"
|
||||||
|
PGPORT="${PGPORT:-5432}"
|
||||||
|
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||||
|
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||||
|
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||||
|
ENC="${BACKUP_ENC_PASSPHRASE:-}"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
ts="$(date -u +%Y%m%d-%H%M%SZ)"
|
||||||
|
if [ -n "$ENC" ]; then
|
||||||
|
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
|
||||||
|
else
|
||||||
|
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
|
||||||
|
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
|
||||||
|
fi
|
||||||
|
tmp="$out.partial"
|
||||||
|
|
||||||
|
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
|
||||||
|
if [ -n "$ENC" ]; then
|
||||||
|
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
|
||||||
|
| gzip -c \
|
||||||
|
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
|
||||||
|
mv "$tmp" "$out"
|
||||||
|
else
|
||||||
|
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
|
||||||
|
mv "$tmp" "$out"
|
||||||
|
else
|
||||||
|
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
|
||||||
|
|
||||||
|
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
|
||||||
|
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
|
||||||
|
if [ "$count" -gt "$KEEP" ]; then
|
||||||
|
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
|
||||||
|
echo "[db-backup] pruning $f"
|
||||||
|
rm -f "$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
33
scripts/db-restore.sh
Normal file
33
scripts/db-restore.sh
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
|
||||||
|
#
|
||||||
|
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
|
||||||
|
# recreates objects before loading. Take a fresh backup first if in doubt.
|
||||||
|
#
|
||||||
|
# Usage (from the project root):
|
||||||
|
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
|
||||||
|
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
|
||||||
|
# backup container's environment).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
|
||||||
|
PGHOST="${PGHOST:-db}"
|
||||||
|
PGPORT="${PGPORT:-5432}"
|
||||||
|
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||||
|
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||||
|
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||||
|
|
||||||
|
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
|
||||||
|
case "$FILE" in
|
||||||
|
*.enc)
|
||||||
|
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
|
||||||
|
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "[db-restore] done."
|
||||||
@@ -10,3 +10,23 @@ 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
|
||||||
|
|
||||||
|
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||||
|
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||||
|
# console (Notifications & email card), where the SMTP host/port/from-address
|
||||||
|
# live. The one secret that must NOT be stored in the database — the SMTP
|
||||||
|
# password — is read from this environment variable instead. Leave it unset
|
||||||
|
# until you have the SMTP details; the toggle stays effectively off (queued
|
||||||
|
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||||
|
# on and SMTP is configured.
|
||||||
|
# SMTP_PASSWORD=your-smtp-app-password
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ browser → NGINX ──serves──> static site (index.html, …)
|
|||||||
|
|
||||||
| 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 |
|
||||||
@@ -33,6 +40,53 @@ 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
|
||||||
@@ -237,14 +291,23 @@ 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):
|
Or via the nginx proxy (replace with your hostname):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
43
server/alembic.ini
Normal file
43
server/alembic.ini
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Alembic configuration for the Work Package Suite.
|
||||||
|
# The database URL is NOT hard-coded here — env.py pulls it from the same place
|
||||||
|
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
|
||||||
|
# migrations always target the same database as the running app.
|
||||||
|
[alembic]
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
# Use OS-native path separators on Windows dev machines.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
63
server/alembic/env.py
Normal file
63
server/alembic/env.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Alembic environment for the Work Package Suite.
|
||||||
|
|
||||||
|
We reuse the application's own database configuration (server/db.py) so a
|
||||||
|
migration always targets the same database the app would connect to — Postgres
|
||||||
|
in production (from POSTGRES_* / DATABASE_URL) or the SQLite dev file otherwise.
|
||||||
|
No connection string is stored in alembic.ini.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Make the `server` package importable no matter where alembic is invoked from
|
||||||
|
# (repo root, /app in the container, etc.). env.py lives at server/alembic/env.py,
|
||||||
|
# so the repo root is two directories up.
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_REPO = os.path.dirname(os.path.dirname(_HERE))
|
||||||
|
if _REPO not in sys.path:
|
||||||
|
sys.path.insert(0, _REPO)
|
||||||
|
|
||||||
|
from server.db import Base, DATABASE_URL, engine # noqa: E402
|
||||||
|
from server import models # noqa: E402,F401 (imported for its side effect: registers all tables on Base.metadata)
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# The app resolves its URL from the environment; feed the same value to Alembic.
|
||||||
|
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Emit SQL to stdout (`alembic upgrade --sql`) without a live connection."""
|
||||||
|
context.configure(
|
||||||
|
url=str(DATABASE_URL),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations against a live connection, reusing the app's engine."""
|
||||||
|
with engine.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
23
server/alembic/script.py.mako
Normal file
23
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
0
server/alembic/versions/.gitkeep
Normal file
0
server/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""user login lockout fields
|
||||||
|
|
||||||
|
Revision ID: 18373f14809e
|
||||||
|
Revises: 47bbe76aa749
|
||||||
|
Create Date: 2026-07-15 14:50:58.423834
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '18373f14809e'
|
||||||
|
down_revision = '47bbe76aa749'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||||
|
op.add_column('users', sa.Column('failed_attempts', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'locked_until')
|
||||||
|
op.drop_column('users', 'failed_attempts')
|
||||||
|
# ### end Alembic commands ###
|
||||||
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""wp archived_at
|
||||||
|
|
||||||
|
Revision ID: 47bbe76aa749
|
||||||
|
Revises: 4e094197c9aa
|
||||||
|
Create Date: 2026-07-15 12:00:14.356398
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '47bbe76aa749'
|
||||||
|
down_revision = '4e094197c9aa'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('work_packages', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_index(op.f('ix_work_packages_archived_at'), 'work_packages', ['archived_at'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_work_packages_archived_at'), table_name='work_packages')
|
||||||
|
op.drop_column('work_packages', 'archived_at')
|
||||||
|
# ### end Alembic commands ###
|
||||||
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""audit log
|
||||||
|
|
||||||
|
Revision ID: 4e094197c9aa
|
||||||
|
Revises: c6af106a04da
|
||||||
|
Create Date: 2026-07-15 10:12:52.859694
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4e094197c9aa'
|
||||||
|
down_revision = 'c6af106a04da'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('audit_log',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('actor', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('action', sa.String(length=60), nullable=False),
|
||||||
|
sa.Column('entity_type', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('summary', sa.String(length=400), nullable=False),
|
||||||
|
sa.Column('detail', sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_entity_id'), 'audit_log', ['entity_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_log_project_id'), 'audit_log', ['project_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_audit_log_project_id'), table_name='audit_log')
|
||||||
|
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
|
||||||
|
op.drop_index(op.f('ix_audit_log_entity_id'), table_name='audit_log')
|
||||||
|
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
|
||||||
|
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
|
||||||
|
op.drop_table('audit_log')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""assignment + settings + notifications
|
||||||
|
|
||||||
|
Revision ID: 57dec34f11cb
|
||||||
|
Revises: ad8e6cc5de0f
|
||||||
|
Create Date: 2026-07-15 16:43:09.230419
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '57dec34f11cb'
|
||||||
|
down_revision = 'ad8e6cc5de0f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('app_settings',
|
||||||
|
sa.Column('key', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('value', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('key')
|
||||||
|
)
|
||||||
|
op.create_table('notifications',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('subject', sa.String(length=300), nullable=False),
|
||||||
|
sa.Column('body', sa.Text(), nullable=False),
|
||||||
|
sa.Column('link', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('error', sa.String(length=400), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_notifications_kind'), 'notifications', ['kind'], unique=False)
|
||||||
|
op.create_index(op.f('ix_notifications_project_id'), 'notifications', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_notifications_status'), 'notifications', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
|
||||||
|
op.add_column('work_packages', sa.Column('assignee_id', sa.String(length=40), nullable=True))
|
||||||
|
op.create_index(op.f('ix_work_packages_assignee_id'), 'work_packages', ['assignee_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_work_packages_assignee_id'), table_name='work_packages')
|
||||||
|
op.drop_column('work_packages', 'assignee_id')
|
||||||
|
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
|
||||||
|
op.drop_index(op.f('ix_notifications_status'), table_name='notifications')
|
||||||
|
op.drop_index(op.f('ix_notifications_project_id'), table_name='notifications')
|
||||||
|
op.drop_index(op.f('ix_notifications_kind'), table_name='notifications')
|
||||||
|
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
|
||||||
|
op.drop_table('notifications')
|
||||||
|
op.drop_table('app_settings')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""project archive + default members on new projects
|
||||||
|
|
||||||
|
Two changes that ship together:
|
||||||
|
• projects.archived_at — an archived project disappears from every picker,
|
||||||
|
switcher and search and is frozen read-only. Mirrors work_packages.archived_at
|
||||||
|
(47bbe76aa749): NULL means "live", which is what every existing row gets, so
|
||||||
|
nothing changes for current data.
|
||||||
|
• users.auto_add_projects / users.auto_add_role — a PM or QA lead who belongs on
|
||||||
|
every job is added to each new project automatically. auto_add_role shares
|
||||||
|
project_members.role's value space ('' = inherit the account's own role).
|
||||||
|
|
||||||
|
Revision ID: a7c31f9e5b02
|
||||||
|
Revises: d15b8c4ef207
|
||||||
|
Create Date: 2026-08-05 10:12:47.503914
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a7c31f9e5b02'
|
||||||
|
down_revision = 'd15b8c4ef207'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('projects', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_index(op.f('ix_projects_archived_at'), 'projects', ['archived_at'], unique=False)
|
||||||
|
# These two are NOT NULL and land on a table that already has rows, so
|
||||||
|
# server_default is what backfills them (nobody is auto-added until an admin
|
||||||
|
# turns it on). The defaults are deliberately LEFT IN PLACE afterwards, as every
|
||||||
|
# other migration here does (18373f14809e, b41c7ae90d52, c93f2b1d7e04,
|
||||||
|
# d15b8c4ef207): dropping one needs ALTER COLUMN, which SQLite only fakes via a
|
||||||
|
# batch_alter_table table rebuild, and dev runs on SQLite (wpsuite.db). The ORM
|
||||||
|
# supplies both values on every INSERT, so the leftover default is only ever
|
||||||
|
# read by hand-written SQL — and there it is the answer we'd want anyway.
|
||||||
|
op.add_column('users', sa.Column('auto_add_projects', sa.Boolean(),
|
||||||
|
nullable=False, server_default=sa.false()))
|
||||||
|
op.add_column('users', sa.Column('auto_add_role', sa.String(length=20),
|
||||||
|
nullable=False, server_default=''))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'auto_add_role')
|
||||||
|
op.drop_column('users', 'auto_add_projects')
|
||||||
|
op.drop_index(op.f('ix_projects_archived_at'), table_name='projects')
|
||||||
|
op.drop_column('projects', 'archived_at')
|
||||||
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""user token_version
|
||||||
|
|
||||||
|
Revision ID: ad8e6cc5de0f
|
||||||
|
Revises: 18373f14809e
|
||||||
|
Create Date: 2026-07-15 16:03:57.736556
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ad8e6cc5de0f'
|
||||||
|
down_revision = '18373f14809e'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||||
|
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'token_version')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""permissions roles + project (job function) role
|
||||||
|
|
||||||
|
Adds `users.project_role` (job function on the project — carries no permissions)
|
||||||
|
and migrates the permissions vocabulary: the legacy role 'user' becomes
|
||||||
|
'project_user'. 'admin' is untouched; 'project_admin' is new and is only ever
|
||||||
|
granted explicitly from the admin console.
|
||||||
|
|
||||||
|
Revision ID: b41c7ae90d52
|
||||||
|
Revises: 57dec34f11cb
|
||||||
|
Create Date: 2026-08-03 15:12:04.118322
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b41c7ae90d52'
|
||||||
|
down_revision = '57dec34f11cb'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# server_default backfills existing rows (the column is NOT NULL).
|
||||||
|
op.add_column('users', sa.Column('project_role', sa.String(length=120),
|
||||||
|
nullable=False, server_default=''))
|
||||||
|
# Legacy 'user' means exactly what 'project_user' means now.
|
||||||
|
op.execute("UPDATE users SET role = 'project_user' WHERE role = 'user'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Fold the new role back onto the legacy value so an older build still reads
|
||||||
|
# the table. A project_admin loses its elevated rights on downgrade.
|
||||||
|
op.execute("UPDATE users SET role = 'user' WHERE role IN ('project_user', 'project_admin')")
|
||||||
|
op.drop_column('users', 'project_role')
|
||||||
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""baseline schema
|
||||||
|
|
||||||
|
Revision ID: c6af106a04da
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-15 08:21:07.450350
|
||||||
|
|
||||||
|
This is the initial baseline. It creates the current schema on a fresh database,
|
||||||
|
and safely ADOPTS an existing database (one whose tables were created by the old
|
||||||
|
`Base.metadata.create_all()` before Alembic was introduced): if the schema is
|
||||||
|
already present it records this revision without recreating anything. That means
|
||||||
|
`alembic upgrade head` is safe to run on both new and existing deployments — no
|
||||||
|
manual `alembic stamp` step required.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c6af106a04da'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if sa.inspect(bind).has_table("projects"):
|
||||||
|
# Existing pre-Alembic database — adopt it as the baseline as-is.
|
||||||
|
return
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('comments',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('source', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('step', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('author', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('page', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('extra', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_comments_sop_id'), 'comments', ['sop_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_comments_source'), 'comments', ['source'], unique=False)
|
||||||
|
op.create_index(op.f('ix_comments_wp_id'), 'comments', ['wp_id'], unique=False)
|
||||||
|
op.create_table('projects',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=300), nullable=False),
|
||||||
|
sa.Column('number', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('client', sa.String(length=300), nullable=False),
|
||||||
|
sa.Column('division', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('site', sa.String(length=300), nullable=False),
|
||||||
|
sa.Column('sample', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('data', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_projects_number'), 'projects', ['number'], unique=False)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('full_name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||||
|
op.create_table('project_members',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'project_id', name='uq_project_member')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_project_members_project_id'), 'project_members', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_project_members_user_id'), 'project_members', ['user_id'], unique=False)
|
||||||
|
op.create_table('sops',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=300), nullable=False),
|
||||||
|
sa.Column('number', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('complete', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('data', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sops_project_id'), 'sops', ['project_id'], unique=False)
|
||||||
|
op.create_table('work_packages',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('parent_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('number', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('subject', sa.String(length=400), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('data', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['sop_id'], ['sops.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_work_packages_parent_id'), 'work_packages', ['parent_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_work_packages_project_id'), 'work_packages', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_work_packages_sop_id'), 'work_packages', ['sop_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_work_packages_sop_id'), table_name='work_packages')
|
||||||
|
op.drop_index(op.f('ix_work_packages_project_id'), table_name='work_packages')
|
||||||
|
op.drop_index(op.f('ix_work_packages_parent_id'), table_name='work_packages')
|
||||||
|
op.drop_table('work_packages')
|
||||||
|
op.drop_index(op.f('ix_sops_project_id'), table_name='sops')
|
||||||
|
op.drop_table('sops')
|
||||||
|
op.drop_index(op.f('ix_project_members_user_id'), table_name='project_members')
|
||||||
|
op.drop_index(op.f('ix_project_members_project_id'), table_name='project_members')
|
||||||
|
op.drop_table('project_members')
|
||||||
|
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_index(op.f('ix_projects_number'), table_name='projects')
|
||||||
|
op.drop_table('projects')
|
||||||
|
op.drop_index(op.f('ix_comments_wp_id'), table_name='comments')
|
||||||
|
op.drop_index(op.f('ix_comments_source'), table_name='comments')
|
||||||
|
op.drop_index(op.f('ix_comments_sop_id'), table_name='comments')
|
||||||
|
op.drop_table('comments')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""user locale + timezone preferences
|
||||||
|
|
||||||
|
Per-user display preferences. Empty means "use the app default (admin console),
|
||||||
|
then the browser". Stored server-side so they follow the person between devices —
|
||||||
|
shared field tablets are the case that matters.
|
||||||
|
|
||||||
|
Revision ID: c93f2b1d7e04
|
||||||
|
Revises: b41c7ae90d52
|
||||||
|
Create Date: 2026-08-03 16:44:10.882931
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c93f2b1d7e04'
|
||||||
|
down_revision = 'b41c7ae90d52'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('locale', sa.String(length=20), nullable=False, server_default=''))
|
||||||
|
op.add_column('users', sa.Column('timezone', sa.String(length=60), nullable=False, server_default=''))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'timezone')
|
||||||
|
op.drop_column('users', 'locale')
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""per-project member role
|
||||||
|
|
||||||
|
Lets someone be Project Admin on one job and a normal Project User on another.
|
||||||
|
Empty string means "inherit the account's own role" (users.role), which is exactly
|
||||||
|
how every existing membership behaved, so this is a no-op for current data.
|
||||||
|
|
||||||
|
Revision ID: d15b8c4ef207
|
||||||
|
Revises: c93f2b1d7e04
|
||||||
|
Create Date: 2026-08-03 17:58:22.401118
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd15b8c4ef207'
|
||||||
|
down_revision = 'c93f2b1d7e04'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('project_members', sa.Column('role', sa.String(length=20),
|
||||||
|
nullable=False, server_default=''))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('project_members', 'role')
|
||||||
1595
server/app.py
1595
server/app.py
File diff suppressed because it is too large
Load Diff
311
server/auth.py
Normal file
311
server/auth.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
Permissions roles (`User.role`) — distinct from a person's job function on the
|
||||||
|
project, which lives in `User.project_role` and grants nothing:
|
||||||
|
• admin application administrator: user administration, app settings,
|
||||||
|
and implicit access to every project.
|
||||||
|
• project_admin within their assigned projects: may delete work packages,
|
||||||
|
modify a SOP after it has been completed, and delete projects.
|
||||||
|
• project_user normal member: creates and edits work packages, authors a SOP
|
||||||
|
up to completion. May NOT delete WPs or change a completed SOP.
|
||||||
|
|
||||||
|
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
|
||||||
|
to the account's address. It is single-use by construction — it embeds the user's
|
||||||
|
`token_version`, which is bumped when the password changes, so a used or
|
||||||
|
superseded link stops validating.
|
||||||
|
"""
|
||||||
|
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, DATABASE_URL
|
||||||
|
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"))
|
||||||
|
# How long an emailed password-reset link stays valid.
|
||||||
|
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||||
|
|
||||||
|
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||||
|
ROLE_ADMIN = "admin"
|
||||||
|
ROLE_PROJECT_ADMIN = "project_admin"
|
||||||
|
ROLE_PROJECT_USER = "project_user"
|
||||||
|
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||||
|
ROLE_LABELS = {
|
||||||
|
ROLE_ADMIN: "Administrator",
|
||||||
|
ROLE_PROJECT_ADMIN: "Project Admin",
|
||||||
|
ROLE_PROJECT_USER: "Project User",
|
||||||
|
}
|
||||||
|
# Job functions offered in the admin console. Free text underneath, so a project
|
||||||
|
# can use a title that isn't on this list.
|
||||||
|
PROJECT_ROLES = (
|
||||||
|
"Project Manager", "Assistant Project Manager", "Construction Manager",
|
||||||
|
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
|
||||||
|
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
|
||||||
|
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_role(role: Optional[str]) -> str:
|
||||||
|
"""Map a stored/incoming role onto the current vocabulary.
|
||||||
|
|
||||||
|
Accounts created before permissions roles existed carry the legacy value
|
||||||
|
'user', which means exactly what 'project_user' means now."""
|
||||||
|
r = (role or "").strip()
|
||||||
|
if r == "user":
|
||||||
|
return ROLE_PROJECT_USER
|
||||||
|
return r if r in ROLES else ROLE_PROJECT_USER
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user: "models.User") -> bool:
|
||||||
|
return normalize_role(user.role) == ROLE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def is_project_admin(user: "models.User") -> bool:
|
||||||
|
"""True for app admins and project admins — the two roles allowed to delete
|
||||||
|
work packages and change a completed SOP."""
|
||||||
|
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
|
||||||
|
|
||||||
|
# Password policy (shared by the API and the CLI).
|
||||||
|
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||||
|
_COMMON_PASSWORDS = {
|
||||||
|
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
||||||
|
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
||||||
|
"iloveyou1", "abc12345", "qwertyuiop",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
||||||
|
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
||||||
|
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
||||||
|
if len(pw) < MIN_PASSWORD_LEN:
|
||||||
|
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
||||||
|
low = pw.lower()
|
||||||
|
if username and low == username.strip().lower():
|
||||||
|
return "Password must not be the same as the username."
|
||||||
|
if email and low == email.strip().lower():
|
||||||
|
return "Password must not be the same as the email."
|
||||||
|
if low in _COMMON_PASSWORDS:
|
||||||
|
return "That password is too common — choose something less guessable."
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 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 key configured. In production (a real database is configured via
|
||||||
|
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
|
||||||
|
# sessions with a throwaway key that silently rotates on every restart. In
|
||||||
|
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
|
||||||
|
# runs zero-config.
|
||||||
|
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
|
||||||
|
# condition db.py uses to pick Postgres, so we don't wrongly block a
|
||||||
|
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
|
||||||
|
is_prod = not str(DATABASE_URL).startswith("sqlite")
|
||||||
|
if is_prod:
|
||||||
|
raise RuntimeError(
|
||||||
|
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
|
||||||
|
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
|
||||||
|
"(see server/.env.example / DEPLOYMENT.md)."
|
||||||
|
)
|
||||||
|
log.warning(
|
||||||
|
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
|
||||||
|
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
"ver": user.token_version or 0,
|
||||||
|
"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.
|
||||||
|
Session cookies only — a token of any other type is rejected."""
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
# A password-reset token must never be usable as a session cookie.
|
||||||
|
if claims.get("typ"):
|
||||||
|
return None
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def create_reset_token(user: "models.User") -> str:
|
||||||
|
"""Short-lived, single-use token for an emailed password-reset link.
|
||||||
|
|
||||||
|
Single-use falls out of `ver`: completing a reset bumps the user's
|
||||||
|
token_version, so the link (and any older link) no longer validates."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"typ": "pwreset",
|
||||||
|
"sub": user.id,
|
||||||
|
"ver": user.token_version or 0,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=RESET_MINUTES),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_reset_token(token: str) -> Optional[dict]:
|
||||||
|
"""Claims for a valid, unexpired reset token, else None."""
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
if claims.get("typ") != "pwreset":
|
||||||
|
return None
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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")
|
||||||
|
# Session revocation: a mismatch means the token was invalidated (e.g. the
|
||||||
|
# password was changed after this token was issued).
|
||||||
|
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
|
||||||
|
if not is_admin(user):
|
||||||
|
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)
|
||||||
|
|||||||
145
server/manage_users.py
Normal file
145
server/manage_users.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""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, username: str = "") -> 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.")
|
||||||
|
problem = auth.password_problem(pw, username)
|
||||||
|
if problem:
|
||||||
|
sys.exit(problem)
|
||||||
|
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), args.username)
|
||||||
|
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), args.username)
|
||||||
|
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()
|
||||||
210
server/models.py
210
server/models.py
@@ -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,49 @@ 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)
|
||||||
|
# Archived projects are hidden from every picker, switcher and search but kept
|
||||||
|
# for the record — a finished job still has to be readable years later. Unlike
|
||||||
|
# an archived work package they are also FROZEN read-only: the API refuses any
|
||||||
|
# write to the project or to anything under it until an admin unarchives it.
|
||||||
|
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
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,
|
||||||
|
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
||||||
|
"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 +74,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 +87,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
|
||||||
)
|
)
|
||||||
@@ -57,7 +99,13 @@ class WorkPackage(Base):
|
|||||||
subject: Mapped[str] = mapped_column(String(400), default="")
|
subject: Mapped[str] = mapped_column(String(400), default="")
|
||||||
type: Mapped[str] = mapped_column(String(120), default="")
|
type: Mapped[str] = mapped_column(String(120), default="")
|
||||||
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
||||||
|
# The accountable owner (a user id), for "My Work Packages" + assignment
|
||||||
|
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
|
||||||
|
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||||
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Archived packages are hidden from the default lists/dashboard but kept for
|
||||||
|
# the record (years-long projects accumulate hundreds of closed WPs).
|
||||||
|
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
@@ -65,9 +113,11 @@ 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, "assignee_id": self.assignee_id,
|
||||||
|
"issued_at": _iso(self.issued_at),
|
||||||
|
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
||||||
"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 +126,85 @@ 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.
|
||||||
|
|
||||||
|
Two independent notions of "role", deliberately separate:
|
||||||
|
• role the PERMISSIONS role — what the account may do in the app.
|
||||||
|
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
|
||||||
|
• project_role the person's JOB FUNCTION on the project (Project Manager,
|
||||||
|
Superintendent, QA/QC, …). Carries no permissions; it's what
|
||||||
|
the SOP team pickers and notification routing read.
|
||||||
|
"""
|
||||||
|
__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="project_user") # permissions role
|
||||||
|
# Job function on the project — free text, offered from a suggested list.
|
||||||
|
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||||
|
# A PM or QA lead who belongs on every job shouldn't have to be ticked into each
|
||||||
|
# new project by hand, so flagged accounts get a ProjectMember row the moment a
|
||||||
|
# project is created. `auto_add_role` is the role they land with and shares
|
||||||
|
# ProjectMember.role's value space: '' = inherit the account's own role,
|
||||||
|
# otherwise 'project_admin' | 'project_user'.
|
||||||
|
auto_add_projects: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
auto_add_role: Mapped[str] = mapped_column(String(20), default="")
|
||||||
|
# Display preferences. Empty means "fall back to the app default, then to the
|
||||||
|
# browser". A stored value follows the person between devices, which matters on
|
||||||
|
# shared field tablets where the browser locale isn't theirs.
|
||||||
|
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
|
||||||
|
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
|
||||||
|
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)
|
||||||
|
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||||
|
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
||||||
|
# change). The value is embedded in the JWT and re-checked on every request.
|
||||||
|
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
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,
|
||||||
|
"project_role": self.project_role or "", "is_active": self.is_active,
|
||||||
|
"auto_add_projects": bool(self.auto_add_projects),
|
||||||
|
"auto_add_role": self.auto_add_role or "",
|
||||||
|
"locale": self.locale or "", "timezone": self.timezone or "",
|
||||||
|
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMember(Base):
|
||||||
|
"""Which users may access which projects, and what they may do there. 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.
|
||||||
|
|
||||||
|
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
||||||
|
one job and a normal Project User on another. Empty means "inherit the account's
|
||||||
|
own role" (User.role), which is how every existing row behaves."""
|
||||||
|
__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
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
||||||
@@ -98,5 +227,74 @@ class Comment(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
"""Append-only history: who changed what, when. Rows are written inside the
|
||||||
|
same transaction as the change they describe (see server/app.py: log_event),
|
||||||
|
so the trail can't drift from the data. `detail` holds a compact JSON summary
|
||||||
|
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
|
||||||
|
|
||||||
|
Not a ForeignKey to any entity on purpose — the log must survive the deletion
|
||||||
|
of the thing it describes (you still want "who deleted WP01, and when")."""
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||||
|
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
|
||||||
|
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
|
||||||
|
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
|
||||||
|
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
|
||||||
|
"entity_type": self.entity_type, "entity_id": self.entity_id,
|
||||||
|
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AppSetting(Base):
|
||||||
|
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
||||||
|
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
||||||
|
the SMTP password are NOT stored here — they come from the environment."""
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||||
|
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
"""Outbox for user notifications (an in-app record + an optional email). A row
|
||||||
|
is written when something notable happens (e.g. a WP assignment); the email
|
||||||
|
sender processes it only when email notifications are enabled AND SMTP is set —
|
||||||
|
otherwise it's recorded as 'skipped'. See server/notify.py."""
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
|
||||||
|
email: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
|
||||||
|
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
subject: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
body: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
link: Mapped[str] = mapped_column(String(500), default="")
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
|
||||||
|
error: Mapped[str] = mapped_column(String(400), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||||
|
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
|
||||||
|
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
|
||||||
|
"status": self.status, "error": self.error,
|
||||||
|
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
||||||
return dt.isoformat() if dt else None
|
return dt.isoformat() if dt else None
|
||||||
|
|||||||
174
server/notify.py
Normal file
174
server/notify.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Notifications: admin-configurable email + an outbox.
|
||||||
|
|
||||||
|
Email notifications are OFF by default and controlled from the admin console (a
|
||||||
|
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
|
||||||
|
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
|
||||||
|
and is NEVER stored in the database or shown in the UI.
|
||||||
|
|
||||||
|
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
|
||||||
|
record — and, when email is on + SMTP is set, the row is delivered by email in a
|
||||||
|
background task. Notification bodies deliberately avoid customer IP: they carry a WP
|
||||||
|
number and a deep link, not the work-package contents.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
log = logging.getLogger("wpsuite.notify")
|
||||||
|
|
||||||
|
SETTINGS_KEY = "notifications"
|
||||||
|
DEFAULTS = {
|
||||||
|
"email_enabled": False, # master toggle — OFF until SMTP is sorted
|
||||||
|
"smtp_host": "",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_use_tls": True,
|
||||||
|
"smtp_username": "",
|
||||||
|
"from_addr": "",
|
||||||
|
"from_name": "Work Package Suite",
|
||||||
|
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
|
||||||
|
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
|
||||||
|
# with it off, the SOP creator hides the BIM section entirely and every SOP is
|
||||||
|
# install-only, so no project can be put on the BIM path by accident.
|
||||||
|
"bim_enabled": False,
|
||||||
|
# Localization defaults for dates, times and numbers. Empty = use each
|
||||||
|
# browser's own locale / timezone. A user's own preference wins over these.
|
||||||
|
"default_locale": "", # BCP47, e.g. en-US
|
||||||
|
"default_timezone": "", # IANA, e.g. America/Chicago
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
||||||
|
# are safe for any authenticated user to read (feature flags + localization
|
||||||
|
# defaults + whether self-service password reset can work at all).
|
||||||
|
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(db: Session) -> dict:
|
||||||
|
row = db.get(models.AppSetting, SETTINGS_KEY)
|
||||||
|
s = dict(DEFAULTS)
|
||||||
|
if row and row.value:
|
||||||
|
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(db: Session, patch: dict) -> dict:
|
||||||
|
cur = get_settings(db)
|
||||||
|
for k in DEFAULTS:
|
||||||
|
if k in patch and patch[k] is not None:
|
||||||
|
cur[k] = patch[k]
|
||||||
|
row = db.get(models.AppSetting, SETTINGS_KEY)
|
||||||
|
if row:
|
||||||
|
row.value = cur
|
||||||
|
else:
|
||||||
|
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
|
||||||
|
db.commit()
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def public_settings(db: Session) -> dict:
|
||||||
|
"""Settings safe to return to the admin UI — no secrets."""
|
||||||
|
s = get_settings(db)
|
||||||
|
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def app_flags(db: Session) -> dict:
|
||||||
|
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
|
||||||
|
`password_reset_enabled` tells the login page whether a self-service reset can
|
||||||
|
actually deliver mail — there's no point offering the link otherwise."""
|
||||||
|
s = get_settings(db)
|
||||||
|
out = {k: s.get(k) for k in PUBLIC_KEYS}
|
||||||
|
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def smtp_ready(s: dict) -> bool:
|
||||||
|
return bool(s.get("smtp_host") and s.get("from_addr"))
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||||
|
"""Send one email via SMTP. Raises on any failure (caller records it)."""
|
||||||
|
if not to_addr:
|
||||||
|
raise ValueError("no recipient email")
|
||||||
|
msg = EmailMessage()
|
||||||
|
from_name = s.get("from_name") or ""
|
||||||
|
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
|
||||||
|
msg["To"] = to_addr
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
host = s["smtp_host"]
|
||||||
|
port = int(s.get("smtp_port") or 587)
|
||||||
|
user = s.get("smtp_username") or ""
|
||||||
|
pw = os.getenv("SMTP_PASSWORD", "")
|
||||||
|
with smtplib.SMTP(host, port, timeout=15) as srv:
|
||||||
|
if s.get("smtp_use_tls", True):
|
||||||
|
srv.starttls()
|
||||||
|
if user:
|
||||||
|
srv.login(user, pw)
|
||||||
|
srv.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
|
||||||
|
"""Send one email immediately, outside the outbox. Used for password resets —
|
||||||
|
a reset link must never sit in a queue, and it must not be persisted in the
|
||||||
|
notifications table where an admin could read it and take over the account.
|
||||||
|
Returns True if it went out."""
|
||||||
|
s = get_settings(db)
|
||||||
|
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
send_email(s, to_addr, subject, body)
|
||||||
|
return True
|
||||||
|
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
|
||||||
|
log.warning("password-reset email to %s failed: %s", to_addr, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
||||||
|
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
||||||
|
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
||||||
|
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
|
||||||
|
commit — the caller commits with its own transaction. Returns the row."""
|
||||||
|
s = get_settings(db)
|
||||||
|
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
|
||||||
|
n = models.Notification(
|
||||||
|
id="ntf_" + uuid.uuid4().hex[:12],
|
||||||
|
user_id=user.id, email=user.email or "", kind=kind,
|
||||||
|
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
|
||||||
|
link=link[:500], status="pending" if deliverable else "skipped",
|
||||||
|
)
|
||||||
|
db.add(n)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def deliver(notif_id: str) -> None:
|
||||||
|
"""Background task: send one pending notification, on its own DB session."""
|
||||||
|
from .db import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
n = db.get(models.Notification, notif_id)
|
||||||
|
if not n or n.status != "pending":
|
||||||
|
return
|
||||||
|
s = get_settings(db)
|
||||||
|
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
|
||||||
|
n.status = "skipped"
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
send_email(s, n.email, n.subject, n.body)
|
||||||
|
n.status = "sent"
|
||||||
|
n.sent_at = models.utcnow()
|
||||||
|
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
|
||||||
|
n.status = "failed"
|
||||||
|
n.error = str(e)[:400]
|
||||||
|
log.warning("notification %s failed to send: %s", notif_id, e)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
fastapi>=0.110
|
# Pinned to exact versions for reproducible builds — no silent dependency drift
|
||||||
uvicorn[standard]>=0.29
|
# on every `docker compose up --build`. To update: bump a version here on purpose,
|
||||||
gunicorn>=21.2
|
# run `pip-audit` against the result, and test. For supply-chain integrity, the
|
||||||
sqlalchemy>=2.0
|
# next step is a hashed lockfile (`pip-compile --generate-hashes` → install with
|
||||||
psycopg[binary]>=3.1
|
# `pip install --require-hashes`).
|
||||||
pydantic>=2.6
|
fastapi==0.138.1
|
||||||
python-dotenv>=1.0
|
uvicorn[standard]==0.49.0
|
||||||
|
gunicorn==26.0.0
|
||||||
|
sqlalchemy==2.0.51
|
||||||
|
alembic==1.18.5 # database migrations
|
||||||
|
psycopg[binary]==3.3.4
|
||||||
|
pydantic==2.13.4
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
bcrypt==5.0.0 # password hashing
|
||||||
|
PyJWT==2.13.0 # signed session tokens
|
||||||
|
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||||
|
|||||||
178
server/seed_demo.py
Normal file
178
server/seed_demo.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/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).
|
||||||
|
# archived=all because /api/projects hides archived projects by default — an
|
||||||
|
# archived DEMO project is still a DEMO project, and --clean has to find it.
|
||||||
|
# (Deleting one is still allowed; only writes to its contents are frozen.)
|
||||||
|
st, projects = call("GET", "/api/projects?archived=all")
|
||||||
|
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())
|
||||||
220
server/smoketest.py
Normal file
220
server/smoketest.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
#!/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}")
|
||||||
|
|
||||||
|
# 12) Archiving a project: it leaves the default list, stays reachable with
|
||||||
|
# archived=all, and freezes read-only — then unarchiving restores all three.
|
||||||
|
# The freeze is the whole point of the feature, so it is asserted, not assumed.
|
||||||
|
st, arch = call("POST", f"/api/projects/{project_id}/archive", {"archived": True})
|
||||||
|
check("archive project", st == 200 and arch.get("archived") is True, f"status={st}")
|
||||||
|
st, lst = call("GET", "/api/projects")
|
||||||
|
check("archived project drops out of the default list",
|
||||||
|
st == 200 and not any(p.get("id") == project_id for p in lst), f"status={st}")
|
||||||
|
st, lst = call("GET", "/api/projects?archived=all")
|
||||||
|
check("archived project is still there with archived=all",
|
||||||
|
st == 200 and any(p.get("id") == project_id for p in lst), f"status={st}")
|
||||||
|
st, refused = call("POST", "/api/wps", {
|
||||||
|
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
||||||
|
"number": "WP01-SMOKE", "subject": "edited while archived", "type": "Conduit Install",
|
||||||
|
"status": "Scheduled", "data": {"disciplines": ["Electrical"], "hours": "40"}})
|
||||||
|
check("writing to an archived project is refused (409)", st == 409, f"status={st} body={refused}")
|
||||||
|
st, unarch = call("POST", f"/api/projects/{project_id}/archive", {"archived": False})
|
||||||
|
check("unarchive project", st == 200 and unarch.get("archived") is False, f"status={st}")
|
||||||
|
st, _ = 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": "In Progress", "data": {"disciplines": ["Electrical"], "hours": "40"}})
|
||||||
|
check("writing succeeds again once unarchived", st == 200, f"status={st}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 13) 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