Compare commits
58 Commits
108a690f66
...
Micron-Ass
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef1fcdd96 | |||
| 3cccdf1c4b | |||
| 153fe97a31 | |||
| 928ab8c900 | |||
| 7bb1f66588 | |||
| 26f4a9242a | |||
| 6c1dc7d45f | |||
| 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 | |||
| 4d111d608d | |||
| 0baf4befc0 | |||
| d1f0901897 | |||
| a37cf14e89 | |||
| 362aa633ed | |||
| fd668f0ea2 | |||
| 960b4a4b94 | |||
| 8598606165 | |||
| 65996c2c0a | |||
| b0a3d74412 | |||
| ffcaa571d1 |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -11,3 +11,20 @@ venv/
|
|||||||
# Local SQLite dev database
|
# Local SQLite dev database
|
||||||
*.db
|
*.db
|
||||||
wpsuite.db
|
wpsuite.db
|
||||||
|
|
||||||
|
# Runtime directories (created by containers)
|
||||||
|
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'
|
||||||
|
> ```
|
||||||
|
|||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/requirements.txt ./server/
|
||||||
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
|
COPY server/ ./server/
|
||||||
|
EXPOSE 8000
|
||||||
|
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is
|
||||||
|
# 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.
|
||||||
@@ -79,6 +79,99 @@ API. The API is already built and testable on its own.
|
|||||||
- Firewall hardening (no external calls).
|
- Firewall hardening (no external calls).
|
||||||
- Export/Import feedback on every surface.
|
- Export/Import feedback on every surface.
|
||||||
- Phase 1 backend (API + schema) + NGINX proxy + deploy docs.
|
- Phase 1 backend (API + schema) + NGINX proxy + deploy docs.
|
||||||
|
- **Discipline strategy, sizing, split & dashboard** (branch `feat/wp-discipline-split-dashboard`) — see below.
|
||||||
|
|
||||||
|
## Discipline strategy, WP sizing, Split-by-Discipline & Dashboard
|
||||||
|
|
||||||
|
Discipline came back — but as a *project policy* set in the SOP, not a fixed
|
||||||
|
field. The Governance step (SOP config, step 5) now also captures:
|
||||||
|
|
||||||
|
- **Disciplines** (default Mechanical / Electrical / Tech) — comma list.
|
||||||
|
- **Discipline strategy** (`governance.discMode`): `single` (one discipline per
|
||||||
|
WP), `multi` (one WP bundles disciplines, scope split per discipline), or
|
||||||
|
`choice` (planner decides per package — build big, split later).
|
||||||
|
- **Split threshold** (`governance.sizeHoursMax`) — the Creator warns when a
|
||||||
|
package's est. hours exceed it.
|
||||||
|
- `governance.instanceSuffix` = `letter` (instances get A/B/C suffixes).
|
||||||
|
|
||||||
|
In the **WP Creator** ([wp-creation-app.js](wp-creation-app.js)):
|
||||||
|
|
||||||
|
- A **Disciplines** picker (hidden unless the SOP defines disciplines). Pick 2+
|
||||||
|
and the single flat work-step list becomes **per-discipline scope sections**,
|
||||||
|
each with its own steps and its own status (e.g. *Issued — Electrical* while
|
||||||
|
*Mechanical* is still *In Progress*). Overall WP status rolls up to the
|
||||||
|
least-advanced discipline.
|
||||||
|
- **⎘ Split by Discipline** turns a multi-discipline package into one instance
|
||||||
|
per discipline: `WP01-…` → `WP01A` (Mech), `WP01B` (Elec), `WP01C` (Tech).
|
||||||
|
Each instance is a single-discipline WP linked to the master via `instanceOf`
|
||||||
|
/ `parentNumber`; the master is kept as a roll-up (`split:true`, `children:[]`).
|
||||||
|
- New per-WP fields on the saved object: `disciplines`, `scope` (`{discipline:
|
||||||
|
[steps]}`), `discStatus`, `instanceOf`, `instanceLabel`, `parentNumber`,
|
||||||
|
`split`, `children`.
|
||||||
|
|
||||||
|
The **Dashboard** (📊 in the Creator header; home card *Work Package Dashboard*;
|
||||||
|
deep-link `work-package-suite.html?view=dashboard` or `…?view=dashboard#…`):
|
||||||
|
metrics (total / release-ready / on-hold / overdue / est vs actual hrs),
|
||||||
|
breakdowns by status & discipline, a **gating panel** (what's blocking each
|
||||||
|
package), and a filterable board with **view / edit / issue** per package.
|
||||||
|
Masters are excluded from counts so split hours aren't double-counted.
|
||||||
|
|
||||||
|
Data source today is `localStorage` via the `WPData` adapter in
|
||||||
|
[wp-creation-app.js](wp-creation-app.js) — swap `list()`/`issue()`/`setStatus()`
|
||||||
|
to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
|
||||||
|
|
||||||
|
**Backend** ([server/](server/)) gained the matching endpoints:
|
||||||
|
`POST /api/wps/{id}/issue` (refuses if constraints are open — the AWP gate),
|
||||||
|
`POST /api/wps/{id}/status`, and `GET /api/wps/metrics`. `work_packages` gained
|
||||||
|
`parent_id` and `issued_at` columns.
|
||||||
|
|
||||||
|
> **Migration caveat:** tables are still auto-created on startup, so the new
|
||||||
|
> `parent_id` / `issued_at` columns appear on a **fresh** DB only. Before there's
|
||||||
|
> real data this is fine; once there is, add Alembic (see open question #2) and
|
||||||
|
> 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`).
|
||||||
|
|||||||
0
backups/.gitkeep
Normal file
0
backups/.gitkeep
Normal file
119
docker-compose.yml
Normal file
119
docker-compose.yml
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: nginx/Dockerfile
|
||||||
|
container_name: nginx_webserver
|
||||||
|
volumes:
|
||||||
|
- nginx_logs:/var/log/nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- proxy # external — reachable by your reverse proxy / traefik
|
||||||
|
- internal # needs a path to the api container
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: wp_api
|
||||||
|
environment:
|
||||||
|
# Preferred: the API builds its own connection string from these and
|
||||||
|
# encodes the password automatically (no manual URL-encoding needed).
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
# Optional full-URL override (must be URL-encoded if used).
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
|
# Signs login session cookies. 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:-}
|
||||||
|
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
||||||
|
# which backs the asset picker in the work package creator. Leave unset and
|
||||||
|
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
||||||
|
# Use a db_datareader login: the app only ever SELECTs.
|
||||||
|
MICRON_DB_URL: ${MICRON_DB_URL:-}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
# Reaching the Micron database means leaving this compose project, and
|
||||||
|
# `internal` is deliberately egress-free. `outbound` is attached to the api
|
||||||
|
# container ONLY — the database and backup containers stay sealed. Detach it
|
||||||
|
# again if you are not using the Micron asset picker.
|
||||||
|
- outbound
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wp_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
# 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
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
nginx_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
internal: true # no route off the host for anything on this network alone
|
||||||
|
outbound:
|
||||||
|
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
||||||
|
# above removes the gateway entirely, which blocks not just the internet but
|
||||||
|
# the LAN and the VPN too — so the api container needs this second network to
|
||||||
|
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
|
||||||
|
# remain on `internal` only and still have no way off the host.
|
||||||
|
# Detach it from api if you are not using the Micron asset picker.
|
||||||
|
driver: bridge
|
||||||
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
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> — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.</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>An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.</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 |
696
html/index.html
Normal file
696
html/index.html
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<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="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>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--cds-background);
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CONTAINER */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 2rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HERO */
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CARDS */
|
||||||
|
.cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--cds-layer);
|
||||||
|
border: 1px solid var(--cds-border-subtle);
|
||||||
|
border-left: 4px solid var(--cds-border-strong);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-left-color: var(--cds-interactive-01);
|
||||||
|
background: var(--cds-layer-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--cds-button-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button {
|
||||||
|
display: inline-block;
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--cds-button-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button:hover {
|
||||||
|
background: var(--cds-hover-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* COMPLETE STATE (SOP done) */
|
||||||
|
.card.complete {
|
||||||
|
border-left-color: var(--cds-support-success);
|
||||||
|
}
|
||||||
|
.card.complete .card-button { background: var(--cds-support-success); }
|
||||||
|
.card.complete .card-button:hover { background: #0e6027; }
|
||||||
|
.card-status {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cds-support-success);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SECTION */
|
||||||
|
.section {
|
||||||
|
background: var(--cds-layer);
|
||||||
|
padding: 1.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid var(--cds-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section p {
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FOOTER */
|
||||||
|
.footer {
|
||||||
|
background: var(--cds-ui-01);
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px solid var(--cds-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--cds-link-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* COMMENTS SECTION */
|
||||||
|
.comments-section {
|
||||||
|
background: var(--cds-layer);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid var(--cds-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-toggle {
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
background: var(--cds-button-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-toggle:hover { background: var(--cds-hover-primary); }
|
||||||
|
|
||||||
|
.comments-panel {
|
||||||
|
display: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--cds-layer-accent);
|
||||||
|
border: 1px solid var(--cds-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-panel.open { display: block; }
|
||||||
|
|
||||||
|
.comments-panel input,
|
||||||
|
.comments-panel textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid var(--cds-border-strong);
|
||||||
|
background: var(--cds-field);
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-panel input:focus,
|
||||||
|
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||||
|
|
||||||
|
.comments-panel textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-buttons button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
background: var(--cds-button-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover { background: var(--cds-hover-primary); }
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: var(--cds-layer-selected);
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
border: 1px solid var(--cds-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover { background: var(--cds-layer-selected-hover); }
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--cds-background);
|
||||||
|
border: 1px solid var(--cds-border-subtle);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
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 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero h1 { font-size: 1.75rem; }
|
||||||
|
.cards-grid { grid-template-columns: 1fr; }
|
||||||
|
.container { padding: 1.5rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header class="wp-appbar">
|
||||||
|
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
|
||||||
|
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||||
|
<span class="wp-appbar-title">Work Package Suite</span>
|
||||||
|
</a>
|
||||||
|
<div class="wp-appbar-spacer"></div>
|
||||||
|
<nav class="wp-appbar-actions">
|
||||||
|
<a class="wp-appbar-link" href="#overview">Overview</a>
|
||||||
|
<a class="wp-appbar-link" href="#comments">Feedback</a>
|
||||||
|
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- MAIN CONTENT -->
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="hero">
|
||||||
|
<h1 id="hero-title">Work Package Suite</h1>
|
||||||
|
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PROJECT SELECTION -->
|
||||||
|
<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 -->
|
||||||
|
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||||
|
<h3>SOP Configuration</h3>
|
||||||
|
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
|
||||||
|
<button class="card-button" id="card-sop-btn">Open Tool</button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- WP CREATOR -->
|
||||||
|
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
|
||||||
|
<h3>Work Package Creator</h3>
|
||||||
|
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
|
||||||
|
<button class="card-button" id="card-wp-btn">Open Tool</button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- WP DASHBOARD -->
|
||||||
|
<a href="work-package-suite.html?view=dashboard" class="card" id="card-dash">
|
||||||
|
<h3>Work Package Dashboard</h3>
|
||||||
|
<p>Track status and gating across every Work Package — release-readiness, on-hold packages, overdue work, hours, and breakdowns by status and discipline. Issue release-ready packages in one click.</p>
|
||||||
|
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMMENTS SECTION -->
|
||||||
|
<div class="comments-section" id="comments">
|
||||||
|
<button class="comments-toggle" onclick="toggleComments()">Leave Feedback</button>
|
||||||
|
<div class="comments-panel" id="comments-panel">
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
|
||||||
|
<input type="text" id="commenter-name" placeholder="Your name">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Feedback</label>
|
||||||
|
<textarea id="comment-text" placeholder="Your feedback here..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="comment-buttons">
|
||||||
|
<button class="submit-btn" onclick="submitComment()">Submit</button>
|
||||||
|
<button class="close-btn" onclick="exportFeedback()">⤓ Export</button>
|
||||||
|
<button class="close-btn" onclick="document.getElementById('feedback-import').click()">⤒ Import</button>
|
||||||
|
<button class="close-btn" onclick="toggleComments()">Close</button>
|
||||||
|
<input type="file" id="feedback-import" accept="application/json" style="display:none" onchange="importFeedback(event)">
|
||||||
|
</div>
|
||||||
|
<div class="comments-list" id="comments-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||||
|
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 = '';
|
||||||
|
try {
|
||||||
|
// Storage is namespaced per project, so these already scope to `active`.
|
||||||
|
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 || '';
|
||||||
|
} catch(e){}
|
||||||
|
|
||||||
|
const sopCard = document.getElementById('card-sop');
|
||||||
|
const sopBtn = document.getElementById('card-sop-btn');
|
||||||
|
const wpCard = document.getElementById('card-wp');
|
||||||
|
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){
|
||||||
|
sopCard.classList.add('complete');
|
||||||
|
sopBtn.textContent = 'Review';
|
||||||
|
const status = document.createElement('div');
|
||||||
|
status.className = 'card-status';
|
||||||
|
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
|
||||||
|
sopCard.insertBefore(status, sopCard.firstChild);
|
||||||
|
if(wpBtn) wpBtn.textContent = 'Open Creator';
|
||||||
|
} else {
|
||||||
|
if(wpCard) wpCard.classList.add('disabled');
|
||||||
|
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initProjects();
|
||||||
|
|
||||||
|
let allComments = [];
|
||||||
|
|
||||||
|
function toggleComments() {
|
||||||
|
const panel = document.getElementById('comments-panel');
|
||||||
|
panel.classList.toggle('open');
|
||||||
|
if (panel.classList.contains('open')) loadComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitComment() {
|
||||||
|
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
||||||
|
const text = document.getElementById('comment-text').value.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
alert('Please enter feedback.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = {
|
||||||
|
name,
|
||||||
|
text,
|
||||||
|
timestamp: new Date().toLocaleString()
|
||||||
|
};
|
||||||
|
|
||||||
|
allComments.push(comment);
|
||||||
|
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||||
|
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
||||||
|
|
||||||
|
document.getElementById('comment-text').value = '';
|
||||||
|
loadComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFeedback() {
|
||||||
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||||
|
const data = saved ? JSON.parse(saved) : [];
|
||||||
|
if (!data.length) { alert('No feedback to export yet.'); return; }
|
||||||
|
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'wp-suite-feedback-home-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importFeedback(ev) {
|
||||||
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => {
|
||||||
|
try {
|
||||||
|
const inc = JSON.parse(r.result);
|
||||||
|
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
||||||
|
if (!incoming.length) { alert('No feedback found in that file.'); return; }
|
||||||
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||||
|
allComments = saved ? JSON.parse(saved) : [];
|
||||||
|
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
||||||
|
let added = 0;
|
||||||
|
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
||||||
|
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||||
|
loadComments();
|
||||||
|
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||||
|
} catch (e) { alert('Could not read that file.'); }
|
||||||
|
ev.target.value = '';
|
||||||
|
};
|
||||||
|
r.readAsText(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComments() {
|
||||||
|
const list = document.getElementById('comments-list');
|
||||||
|
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>';
|
||||||
|
} else {
|
||||||
|
list.innerHTML = allComments.map(c => `
|
||||||
|
<div class="comment-item">
|
||||||
|
<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>
|
||||||
|
`).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 src="wp-chrome.js"></script>
|
||||||
|
</body>
|
||||||
|
</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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
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; }
|
||||||
|
}
|
||||||
1533
html/work-package-suite-app.js
Normal file
1533
html/work-package-suite-app.js
Normal file
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,14 +168,22 @@
|
|||||||
<!-- 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>
|
||||||
|
|
||||||
<!-- STEP 5: GOVERNANCE -->
|
<!-- STEP 5: GOVERNANCE -->
|
||||||
<div class="step" id="sop-step-5" style="display: none;">
|
<div class="step" id="sop-step-5" style="display: none;">
|
||||||
<h2>5. Governance & WP Numbering</h2>
|
<h2>5. Governance & WP Numbering</h2>
|
||||||
<div class="notice">Define how Work Packages are formatted, sized, and issued on this project.</div>
|
<div class="notice">Define how Work Packages are formatted, sized, and issued on this project. The choices here decide how the Work Package Creator behaves for every package.</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Work Package Number Format *</label>
|
<label>Work Package Number Format *</label>
|
||||||
@@ -168,18 +191,63 @@
|
|||||||
<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>Typical WP Size</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>
|
||||||
<input type="text" id="gov_wosize" placeholder="e.g., 3–5 days or 40–80 hours">
|
<select id="gov_issuance" multiple size="4">
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Issuance Strategy</label>
|
|
||||||
<select id="gov_issuance" multiple size="3">
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<h3 style="margin:1.4rem 0 .4rem">How will Work Packages use disciplines?</h3>
|
||||||
|
<div class="notice">Decide whether a single package can carry more than one discipline (e.g. a chiller skid needing Mechanical install + Electrical wire-pull + Tech terminations), or whether each discipline gets its own package. This drives whether the Creator shows per-discipline scope sections and the <strong>Split by Discipline</strong> button.</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Disciplines on this project</label>
|
||||||
|
<input type="text" id="gov_disciplines" placeholder="Mechanical, Electrical, Tech">
|
||||||
|
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<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">
|
||||||
|
<option value="choice">Let the planner choose per package (recommended)</option>
|
||||||
|
<option value="single">One discipline per package (many small packages)</option>
|
||||||
|
<option value="multi">Multiple disciplines per package (scope split by discipline)</option>
|
||||||
|
</select>
|
||||||
|
<small>"Choose per package" lets the planner build a large multi-discipline package and split it later.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin:1.4rem 0 .4rem">Work Package sizing</h3>
|
||||||
|
<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">
|
||||||
|
<label>Typical WP Size</label>
|
||||||
|
<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 class="field">
|
||||||
|
<label>Split threshold — max labor hours<span class="help-tip" data-tip="The Work Package Creator flags any package whose estimated hours exceed this so the planner can break it down. Auto-set by the size band; override if needed.">i</span></label>
|
||||||
|
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
|
||||||
|
<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>
|
||||||
@@ -238,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 -->
|
||||||
@@ -248,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>
|
||||||
|
|
||||||
@@ -279,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>
|
||||||
@@ -292,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>
|
||||||
@@ -307,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>
|
||||||
@@ -320,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>
|
||||||
@@ -334,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);
|
||||||
|
})();
|
||||||
2466
html/wp-creation-app.js
Normal file
2466
html/wp-creation-app.js
Normal file
File diff suppressed because it is too large
Load Diff
412
html/wp-creation-index.html
Normal file
412
html/wp-creation-index.html
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#161616">
|
||||||
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
|
<link rel="stylesheet" href="wp-creation-styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo-wrap embed-hide">
|
||||||
|
<div class="header-logo">Prime Controls</div>
|
||||||
|
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
||||||
|
</div>
|
||||||
|
<div class="header-sep embed-hide">|</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>
|
||||||
|
<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="openSopModal()">👁 View SOP</button>
|
||||||
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||||
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||||
|
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||||
|
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</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 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="ctx-bar" id="ctx-bar"></div>
|
||||||
|
|
||||||
|
<!-- RELEASE READINESS BANNER -->
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
<div class="card">
|
||||||
|
<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="field-grid">
|
||||||
|
<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="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="Scheduled"><input type="radio" name="status"><span class="dot"></span>Scheduled</label>
|
||||||
|
<label class="radio-pill" data-val="Issued"><input type="radio" name="status"><span class="dot"></span>Issued</label>
|
||||||
|
<label class="radio-pill" data-val="In Progress"><input type="radio" name="status"><span class="dot"></span>In Progress</label>
|
||||||
|
<label class="radio-pill pill-hold" data-val="Issue"><input type="radio" name="status"><span class="dot"></span>Issue (Hold)</label>
|
||||||
|
<label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label>
|
||||||
|
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint">Cannot move to <strong>Issued</strong> or beyond until all constraints are cleared.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notice">WP number builds automatically from these scope fields + the WP type (per the SOP naming format):</div>
|
||||||
|
<div class="field-grid" id="number-dims"></div>
|
||||||
|
<div class="field field-grid col1"><div class="field"><label>Subject / Title <span class="req">*</span></label><input type="text" id="wp_subject" placeholder="e.g. Utility Level 2P Inert Gas Room Wall Mount Midas/ Rack"></div></div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>WP Type <span class="req">*</span></label><select id="wp_type" onchange="onTypeChange()"></select><div class="field-hint sop-hint">from SOP types</div></div>
|
||||||
|
<div class="field"><label>System / Facility Code / UPN</label><input type="text" id="wp_system" placeholder="ties to controls.dev / COIN"></div>
|
||||||
|
<div class="field"><label>Location</label><input type="text" id="wp_location" placeholder="building / level / sector / room"></div>
|
||||||
|
<div class="field"><label>Cost Code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
|
||||||
|
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<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>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>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 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>
|
||||||
|
|
||||||
|
<!-- ASSETS (Micron asset catalog) -->
|
||||||
|
<div class="card" id="asset-card">
|
||||||
|
<div class="sub-heading">Assets<span class="help-tip" data-tip="Every work package is built around one or more assets. Search the Micron DB by asset ID, paste a column of IDs straight from Excel, or load a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows. The Micron DB is read-only here — picking an asset never changes it.">i</span></div>
|
||||||
|
<div class="notice">Every work package is based on one or more assets from the <strong>Micron DB</strong>. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. <span style="color:var(--text-dim)">The Micron DB is read-only — nothing you do here changes it.</span></div>
|
||||||
|
<div class="asset-pick" id="asset-pick">
|
||||||
|
<input type="search" class="asset-search" id="asset-search" autocomplete="off"
|
||||||
|
placeholder="Search asset IDs, or paste a column from Excel…"
|
||||||
|
aria-label="Search the Micron DB by asset ID" aria-controls="asset-results" aria-expanded="false">
|
||||||
|
<div class="asset-results" id="asset-results" hidden></div>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" id="asset-source-note"></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th style="width:260px">Asset ID</th><th>Note <span style="font-weight:400;color:var(--text-dim)">(what this asset is / why it's in scope)</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
||||||
|
<div class="material-actions">
|
||||||
|
<button class="add-btn" onclick="addManualAsset()" title="Add an asset that is not in the Micron DB yet">+ Add asset not in the Micron DB</button>
|
||||||
|
<button class="add-btn" onclick="document.getElementById('asset-import').click()" title="Load a list of asset IDs from a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows.">⤒ Load from CSV</button>
|
||||||
|
<input type="file" id="asset-import" accept=".csv,text/csv" style="display:none" onchange="importAssets(event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DISCIPLINES -->
|
||||||
|
<div class="card" id="discipline-card" style="display:none">
|
||||||
|
<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="disc-picker" id="discipline-picker"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCOPE & WORK -->
|
||||||
|
<div class="card">
|
||||||
|
<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 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 id="worksteps-body"></div>
|
||||||
|
<button class="add-btn" onclick="addWorkStep()">+ Add Step</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="scope-by-discipline" style="display:none"></div>
|
||||||
|
<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"><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>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>
|
||||||
|
|
||||||
|
<!-- MATERIAL LIST -->
|
||||||
|
<div class="card" id="material-card">
|
||||||
|
<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="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">
|
||||||
|
<button class="add-btn" onclick="addMaterial()">+ Add Material Line</button>
|
||||||
|
<button class="add-btn" onclick="document.getElementById('material-import').click()">⤒ Import from Excel/CSV</button>
|
||||||
|
<button class="add-btn" onclick="downloadMaterialTemplate()">⤓ Download Template</button>
|
||||||
|
<input type="file" id="material-import" accept=".csv,.xlsx,.xls" style="display:none" onchange="importMaterials(event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DRAWINGS / ATTACHMENTS -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="sub-heading">Drawings & Attachments</div>
|
||||||
|
<div id="sop-ref-links" class="sop-ref-links"></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>Document / Drawing</th><th style="width:90px">Rev</th><th>Link / Note</th><th style="width:44px"></th></tr></thead><tbody id="attach-body"></tbody></table></div>
|
||||||
|
<button class="add-btn" onclick="addAttach()">+ Add Document</button>
|
||||||
|
<button class="add-btn" onclick="toggleSopFilePanel()">+ Add files from SOP folder</button>
|
||||||
|
<div id="sop-file-panel" style="display:none; margin-top:0.75rem; padding:0.75rem; border:1px dashed var(--border); border-radius:6px; background:var(--bg);">
|
||||||
|
<div id="sop-file-folders"></div>
|
||||||
|
<div class="field-hint" style="margin-top:0.5rem;">2) Paste the file links here, one per line:</div>
|
||||||
|
<textarea id="sop-file-links" rows="4" placeholder="https://primecontrolsdallas.sharepoint.com/:b:/r/.../Drawings/EE-1YA-2P.pdf?csf=1&web=1&e=..." style="width:100%; font-size:12px; padding:0.5rem; border:1px solid var(--border); border-radius:4px; box-sizing:border-box;"></textarea>
|
||||||
|
<div style="margin-top:0.5rem; display:flex; gap:0.5rem;">
|
||||||
|
<button class="add-btn" onclick="addPastedFileLinks()">Add to attachments</button>
|
||||||
|
<button class="add-btn" onclick="toggleSopFilePanel()" style="background:transparent;">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KITTING & MIMO -->
|
||||||
|
<div class="card" id="mimo-card">
|
||||||
|
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>Kitting Status</label>
|
||||||
|
<select id="wp_kit_status"><option value="">—</option><option>Open</option><option>In Progress</option><option>Kitted</option><option>Delivered</option></select></div>
|
||||||
|
<div class="field"><label>Warehouse Owner</label><input type="text" id="wp_kit_owner" placeholder="name"></div>
|
||||||
|
<div class="field"><label>Kitting Need Date</label><input type="date" id="wp_kit_date"></div>
|
||||||
|
<div class="field"><label>MIMO Sch. Time</label><input type="datetime-local" id="wp_mimo_time"><div class="field-hint">scheduled material-move date & time</div></div>
|
||||||
|
<div class="field"><label>MIMO Location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONSTRAINTS / RELEASE READINESS -->
|
||||||
|
<div class="card" id="constraint-card">
|
||||||
|
<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="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>
|
||||||
|
|
||||||
|
<!-- QUALITY & HOLD POINTS -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>QC Required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
|
||||||
|
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||||
|
<div class="field"><label>Photo Documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
|
||||||
|
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="field field-grid col1"><div class="field"><label>Witness / Hold Points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
|
||||||
|
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div>
|
||||||
|
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SIGN-OFFS -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="sub-heading">Approvals & Sign-offs</div>
|
||||||
|
<div class="notice">Per the AWP IWP checklist. A package should be signed by these roles before release.</div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th style="width:220px">Role</th><th>Name</th><th style="width:150px">Date</th><th style="width:80px;text-align:center">Signed</th></tr></thead><tbody id="signoff-body"></tbody></table></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CLOSEOUT -->
|
||||||
|
<div class="card" id="closeout-card">
|
||||||
|
<div class="sub-heading">Closeout</div>
|
||||||
|
<div class="notice">Completed at QC / Closed — captures as-built reality and lessons learned.</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label>Actual Hrs.</label><input type="number" id="wp_actual_hrs" min="0" step="1"></div>
|
||||||
|
<div class="field"><label>Installed Quantity</label><input type="text" id="wp_installed_qty" placeholder="e.g. 42 of 42 tags"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field field-grid col1"><div class="field"><label>Redlines / As-Built Notes</label><textarea id="wp_redlines" rows="2"></textarea></div></div>
|
||||||
|
<div class="field field-grid col1"><div class="field"><label>Lessons Learned</label><textarea id="wp_lessons" rows="2"></textarea></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
||||||
|
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<div id="dashboard-view" style="display:none">
|
||||||
|
<div class="output-toolbar">
|
||||||
|
<button class="btn btn-ghost" onclick="showForm()">← Back to Form</button>
|
||||||
|
<div style="font-weight:700;font-size:15px">Work Package Dashboard</div>
|
||||||
|
<div style="display:flex;gap:10px;margin-left:auto">
|
||||||
|
<button class="btn btn-ghost" onclick="newPackage()">+ New WP</button>
|
||||||
|
<button class="btn btn-ghost" onclick="renderDashboard()">↻ Refresh</button>
|
||||||
|
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ctx-bar" style="margin:0 0 14px"><div class="field-hint">Status, metrics and gating across all saved work packages on this device. <span style="color:var(--text-dim)">Reads local data now; wires to the shared SQL database in Phase 2.</span></div></div>
|
||||||
|
<div id="dash-body"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OUTPUT -->
|
||||||
|
<div id="pkg-output" style="display:none">
|
||||||
|
<div class="output-toolbar">
|
||||||
|
<button class="btn btn-ghost" onclick="showForm()">← Back to Form</button>
|
||||||
|
<button class="btn btn-primary" onclick="printPackage()">⎙ Print / Save PDF</button>
|
||||||
|
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
|
||||||
|
</div>
|
||||||
|
<div class="output-doc" id="pkg-doc"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SAVED -->
|
||||||
|
<div class="card" id="saved-card" style="display:none">
|
||||||
|
<div class="sub-heading">Saved Work Packages <span id="saved-count"></span></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>WP #</th><th>Type</th><th>Subject</th><th>Status</th><th style="width:110px">Ready?</th><th style="width:120px"></th></tr></thead><tbody id="saved-body"></tbody></table></div>
|
||||||
|
<button class="add-btn" onclick="exportPackages()">⤓ Export All (JSON)</button>
|
||||||
|
<button class="add-btn" onclick="clearSaved()">Clear All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HOLD LOG MODAL (comment 7) -->
|
||||||
|
<div class="modal-overlay" id="hold-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head"><div class="modal-title">Log Hold — On-Hold Constraint</div><button class="cmt-x" onclick="cancelHold()" title="Cancel">✕</button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="notice">Moving a package to <strong>Issue (Hold)</strong> requires logging the constraint that blocked it.</div>
|
||||||
|
<div class="field"><label>Constraint type <span class="req">*</span></label><select id="hold-constraint"></select></div>
|
||||||
|
<div class="field"><label>Details <span class="req">*</span></label><textarea id="hold-details" rows="3" placeholder="What reopened / blocked this package?"></textarea></div>
|
||||||
|
<div class="field"><label>Supporting document link</label><input type="text" id="hold-doclink" placeholder="link to RFI, photo, email, etc. (optional)"></div>
|
||||||
|
<div class="field"><label>Supporting photo</label><input type="file" id="hold-photo" accept="image/*" onchange="holdPhotoChange(event)"><div class="hold-photo-preview" id="hold-photo-preview"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot"><button class="btn btn-ghost" onclick="cancelHold()">Cancel</button><button class="btn btn-generate" onclick="submitHold()">Log Hold</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW SOP MODAL (comment 2) -->
|
||||||
|
<div class="modal-overlay" id="sop-modal">
|
||||||
|
<div class="modal" style="max-width:640px">
|
||||||
|
<div class="modal-head"><div class="modal-title">Project SOP — Reference</div><button class="cmt-x" onclick="closeSopModal()" title="Close">✕</button></div>
|
||||||
|
<div class="modal-body" id="sop-modal-body"></div>
|
||||||
|
<div class="modal-foot"><button class="btn btn-primary" onclick="closeSopModal()">Close</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMMENTS DRAWER -->
|
||||||
|
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div>
|
||||||
|
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
|
||||||
|
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
|
||||||
|
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
|
||||||
|
<div class="cmt-compose"><div class="cmt-compose-label">Comment on <strong id="cmt-cur-step">this form</strong></div>
|
||||||
|
<textarea id="cmt-input" rows="3" placeholder="Add feedback…"></textarea>
|
||||||
|
<button class="btn btn-primary cmt-add" onclick="addComment()">Add Comment</button></div>
|
||||||
|
<div class="cmt-list" id="cmt-list"></div>
|
||||||
|
<div class="cmt-foot"><div class="cmt-note">Comments are saved in your browser. Use <strong>Export</strong> to send feedback back; the owner can <strong>Import</strong> each file.</div>
|
||||||
|
<div class="cmt-foot-btns"><button class="btn btn-ghost" onclick="exportComments()">⤓ Export</button>
|
||||||
|
<button class="btn btn-ghost" onclick="document.getElementById('cmt-import').click()">⤒ Import</button>
|
||||||
|
<button class="btn btn-ghost cmt-clear" onclick="clearMyComments()">Clear Mine</button>
|
||||||
|
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
||||||
|
</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="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
|
<script src="wp-creation-app.js"></script>
|
||||||
|
</body>
|
||||||
|
</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; }
|
||||||
|
|
||||||
@@ -206,14 +218,14 @@
|
|||||||
/* ── MISC ── */
|
/* ── MISC ── */
|
||||||
.divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
|
.divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
|
||||||
.sub-heading {
|
.sub-heading {
|
||||||
font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .15em;
|
font-family: var(--mono); font-size: 15px; font-weight: 700; letter-spacing: .12em;
|
||||||
text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 10px;
|
text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 10px;
|
||||||
}
|
}
|
||||||
.sub-heading::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
.sub-heading::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
.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; }
|
||||||
@@ -563,3 +575,342 @@
|
|||||||
/* Sign-off date override (comment 5) */
|
/* Sign-off date override (comment 5) */
|
||||||
.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; }
|
||||||
|
|
||||||
|
/* ── asset picker (Micron asset catalog) ────────────────────────────────────
|
||||||
|
A search box over a read-only catalog. Results drop below the input and are
|
||||||
|
added to the table as rows; the catalog itself is never written to. */
|
||||||
|
.asset-pick { position:relative; margin-bottom:10px; }
|
||||||
|
.asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
|
||||||
|
border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
|
||||||
|
box-sizing:border-box; }
|
||||||
|
.asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||||
|
.asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
|
||||||
|
.asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
|
||||||
|
max-height:320px; overflow-y:auto; background:var(--surface);
|
||||||
|
border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
|
||||||
|
box-shadow:0 8px 24px rgba(20,30,50,.18); }
|
||||||
|
.asset-results[hidden] { display:none; }
|
||||||
|
.asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
|
||||||
|
width:100%; text-align:left; background:none; border:0;
|
||||||
|
font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
|
||||||
|
.asset-result:hover:not(:disabled) { background:var(--surface2); }
|
||||||
|
.asset-result:disabled { cursor:default; opacity:.55; }
|
||||||
|
.asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
|
||||||
|
.asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
|
||||||
|
.asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
|
||||||
|
/* Marks rows the catalog vouches for, so a manually typed asset is never
|
||||||
|
mistaken for a looked-up one. */
|
||||||
|
.asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
|
||||||
|
font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
|
||||||
|
color:var(--accent); background:var(--accent-dim); vertical-align:middle;
|
||||||
|
white-space:nowrap; } /* two words now — must not wrap under the asset ID */
|
||||||
|
.asset-tag { font-weight:600; }
|
||||||
|
.asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
|
.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);
|
||||||
|
border-radius:20px; font-size:13px; font-weight:600; cursor:pointer; user-select:none; transition:border-color .12s, background .12s, color .12s; }
|
||||||
|
.disc-pill:hover { border-color:var(--accent); color:var(--text); }
|
||||||
|
.disc-pill input { display:none; }
|
||||||
|
.disc-pill .dot { width:7px; height:7px; border-radius:50%; background:var(--border-strong); transition:background .12s; flex-shrink:0; }
|
||||||
|
.disc-pill.selected { border-color:var(--accent); background:var(--accent-dim,#eef3fd); color:var(--accent); }
|
||||||
|
.disc-pill.selected .dot { background:var(--accent); }
|
||||||
|
.disc-scope { border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:6px; padding:12px 14px; margin-bottom:12px; background:var(--bg); }
|
||||||
|
.disc-scope-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
|
||||||
|
.disc-tag { font-weight:700; font-size:13px; color:var(--accent); text-transform:uppercase; letter-spacing:.03em; }
|
||||||
|
.disc-status { font-size:12px; color:var(--text-muted); }
|
||||||
|
.disc-status select { font-size:12px; padding:3px 6px; }
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.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:0; padding:14px 16px; text-align:center; }
|
||||||
|
.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-green .dm-val { color:var(--accent-green); }
|
||||||
|
.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-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.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:0; padding:14px 16px; margin-bottom:16px; }
|
||||||
|
.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 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 td { border-bottom:1px solid var(--border); padding:6px 8px; vertical-align:top; }
|
||||||
|
.dash-filters { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
||||||
|
.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; }
|
||||||
|
@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); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
582
index.html
582
index.html
@@ -1,582 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Work Package Suite — Prime Controls</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 {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: var(--cds-background);
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
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 {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 3rem 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HERO */
|
|
||||||
.hero {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
font-size: 2.625rem;
|
|
||||||
font-weight: 300;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero p {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: var(--cds-text-secondary);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
max-width: 700px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CARDS */
|
|
||||||
.cards-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--cds-layer);
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: var(--cds-button-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--cds-button-primary);
|
|
||||||
color: white;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card p {
|
|
||||||
color: var(--cds-text-secondary);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-button {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--cds-button-primary);
|
|
||||||
color: white;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
transition: background 0.2s;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-button:hover {
|
|
||||||
background: var(--cds-hover-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* COMPLETE STATE (SOP done) */
|
|
||||||
.card.complete {
|
|
||||||
background: #ecfdf5;
|
|
||||||
border-color: #16a34a;
|
|
||||||
}
|
|
||||||
.card.complete .card-button { background: #16a34a; }
|
|
||||||
.card.complete .card-button:hover { background: #15803d; }
|
|
||||||
.card-status {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #16a34a;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.card.disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SECTION */
|
|
||||||
.section {
|
|
||||||
background: var(--cds-layer);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section p {
|
|
||||||
color: var(--cds-text-secondary);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
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 {
|
|
||||||
background: var(--cds-ui-01);
|
|
||||||
color: var(--cds-text-secondary);
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
border-top: 1px solid var(--cds-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
color: var(--cds-link-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
/* COMMENTS SECTION */
|
|
||||||
.comments-section {
|
|
||||||
background: var(--cds-layer);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-toggle {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: var(--cds-button-primary);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-toggle:hover { background: var(--cds-hover-primary); }
|
|
||||||
|
|
||||||
.comments-panel {
|
|
||||||
display: none;
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--cds-ui-01);
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-panel.open { display: block; }
|
|
||||||
|
|
||||||
.comments-panel input,
|
|
||||||
.comments-panel textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--cds-ui-02);
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
font-family: inherit;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-panel textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-buttons button {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
background: var(--cds-button-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:hover { background: var(--cds-hover-primary); }
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: var(--cds-border-subtle);
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover { background: var(--cds-hover-ui); }
|
|
||||||
|
|
||||||
.comments-list {
|
|
||||||
margin-top: 1rem;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-item {
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--cds-background);
|
|
||||||
border: 1px solid var(--cds-border-subtle);
|
|
||||||
border-radius: 3px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-meta {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--cds-text-secondary);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-text {
|
|
||||||
color: var(--cds-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* RESPONSIVE */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header-content { flex-direction: column; text-align: center; }
|
|
||||||
.header-spacer { display: none; }
|
|
||||||
.hero h1 { font-size: 1.75rem; }
|
|
||||||
.cards-grid { grid-template-columns: 1fr; }
|
|
||||||
.container { padding: 1.5rem; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- HEADER -->
|
|
||||||
<header class="header">
|
|
||||||
<div class="header-content">
|
|
||||||
<a href="index.html" class="logo">
|
|
||||||
<img src="prime-controls-logo.jpg" alt="Prime Controls">
|
|
||||||
<div>Work Package Suite</div>
|
|
||||||
</a>
|
|
||||||
<div class="header-spacer"></div>
|
|
||||||
<nav class="header-nav">
|
|
||||||
<a href="#overview">Overview</a>
|
|
||||||
<a href="#comments">Feedback</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- MAIN CONTENT -->
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<!-- HERO -->
|
|
||||||
<div class="hero">
|
|
||||||
<h1>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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TOOL CARDS -->
|
|
||||||
<div class="cards-grid" id="overview">
|
|
||||||
|
|
||||||
<!-- SOP CONFIG -->
|
|
||||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
|
||||||
<h3>SOP Configuration</h3>
|
|
||||||
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
|
|
||||||
<button class="card-button" id="card-sop-btn">Open Tool</button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- WP CREATOR -->
|
|
||||||
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
|
|
||||||
<h3>Work Package Creator</h3>
|
|
||||||
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
|
|
||||||
<button class="card-button" id="card-wp-btn">Open Tool</button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- COMMENTS SECTION -->
|
|
||||||
<div class="comments-section" id="comments">
|
|
||||||
<button class="comments-toggle" onclick="toggleComments()">Leave Feedback</button>
|
|
||||||
<div class="comments-panel" id="comments-panel">
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
|
|
||||||
<input type="text" id="commenter-name" placeholder="Your name">
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Feedback</label>
|
|
||||||
<textarea id="comment-text" placeholder="Your feedback here..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="comment-buttons">
|
|
||||||
<button class="submit-btn" onclick="submitComment()">Submit</button>
|
|
||||||
<button class="close-btn" onclick="exportFeedback()">⤓ Export</button>
|
|
||||||
<button class="close-btn" onclick="document.getElementById('feedback-import').click()">⤒ Import</button>
|
|
||||||
<button class="close-btn" onclick="toggleComments()">Close</button>
|
|
||||||
<input type="file" id="feedback-import" accept="application/json" style="display:none" onchange="importFeedback(event)">
|
|
||||||
</div>
|
|
||||||
<div class="comments-list" id="comments-list"></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>
|
|
||||||
|
|
||||||
<!-- FOOTER -->
|
|
||||||
<footer class="footer">
|
|
||||||
<p>Work Package Suite v1.0 | Prime Controls | All files work offline with local browser storage</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
|
||||||
<script>
|
|
||||||
// Reflect SOP completion on the tool cards.
|
|
||||||
(function reflectSOPStatus(){
|
|
||||||
let complete = false, projName = '';
|
|
||||||
try {
|
|
||||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
|
||||||
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
|
||||||
projName = sop && sop.project && sop.project.name || '';
|
|
||||||
} catch(e){}
|
|
||||||
|
|
||||||
const sopCard = document.getElementById('card-sop');
|
|
||||||
const sopBtn = document.getElementById('card-sop-btn');
|
|
||||||
const wpCard = document.getElementById('card-wp');
|
|
||||||
const wpBtn = document.getElementById('card-wp-btn');
|
|
||||||
|
|
||||||
if(complete){
|
|
||||||
sopCard.classList.add('complete');
|
|
||||||
sopBtn.textContent = 'Review';
|
|
||||||
const status = document.createElement('div');
|
|
||||||
status.className = 'card-status';
|
|
||||||
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
|
|
||||||
sopCard.insertBefore(status, sopCard.firstChild);
|
|
||||||
if(wpBtn) wpBtn.textContent = 'Open Creator';
|
|
||||||
} else {
|
|
||||||
if(wpCard) wpCard.classList.add('disabled');
|
|
||||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
let allComments = [];
|
|
||||||
|
|
||||||
function toggleComments() {
|
|
||||||
const panel = document.getElementById('comments-panel');
|
|
||||||
panel.classList.toggle('open');
|
|
||||||
if (panel.classList.contains('open')) loadComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitComment() {
|
|
||||||
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
|
||||||
const text = document.getElementById('comment-text').value.trim();
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
alert('Please enter feedback.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const comment = {
|
|
||||||
name,
|
|
||||||
text,
|
|
||||||
timestamp: new Date().toLocaleString()
|
|
||||||
};
|
|
||||||
|
|
||||||
allComments.push(comment);
|
|
||||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
|
||||||
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
|
||||||
|
|
||||||
document.getElementById('comment-text').value = '';
|
|
||||||
document.getElementById('commenter-name').value = '';
|
|
||||||
loadComments();
|
|
||||||
alert('Thank you! Feedback submitted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportFeedback() {
|
|
||||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
|
||||||
const data = saved ? JSON.parse(saved) : [];
|
|
||||||
if (!data.length) { alert('No feedback to export yet.'); return; }
|
|
||||||
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = 'wp-suite-feedback-home-' + new Date().toISOString().slice(0, 10) + '.json';
|
|
||||||
a.click();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function importFeedback(ev) {
|
|
||||||
const f = ev.target.files && ev.target.files[0];
|
|
||||||
if (!f) return;
|
|
||||||
const r = new FileReader();
|
|
||||||
r.onload = () => {
|
|
||||||
try {
|
|
||||||
const inc = JSON.parse(r.result);
|
|
||||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
|
||||||
if (!incoming.length) { alert('No feedback found in that file.'); return; }
|
|
||||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
|
||||||
allComments = saved ? JSON.parse(saved) : [];
|
|
||||||
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
|
||||||
let added = 0;
|
|
||||||
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
|
||||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
|
||||||
loadComments();
|
|
||||||
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
|
||||||
} catch (e) { alert('Could not read that file.'); }
|
|
||||||
ev.target.value = '';
|
|
||||||
};
|
|
||||||
r.readAsText(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadComments() {
|
|
||||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
|
||||||
if (saved) allComments = JSON.parse(saved);
|
|
||||||
|
|
||||||
const list = document.getElementById('comments-list');
|
|
||||||
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>';
|
|
||||||
} else {
|
|
||||||
list.innerHTML = allComments.map(c => `
|
|
||||||
<div class="comment-item">
|
|
||||||
<div class="comment-meta"><strong>${c.name}</strong> • ${c.timestamp}</div>
|
|
||||||
<div class="comment-text">${c.text.replace(/</g,'<').replace(/>/g,'>')}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
4
nginx/Dockerfile
Normal file
4
nginx/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx/conf.d/wp-suite.conf /etc/nginx/conf.d/wp-suite.conf
|
||||||
|
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY html/ /usr/share/nginx/html/
|
||||||
58
nginx/conf.d/wp-suite.conf
Normal file
58
nginx/conf.d/wp-suite.conf
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Work Package Suite — NGINX site config
|
||||||
|
# This container sits behind an external reverse proxy that handles SSL.
|
||||||
|
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||||
|
|
||||||
|
# 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 {
|
||||||
|
listen 80;
|
||||||
|
server_name wp.controls.dev;
|
||||||
|
|
||||||
|
root /usr/share/nginx/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 / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
# 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
nginx/nginx.conf
Normal file
17
nginx/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
35
scripts/backup-cron.sh
Normal file
35
scripts/backup-cron.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Entry point for the `backup` sidecar container's periodic loop. Runs
|
||||||
|
# db-backup.sh on a fixed interval (default: daily) -- a sleep loop instead of
|
||||||
|
# a cron daemon, kept deliberately simple so it works in a bare
|
||||||
|
# postgres:16-alpine image.
|
||||||
|
#
|
||||||
|
# Resolves db-backup.sh the same way entrypoint.sh resolves this file: prefer
|
||||||
|
# the live bind-mounted copy at /scripts (so edits don't need a rebuild), fall
|
||||||
|
# back to the copy baked into the image at build time if the mount is
|
||||||
|
# missing, empty, or stale. Resolving fresh on every loop iteration also means
|
||||||
|
# that if the mount comes back healthy later (e.g. someone fixes the host
|
||||||
|
# directory) this container picks it up on the very next run, with no
|
||||||
|
# restart needed.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
resolve() {
|
||||||
|
# $1 = script filename, e.g. db-backup.sh
|
||||||
|
if [ -f "/scripts/$1" ]; then
|
||||||
|
echo "/scripts/$1"
|
||||||
|
else
|
||||||
|
echo "/app/scripts-default/$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
DB_BACKUP="$(resolve db-backup.sh)"
|
||||||
|
sh "$DB_BACKUP" || echo "[backup] run failed; will retry next interval" >&2
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
19
scripts/backup.Dockerfile
Normal file
19
scripts/backup.Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
|
||||||
|
# at-rest encryption of dumps.
|
||||||
|
#
|
||||||
|
# The scripts are bind-mounted live at runtime (see the `backup` service in
|
||||||
|
# docker-compose.yml) so they can be edited without a rebuild -- but they're
|
||||||
|
# ALSO baked in here as a fallback default under /app/scripts-default/.
|
||||||
|
# entrypoint.sh prefers the live mount and only falls back to this baked-in
|
||||||
|
# copy if the mount is missing, empty, or stale. That fallback is what keeps
|
||||||
|
# a broken bind mount from crash-looping the container into an unreachable
|
||||||
|
# state (see entrypoint.sh for the full story).
|
||||||
|
FROM postgres:16-alpine
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
COPY scripts/backup-cron.sh scripts/db-backup.sh scripts/db-restore.sh /app/scripts-default/
|
||||||
|
COPY scripts/entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/scripts-default/*.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
CMD []
|
||||||
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."
|
||||||
43
scripts/entrypoint.sh
Normal file
43
scripts/entrypoint.sh
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Entrypoint for the `backup` sidecar. Prefers the live, bind-mounted copy of
|
||||||
|
# backup-cron.sh at /scripts (so it can be edited without a rebuild), and
|
||||||
|
# falls back to the copy baked into this image at build time if that bind
|
||||||
|
# mount is missing, empty, or stale.
|
||||||
|
#
|
||||||
|
# Why this exists: the previous entrypoint ran `/bin/sh /scripts/backup-cron.sh`
|
||||||
|
# directly. If that file wasn't there -- e.g. because the host directory
|
||||||
|
# backing the ./scripts bind mount hadn't been populated by whatever deploy
|
||||||
|
# process manages this stack -- the container failed instantly, and
|
||||||
|
# `restart: unless-stopped` retried in a tight crash loop forever: fast enough
|
||||||
|
# that the container was never "running" long enough for `docker exec` or
|
||||||
|
# Portainer's console to attach. That made the failure itself undiagnosable
|
||||||
|
# from inside the container -- you could only ever see it in the logs, and
|
||||||
|
# only by getting lucky with timing. This wrapper guarantees something always
|
||||||
|
# runs, and that the container always stays reachable, even in the worst case.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
LIVE="/scripts/backup-cron.sh"
|
||||||
|
FALLBACK="/app/scripts-default/backup-cron.sh"
|
||||||
|
|
||||||
|
if [ -f "$LIVE" ]; then
|
||||||
|
echo "[entrypoint] using live scripts from /scripts (bind mount present)"
|
||||||
|
exec /bin/sh "$LIVE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] WARNING: $LIVE not found." >&2
|
||||||
|
echo "[entrypoint] The ./scripts bind mount is missing, empty, or stale on the host." >&2
|
||||||
|
echo "[entrypoint] Check the directory backing that mount (see docker-compose.yml)." >&2
|
||||||
|
|
||||||
|
if [ -f "$FALLBACK" ]; then
|
||||||
|
echo "[entrypoint] Falling back to the scripts baked into this image at build time." >&2
|
||||||
|
echo "[entrypoint] Backups will still run, on whatever version was current when this" >&2
|
||||||
|
echo "[entrypoint] image was last built -- not any newer live edits to ./scripts." >&2
|
||||||
|
exec /bin/sh "$FALLBACK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] FATAL: no backup-cron.sh in the bind mount or the image." >&2
|
||||||
|
echo "[entrypoint] Staying up (idle) instead of crash-looping, so this container" >&2
|
||||||
|
echo "[entrypoint] can still be reached via 'docker exec' / the Portainer console." >&2
|
||||||
|
while true; do
|
||||||
|
sleep 3600
|
||||||
|
done
|
||||||
@@ -10,3 +10,45 @@ 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
|
||||||
|
|
||||||
|
# ── Micron asset catalog (optional) ───────────────────────────────────────────
|
||||||
|
# Backs the searchable asset picker in the work package creator. READ-ONLY: the
|
||||||
|
# app only ever runs the single SELECT in server/assets_db.py, so give it a
|
||||||
|
# db_datareader login and nothing more.
|
||||||
|
#
|
||||||
|
# Leave this unset and the suite works normally — the picker reports that no
|
||||||
|
# catalog is configured and people type asset tags in by hand.
|
||||||
|
#
|
||||||
|
# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
|
||||||
|
# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
|
||||||
|
#
|
||||||
|
# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
|
||||||
|
# and install the Microsoft ODBC driver in the image:
|
||||||
|
# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
|
||||||
|
#
|
||||||
|
# Two things to check when the picker says the catalog is unreachable:
|
||||||
|
# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
|
||||||
|
# Micron schema — that one constant is the whole schema contract.
|
||||||
|
# 2. The api container is on the `outbound` network in docker-compose.yml. The
|
||||||
|
# `internal` network has no default gateway, which blocks the VPN as well as
|
||||||
|
# the internet.
|
||||||
|
|||||||
294
server/README.md
294
server/README.md
@@ -6,14 +6,21 @@ to this service.
|
|||||||
|
|
||||||
```
|
```
|
||||||
browser → NGINX ──serves──> static site (index.html, …)
|
browser → NGINX ──serves──> static site (index.html, …)
|
||||||
└─proxy /api/─> this API (uvicorn/gunicorn :8000) → PostgreSQL
|
└─proxy /api/─> api container (:8000) → db container (postgres)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| GET | `/api/health` | liveness check |
|
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||||
|
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||||
|
| POST | `/api/auth/logout` | clear the session cookie |
|
||||||
|
| GET | `/api/auth/me` | the logged-in user |
|
||||||
|
| POST | `/api/auth/password` | change your own password |
|
||||||
|
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||||
|
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||||
|
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||||
| GET | `/api/sops` | list SOP summaries |
|
| GET | `/api/sops` | list SOP summaries |
|
||||||
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
||||||
@@ -31,6 +38,55 @@ Interactive docs once running: **`/api/docs`**.
|
|||||||
The full client document is stored in each row's `data` (JSON) column; common
|
The full client document is stored in each row's `data` (JSON) column; common
|
||||||
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login portal (user accounts)
|
||||||
|
|
||||||
|
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||||
|
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||||
|
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||||
|
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||||
|
request is validated by checking the cookie's signature and expiry.
|
||||||
|
|
||||||
|
**The real security boundary is the API:** every `/api/` data route is refused
|
||||||
|
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||||
|
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||||
|
to `login.html` when there's no session — that's for UX, not protection.
|
||||||
|
|
||||||
|
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||||
|
`admin` (may manage users) and `user`.
|
||||||
|
|
||||||
|
### Set the signing secret
|
||||||
|
|
||||||
|
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
|
||||||
|
without it the API uses a random per-process key, so logins reset on restart.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the first admin
|
||||||
|
|
||||||
|
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||||
|
shell (run from the **project root**, like uvicorn):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||||
|
# prompts for a password (min 8 chars)
|
||||||
|
```
|
||||||
|
|
||||||
|
In Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||||
|
```
|
||||||
|
|
||||||
|
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||||
|
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||||
|
API (or you can keep using the CLI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,49 +101,215 @@ Then open http://localhost:8000/api/docs.
|
|||||||
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
||||||
> `server/` directory), because the import path is `server.app:app`.
|
> `server/` directory), because the import path is `server.app:app`.
|
||||||
|
|
||||||
## PostgreSQL setup (production)
|
---
|
||||||
|
|
||||||
|
## Production — Docker Compose
|
||||||
|
|
||||||
|
This is the recommended production setup. Three containers run in an isolated
|
||||||
|
internal network; only NGINX is exposed to the outside via the external `proxy`
|
||||||
|
network.
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE wpsuite;
|
|
||||||
CREATE USER wpsuite WITH PASSWORD 'CHANGE_ME';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE wpsuite TO wpsuite;
|
|
||||||
```
|
```
|
||||||
Tables are created automatically on first startup. (For future schema changes,
|
[external proxy network]
|
||||||
introduce Alembic migrations rather than editing tables by hand.)
|
│
|
||||||
|
┌────▼────┐ internal network ┌──────────┐ ┌────────┐
|
||||||
## Run in production (gunicorn + systemd)
|
│ nginx │ ───────────────────> │ api │ → │ db │
|
||||||
|
└─────────┘ └──────────┘ └────────┘
|
||||||
`/etc/systemd/system/wp-suite-api.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Work Package Suite API
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=www-data
|
|
||||||
WorkingDirectory=/opt/wp-suite
|
|
||||||
Environment="DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite"
|
|
||||||
ExecStart=/opt/wp-suite/.venv/bin/gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 1. Create the credentials file
|
||||||
|
|
||||||
|
Create `.env` in the **project root** (same directory as `docker-compose.yml`).
|
||||||
|
This file is never committed — add it to `.gitignore`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
# .env — project root
|
||||||
sudo systemctl enable --now wp-suite-api
|
POSTGRES_DB=wpsuite
|
||||||
|
POSTGRES_USER=wpsuite
|
||||||
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
|
|
||||||
|
# Must match POSTGRES_* above; hostname is the compose service name "db"
|
||||||
|
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||||
```
|
```
|
||||||
|
|
||||||
NGINX already proxies `/api/` to `127.0.0.1:8000` (see `nginx-wp-suite.conf`).
|
Generate a strong password:
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add the Dockerfile
|
||||||
|
|
||||||
|
Create `Dockerfile` in the **project root**:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/requirements.txt ./server/
|
||||||
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
|
COPY server/ ./server/
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||||
|
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update the NGINX site config
|
||||||
|
|
||||||
|
The API is no longer at `127.0.0.1:8000` — it is the `api` container.
|
||||||
|
Update the `/api/` proxy block in your nginx conf (e.g. `nginx/conf.d/wp-suite.conf`):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000; # ← service name, not localhost
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. docker-compose.yml
|
||||||
|
|
||||||
|
Replace your existing `docker-compose.yml` with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: nginx_webserver
|
||||||
|
volumes:
|
||||||
|
- ./html:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./logs:/var/log/nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- proxy # external — reachable by your reverse proxy / traefik
|
||||||
|
- internal # needs a path to the api container
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: wp_api
|
||||||
|
env_file: .env # loads DATABASE_URL
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wp_db
|
||||||
|
env_file: .env # loads POSTGRES_DB / USER / PASSWORD
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
internal: true # no outbound internet access from api/db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. First-time startup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the api image and start all containers
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Confirm all three containers are running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Tail logs (Ctrl-C to stop following)
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables are created automatically on first API startup — no manual `CREATE TABLE`
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Authentication notes
|
||||||
|
|
||||||
|
**Postgres → API authentication** is handled entirely through `DATABASE_URL` in
|
||||||
|
`.env`. The `db` container uses `POSTGRES_USER` / `POSTGRES_PASSWORD` to
|
||||||
|
initialise the database on first run; the `api` container uses the matching
|
||||||
|
credentials in `DATABASE_URL` to connect. Neither credential ever appears in the
|
||||||
|
compose file itself.
|
||||||
|
|
||||||
|
**Network isolation**: the `db` container is on the `internal` network only —
|
||||||
|
it has no port exposed to the host and is unreachable from outside the compose
|
||||||
|
stack. Only the `api` container can open a connection to it.
|
||||||
|
|
||||||
|
**Changing the password**: update both `POSTGRES_PASSWORD` and the password
|
||||||
|
in `DATABASE_URL` in `.env`, then:
|
||||||
|
```bash
|
||||||
|
# Stop api first (db must keep running to accept the ALTER USER command)
|
||||||
|
docker compose stop api
|
||||||
|
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
|
||||||
|
docker compose start api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Day-to-day operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild api after a code change
|
||||||
|
docker compose up -d --build api
|
||||||
|
|
||||||
|
# View postgres data directly
|
||||||
|
docker compose exec db psql -U wpsuite -d wpsuite
|
||||||
|
|
||||||
|
# Take a database backup
|
||||||
|
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql
|
||||||
|
|
||||||
|
# Stop everything (data volume is preserved)
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop everything AND delete all data
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick test
|
## Quick test
|
||||||
|
|
||||||
```bash
|
`/api/health` is open; data routes now require a session, so log in first and
|
||||||
curl -X POST http://127.0.0.1:8000/api/comments \
|
reuse the cookie jar:
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
|
|
||||||
|
|
||||||
curl http://127.0.0.1:8000/api/comments
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||||
|
|
||||||
|
# Sign in, saving the session cookie to a jar
|
||||||
|
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"alice","password":"<password>"}'
|
||||||
|
|
||||||
|
# Reuse the cookie on protected routes
|
||||||
|
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||||
|
|
||||||
|
Or via the nginx proxy (replace with your hostname):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://wp-suite.company.local/api/health
|
||||||
```
|
```
|
||||||
|
|||||||
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')
|
||||||
1684
server/app.py
1684
server/app.py
File diff suppressed because it is too large
Load Diff
199
server/assets_db.py
Normal file
199
server/assets_db.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""Read-only reader for the Micron asset catalog.
|
||||||
|
|
||||||
|
The work package creator used to ask people to paste a controls.dev link for
|
||||||
|
every asset. Assets actually live in the Micron database — a SQL Server instance
|
||||||
|
that is NOT part of this repo and whose schema is not managed here. This module
|
||||||
|
gives the API a *read-only* window onto it so the creator can offer a searchable
|
||||||
|
picker instead of free-text links.
|
||||||
|
|
||||||
|
How it works: the whole catalog is fetched in one query and handed to the browser
|
||||||
|
when the creator loads. Searching then happens in the browser with no round trip
|
||||||
|
at all. The catalog is a list of asset IDs — about 9k of them today and not
|
||||||
|
expected past 100k — so it is small enough to send whole, and it is slow-moving
|
||||||
|
reference data, so there is nothing to gain from querying it per keystroke and a
|
||||||
|
lot of latency to lose. A short server-side cache keeps a room full of people
|
||||||
|
opening the page from turning into a query each.
|
||||||
|
|
||||||
|
Other deliberate constraints:
|
||||||
|
|
||||||
|
* **Read-only, always.** The only statement in this file is the SELECT below.
|
||||||
|
Point it at a login with `db_datareader` and nothing else.
|
||||||
|
* **No prime_db dependency.** A plain SQLAlchemy connection built from a
|
||||||
|
connection string, kept separate from the app's own engine in `db.py`, so a
|
||||||
|
Micron outage can never affect the suite's own database.
|
||||||
|
|
||||||
|
Unconfigured is a first-class state: with no `MICRON_DB_URL` set, `configured()`
|
||||||
|
returns False, the API says so, and the UI falls back to manual entry. The suite
|
||||||
|
boots and runs fine without the Micron database being reachable.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── The query ─────────────────────────────────────────────────────────────────
|
||||||
|
# The only place the Micron schema appears; everything else here is plumbing.
|
||||||
|
# Returns one row per asset, aliased `tag`. No row cap: the catalog is small
|
||||||
|
# enough to hand over whole, and a partial list would silently hide assets.
|
||||||
|
#
|
||||||
|
# Add a WHERE clause here if some rows should never be offered at all
|
||||||
|
# (decommissioned assets, other sites, …). Filtering at the source keeps the
|
||||||
|
# payload small, which matters more than anything else here.
|
||||||
|
ASSET_QUERY = """
|
||||||
|
SELECT a.AssetID AS tag
|
||||||
|
FROM Asset.Asset AS a
|
||||||
|
ORDER BY a.AssetID
|
||||||
|
"""
|
||||||
|
|
||||||
|
# How long a fetched catalog is reused before the next page load re-queries.
|
||||||
|
CACHE_SECONDS = int(os.getenv("MICRON_ASSETS_CACHE_SECONDS", "300")) # 5 min
|
||||||
|
CONNECT_TIMEOUT = 8
|
||||||
|
|
||||||
|
|
||||||
|
class AssetSourceError(RuntimeError):
|
||||||
|
"""The catalog is configured but could not be read."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Engine (lazy, process-wide) ───────────────────────────────────────────────
|
||||||
|
# A full SQLAlchemy URL, e.g.
|
||||||
|
# mssql+pymssql://user:pass@host:1433/MicronDB
|
||||||
|
# mssql+pyodbc://user:pass@host/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
|
||||||
|
# URL-encode any special characters in the password.
|
||||||
|
_engine = None
|
||||||
|
_engine_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_url() -> str:
|
||||||
|
return os.getenv("MICRON_DB_URL", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def configured() -> bool:
|
||||||
|
return bool(_db_url())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_url(url: str) -> None:
|
||||||
|
"""Catch the one URL mistake that produces a baffling error message.
|
||||||
|
|
||||||
|
A password containing an unencoded '@' makes the URL ambiguous: the parser
|
||||||
|
splits on the first '@', so part of the password ends up parsed as the host.
|
||||||
|
The driver then reports a connection failure against a nonsense hostname that
|
||||||
|
happens to contain a fragment of the password — confusing to read and unsafe
|
||||||
|
to display. Detect it here and say plainly what is wrong.
|
||||||
|
|
||||||
|
Nothing from the URL is included in the message; it never leaves this process.
|
||||||
|
"""
|
||||||
|
authority = url.split("://", 1)[-1].split("/", 1)[0]
|
||||||
|
if authority.count("@") > 1:
|
||||||
|
raise AssetSourceError(
|
||||||
|
"MICRON_DB_URL is ambiguous: the username or password contains an "
|
||||||
|
"unencoded '@'. Percent-encode the special characters — @ = %40, "
|
||||||
|
": = %3A, / = %2F, # = %23, ? = %3F, % = %25."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _connect_args(url: str) -> dict:
|
||||||
|
"""Per-driver connect timeouts, so an unreachable Micron host fails fast
|
||||||
|
instead of tying up a worker until the OS gives up."""
|
||||||
|
if url.startswith("mssql+pymssql"):
|
||||||
|
return {"login_timeout": CONNECT_TIMEOUT, "timeout": CONNECT_TIMEOUT}
|
||||||
|
if url.startswith("mssql+pyodbc"):
|
||||||
|
return {"timeout": CONNECT_TIMEOUT}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is not None:
|
||||||
|
return _engine
|
||||||
|
url = _db_url()
|
||||||
|
if not url:
|
||||||
|
raise AssetSourceError("The Micron DB is not configured.")
|
||||||
|
_validate_url(url)
|
||||||
|
with _engine_lock:
|
||||||
|
if _engine is None:
|
||||||
|
try:
|
||||||
|
_engine = create_engine(
|
||||||
|
url,
|
||||||
|
connect_args=_connect_args(url),
|
||||||
|
pool_pre_ping=True, # a recycled dead connection retries instead of erroring
|
||||||
|
pool_recycle=1800,
|
||||||
|
pool_size=1, # one catalog query now and then, not a workload
|
||||||
|
max_overflow=1,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # bad URL, missing driver package, …
|
||||||
|
# See the note on load() — the exception text can echo the
|
||||||
|
# connection string, so it is logged and not propagated.
|
||||||
|
log.error("Micron asset catalog: could not open the connection: %s", exc)
|
||||||
|
raise AssetSourceError(
|
||||||
|
"Could not open a connection to the Micron DB. "
|
||||||
|
"Check MICRON_DB_URL and the API log for the driver error."
|
||||||
|
) from exc
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Every page load asks for the whole catalog, so without this a shift change
|
||||||
|
# would be one full-table query per person. Held per worker process.
|
||||||
|
_cache: list[dict] | None = None
|
||||||
|
_cached_at = 0.0
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def load(force: bool = False) -> list[dict]:
|
||||||
|
"""Return the whole catalog as [{'tag': …}, …]. Never writes."""
|
||||||
|
global _cache, _cached_at
|
||||||
|
with _cache_lock:
|
||||||
|
if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS:
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
engine = _get_engine()
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text(ASSET_QUERY)).mappings().all()
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
# The driver's message is NOT propagated. AssetSourceError text reaches the
|
||||||
|
# browser, and connection errors quote the host, the login, and — when the
|
||||||
|
# URL is malformed — fragments of the password. Operators get the detail
|
||||||
|
# from the API log, where it belongs; users get a message they can act on.
|
||||||
|
log.error("Micron asset catalog query failed: %s", exc)
|
||||||
|
raise AssetSourceError(
|
||||||
|
"The Micron DB could not be read. Check that the host is "
|
||||||
|
"reachable, that the login has SELECT on the asset table, and that "
|
||||||
|
"ASSET_QUERY matches the real schema — the API log has the driver error."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Drop rows with no identifier — an asset with no tag is not selectable and
|
||||||
|
# would render as a blank line in the picker.
|
||||||
|
rows = [{"tag": str(r["tag"])} for r in result if r.get("tag") not in (None, "")]
|
||||||
|
with _cache_lock:
|
||||||
|
_cache, _cached_at = rows, time.monotonic()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> dict:
|
||||||
|
"""Describe the source for the UI, so it can explain itself rather than just
|
||||||
|
showing an empty dropdown."""
|
||||||
|
if not configured():
|
||||||
|
return {
|
||||||
|
"configured": False, "ok": False, "count": 0,
|
||||||
|
"detail": "The Micron DB is not configured — enter assets manually.",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
rows = load()
|
||||||
|
except AssetSourceError as exc:
|
||||||
|
return {"configured": True, "ok": False, "count": 0, "detail": str(exc)}
|
||||||
|
return {"configured": True, "ok": True, "count": len(rows),
|
||||||
|
"detail": f"{len(rows):,} asset IDs from the Micron DB."}
|
||||||
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()
|
||||||
212
server/models.py
212
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,13 +87,25 @@ 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
|
||||||
)
|
)
|
||||||
|
# parent_id links a discipline instance (WP01A) back to its master (WP01).
|
||||||
|
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||||
number: Mapped[str] = mapped_column(String(120), default="")
|
number: Mapped[str] = mapped_column(String(120), default="")
|
||||||
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)
|
||||||
|
# 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)
|
||||||
@@ -62,8 +113,11 @@ class WorkPackage(Base):
|
|||||||
|
|
||||||
def summary(self) -> dict:
|
def summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id, "sop_id": self.sop_id, "number": self.number,
|
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||||
"subject": self.subject, "type": self.type, "status": self.status,
|
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||||
|
"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),
|
||||||
}
|
}
|
||||||
@@ -72,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"
|
||||||
|
|
||||||
@@ -94,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,22 @@
|
|||||||
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
|
||||||
|
pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
|
||||||
|
# Chosen over pyodbc because it ships self-contained wheels —
|
||||||
|
# pyodbc would also need msodbcsql18 + unixODBC installed in
|
||||||
|
# the image. To use pyodbc instead, add it here, install the
|
||||||
|
# Microsoft ODBC driver in the Dockerfile, and switch
|
||||||
|
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
|
||||||
|
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())
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
// ── GLOBAL STATE ──────────────────────────────────────────────────────────────
|
|
||||||
let currentTool = 'sop';
|
|
||||||
let currentStep = 1;
|
|
||||||
let sopComplete = false;
|
|
||||||
let allComments = [];
|
|
||||||
|
|
||||||
let state = {
|
|
||||||
project: {name:'', number:'', client:'', division:'', site:''},
|
|
||||||
team: {pm:'', apm:'', cm:'', qm:''},
|
|
||||||
teamMembers: [],
|
|
||||||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
|
||||||
wpTypes: [],
|
|
||||||
governance: {woformat:'', wosize:'', issuance:[]},
|
|
||||||
quality: {qcreq:'', photo:'', hold:''},
|
|
||||||
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
|
|
||||||
sequence: [],
|
|
||||||
constraints: [],
|
|
||||||
sources: []
|
|
||||||
};
|
|
||||||
|
|
||||||
let sop = null; // Generated SOP for WP tool
|
|
||||||
|
|
||||||
// ── LIBRARIES ─────────────────────────────────────────────────────────────────
|
|
||||||
const DEFAULT_WP_TYPES = [
|
|
||||||
{name:'Rough-In', enabled:true},
|
|
||||||
{name:'Mechanical Install', enabled:true},
|
|
||||||
{name:'Panel Install', enabled:true},
|
|
||||||
{name:'Conduit Install', enabled:true},
|
|
||||||
{name:'Tray Install', enabled:true},
|
|
||||||
{name:'Mechanical Tubing', enabled:false},
|
|
||||||
{name:'Instrument Install', enabled:true},
|
|
||||||
{name:'Wire Pull', enabled:true},
|
|
||||||
{name:'Terminations', enabled:true},
|
|
||||||
{name:'Prefab/Kitting', enabled:false},
|
|
||||||
{name:'Network Cabling', enabled:false},
|
|
||||||
{name:'Fiber', enabled:false},
|
|
||||||
{name:'Calibrations', enabled:false}
|
|
||||||
];
|
|
||||||
|
|
||||||
const STANDARD_10_CONSTRAINTS = [
|
|
||||||
{name:'Safety & Permitting', description:'Permits, safety reviews, environmental clearances'},
|
|
||||||
{name:'Quality Control / Inspection', description:'QC approval, inspection readiness'},
|
|
||||||
{name:'IFC Drawings & Specs', description:'Issued-for-Construction drawings and specifications'},
|
|
||||||
{name:'Schedule', description:'Scheduled sequence timing confirmed'},
|
|
||||||
{name:'Materials (on site, bagged & tagged)', description:'All required materials on site and staged'},
|
|
||||||
{name:'Prefabrication', description:'Prefabricated assemblies complete'},
|
|
||||||
{name:'Work Access & Laydown', description:'Work area accessible and staged'},
|
|
||||||
{name:'Craft Availability', description:'Required craft trades available'},
|
|
||||||
{name:'Construction Equipment & Tools', description:'Special equipment and tools on site'},
|
|
||||||
{name:'Scaffolding / Access Equipment', description:'Temporary access structures in place'}
|
|
||||||
];
|
|
||||||
|
|
||||||
const CONSTRAINT_LIBRARY = [
|
|
||||||
'Utility Clearances',
|
|
||||||
'Third-Party Approvals',
|
|
||||||
'Engineering Change Notices',
|
|
||||||
'Commissioning Sign-Off',
|
|
||||||
'As-Built Documentation',
|
|
||||||
'Labeling & Identification',
|
|
||||||
'Testing & Certification',
|
|
||||||
'Rework Completion',
|
|
||||||
'Coordination with Other Trades',
|
|
||||||
'Environmental Controls',
|
|
||||||
'Critical Path Gate',
|
|
||||||
'Client Walkthrough Approval'
|
|
||||||
];
|
|
||||||
|
|
||||||
const OPTIONAL_ROLES = [
|
|
||||||
'Assistant Project Manager',
|
|
||||||
'General Foreman',
|
|
||||||
'Construction Manager',
|
|
||||||
'HSE Professional',
|
|
||||||
'Quality Representative',
|
|
||||||
'Planner',
|
|
||||||
'Safety Manager',
|
|
||||||
'Project Controls Manager'
|
|
||||||
];
|
|
||||||
|
|
||||||
const LABOR_COST_CODES = [
|
|
||||||
'1000|Project Management',
|
|
||||||
'2000|Design and Development',
|
|
||||||
'2100|Design',
|
|
||||||
'2110|Control System Design',
|
|
||||||
'2120|Instrument Design',
|
|
||||||
'2130|Electrical Design',
|
|
||||||
'2140|Panel Design',
|
|
||||||
'2141|Panel Design Rework',
|
|
||||||
'2150|BIM',
|
|
||||||
'2151|BIM Rework',
|
|
||||||
'2160|Documentation',
|
|
||||||
'2200|Development',
|
|
||||||
'2210|PLC Programming',
|
|
||||||
'2220|OIT Programming',
|
|
||||||
'2230|SCADA Programming',
|
|
||||||
'2240|Simulation Development',
|
|
||||||
'2300|Customer Training',
|
|
||||||
'3000|Operational Technology',
|
|
||||||
'3100|OT Design',
|
|
||||||
'3200|Rack Assembly',
|
|
||||||
'3300|Network Configuration',
|
|
||||||
'3400|Computer Configuration',
|
|
||||||
'4000|Construction',
|
|
||||||
'4010|Instruments Install',
|
|
||||||
'4020|Network & Computers Install',
|
|
||||||
'4040|PLC Install',
|
|
||||||
'4050|Panel Install',
|
|
||||||
'4060|Electrical Install',
|
|
||||||
'4070|Mechanical Install',
|
|
||||||
'4080|Security Install',
|
|
||||||
'4090|Radio Install',
|
|
||||||
'4100|Commissioning',
|
|
||||||
'6000|Production',
|
|
||||||
'7000|Quality',
|
|
||||||
'7100|Panel Quality Control',
|
|
||||||
'7200|Factory Acceptance Testing',
|
|
||||||
'7300|Site Acceptance Testing',
|
|
||||||
'8000|Safety',
|
|
||||||
'9000|Administration'
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── INITIALIZATION ────────────────────────────────────────────────────────────
|
|
||||||
window.addEventListener('DOMContentLoaded',()=>{
|
|
||||||
initializeWPTypes();
|
|
||||||
renderTeamMembers();
|
|
||||||
renderOptionalRoles();
|
|
||||||
renderStandardConstraints();
|
|
||||||
renderSequenceSteps();
|
|
||||||
renderSources();
|
|
||||||
restoreSavedSOP();
|
|
||||||
updateStepUI();
|
|
||||||
updateProjectDisplay();
|
|
||||||
|
|
||||||
// Deep-link: ?tab=sop | ?tab=wp from the home page cards.
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const tab = params.get('tab');
|
|
||||||
if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
|
||||||
|
|
||||||
track('app_open');
|
|
||||||
let _fieldTimer;
|
|
||||||
document.addEventListener('input', e=>{
|
|
||||||
const t = e.target;
|
|
||||||
if(t && t.id && /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)){
|
|
||||||
clearTimeout(_fieldTimer);
|
|
||||||
_fieldTimer = setTimeout(()=> track('field_edit', {field:t.id}), 600);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
window.addEventListener('beforeunload', trackStepDwell);
|
|
||||||
|
|
||||||
function initializeWPTypes(){
|
|
||||||
state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''}));
|
|
||||||
renderWPTypes();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
|
||||||
function loadSampleData(){
|
|
||||||
// Populate Step 1
|
|
||||||
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
|
||||||
document.getElementById('proj_number').value = '26-67-008';
|
|
||||||
document.getElementById('proj_client').value = 'Micron Technology, Inc.';
|
|
||||||
document.getElementById('proj_division').value = 'Semiconductor';
|
|
||||||
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
|
||||||
|
|
||||||
// Populate Step 2
|
|
||||||
document.getElementById('proj_pm').value = 'Mariano Sanchez';
|
|
||||||
document.getElementById('proj_apm').value = 'Assistant PM';
|
|
||||||
document.getElementById('proj_cm').value = 'K. Boyd';
|
|
||||||
document.getElementById('proj_qm').value = 'D. Nguyen';
|
|
||||||
|
|
||||||
// Step 3 already has defaults
|
|
||||||
document.getElementById('role_super_name').value = 'John Smith';
|
|
||||||
document.getElementById('role_foreman_name').value = 'Mike Jones';
|
|
||||||
|
|
||||||
// Populate Step 5
|
|
||||||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
|
||||||
document.getElementById('gov_wosize').value = '3–5 days / 40–80 hours';
|
|
||||||
|
|
||||||
// Populate Step 6
|
|
||||||
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
|
||||||
document.getElementById('qual_photo').value = 'Key checkpoints only';
|
|
||||||
document.getElementById('qual_hold').value = 'HOLD: Prime QAQC to inspect rough-in before cover/cover-up.\nWITNESS: Client QC to observe megger test before energization.';
|
|
||||||
|
|
||||||
// Step 7 already has defaults
|
|
||||||
|
|
||||||
// Collect all data
|
|
||||||
collectStepData();
|
|
||||||
track('sample_loaded');
|
|
||||||
|
|
||||||
alert('✓ Sample data loaded!\n\nNavigate through the SOP steps to see example values. You can edit or replace any field.');
|
|
||||||
|
|
||||||
// Switch to step 1
|
|
||||||
currentStep = 1;
|
|
||||||
updateStepUI();
|
|
||||||
updateProjectDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RESTORE A COMPLETED SOP (for "Review" + WP tab across reloads) ─────────────
|
|
||||||
function restoreSavedSOP(){
|
|
||||||
let savedState = null, savedSop = null, complete = false;
|
|
||||||
try {
|
|
||||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
|
||||||
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null');
|
|
||||||
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
|
||||||
} catch(e){}
|
|
||||||
if(!complete || !savedState) return;
|
|
||||||
|
|
||||||
state = savedState;
|
|
||||||
sop = savedSop;
|
|
||||||
sopComplete = true;
|
|
||||||
|
|
||||||
// Re-render dynamic lists from restored state.
|
|
||||||
renderWPTypes();
|
|
||||||
renderTeamMembers();
|
|
||||||
renderOptionalRoles();
|
|
||||||
renderStandardConstraints();
|
|
||||||
renderSequenceSteps();
|
|
||||||
renderSources();
|
|
||||||
repopulateForm();
|
|
||||||
|
|
||||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push restored state values back into the static form inputs.
|
|
||||||
function repopulateForm(){
|
|
||||||
const set = (id,val)=>{ const el=document.getElementById(id); if(el!=null && val!=null) el.value = val; };
|
|
||||||
set('proj_name', state.project.name);
|
|
||||||
set('proj_number', state.project.number);
|
|
||||||
set('proj_client', state.project.client);
|
|
||||||
set('proj_division', state.project.division);
|
|
||||||
set('proj_site', state.project.site);
|
|
||||||
set('proj_pm', state.team.pm);
|
|
||||||
set('proj_apm', state.team.apm);
|
|
||||||
set('proj_cm', state.team.cm);
|
|
||||||
set('proj_qm', state.team.qm);
|
|
||||||
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
|
||||||
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
|
||||||
set('gov_woformat', state.governance.woformat);
|
|
||||||
set('gov_wosize', state.governance.wosize);
|
|
||||||
set('qual_qcreq', state.quality.qcreq);
|
|
||||||
set('qual_photo', state.quality.photo);
|
|
||||||
set('qual_hold', state.quality.hold);
|
|
||||||
set('plat_tracking', state.platforms.tracking);
|
|
||||||
set('plat_commissioning', state.platforms.commissioning);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
|
|
||||||
function switchTool(tool){
|
|
||||||
currentTool = tool;
|
|
||||||
|
|
||||||
// Update nav tabs
|
|
||||||
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
|
||||||
document.querySelector(`[data-tab="${tool}"]`).classList.add('active');
|
|
||||||
|
|
||||||
// Update content
|
|
||||||
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
|
||||||
document.getElementById(`tool-${tool}`).classList.add('active');
|
|
||||||
|
|
||||||
// Reset step counter
|
|
||||||
if(tool === 'sop'){
|
|
||||||
document.getElementById('total-steps').textContent = '10';
|
|
||||||
}else{
|
|
||||||
document.getElementById('total-steps').textContent = '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
if(tool === 'wp') renderWPTab();
|
|
||||||
|
|
||||||
updateStepUI();
|
|
||||||
updateProjectDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
|
||||||
function renderWPTab(){
|
|
||||||
const gate = document.getElementById('wp-gate');
|
|
||||||
const frame = document.getElementById('wp-frame');
|
|
||||||
if(!gate || !frame) return;
|
|
||||||
if(sopComplete){
|
|
||||||
gate.style.display = 'none';
|
|
||||||
frame.style.display = 'block';
|
|
||||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
|
||||||
frame.src = 'wp-creation-index.html?embedded=1&t=' + Date.now();
|
|
||||||
}else{
|
|
||||||
gate.style.display = 'block';
|
|
||||||
frame.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called from completeSOP / restoreSavedSOP once an SOP is available.
|
|
||||||
function onSOPReady(){
|
|
||||||
if(currentTool === 'wp') renderWPTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateProjectDisplay(){
|
|
||||||
const projName = document.getElementById('proj_name')?.value || 'Project';
|
|
||||||
const display = document.getElementById('project-display');
|
|
||||||
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── RENDERING (SOP) ────────────────────────────────────────────────────────────
|
|
||||||
function renderWPTypes(){
|
|
||||||
const container = document.getElementById('wp-types-table');
|
|
||||||
container.innerHTML = `<div class="wp-types-header">
|
|
||||||
<div>Work Order Type</div>
|
|
||||||
<div style="text-align:center;">Enabled</div>
|
|
||||||
<div>Special Rules / Notes</div>
|
|
||||||
<div>WO Complete Approval</div>
|
|
||||||
</div>`;
|
|
||||||
state.wpTypes.forEach((t,i)=>{
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'wp-type-row';
|
|
||||||
row.innerHTML = `
|
|
||||||
<div style="font-weight:600;">${t.name}</div>
|
|
||||||
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
|
||||||
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
`;
|
|
||||||
container.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleWPType(i){
|
|
||||||
state.wpTypes[i].enabled = !state.wpTypes[i].enabled;
|
|
||||||
renderWPTypes();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTeamMembers(){
|
|
||||||
const container = document.getElementById('team-members-list');
|
|
||||||
if(!container) return;
|
|
||||||
container.innerHTML = state.teamMembers.map((m,i)=>`
|
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
|
||||||
<input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'"')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
<input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'"')}" onchange="state.teamMembers[${i}].name=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTeamMember(){
|
|
||||||
state.teamMembers.push({role:'',name:''});
|
|
||||||
renderTeamMembers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTeamMember(i){
|
|
||||||
state.teamMembers.splice(i,1);
|
|
||||||
renderTeamMembers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOptionalRoles(){
|
|
||||||
const container = document.getElementById('optional-roles-list');
|
|
||||||
const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
|
|
||||||
container.innerHTML = current.map((r,i)=>`
|
|
||||||
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
|
||||||
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
|
|
||||||
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
<input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
|
|
||||||
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addOptionalRole(){
|
|
||||||
state.signoffRoles.push({role:'General Foreman',name:''});
|
|
||||||
renderOptionalRoles();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRole(i){
|
|
||||||
state.signoffRoles.splice(i,1);
|
|
||||||
renderOptionalRoles();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStandardConstraints(){
|
|
||||||
const container = document.getElementById('standard-constraints');
|
|
||||||
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
|
|
||||||
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
|
||||||
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
|
||||||
<div style="flex:1;">
|
|
||||||
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
|
|
||||||
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleConstraint(name){
|
|
||||||
const idx = state.constraints.findIndex(c=>c.name===name);
|
|
||||||
if(idx>=0) state.constraints.splice(idx,1);
|
|
||||||
else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name));
|
|
||||||
}
|
|
||||||
|
|
||||||
function showConstraintLibrary(){
|
|
||||||
const modal = document.getElementById('constraint-modal');
|
|
||||||
const lib = document.getElementById('constraint-library');
|
|
||||||
lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
|
|
||||||
<div class="constraint-option" style="padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.borderColor='var(--primary)'; this.style.background='var(--primary-light)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--bg)'" onclick="addCustomConstraint('${c}')">
|
|
||||||
<strong>${c}</strong>
|
|
||||||
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeConstraintModal(){
|
|
||||||
document.getElementById('constraint-modal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCustomConstraint(name){
|
|
||||||
if(!state.constraints.find(c=>c.name===name)){
|
|
||||||
state.constraints.push({name,description:''});
|
|
||||||
}
|
|
||||||
closeConstraintModal();
|
|
||||||
renderStandardConstraints();
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
|
|
||||||
|
|
||||||
let seqDragIndex = null;
|
|
||||||
function renderSequenceSteps(){
|
|
||||||
const container = document.getElementById('sequence-list');
|
|
||||||
if(!container) return;
|
|
||||||
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label:s,kind:'step'}));
|
|
||||||
container.innerHTML = '';
|
|
||||||
let stepNo = 0;
|
|
||||||
state.sequence.forEach((item,i)=>{
|
|
||||||
const isGate = item.kind==='gate';
|
|
||||||
if(!isGate) stepNo++;
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'seq-step' + (isGate?' gate':'');
|
|
||||||
row.draggable = true;
|
|
||||||
row.dataset.idx = i;
|
|
||||||
const badge = isGate ? `<span class="seq-gate-badge" title="QC / hold gate">◆ HOLD</span>`
|
|
||||||
: `<span class="seq-num">${stepNo}</span>`;
|
|
||||||
row.innerHTML = `
|
|
||||||
<span class="seq-handle" title="Drag to reorder">⠿</span>
|
|
||||||
${badge}
|
|
||||||
<input type="text" class="seq-label" value="${(item.label||'').replace(/"/g,'"')}" oninput="state.sequence[${i}].label=this.value">
|
|
||||||
<button class="seq-del" title="Remove" onclick="removeSeqStep(${i})">✕</button>`;
|
|
||||||
row.addEventListener('dragstart',e=>{ seqDragIndex=i; row.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
|
|
||||||
row.addEventListener('dragend',()=>{ row.classList.remove('dragging'); document.querySelectorAll('.seq-step').forEach(s=>s.classList.remove('drag-over')); });
|
|
||||||
row.addEventListener('dragover',e=>{ e.preventDefault(); row.classList.add('drag-over'); e.dataTransfer.dropEffect='move'; });
|
|
||||||
row.addEventListener('dragleave',()=>row.classList.remove('drag-over'));
|
|
||||||
row.addEventListener('drop',e=>{
|
|
||||||
e.preventDefault(); row.classList.remove('drag-over');
|
|
||||||
const from=seqDragIndex, to=i;
|
|
||||||
if(from===null||from===to) return;
|
|
||||||
const moved=state.sequence.splice(from,1)[0];
|
|
||||||
state.sequence.splice(to,0,moved);
|
|
||||||
seqDragIndex=null; renderSequenceSteps();
|
|
||||||
});
|
|
||||||
container.appendChild(row);
|
|
||||||
if(i<state.sequence.length-1){ const a=document.createElement('div'); a.className='seq-arrow'; a.textContent='↓'; container.appendChild(a); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSequenceStep(){
|
|
||||||
const inp = document.getElementById('seq-add-input');
|
|
||||||
const v = (inp && inp.value || '').trim();
|
|
||||||
state.sequence.push({label: v || 'New Step', kind:'step'});
|
|
||||||
if(inp) inp.value = '';
|
|
||||||
renderSequenceSteps();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSequenceGate(){
|
|
||||||
state.sequence.push({label:'QC Hold', kind:'gate'});
|
|
||||||
renderSequenceSteps();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeSeqStep(i){
|
|
||||||
state.sequence.splice(i,1);
|
|
||||||
renderSequenceSteps();
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SOURCES = [
|
|
||||||
{label:'Design Drawings', ph:'e.g. Procore, Bluebeam'},
|
|
||||||
{label:'Spool Drawings', ph:'e.g. SharePoint, BIM'},
|
|
||||||
{label:'Installation Detail Drawings', ph:'e.g. Prime details + Micron MVDs'},
|
|
||||||
{label:'IO List', ph:'e.g. controls.dev, Excel'},
|
|
||||||
{label:'Cable Schedule', ph:'e.g. Excel on SharePoint'},
|
|
||||||
{label:'Conduit Schedule', ph:'e.g. Excel on SharePoint'},
|
|
||||||
{label:'Tray Schedule', ph:'e.g. Excel on SharePoint'},
|
|
||||||
{label:'Datasheets', ph:'e.g. Procore'},
|
|
||||||
{label:'Specifications', ph:'e.g. client portal'},
|
|
||||||
{label:'Asset Register', ph:'e.g. CxAlloy, Excel'},
|
|
||||||
{label:'RFIs', ph:'e.g. Procore RFI log'},
|
|
||||||
{label:'Safety Documentation', ph:'e.g. site safety binder'}
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderSources(){
|
|
||||||
const container = document.getElementById('sources-list');
|
|
||||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph}));
|
|
||||||
container.innerHTML = state.sources.map((s,i)=>`
|
|
||||||
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
|
||||||
<input type="text" value="${s.label}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
<input type="text" value="${s.system}" placeholder="${s.ph||'System of record'}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
<input type="text" value="${s.link}" placeholder="URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
<input type="text" value="${s.notes}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
|
||||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSource(){
|
|
||||||
state.sources.push({label:'',system:'',notes:'',link:''});
|
|
||||||
renderSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── STEP NAVIGATION ────────────────────────────────────────────────────────────
|
|
||||||
function goToStep(n){
|
|
||||||
if(!validateStep(currentStep)) return;
|
|
||||||
currentStep = n;
|
|
||||||
updateStepUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextStep(){
|
|
||||||
if(!validateStep(currentStep)) return;
|
|
||||||
if(currentStep < 10){
|
|
||||||
currentStep++;
|
|
||||||
updateStepUI();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousStep(){
|
|
||||||
if(currentStep > 1){
|
|
||||||
currentStep--;
|
|
||||||
updateStepUI();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStepUI(){
|
|
||||||
trackStepDwell();
|
|
||||||
track('step_view', {step: currentStep});
|
|
||||||
// SOP steps
|
|
||||||
document.querySelectorAll('[id^="sop-step-"]').forEach(s=>s.style.display='none');
|
|
||||||
document.getElementById(`sop-step-${currentStep}`)?.style.display && (document.getElementById(`sop-step-${currentStep}`).style.display='block');
|
|
||||||
|
|
||||||
// Step indicators
|
|
||||||
document.querySelectorAll('.step-item').forEach(s=>s.classList.remove('active'));
|
|
||||||
document.querySelector(`[data-step="${currentStep}"]`)?.classList.add('active');
|
|
||||||
|
|
||||||
// Update counter
|
|
||||||
document.getElementById('current-step').textContent = currentStep;
|
|
||||||
|
|
||||||
// Update buttons
|
|
||||||
document.getElementById('sop-prev-btn').disabled = currentStep === 1;
|
|
||||||
document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none';
|
|
||||||
document.getElementById('sop-complete-btn').style.display = currentStep === 10 ? 'block' : 'none';
|
|
||||||
|
|
||||||
// Collect form data
|
|
||||||
collectStepData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectStepData(){
|
|
||||||
switch(currentStep){
|
|
||||||
case 1:
|
|
||||||
state.project.name = document.getElementById('proj_name').value;
|
|
||||||
state.project.number = document.getElementById('proj_number').value;
|
|
||||||
state.project.client = document.getElementById('proj_client').value;
|
|
||||||
state.project.division = document.getElementById('proj_division').value;
|
|
||||||
state.project.site = document.getElementById('proj_site').value;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
state.team.pm = document.getElementById('proj_pm').value;
|
|
||||||
state.team.apm = document.getElementById('proj_apm').value;
|
|
||||||
state.team.cm = document.getElementById('proj_cm').value;
|
|
||||||
state.team.qm = document.getElementById('proj_qm').value;
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
state.signoffRoles[0].name = document.getElementById('role_super_name').value;
|
|
||||||
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
|
|
||||||
break;
|
|
||||||
case 5:
|
|
||||||
state.governance.woformat = document.getElementById('gov_woformat').value;
|
|
||||||
state.governance.wosize = document.getElementById('gov_wosize').value;
|
|
||||||
const sel = document.getElementById('gov_issuance');
|
|
||||||
state.governance.issuance = Array.from(sel.selectedOptions).map(o=>o.value);
|
|
||||||
break;
|
|
||||||
case 6:
|
|
||||||
state.quality.qcreq = document.getElementById('qual_qcreq').value;
|
|
||||||
state.quality.photo = document.getElementById('qual_photo').value;
|
|
||||||
state.quality.hold = document.getElementById('qual_hold').value;
|
|
||||||
break;
|
|
||||||
case 7:
|
|
||||||
state.platforms.tracking = document.getElementById('plat_tracking').value;
|
|
||||||
state.platforms.commissioning = document.getElementById('plat_commissioning').value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateStep(n){
|
|
||||||
collectStepData();
|
|
||||||
const proj = state.project;
|
|
||||||
|
|
||||||
if(n===1 && (!proj.name || !proj.number || !proj.client || !proj.division || !proj.site)){
|
|
||||||
alert('Please complete all required fields: Project Name, Number, Client, Division, and Site Location.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if(n===5 && !state.governance.woformat){
|
|
||||||
alert('Please enter a Work Package Number Format.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if(n===6 && !state.quality.qcreq){
|
|
||||||
alert('Please select a QC requirement.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
|
|
||||||
function completeSOP(){
|
|
||||||
if(!validateStep(10)) return;
|
|
||||||
collectStepData();
|
|
||||||
|
|
||||||
sop = {
|
|
||||||
meta: {tool:'Work Package Configuration', sample:false},
|
|
||||||
project: {
|
|
||||||
name: state.project.name,
|
|
||||||
number: state.project.number,
|
|
||||||
client: state.project.client,
|
|
||||||
division: state.project.division,
|
|
||||||
pm: state.team.pm,
|
|
||||||
apm: state.team.apm,
|
|
||||||
cm: state.team.cm,
|
|
||||||
qm: state.team.qm,
|
|
||||||
site: state.project.site,
|
|
||||||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name))
|
|
||||||
},
|
|
||||||
roles: state.signoffRoles.filter(r=>r.role),
|
|
||||||
governance: {
|
|
||||||
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
|
|
||||||
woSize: state.governance.wosize,
|
|
||||||
woFormat: state.governance.woformat
|
|
||||||
},
|
|
||||||
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
|
|
||||||
name: t.name,
|
|
||||||
enabled: true,
|
|
||||||
notes: t.notes || '',
|
|
||||||
approval: t.approval || ''
|
|
||||||
})),
|
|
||||||
sources: state.sources.filter(s=>s.label),
|
|
||||||
field: {trackPlatform: state.platforms.tracking},
|
|
||||||
commissioning: {tool: state.platforms.commissioning},
|
|
||||||
quality: {
|
|
||||||
qcReq: state.quality.qcreq,
|
|
||||||
photo: state.quality.photo,
|
|
||||||
holdPoints: state.quality.hold,
|
|
||||||
cxTool: state.platforms.commissioning
|
|
||||||
},
|
|
||||||
sequence: state.sequence.filter(s=>s.label).map(s=>({
|
|
||||||
label: s.label,
|
|
||||||
kind: s.kind || 'step'
|
|
||||||
})),
|
|
||||||
costCodes: LABOR_COST_CODES,
|
|
||||||
constraints: state.constraints.map(c=>({name: c.name, description: c.description || ''}))
|
|
||||||
};
|
|
||||||
|
|
||||||
sopComplete = true;
|
|
||||||
updateProjectDisplay();
|
|
||||||
|
|
||||||
// Persist for the home page (green / "Review") and for the WP Creator tab.
|
|
||||||
try {
|
|
||||||
localStorage.setItem('wp_suite_sop', JSON.stringify(sop));
|
|
||||||
localStorage.setItem('wp_suite_state', JSON.stringify(state));
|
|
||||||
localStorage.setItem('wp_suite_sop_complete', '1');
|
|
||||||
} catch(e){}
|
|
||||||
|
|
||||||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
|
||||||
|
|
||||||
alert('✓ SOP Configuration Complete!\n\nSwitch to "Work Package Creation" to start creating Work Packages.');
|
|
||||||
|
|
||||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab.
|
|
||||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
|
||||||
function toggleComments(){
|
|
||||||
const panel = document.getElementById('comments-panel');
|
|
||||||
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
|
||||||
if(panel.style.display === 'block') loadStepComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitComment(){
|
|
||||||
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
|
||||||
const text = document.getElementById('comment-text').value.trim();
|
|
||||||
|
|
||||||
if(!text){ alert('Please enter a comment.'); return; }
|
|
||||||
|
|
||||||
const comment = {
|
|
||||||
step: currentStep,
|
|
||||||
name: name,
|
|
||||||
text: text,
|
|
||||||
timestamp: new Date().toLocaleString()
|
|
||||||
};
|
|
||||||
|
|
||||||
allComments.push(comment);
|
|
||||||
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
|
|
||||||
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
|
|
||||||
|
|
||||||
document.getElementById('comment-text').value = '';
|
|
||||||
document.getElementById('commenter-name').value = '';
|
|
||||||
loadStepComments();
|
|
||||||
alert('✓ Comment submitted!');
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportComments(){
|
|
||||||
const saved = localStorage.getItem('wp_suite_comments');
|
|
||||||
const data = saved ? JSON.parse(saved) : [];
|
|
||||||
if(!data.length){ alert('No comments to export yet.'); return; }
|
|
||||||
const payload = {app:'Work Package Suite', source:'sop', exportedAt:new Date().toISOString(), comments:data};
|
|
||||||
const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'});
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = 'wp-suite-comments-sop-' + new Date().toISOString().slice(0,10) + '.json';
|
|
||||||
a.click();
|
|
||||||
setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function importComments(ev){
|
|
||||||
const f = ev.target.files && ev.target.files[0];
|
|
||||||
if(!f) return;
|
|
||||||
const r = new FileReader();
|
|
||||||
r.onload = ()=>{
|
|
||||||
try{
|
|
||||||
const inc = JSON.parse(r.result);
|
|
||||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
|
||||||
if(!incoming.length){ alert('No comments found in that file.'); return; }
|
|
||||||
const saved = localStorage.getItem('wp_suite_comments');
|
|
||||||
allComments = saved ? JSON.parse(saved) : [];
|
|
||||||
const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text));
|
|
||||||
let added = 0;
|
|
||||||
incoming.forEach(c=>{ const k=c.step+'|'+c.timestamp+'|'+c.text; if(c.text && !seen.has(k)){ allComments.push(c); seen.add(k); added++; }});
|
|
||||||
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
|
|
||||||
loadStepComments();
|
|
||||||
alert('Imported ' + added + ' comment' + (added===1?'':'s') + '.');
|
|
||||||
}catch(e){ alert('Could not read that file.'); }
|
|
||||||
ev.target.value = '';
|
|
||||||
};
|
|
||||||
r.readAsText(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStepComments(){
|
|
||||||
const saved = localStorage.getItem('wp_suite_comments');
|
|
||||||
if(saved) allComments = JSON.parse(saved);
|
|
||||||
|
|
||||||
const stepComments = allComments.filter(c=>c.step===currentStep);
|
|
||||||
const list = document.getElementById('comments-list');
|
|
||||||
|
|
||||||
if(stepComments.length === 0){
|
|
||||||
list.innerHTML = '<div style="font-size:12px; color:var(--text-dim); font-style:italic;">No comments yet on this step.</div>';
|
|
||||||
}else{
|
|
||||||
list.innerHTML = stepComments.map(c=>`
|
|
||||||
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
|
|
||||||
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
|
|
||||||
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'<').replace(/>/g,'>')}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── USAGE ANALYTICS ─────────────────────────────────────────────────────────
|
|
||||||
// Lightweight usage analytics stored in localStorage so the tool owner can review
|
|
||||||
// engagement over time. No field VALUES are stored (field-edit events record only
|
|
||||||
// the field id), keeping captured data non-sensitive.
|
|
||||||
const ANALYTICS_KEY = 'wp_suite_analytics_v1';
|
|
||||||
let _stepEnter = Date.now();
|
|
||||||
const _session = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6);
|
|
||||||
|
|
||||||
function analyticsLoad(){
|
|
||||||
try { return JSON.parse(localStorage.getItem(ANALYTICS_KEY)) || {events:[]}; }
|
|
||||||
catch(e){ return {events:[]}; }
|
|
||||||
}
|
|
||||||
function analyticsSave(data){
|
|
||||||
try { localStorage.setItem(ANALYTICS_KEY, JSON.stringify(data)); }
|
|
||||||
catch(e){ /* storage unavailable — degrade silently */ }
|
|
||||||
}
|
|
||||||
function track(event, detail){
|
|
||||||
try {
|
|
||||||
const data = analyticsLoad();
|
|
||||||
data.events.push({ ts: new Date().toISOString(), session: _session, event, detail: detail||null });
|
|
||||||
if(data.events.length > 5000) data.events = data.events.slice(-5000);
|
|
||||||
analyticsSave(data);
|
|
||||||
} catch(e){}
|
|
||||||
}
|
|
||||||
function trackStepDwell(){
|
|
||||||
const ms = Date.now() - _stepEnter;
|
|
||||||
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
|
|
||||||
_stepEnter = Date.now();
|
|
||||||
}
|
|
||||||
function analyticsSummary(){
|
|
||||||
const data = analyticsLoad();
|
|
||||||
const byEvent = {}, byStep = {}, dwell = {}, sessions = new Set();
|
|
||||||
data.events.forEach(e=>{
|
|
||||||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
|
||||||
sessions.add(e.session);
|
|
||||||
if(e.event==='step_view' && e.detail) byStep[e.detail.step]=(byStep[e.detail.step]||0)+1;
|
|
||||||
if(e.event==='step_dwell' && e.detail){ dwell[e.detail.step]=(dwell[e.detail.step]||0)+e.detail.ms; }
|
|
||||||
});
|
|
||||||
return {total:data.events.length, sessions:sessions.size, byEvent, byStep, dwell, first:data.events[0]?.ts, last:data.events[data.events.length-1]?.ts};
|
|
||||||
}
|
|
||||||
function showAnalytics(){
|
|
||||||
const s = analyticsSummary();
|
|
||||||
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
|
|
||||||
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'} → ${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
|
|
||||||
for(let i=1;i<=10;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
|
|
||||||
txt += `\nActions:\n`;
|
|
||||||
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
|
|
||||||
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
|
|
||||||
txt += `\nDownload full event log as JSON?`;
|
|
||||||
if(confirm(txt)) downloadAnalytics();
|
|
||||||
}
|
|
||||||
function downloadAnalytics(){
|
|
||||||
const data = analyticsLoad();
|
|
||||||
const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
track('analytics_exported');
|
|
||||||
}
|
|
||||||
@@ -1,669 +0,0 @@
|
|||||||
/* Work Package (IWP) form — the downstream installation work package record.
|
|
||||||
Parameters (numbering, types, QC, sources) are inherited from the project SOP
|
|
||||||
exported by the Configuration tool. Implements the AWP constraint checklist +
|
|
||||||
release gate from IR 272-2 Vol II. */
|
|
||||||
|
|
||||||
// ── SAMPLE SOP (same shape the Configuration tool exports) ───────────────────
|
|
||||||
const SAMPLE_SOP = {
|
|
||||||
meta:{tool:'Work Package Configuration', sample:true},
|
|
||||||
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
|
||||||
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
|
||||||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]' },
|
|
||||||
woTypes:[
|
|
||||||
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
|
||||||
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
|
||||||
{name:'Instrument Install', enabled:true}, {name:'Panel Install', enabled:true},
|
|
||||||
],
|
|
||||||
sources:[
|
|
||||||
{label:'Design Drawings',system:'Procore',notes:'',link:'https://us02.procore.com/562949954073428/project/documents/folders/drawings'},
|
|
||||||
{label:'Specifications',system:'Procore',notes:'',link:'https://us02.procore.com/562949954073428/project/documents/folders/specs'},
|
|
||||||
{label:'IO List',system:'controls.dev',notes:'',link:'https://controls.dev/io'},
|
|
||||||
{label:'Cable Schedule',system:'SharePoint',notes:'',link:'https://primecontrols.sharepoint.com/cable-schedule'},
|
|
||||||
],
|
|
||||||
field:{ trackPlatform:'CxAlloy' },
|
|
||||||
quality:{ qcReq:'Yes', qcScope:'Detailed inspection items', photo:'Key checkpoints only', cxTool:'CxAlloy', holdPoints:'HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.' },
|
|
||||||
sequence:[{label:'Layout',kind:'step'},{label:'Conduit Install',kind:'step'},{label:'Tray Install',kind:'step'},{label:'Wire Pull',kind:'step'},{label:'Terminations',kind:'step'},{label:'Commissioning',kind:'step'}],
|
|
||||||
costCodes:['5100 - Rough In','5200 - Wire Pull','5300 - Terminations','5400 - Instrumentation'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Standard AWP constraint set (Vol I §2.3.2; Vol II IWP checklists)
|
|
||||||
const DEFAULT_CONSTRAINTS = ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)','Prefabrication','Work Access & Laydown','Craft Availability','Construction Equipment & Tools','Scaffolding / Access Equipment'];
|
|
||||||
const SIGNOFF_ROLES = ['Planner','Superintendent','HSE Professional','Quality Representative','Work Foreman'];
|
|
||||||
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
|
|
||||||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
|
||||||
|
|
||||||
// Acumatica cost codes (comment 10) — code|description
|
|
||||||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract','5010|Instrument Material','5020|Network & Computers Material','5030|Software Material','5040|PLC Material','5050|Panel Material','5060|Electrical Material','5070|Mechanical Material','5080|Security Material','5090|Radio Material','6000|Production','7000|Quality','7100|Panel Quality Control','7200|Factory Acceptance Testing','7300|Site Acceptance Testing','8000|Safety','9000|Administration','9100|Warranty','9200|Freight','9300|Travel','9350|Jobsite Costs/Consumable/Other Direct Costs','9400|Contingency','9450|Other','9500|Accrued Incentive Compensation','9600|Sales Tax','9650|Job Cost Labor Burden','9700|Bonding','9800|Non-Billable Compensation'];
|
|
||||||
// Acumatica allowed units of measure (comment 15) — common first
|
|
||||||
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
|
||||||
|
|
||||||
// Example built work package (comment 4 / "Load Example") — WP02 export
|
|
||||||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Bill Clarida (Prime Controls), Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
|
||||||
|
|
||||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
|
||||||
let SOP=null, editingId=null, numberDirty=false;
|
|
||||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[];
|
|
||||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
|
||||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
|
||||||
let prevStatus='Draft';
|
|
||||||
let devMode=false; // comment 7: pauses usage tracking during review
|
|
||||||
let savedPackages=[];
|
|
||||||
let currentView='Work Package Form';
|
|
||||||
|
|
||||||
// ── HELPERS ──────────────────────────────────────────────────────────────────
|
|
||||||
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
|
|
||||||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
||||||
function ns(){ return '<span style="color:var(--text-dim)">—</span>'; }
|
|
||||||
function cell(v){ return v ? esc(v) : ns(); }
|
|
||||||
function pad2(n){ return n<10?'0'+n:''+n; }
|
|
||||||
function typeCode(name){ return (name||'').replace(/[^a-z0-9]+/gi,''); }
|
|
||||||
function linkify(v){ if(!v) return ''; return esc(v).replace(/(https?:\/\/[^\s]+)/g,u=>`<a href="${u}" target="_blank" rel="noopener">${u}</a>`); }
|
|
||||||
function toast(msg){ let t=document.getElementById('toast'); if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); } t.textContent=msg; t.classList.add('show'); clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200); }
|
|
||||||
function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; }
|
|
||||||
function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); }
|
|
||||||
function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
|
|
||||||
function constraintNames(){ return (SOP&&Array.isArray(SOP.constraints)&&SOP.constraints.length)?SOP.constraints:DEFAULT_CONSTRAINTS; }
|
|
||||||
function nextSeq(){ return savedPackages.length+1; }
|
|
||||||
|
|
||||||
// ── SOP LOADING ──────────────────────────────────────────────────────────────
|
|
||||||
function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); }
|
|
||||||
function importSOP(ev){
|
|
||||||
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader();
|
|
||||||
r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ alert('That file does not look like an SOP export from the Configuration tool.'); return; }
|
|
||||||
SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported');
|
|
||||||
}catch(e){ alert('Could not read that file.'); } ev.target.value=''; };
|
|
||||||
r.readAsText(f);
|
|
||||||
}
|
|
||||||
function applySOP(){
|
|
||||||
renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims();
|
|
||||||
renderSopRefLinks(); renderSpecFolderLink();
|
|
||||||
// SOP-inherited Quality fields — populated then locked (editable only with a logged reason)
|
|
||||||
if(SOP.quality){
|
|
||||||
document.getElementById('wp_qc').value=[SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
|
|
||||||
document.getElementById('wp_photo').value=SOP.quality.photo||'';
|
|
||||||
document.getElementById('wp_hold').value=SOP.quality.holdPoints||'';
|
|
||||||
}
|
|
||||||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
|
||||||
const hint=document.getElementById('wp_number_hint'); hint.textContent = SOP.governance&&SOP.governance.woFormat ? 'auto-built · format: '+SOP.governance.woFormat : '';
|
|
||||||
buildConstraints(); buildSignoffs();
|
|
||||||
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
|
||||||
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
|
||||||
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
|
||||||
updateNumber(); updateReleaseBanner();
|
|
||||||
}
|
|
||||||
function buildCostCodes(){
|
|
||||||
const sel=document.getElementById('wp_cost'); const cur=sel.value;
|
|
||||||
sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code} — ${esc(desc)}</option>`;}).join('');
|
|
||||||
if(cur) sel.value=cur;
|
|
||||||
}
|
|
||||||
// ── SOP-LOCKED QUALITY FIELDS (comment 3) ────────────────────────────────────
|
|
||||||
function sopValueFor(id){
|
|
||||||
if(!SOP||!SOP.quality) return '';
|
|
||||||
if(id==='wp_qc') return [SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
|
|
||||||
if(id==='wp_photo') return SOP.quality.photo||'';
|
|
||||||
if(id==='wp_hold') return SOP.quality.holdPoints||'';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
function lockQuality(id){
|
|
||||||
const el=document.getElementById(id); if(!el) return;
|
|
||||||
const fromSOP = !!(SOP&&SOP.quality) && !pkgOverrides[id];
|
|
||||||
el.readOnly = fromSOP;
|
|
||||||
el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
|
|
||||||
el.classList.toggle('locked-field', false);
|
|
||||||
const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note');
|
|
||||||
if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP';
|
|
||||||
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
|
|
||||||
}
|
|
||||||
function editQuality(id){
|
|
||||||
if(pkgOverrides[id]){ // currently overridden -> revert to SOP value
|
|
||||||
delete pkgOverrides[id];
|
|
||||||
document.getElementById(id).value=sopValueFor(id);
|
|
||||||
lockQuality(id); track('quality_reverted',{field:id}); return;
|
|
||||||
}
|
|
||||||
const reason=prompt('This field is set by the project SOP. Enter the reason for overriding it on this package:');
|
|
||||||
if(reason===null) return;
|
|
||||||
if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; }
|
|
||||||
pkgOverrides[id]=reason.trim();
|
|
||||||
const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus();
|
|
||||||
lockQuality(id); track('quality_overridden',{field:id});
|
|
||||||
}
|
|
||||||
function renderCtxBar(){
|
|
||||||
const bar=document.getElementById('ctx-bar');
|
|
||||||
if(!SOP){ bar.innerHTML=`<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; return; }
|
|
||||||
const p=SOP.project||{}, g=SOP.governance||{};
|
|
||||||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
|
||||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
|
||||||
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
|
|
||||||
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DEV MODE (comment 7) ─────────────────────────────────────────────────────
|
|
||||||
function toggleDevMode(){
|
|
||||||
devMode=!devMode;
|
|
||||||
document.body.classList.toggle('dev-mode', devMode);
|
|
||||||
document.getElementById('dev-banner').style.display = devMode ? '' : 'none';
|
|
||||||
toast(devMode ? 'DEV MODE on — usage tracking paused' : 'DEV MODE off — tracking resumed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SOP REFERENCE LINKS (comments 1, 3, 4) ───────────────────────────────────
|
|
||||||
function sopLinkedSources(){ return ((SOP&&SOP.sources)||[]).filter(s=>s.link); }
|
|
||||||
function renderSopRefLinks(){
|
|
||||||
const box=document.getElementById('sop-ref-links'); if(!box) return;
|
|
||||||
const srcs=sopLinkedSources();
|
|
||||||
if(!srcs.length){ box.innerHTML=''; return; }
|
|
||||||
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find & copy the specific file link:</div>`+
|
|
||||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
|
||||||
}
|
|
||||||
function renderSpecFolderLink(){
|
|
||||||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
|
||||||
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
|
|
||||||
el.innerHTML = spec ? `<a href="${esc(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
|
||||||
}
|
|
||||||
function buildTypePicker(){ document.getElementById('wp_type').innerHTML=`<option value="">Select…</option>`+enabledTypes().map(t=>`<option>${esc(t.name)}</option>`).join(''); }
|
|
||||||
function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); }
|
|
||||||
function onTypeChange(){
|
|
||||||
updateNumber(); track('type_selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WP NUMBER (auto-built from per-WP dimensions + type + sequence) ───────────
|
|
||||||
function numberTokens(){
|
|
||||||
const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]';
|
|
||||||
return (fmt.match(/\[[^\]]+\]/g)||[]).map(t=>t.replace(/[\[\]]/g,'')).filter(t=>t.toUpperCase()!=='TYPE');
|
|
||||||
}
|
|
||||||
function buildNumberDims(){
|
|
||||||
const wrap=document.getElementById('number-dims'); if(!wrap) return;
|
|
||||||
const toks=numberTokens();
|
|
||||||
wrap.innerHTML = toks.length ? toks.map(t=>{
|
|
||||||
return `<div class="field"><label>${esc(t)}</label>
|
|
||||||
<input type="text" id="ndim_${esc(t)}" value="${(numberDims[t]||'').replace(/"/g,'"')}" placeholder="${esc(t)} code" oninput="numberDims['${esc(t)}']=this.value;updateNumber()"></div>`;
|
|
||||||
}).join('') : '<div class="field-hint">SOP number format has no scope tokens.</div>';
|
|
||||||
}
|
|
||||||
function typeNumberCode(name){ return (name||'').replace(/\b(install|installation)\b/ig,'').replace(/[^a-z0-9]+/gi,'').trim(); }
|
|
||||||
function updateNumber(){
|
|
||||||
const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]';
|
|
||||||
const t=gv('wp_type');
|
|
||||||
let out=fmt.replace(/WO##|WP##/i,'WP'+pad2(editingSeq()));
|
|
||||||
numberTokens().forEach(tok=>{
|
|
||||||
const v=(numberDims[tok]||'').trim();
|
|
||||||
out=out.split('['+tok+']').join(v||('['+tok+']'));
|
|
||||||
});
|
|
||||||
out=out.split('[TYPE]').join(t?typeNumberCode(t):'[TYPE]');
|
|
||||||
document.getElementById('wp_number').value=out;
|
|
||||||
}
|
|
||||||
function editingSeq(){ // keep an existing package's sequence number if editing, else next
|
|
||||||
if(editingId){ const ix=savedPackages.findIndex(p=>p.id===editingId); if(ix>=0) return ix+1; }
|
|
||||||
return nextSeq();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MATERIAL LIST ────────────────────────────────────────────────────────────
|
|
||||||
function unitOptions(cur){
|
|
||||||
const list=ACU_UNITS.slice(); const c=(cur||'').toUpperCase();
|
|
||||||
if(c && !list.includes(c)) list.unshift(c);
|
|
||||||
return `<option value="">Unit…</option>`+list.map(u=>`<option ${c===u?'selected':''}>${esc(u)}</option>`).join('');
|
|
||||||
}
|
|
||||||
function buildMaterials(){
|
|
||||||
const tb=document.getElementById('material-body'); tb.innerHTML='';
|
|
||||||
pkgMaterials.forEach((m,i)=>{ const tr=document.createElement('tr');
|
|
||||||
tr.innerHTML=`<td><input type="text" value="${(m.qty||'').replace(/"/g,'"')}" placeholder="20" oninput="pkgMaterials[${i}].qty=this.value"></td>
|
|
||||||
<td><select onchange="pkgMaterials[${i}].unit=this.value">${unitOptions(m.unit)}</select></td>
|
|
||||||
<td><input type="text" value="${(m.desc||'').replace(/"/g,'"')}" placeholder="3/8 lock washers gold" oninput="pkgMaterials[${i}].desc=this.value"></td>
|
|
||||||
<td class="center"><button class="row-del" onclick="removeMaterial(${i})">✕</button></td>`;
|
|
||||||
tb.appendChild(tr); });
|
|
||||||
}
|
|
||||||
function addMaterial(){ pkgMaterials.push({qty:'',unit:'',desc:''}); buildMaterials(); }
|
|
||||||
function removeMaterial(i){ pkgMaterials.splice(i,1); if(!pkgMaterials.length)pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
|
||||||
function downloadMaterialTemplate(){
|
|
||||||
const csv='Qty,Unit,Description\n10,FT,Single deep strut gold\n4,EA,2-hole strut base gold\n200,FT,3/4 EMT conduit\n';
|
|
||||||
const blob=new Blob([csv],{type:'text/csv'}); const a=document.createElement('a');
|
|
||||||
a.href=URL.createObjectURL(blob); a.download='material-list-template.csv'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('material_template');
|
|
||||||
}
|
|
||||||
function parseCSV(text){
|
|
||||||
const rows=[]; text.replace(/\r\n/g,'\n').split('\n').forEach(line=>{
|
|
||||||
if(!line.trim()) return;
|
|
||||||
const cells=[]; let cur='', q=false;
|
|
||||||
for(let i=0;i<line.length;i++){ const ch=line[i];
|
|
||||||
if(q){ if(ch==='"'){ if(line[i+1]==='"'){cur+='"';i++;} else q=false; } else cur+=ch; }
|
|
||||||
else { if(ch===','){cells.push(cur);cur='';} else if(ch==='"'){q=true;} else cur+=ch; } }
|
|
||||||
cells.push(cur); rows.push(cells);
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
function importMaterials(ev){
|
|
||||||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
|
||||||
if(/\.xlsx?$/i.test(f.name)){ alert('Please save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.'); ev.target.value=''; return; }
|
|
||||||
const r=new FileReader(); r.onload=()=>{
|
|
||||||
try{
|
|
||||||
const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; }
|
|
||||||
let start=0; const h=rows[0].map(c=>c.trim().toLowerCase());
|
|
||||||
const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description');
|
|
||||||
let cq=0,cu=1,cd=2;
|
|
||||||
if(qi>-1||ui>-1||di>-1){ start=1; if(qi>-1)cq=qi; if(ui>-1)cu=ui; if(di>-1)cd=di; }
|
|
||||||
const imported=[];
|
|
||||||
for(let i=start;i<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim();
|
|
||||||
if(!desc && !(c[cq]||'').trim()) continue;
|
|
||||||
imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); }
|
|
||||||
if(!imported.length){ alert('No material rows found.'); return; }
|
|
||||||
pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length});
|
|
||||||
}catch(e){ alert('Could not parse that CSV.'); }
|
|
||||||
ev.target.value='';
|
|
||||||
};
|
|
||||||
r.readAsText(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DESCRIPTION OF WORK — STEP LIST (comment 16) ─────────────────────────────
|
|
||||||
function buildWorkSteps(){
|
|
||||||
const tb=document.getElementById('worksteps-body'); if(!tb) return; tb.innerHTML='';
|
|
||||||
pkgWorkSteps.forEach((s,i)=>{ const row=document.createElement('div'); row.className='workstep-row';
|
|
||||||
row.innerHTML=`<span class="ws-num">${i+1}</span>
|
|
||||||
<textarea rows="1" placeholder="Describe step ${i+1}…" oninput="pkgWorkSteps[${i}]=this.value">${esc(s)}</textarea>
|
|
||||||
<button class="row-del" onclick="removeWorkStep(${i})" title="Remove step">✕</button>`;
|
|
||||||
tb.appendChild(row); });
|
|
||||||
}
|
|
||||||
function addWorkStep(){ pkgWorkSteps.push(''); buildWorkSteps(); }
|
|
||||||
function removeWorkStep(i){ pkgWorkSteps.splice(i,1); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); }
|
|
||||||
|
|
||||||
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
|
||||||
function buildAttach(){
|
|
||||||
const tb=document.getElementById('attach-body'); tb.innerHTML='';
|
|
||||||
pkgAttach.forEach((a,i)=>{ const tr=document.createElement('tr');
|
|
||||||
tr.innerHTML=`<td><input type="text" value="${(a.doc||'').replace(/"/g,'"')}" placeholder="drawing / spec name" oninput="pkgAttach[${i}].doc=this.value"></td>
|
|
||||||
<td><input type="text" value="${(a.rev||'').replace(/"/g,'"')}" placeholder="0" oninput="pkgAttach[${i}].rev=this.value"></td>
|
|
||||||
<td><input type="text" value="${(a.link||'').replace(/"/g,'"')}" placeholder="link or note" oninput="pkgAttach[${i}].link=this.value"></td>
|
|
||||||
<td class="center"><button class="row-del" onclick="removeAttach(${i})">✕</button></td>`;
|
|
||||||
tb.appendChild(tr); });
|
|
||||||
}
|
|
||||||
function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); }
|
|
||||||
function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
|
||||||
|
|
||||||
// ── CONSTRAINTS + RELEASE GATE ───────────────────────────────────────────────
|
|
||||||
function buildConstraints(){
|
|
||||||
const names=constraintNames().map(n=> typeof n==='string' ? n : ((n&&n.name)||String(n)));
|
|
||||||
// preserve existing statuses if rebuilding
|
|
||||||
const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c);
|
|
||||||
pkgConstraints=names.map(n=> prev[n] || {name:n, status:'open', comment:''});
|
|
||||||
const tb=document.getElementById('constraint-body'); tb.innerHTML='';
|
|
||||||
pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr');
|
|
||||||
tr.innerHTML=`<td class="row-label">${esc(c.name)}</td>
|
|
||||||
<td><span class="cstatus">
|
|
||||||
<button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button>
|
|
||||||
<button class="${c.status==='cleared'?'on-cleared':''}" onclick="setConstraint(${i},'cleared')">Cleared</button>
|
|
||||||
<button class="${c.status==='na'?'on-na':''}" onclick="setConstraint(${i},'na')">N/A</button>
|
|
||||||
</span></td>
|
|
||||||
<td><input type="text" value="${(c.comment||'').replace(/"/g,'"')}" placeholder="note" oninput="pkgConstraints[${i}].comment=this.value"></td>`;
|
|
||||||
tb.appendChild(tr); });
|
|
||||||
}
|
|
||||||
function setConstraint(i,val){
|
|
||||||
const before=pkgConstraints[i].status;
|
|
||||||
pkgConstraints[i].status=val; buildConstraints(); updateReleaseBanner();
|
|
||||||
// If a constraint is flagged Open after the package was released, require logging the issue
|
|
||||||
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
|
|
||||||
prevStatus=getRadio('status'); holdContext={index:i, before};
|
|
||||||
openHoldModal(pkgConstraints[i].name, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
|
|
||||||
function updateReleaseBanner(){
|
|
||||||
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
|
|
||||||
let cls, txt;
|
|
||||||
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
|
|
||||||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
|
||||||
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
|
|
||||||
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
|
||||||
}
|
|
||||||
function onStatusChange(target){
|
|
||||||
const idx=STATUS_ORDER.indexOf(target);
|
|
||||||
if(idx>=ISSUED_IDX && readiness().open>0){
|
|
||||||
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
|
||||||
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
|
|
||||||
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
|
||||||
}
|
|
||||||
if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts
|
|
||||||
prevStatus=target; updateReleaseBanner(); track('status_change',{status:target});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HOLD LOG MODAL (comments 7) ──────────────────────────────────────────────
|
|
||||||
let holdPhotoData='', holdContext=null;
|
|
||||||
function openHoldModal(preselect, fromConstraint){
|
|
||||||
holdPhotoData=''; if(!fromConstraint) holdContext=null;
|
|
||||||
const sel=document.getElementById('hold-constraint');
|
|
||||||
sel.innerHTML = pkgConstraints.map(c=>`<option ${c.name===preselect?'selected':''}>${esc(c.name)}</option>`).join('') + '<option>Other</option>';
|
|
||||||
const existing = fromConstraint ? (pkgConstraints.find(c=>c.name===preselect)||{}).comment||'' : '';
|
|
||||||
document.getElementById('hold-details').value=existing;
|
|
||||||
document.getElementById('hold-doclink').value='';
|
|
||||||
document.getElementById('hold-photo-preview').innerHTML='';
|
|
||||||
document.getElementById('hold-modal').classList.add('open');
|
|
||||||
}
|
|
||||||
function holdPhotoChange(ev){
|
|
||||||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
|
||||||
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; document.getElementById('hold-photo-preview').innerHTML=`<img src="${holdPhotoData}" alt="supporting photo">`; }; r.readAsDataURL(f);
|
|
||||||
}
|
|
||||||
function submitHold(){
|
|
||||||
const constraint=document.getElementById('hold-constraint').value;
|
|
||||||
const details=document.getElementById('hold-details').value.trim();
|
|
||||||
const doclink=document.getElementById('hold-doclink').value.trim();
|
|
||||||
if(!details){ alert('A comment defining the issue is required.'); return; }
|
|
||||||
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'' });
|
|
||||||
const c=pkgConstraints.find(x=>x.name===constraint); if(c){ c.status='open'; c.comment=details; }
|
|
||||||
buildConstraints(); setRadio('status','Issue'); prevStatus='Issue'; holdContext=null;
|
|
||||||
document.getElementById('hold-modal').classList.remove('open');
|
|
||||||
updateReleaseBanner(); toast('Hold logged'); track('hold_logged',{constraint});
|
|
||||||
}
|
|
||||||
function cancelHold(){
|
|
||||||
if(holdContext){ // came from flagging a constraint on a released package -> revert that constraint
|
|
||||||
pkgConstraints[holdContext.index].status=holdContext.before; holdContext=null; buildConstraints(); updateReleaseBanner();
|
|
||||||
document.getElementById('hold-modal').classList.remove('open'); return;
|
|
||||||
}
|
|
||||||
closeHoldModal(false);
|
|
||||||
}
|
|
||||||
function closeHoldModal(committed){
|
|
||||||
document.getElementById('hold-modal').classList.remove('open');
|
|
||||||
if(!committed){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SIGN-OFFS ────────────────────────────────────────────────────────────────
|
|
||||||
function sopNameForSignoff(role){
|
|
||||||
if(!SOP) return '';
|
|
||||||
const roles=SOP.roles||[]; const proj=SOP.project||{};
|
|
||||||
const find=(re)=>{ const m=roles.find(r=>re.test(r.role||'')); return m&&m.name||''; };
|
|
||||||
switch(role){
|
|
||||||
case 'Planner': return find(/planner/i);
|
|
||||||
case 'Superintendent': return find(/superintendent/i) || proj.cm || '';
|
|
||||||
case 'HSE Professional': return find(/safety|hse|ehs/i);
|
|
||||||
case 'Quality Representative': return find(/quality/i) || proj.qm || '';
|
|
||||||
case 'Work Foreman': return find(/general foreman|foreman/i);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
function buildSignoffs(){
|
|
||||||
const prev={}; pkgSignoffs.forEach(s=>prev[s.role]=s);
|
|
||||||
pkgSignoffs=SIGNOFF_ROLES.map(r=>{
|
|
||||||
if(prev[r]) return prev[r];
|
|
||||||
return {role:r, name:sopNameForSignoff(r), date:'', signed:false, fromSOP:!!sopNameForSignoff(r)};
|
|
||||||
});
|
|
||||||
const tb=document.getElementById('signoff-body'); tb.innerHTML='';
|
|
||||||
pkgSignoffs.forEach((s,i)=>{ const tr=document.createElement('tr');
|
|
||||||
tr.innerHTML=`<td class="row-label">${esc(s.role)}</td>
|
|
||||||
<td><input type="text" value="${(s.name||'').replace(/"/g,'"')}" placeholder="name" oninput="pkgSignoffs[${i}].name=this.value">${s.fromSOP?'<span class="sop-tag">from SOP</span>':''}</td>
|
|
||||||
<td><span class="so-date">${s.date?esc(s.date):'—'}</span> <button class="lock-edit so-ovr" onclick="onSignoffDateOverride(${i})" title="Manual date override (reason required)">✎ override</button>${s.dateReason?`<span class="override-note" title="${esc(s.dateReason)}">overridden</span>`:''}</td>
|
|
||||||
<td class="center"><input type="checkbox" class="signoff-check" ${s.signed?'checked':''} onchange="onSignoffSigned(${i},this.checked)"></td>`;
|
|
||||||
tb.appendChild(tr); });
|
|
||||||
}
|
|
||||||
function todayStr(){ const d=new Date(); return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate()); }
|
|
||||||
function onSignoffSigned(i,checked){
|
|
||||||
pkgSignoffs[i].signed=checked;
|
|
||||||
if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; }
|
|
||||||
buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked});
|
|
||||||
}
|
|
||||||
function onSignoffDateOverride(i){
|
|
||||||
const s=pkgSignoffs[i];
|
|
||||||
const nd=prompt('Manual sign-off date for '+s.role+' (YYYY-MM-DD). The date is normally set automatically when Signed is checked.', s.date||todayStr());
|
|
||||||
if(nd===null) return;
|
|
||||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(nd.trim())){ alert('Enter the date as YYYY-MM-DD.'); return; }
|
|
||||||
const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:');
|
|
||||||
if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; }
|
|
||||||
s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SAVE / OUTPUT ────────────────────────────────────────────────────────────
|
|
||||||
function collectPackage(){
|
|
||||||
const steps=pkgWorkSteps.map(s=>s.trim()).filter(Boolean);
|
|
||||||
return {
|
|
||||||
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
|
||||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
|
||||||
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
|
|
||||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
|
||||||
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
|
|
||||||
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
|
|
||||||
hours:gv('wp_hours'), seq:gv('wp_seq'),
|
|
||||||
materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc),
|
|
||||||
kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'),
|
|
||||||
mimoTime:gv('wp_mimo_time'), mimoLoc:gv('wp_mimo_loc'),
|
|
||||||
constraints:pkgConstraints.map(c=>({name:c.name,status:c.status,comment:c.comment})),
|
|
||||||
qc:gv('wp_qc'), photo:gv('wp_photo'), hold:gv('wp_hold'),
|
|
||||||
qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides},
|
|
||||||
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
|
|
||||||
holds:pkgHolds.map(h=>({...h})),
|
|
||||||
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
|
|
||||||
project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'',
|
|
||||||
updatedAt:new Date().toISOString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function savePackage(view){
|
|
||||||
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
|
|
||||||
const pkg=collectPackage();
|
|
||||||
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
|
||||||
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
|
||||||
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
|
|
||||||
document.getElementById('loadingOverlay').classList.add('active');
|
|
||||||
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
|
|
||||||
}
|
|
||||||
function renderPackage(pkg){
|
|
||||||
const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0};
|
|
||||||
const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS'));
|
|
||||||
let h=`<h1>${esc(pkg.number||'(no number)')} — Work Package</h1>
|
|
||||||
<div class="doc-subtitle">${esc(pkg.project)} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}</div>`;
|
|
||||||
const costDesc = (COST_CODES.find(c=>c.split('|')[0]===pkg.cost)||'').split('|')[1];
|
|
||||||
h+=`<h2>1.0 General Information</h2><table><tbody>
|
|
||||||
<tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr>
|
|
||||||
<tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr>
|
|
||||||
<tr><th>Type</th><td>${cell(pkg.type)}</td></tr>
|
|
||||||
<tr><th>System / Facility Code / UPN</th><td>${cell(pkg.system)}</td></tr>
|
|
||||||
<tr><th>Location</th><td>${cell(pkg.location)}</td></tr>
|
|
||||||
<tr><th>Cost Code</th><td>${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}</td></tr>
|
|
||||||
<tr><th>Acumatica Task</th><td>${cell(pkg.wbs)}</td></tr>
|
|
||||||
<tr><th>Assignees</th><td>${cell(pkg.assignees)}</td></tr>
|
|
||||||
<tr><th>Distribution</th><td>${cell(pkg.distribution)}</td></tr>
|
|
||||||
<tr><th>Due Date</th><td>${cell(pkg.due)}</td></tr>
|
|
||||||
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
|
|
||||||
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
|
|
||||||
</tbody></table>`;
|
|
||||||
const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]);
|
|
||||||
const stepsHtml = stepsArr.length ? '<ol style="margin:0;padding-left:18px">'+stepsArr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>' : ns();
|
|
||||||
h+=`<h2>2.0 Scope & Work</h2><table><tbody>
|
|
||||||
<tr><th style="width:200px">Description of Work</th><td>${stepsHtml}</td></tr>
|
|
||||||
<tr><th>Labor Est. Hrs.</th><td>${cell(pkg.hours)}</td></tr>
|
|
||||||
<tr><th>Package Predecessor</th><td>${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}</td></tr>
|
|
||||||
</tbody></table>`;
|
|
||||||
if(pkg.materials&&pkg.materials.length){ h+=`<h2>3.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th></tr></thead><tbody>`;
|
|
||||||
pkg.materials.forEach(m=>h+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td></tr>`); h+=`</tbody></table>`; }
|
|
||||||
if(pkg.attachments&&pkg.attachments.length){ h+=`<h2>4.0 Drawings & Attachments</h2><table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
|
|
||||||
pkg.attachments.forEach(a=>h+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
|
|
||||||
h+=`<h2>5.0 Kitting & MIMO</h2><table><tbody>
|
|
||||||
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
|
|
||||||
<tr><th>Warehouse Owner</th><td>${cell(pkg.kitOwner)}</td></tr>
|
|
||||||
<tr><th>Kitting Need Date</th><td>${cell(pkg.kitDate)}</td></tr>
|
|
||||||
<tr><th>MIMO Sch. Time / Location</th><td>${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}</td></tr>
|
|
||||||
</tbody></table>`;
|
|
||||||
h+=`<h2>6.0 Constraints — Release Readiness</h2><table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`;
|
|
||||||
(pkg.constraints||[]).forEach(c=>{ const lbl=c.status==='cleared'?'Cleared':c.status==='na'?'N/A':'Open'; const col=c.status==='cleared'?'var(--accent-green)':c.status==='na'?'var(--text-dim)':'var(--red)';
|
|
||||||
h+=`<tr><td>${esc(c.name)}</td><td style="color:${col};font-weight:700">${lbl}</td><td>${cell(c.comment)}</td></tr>`; });
|
|
||||||
h+=`</tbody></table>`;
|
|
||||||
h+=`<h2>7.0 Quality & Hold Points</h2><table><tbody>
|
|
||||||
<tr><th style="width:200px">QC</th><td>${cell(pkg.qc)}${pkg.overrides&&pkg.overrides.wp_qc?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_qc)})</span>`:(pkg.qcFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
|
|
||||||
<tr><th>Photo Documentation</th><td>${cell(pkg.photo)}${pkg.overrides&&pkg.overrides.wp_photo?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_photo)})</span>`:(pkg.photoFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
|
|
||||||
<tr><th>Witness / Hold Points</th><td>${cell(pkg.hold)}</td></tr>
|
|
||||||
</tbody></table>`;
|
|
||||||
if(pkg.holds&&pkg.holds.length){
|
|
||||||
h+=`<h2>7.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
|
||||||
pkg.holds.forEach(hd=>{ const when=hd.ts?new Date(hd.ts).toLocaleString():''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
|
|
||||||
h+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
|
|
||||||
h+=`</tbody></table>`;
|
|
||||||
}
|
|
||||||
h+=`<h2>8.0 Approvals & Sign-offs</h2><table><thead><tr><th>Role</th><th>Name</th><th style="width:120px">Date</th><th style="width:80px">Signed</th></tr></thead><tbody>`;
|
|
||||||
(pkg.signoffs||[]).forEach(s=>h+=`<tr><td>${esc(s.role)}</td><td>${cell(s.name)}</td><td>${cell(s.date)}</td><td>${s.signed?'✓':'—'}</td></tr>`);
|
|
||||||
h+=`</tbody></table>`;
|
|
||||||
if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`<h2>9.0 Closeout</h2><table><tbody>
|
|
||||||
<tr><th style="width:200px">Actual Hrs.</th><td>${cell(pkg.actualHrs)}</td></tr>
|
|
||||||
<tr><th>Installed Quantity</th><td>${cell(pkg.installedQty)}</td></tr>
|
|
||||||
<tr><th>Redlines / As-Built</th><td>${cell(pkg.redlines)}</td></tr>
|
|
||||||
<tr><th>Lessons Learned</th><td>${cell(pkg.lessons)}</td></tr>
|
|
||||||
</tbody></table>`; }
|
|
||||||
h+=`<p style="margin-top:18px;color:var(--text-dim);font-size:11px">Pushed to: ${cell(pkg.track)}</p>`;
|
|
||||||
document.getElementById('pkg-doc').innerHTML=h; showOutput();
|
|
||||||
}
|
|
||||||
function printPackage(){
|
|
||||||
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
|
|
||||||
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#1a2230;padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid #cdd9f2;padding-bottom:4px;margin-top:22px;color:#2563d6}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:#f0f2f5;border:1px solid #ccc;padding:5px 8px;text-align:left}td{border:1px solid #ccc;padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
|
|
||||||
w.document.close(); w.print();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
|
||||||
function showOutput(){ document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
|
||||||
function showForm(){ document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
|
||||||
|
|
||||||
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
|
|
||||||
const STORE_KEY='wp_iwp_v1';
|
|
||||||
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} }
|
|
||||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} }
|
|
||||||
function renderSavedList(){
|
|
||||||
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
|
||||||
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
|
||||||
if(!savedPackages.length){ card.style.display='none'; return; }
|
|
||||||
if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display='';
|
|
||||||
body.innerHTML=savedPackages.map((p,i)=>{ const open=(p.constraints||[]).filter(c=>c.status==='open').length;
|
|
||||||
const ready = p.status==='Issue' ? '<span class="badge badge-N">On Hold</span>' : (open===0?'<span class="badge badge-Y">Ready</span>':`<span class="badge badge-O">${open} open</span>`);
|
|
||||||
return `<tr><td class="row-label">${esc(p.number||'—')}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
|
||||||
<td>${esc(p.status||'')}</td><td>${ready}</td>
|
|
||||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
|
||||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); }
|
|
||||||
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages on this device?')) return; savedPackages=[]; saveStore(); renderSavedList(); }
|
|
||||||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
|
|
||||||
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
|
||||||
function loadPackageIntoForm(p){
|
|
||||||
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
|
|
||||||
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
|
|
||||||
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
|
|
||||||
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
|
|
||||||
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
|
|
||||||
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
|
|
||||||
buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
|
|
||||||
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
|
|
||||||
set('wp_wbs',p.wbs);
|
|
||||||
document.getElementById('wp_kit_status').value=p.kitStatus||'';
|
|
||||||
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
|
|
||||||
setRadio('status',p.status||'Draft');
|
|
||||||
// number dimensions
|
|
||||||
numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims();
|
|
||||||
// overrides + locked quality/hold
|
|
||||||
pkgOverrides=p.overrides?{...p.overrides}:{};
|
|
||||||
set('wp_qc', p.qc!=null?p.qc:sopValueFor('wp_qc'));
|
|
||||||
set('wp_photo', p.photo!=null?p.photo:sopValueFor('wp_photo'));
|
|
||||||
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
|
|
||||||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
|
||||||
// collections
|
|
||||||
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
|
|
||||||
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
|
|
||||||
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
|
|
||||||
pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows();
|
|
||||||
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
|
|
||||||
pkgHolds=(p.holds||[]).map(h=>({...h}));
|
|
||||||
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm();
|
|
||||||
}
|
|
||||||
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
|
|
||||||
tmp.forEach(s=>{ const c=pkgConstraints.find(x=>x.name===s.name); if(c){ c.status=s.status; c.comment=s.comment; }});
|
|
||||||
buildConstraints(); }
|
|
||||||
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
|
||||||
|
|
||||||
function newPackage(){
|
|
||||||
editingId=null;
|
|
||||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
|
||||||
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
|
|
||||||
setRadio('status','Draft');
|
|
||||||
numberDims={}; buildNumberDims();
|
|
||||||
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
|
||||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
|
||||||
pkgWorkSteps=['']; buildWorkSteps();
|
|
||||||
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
|
|
||||||
pkgHolds=[]; pkgOverrides={};
|
|
||||||
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
|
||||||
prevStatus='Draft';
|
|
||||||
updateNumber(); updateReleaseBanner(); showForm(); track('new_package');
|
|
||||||
}
|
|
||||||
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
|
|
||||||
function exportPackages(){
|
|
||||||
if(!savedPackages.length){ alert('No packages saved yet.'); return; }
|
|
||||||
const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages};
|
|
||||||
const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a');
|
|
||||||
a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json';
|
|
||||||
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('packages_exported',{count:savedPackages.length});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
|
|
||||||
function openSopModal(){
|
|
||||||
if(!SOP){ alert('No SOP loaded.'); return; }
|
|
||||||
const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{};
|
|
||||||
const row=(k,v)=>`<tr><th style="width:170px;text-align:left;padding:4px 8px;background:var(--surface2)">${esc(k)}</th><td style="padding:4px 8px">${v||'—'}</td></tr>`;
|
|
||||||
let h=`<table style="width:100%;border-collapse:collapse;font-size:13px">`;
|
|
||||||
h+=row('Project', esc(p.name)); h+=row('Number', esc(p.number)); h+=row('Client', esc(p.client));
|
|
||||||
h+=row('Division', esc(p.division)); h+=row('PM / CM / QM', [p.pm,p.cm,p.qm].filter(Boolean).map(esc).join(' / '));
|
|
||||||
h+=row('WP number format', `<code>${esc(g.woFormat||'—')}</code>`); h+=row('Issuance', esc((g.issuance||[]).join(', ')));
|
|
||||||
h+=row('Target WP size', esc(g.woSize));
|
|
||||||
h+=row('WP types', enabledTypes().map(t=>esc(t.name)).join(', '));
|
|
||||||
h+=row('QC', esc([q.qcReq,q.qcScope].filter(Boolean).join(' — '))); h+=row('Photo', esc(q.photo));
|
|
||||||
h+=row('Hold points', esc(q.holdPoints)); h+=row('Tracking tool', esc((SOP.field&&SOP.field.trackPlatform)));
|
|
||||||
h+=row('Sequence', ((SOP.sequence||[]).map(s=>esc(s.label)+(s.kind==='gate'?' (gate)':'')).join(' → ')));
|
|
||||||
h+=row('Roles', (SOP.roles||[]).map(r=>esc(r.role)+': '+esc(r.name)).join('; '));
|
|
||||||
h+=row('Sources', (SOP.sources||[]).map(s=>esc(s.label)+' ('+esc(s.system)+')').join('; '));
|
|
||||||
h+=`</table>`;
|
|
||||||
document.getElementById('sop-modal-body').innerHTML=h;
|
|
||||||
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
|
|
||||||
}
|
|
||||||
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
|
|
||||||
const ANALYTICS_KEY='wp_iwp_analytics_v1'; const _session='s_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6);
|
|
||||||
function analyticsLoad(){ try{ return JSON.parse(localStorage.getItem(ANALYTICS_KEY))||{events:[]}; }catch(e){ return {events:[]}; } }
|
|
||||||
function analyticsSave(d){ try{ localStorage.setItem(ANALYTICS_KEY, JSON.stringify(d)); }catch(e){} }
|
|
||||||
function track(event,detail){ if(devMode) return; try{ const d=analyticsLoad(); d.events.push({ts:new Date().toISOString(),session:_session,event,detail:detail||null}); if(d.events.length>5000)d.events=d.events.slice(-5000); analyticsSave(d); }catch(e){} }
|
|
||||||
function showAnalytics(){ const d=analyticsLoad(); const by={}; const ses=new Set(); d.events.forEach(e=>{by[e.event]=(by[e.event]||0)+1;ses.add(e.session);});
|
|
||||||
let t=`USAGE ANALYTICS\n\nSessions: ${ses.size} Events: ${d.events.length}\n\nActions:\n`; Object.keys(by).forEach(k=>t+=` ${k}: ${by[k]}\n`); t+=`\nSaved packages (this device): ${savedPackages.length}\n\nDownload full log as JSON?`;
|
|
||||||
if(confirm(t)) downloadAnalytics(); }
|
|
||||||
function downloadAnalytics(){ const d=analyticsLoad(); const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='wp-iwp-usage-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('analytics_exported'); }
|
|
||||||
|
|
||||||
// ── COMMENTS ─────────────────────────────────────────────────────────────────
|
|
||||||
const COMMENTS_KEY='wp_iwp_comments_v1';
|
|
||||||
function cmtLoad(){ let d; try{ d=JSON.parse(localStorage.getItem(COMMENTS_KEY)); }catch(e){} if(!d||typeof d!=='object')d={}; if(!d.clientId)d.clientId='c_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); if(typeof d.author!=='string')d.author=''; if(!Array.isArray(d.comments))d.comments=[]; return d; }
|
|
||||||
function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d)); }catch(e){} }
|
|
||||||
function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); }
|
|
||||||
function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=document.getElementById('cmt-overlay'); const open=!dr.classList.contains('open'); dr.classList.toggle('open',open); ov.classList.toggle('open',open); dr.setAttribute('aria-hidden',open?'false':'true'); if(open){ cmtUpdateCurStep(); renderComments(); const a=document.getElementById('cmt-author'); if(a&&!a.value)a.focus(); else document.getElementById('cmt-input')?.focus(); } }
|
|
||||||
function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; }
|
|
||||||
function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); }
|
|
||||||
function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
|
||||||
function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?new Date(c.ts).toLocaleString():''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); }
|
|
||||||
function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } }
|
|
||||||
function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); }
|
|
||||||
function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); }
|
|
||||||
function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length; if(!mine){ alert('No comments to clear.'); return; } if(!confirm(`Delete your ${mine} comment(s)?`)) return; d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
|
||||||
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
|
|
||||||
|
|
||||||
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
|
||||||
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
|
|
||||||
if(e.target.tagName==='INPUT') return;
|
|
||||||
const v=p.dataset.val;
|
|
||||||
prevStatus = getRadio('status') || 'Draft'; // capture before switching (for revert)
|
|
||||||
document.querySelectorAll('#status-group .radio-pill').forEach(x=>x.classList.remove('selected'));
|
|
||||||
p.classList.add('selected'); p.querySelector('input').checked=true;
|
|
||||||
onStatusChange(v);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
|
||||||
loadStore();
|
|
||||||
(function bootSOP(){
|
|
||||||
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
|
||||||
// and prefer the SOP the Suite just completed (persisted to localStorage).
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('wp_suite_sop');
|
|
||||||
if(raw){
|
|
||||||
const d = JSON.parse(raw);
|
|
||||||
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
|
||||||
}
|
|
||||||
} catch(e){}
|
|
||||||
loadSampleSOP();
|
|
||||||
})();
|
|
||||||
setRadio('status','Draft');
|
|
||||||
renderSavedList();
|
|
||||||
cmtInit();
|
|
||||||
track('app_open');
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Work Package (IWP) — Prime Controls</title>
|
|
||||||
<link rel="icon" href="favicon.ico" sizes="any">
|
|
||||||
<link rel="stylesheet" href="theme-light.css">
|
|
||||||
<link rel="stylesheet" href="wp-creation-styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo-wrap">
|
|
||||||
<div class="header-logo">Prime Controls</div>
|
|
||||||
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
|
||||||
</div>
|
|
||||||
<div class="header-sep">|</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>
|
|
||||||
<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-first" 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" 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="showAnalytics()">▤ Usage Data</button>
|
|
||||||
</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="ctx-bar" id="ctx-bar"></div>
|
|
||||||
|
|
||||||
<!-- RELEASE READINESS BANNER -->
|
|
||||||
<div class="release-banner" id="release-banner"></div>
|
|
||||||
|
|
||||||
<div class="main">
|
|
||||||
|
|
||||||
<!-- GENERAL INFORMATION -->
|
|
||||||
<div class="card">
|
|
||||||
<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="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>Status</label>
|
|
||||||
<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="Scheduled"><input type="radio" name="status"><span class="dot"></span>Scheduled</label>
|
|
||||||
<label class="radio-pill" data-val="Issued"><input type="radio" name="status"><span class="dot"></span>Issued</label>
|
|
||||||
<label class="radio-pill" data-val="In Progress"><input type="radio" name="status"><span class="dot"></span>In Progress</label>
|
|
||||||
<label class="radio-pill pill-hold" data-val="Issue"><input type="radio" name="status"><span class="dot"></span>Issue (Hold)</label>
|
|
||||||
<label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label>
|
|
||||||
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
|
|
||||||
</div>
|
|
||||||
<div class="field-hint">Cannot move to <strong>Issued</strong> or beyond until all constraints are cleared.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notice">WP number builds automatically from these scope fields + the WP type (per the SOP naming format):</div>
|
|
||||||
<div class="field-grid" id="number-dims"></div>
|
|
||||||
<div class="field field-grid col1"><div class="field"><label>Subject / Title <span class="req">*</span></label><input type="text" id="wp_subject" placeholder="e.g. Utility Level 2P Inert Gas Room Wall Mount Midas/ Rack"></div></div>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>WP Type <span class="req">*</span></label><select id="wp_type" onchange="onTypeChange()"></select><div class="field-hint sop-hint">from SOP types</div></div>
|
|
||||||
<div class="field"><label>System / Facility Code / UPN</label><input type="text" id="wp_system" placeholder="ties to controls.dev / COIN"></div>
|
|
||||||
<div class="field"><label>Location</label><input type="text" id="wp_location" placeholder="building / level / sector / room"></div>
|
|
||||||
<div class="field"><label>Cost Code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
|
|
||||||
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
|
||||||
</div>
|
|
||||||
<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>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></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>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- SCOPE & WORK -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="sub-heading">Scope & Work</div>
|
|
||||||
<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 id="worksteps-body"></div>
|
|
||||||
<button class="add-btn" onclick="addWorkStep()">+ Add Step</button>
|
|
||||||
</div>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>Labor – Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20"><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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- MATERIAL LIST -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="sub-heading">Material 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 style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
|
||||||
<div class="material-actions">
|
|
||||||
<button class="add-btn" onclick="addMaterial()">+ Add Material Line</button>
|
|
||||||
<button class="add-btn" onclick="document.getElementById('material-import').click()">⤒ Import from Excel/CSV</button>
|
|
||||||
<button class="add-btn" onclick="downloadMaterialTemplate()">⤓ Download Template</button>
|
|
||||||
<input type="file" id="material-import" accept=".csv,.xlsx,.xls" style="display:none" onchange="importMaterials(event)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DRAWINGS / ATTACHMENTS -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="sub-heading">Drawings & Attachments</div>
|
|
||||||
<div id="sop-ref-links" class="sop-ref-links"></div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>Document / Drawing</th><th style="width:90px">Rev</th><th>Link / Note</th><th style="width:44px"></th></tr></thead><tbody id="attach-body"></tbody></table></div>
|
|
||||||
<button class="add-btn" onclick="addAttach()">+ Add Document</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KITTING & MIMO -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>Kitting Status</label>
|
|
||||||
<select id="wp_kit_status"><option value="">—</option><option>Open</option><option>In Progress</option><option>Kitted</option><option>Delivered</option></select></div>
|
|
||||||
<div class="field"><label>Warehouse Owner</label><input type="text" id="wp_kit_owner" placeholder="name"></div>
|
|
||||||
<div class="field"><label>Kitting Need Date</label><input type="date" id="wp_kit_date"></div>
|
|
||||||
<div class="field"><label>MIMO Sch. Time</label><input type="datetime-local" id="wp_mimo_time"><div class="field-hint">scheduled material-move date & time</div></div>
|
|
||||||
<div class="field"><label>MIMO Location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONSTRAINTS / RELEASE READINESS -->
|
|
||||||
<div class="card" id="constraint-card">
|
|
||||||
<div class="sub-heading">Constraints — Release Readiness</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>
|
|
||||||
|
|
||||||
<!-- QUALITY & HOLD POINTS -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>QC Required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
|
|
||||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
|
||||||
<div class="field"><label>Photo Documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
|
|
||||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="field field-grid col1"><div class="field"><label>Witness / Hold Points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
|
|
||||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div>
|
|
||||||
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SIGN-OFFS -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="sub-heading">Approvals & Sign-offs</div>
|
|
||||||
<div class="notice">Per the AWP IWP checklist. A package should be signed by these roles before release.</div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th style="width:220px">Role</th><th>Name</th><th style="width:150px">Date</th><th style="width:80px;text-align:center">Signed</th></tr></thead><tbody id="signoff-body"></tbody></table></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CLOSEOUT -->
|
|
||||||
<div class="card" id="closeout-card">
|
|
||||||
<div class="sub-heading">Closeout</div>
|
|
||||||
<div class="notice">Completed at QC / Closed — captures as-built reality and lessons learned.</div>
|
|
||||||
<div class="field-grid">
|
|
||||||
<div class="field"><label>Actual Hrs.</label><input type="number" id="wp_actual_hrs" min="0" step="1"></div>
|
|
||||||
<div class="field"><label>Installed Quantity</label><input type="text" id="wp_installed_qty" placeholder="e.g. 42 of 42 tags"></div>
|
|
||||||
</div>
|
|
||||||
<div class="field field-grid col1"><div class="field"><label>Redlines / As-Built Notes</label><textarea id="wp_redlines" rows="2"></textarea></div></div>
|
|
||||||
<div class="field field-grid col1"><div class="field"><label>Lessons Learned</label><textarea id="wp_lessons" rows="2"></textarea></div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
|
||||||
<div style="display:flex;gap:10px">
|
|
||||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
|
||||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
|
||||||
</div></div>
|
|
||||||
|
|
||||||
<!-- OUTPUT -->
|
|
||||||
<div id="pkg-output" style="display:none">
|
|
||||||
<div class="output-toolbar">
|
|
||||||
<button class="btn btn-ghost" onclick="showForm()">← Back to Form</button>
|
|
||||||
<button class="btn btn-primary" onclick="printPackage()">⎙ Print / Save PDF</button>
|
|
||||||
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
|
|
||||||
</div>
|
|
||||||
<div class="output-doc" id="pkg-doc"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SAVED -->
|
|
||||||
<div class="card" id="saved-card" style="display:none">
|
|
||||||
<div class="sub-heading">Saved Work Packages <span id="saved-count"></span></div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>WP #</th><th>Type</th><th>Subject</th><th>Status</th><th style="width:110px">Ready?</th><th style="width:120px"></th></tr></thead><tbody id="saved-body"></tbody></table></div>
|
|
||||||
<button class="add-btn" onclick="exportPackages()">⤓ Export All (JSON)</button>
|
|
||||||
<button class="add-btn" onclick="clearSaved()">Clear All</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- HOLD LOG MODAL (comment 7) -->
|
|
||||||
<div class="modal-overlay" id="hold-modal">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-head"><div class="modal-title">Log Hold — On-Hold Constraint</div><button class="cmt-x" onclick="cancelHold()" title="Cancel">✕</button></div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="notice">Moving a package to <strong>Issue (Hold)</strong> requires logging the constraint that blocked it.</div>
|
|
||||||
<div class="field"><label>Constraint type <span class="req">*</span></label><select id="hold-constraint"></select></div>
|
|
||||||
<div class="field"><label>Details <span class="req">*</span></label><textarea id="hold-details" rows="3" placeholder="What reopened / blocked this package?"></textarea></div>
|
|
||||||
<div class="field"><label>Supporting document link</label><input type="text" id="hold-doclink" placeholder="link to RFI, photo, email, etc. (optional)"></div>
|
|
||||||
<div class="field"><label>Supporting photo</label><input type="file" id="hold-photo" accept="image/*" onchange="holdPhotoChange(event)"><div class="hold-photo-preview" id="hold-photo-preview"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-foot"><button class="btn btn-ghost" onclick="cancelHold()">Cancel</button><button class="btn btn-generate" onclick="submitHold()">Log Hold</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- VIEW SOP MODAL (comment 2) -->
|
|
||||||
<div class="modal-overlay" id="sop-modal">
|
|
||||||
<div class="modal" style="max-width:640px">
|
|
||||||
<div class="modal-head"><div class="modal-title">Project SOP — Reference</div><button class="cmt-x" onclick="closeSopModal()" title="Close">✕</button></div>
|
|
||||||
<div class="modal-body" id="sop-modal-body"></div>
|
|
||||||
<div class="modal-foot"><button class="btn btn-primary" onclick="closeSopModal()">Close</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- COMMENTS DRAWER -->
|
|
||||||
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div>
|
|
||||||
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
|
|
||||||
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
|
|
||||||
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
|
|
||||||
<div class="cmt-compose"><div class="cmt-compose-label">Comment on <strong id="cmt-cur-step">this form</strong></div>
|
|
||||||
<textarea id="cmt-input" rows="3" placeholder="Add feedback…"></textarea>
|
|
||||||
<button class="btn btn-primary cmt-add" onclick="addComment()">Add Comment</button></div>
|
|
||||||
<div class="cmt-list" id="cmt-list"></div>
|
|
||||||
<div class="cmt-foot"><div class="cmt-note">Comments are saved in your browser. Use <strong>Export</strong> to send feedback back; the owner can <strong>Import</strong> each file.</div>
|
|
||||||
<div class="cmt-foot-btns"><button class="btn btn-ghost" onclick="exportComments()">⤓ Export</button>
|
|
||||||
<button class="btn btn-ghost" onclick="document.getElementById('cmt-import').click()">⤒ Import</button>
|
|
||||||
<button class="btn btn-ghost cmt-clear" onclick="clearMyComments()">Clear Mine</button>
|
|
||||||
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
|
||||||
<script src="wp-creation-app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user