Compare commits
9 Commits
b38348e6ae
...
docs/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
| 153fe97a31 | |||
| 928ab8c900 | |||
| e5977758c0 | |||
| a9b22f2add | |||
| e3527a6e1d | |||
| 917a728399 | |||
| 40bd19b6cf | |||
| 6c3098922f | |||
| fcba74b584 |
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.
|
||||||
129
DEPLOYMENT.md
129
DEPLOYMENT.md
@@ -209,11 +209,11 @@ users on the same project see the same server-stored SOP and Work Packages.
|
|||||||
|
|
||||||
| Table | Holds | Key columns |
|
| Table | Holds | Key columns |
|
||||||
|-------|-------|-------------|
|
|-------|-------|-------------|
|
||||||
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `archived_at`, `data` |
|
||||||
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||||
| `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) |
|
| `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` |
|
| `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`, login-lockout + `token_version` fields |
|
| `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 |
|
| `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` |
|
| `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) |
|
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||||
@@ -224,18 +224,23 @@ column; frequently-listed fields are promoted to real columns for filtering.
|
|||||||
|
|
||||||
### Endpoints (summary)
|
### Endpoints (summary)
|
||||||
|
|
||||||
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
|
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}` ·
|
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}`,
|
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`,
|
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
|
||||||
`GET /api/wps/metrics` ·
|
`GET /api/wps/metrics` ·
|
||||||
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
||||||
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
||||||
under `/api/auth/users` · Admin-only `GET/PUT /api/settings`,
|
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`,
|
`POST /api/settings/test-email`, `GET /api/notifications`,
|
||||||
`GET /api/projects/{id}/members`.
|
`GET /api/projects/{id}/members`.
|
||||||
List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
|
List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects`
|
||||||
and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -416,8 +421,9 @@ not get a second bar.
|
|||||||
- Switching project reloads the current page with `?project=<id>`; every page
|
- Switching project reloads the current page with `?project=<id>`; every page
|
||||||
already resolves its project from that parameter.
|
already resolves its project from that parameter.
|
||||||
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
|
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
|
||||||
(`scope_to_access`) and hides archived work packages. LIKE wildcards in the query
|
(`scope_to_access`) and hides archived work packages, archived projects, and
|
||||||
are escaped, so searching `100%` matches a literal `100%`. Two-character minimum.
|
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.
|
- Ctrl/Cmd-K focuses the field from anywhere.
|
||||||
|
|
||||||
## Schema migrations (Alembic)
|
## Schema migrations (Alembic)
|
||||||
@@ -446,3 +452,110 @@ 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)
|
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/`
|
§ *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).
|
(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'
|
||||||
|
> ```
|
||||||
|
|||||||
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.
|
||||||
309
html/admin.html
309
html/admin.html
@@ -5,64 +5,207 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Admin Console — Work Package Suite</title>
|
<title>Admin Console — Work Package Suite</title>
|
||||||
<script src="auth-guard.js"></script>
|
<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">
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
<meta name="theme-color" content="#161616">
|
<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="wp-chrome.css">
|
||||||
<style>
|
<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;
|
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
|
||||||
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
|
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
|
||||||
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd; --mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace; }
|
--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; }
|
*{ 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); }
|
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
|
||||||
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
|
|
||||||
h1{ font-size:20px; margin:0 0 2px; }
|
/* ══ PAGE ════════════════════════════════════════════════════════════════
|
||||||
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
|
1240px, not 860: the user table is nine columns wide and at 860 it spilled
|
||||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:18px 20px; margin-bottom:16px; }
|
straight out of its own white card. Wide enough for that table, still a
|
||||||
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
|
readable measure for the prose, which is capped separately. */
|
||||||
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; padding:8px 14px; cursor:pointer;
|
.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); }
|
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
|
||||||
button:hover{ border-color:var(--accent); color:var(--accent); }
|
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{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
button.primary:hover{ background:#1e54bb; 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{ border-color:var(--red); color:var(--red); }
|
||||||
button.danger:hover{ background:var(--red-bg); }
|
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
|
||||||
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||||
.banner{ padding:10px 14px; border-radius:0; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
|
/* The filter / search / button strip at the top of a card. */
|
||||||
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
|
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
|
||||||
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
|
.toolbar + .banner{ margin-top:0; }
|
||||||
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:12px 14px; font-family:var(--mono);
|
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||||
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
|
/* 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; }
|
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
|
||||||
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
|
table.kv{ border-collapse:collapse; font-size:13px; margin-top:var(--s2); }
|
||||||
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
|
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:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
|
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
|
||||||
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
|
|
||||||
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
|
/* ══ DATA TABLES ═════════════════════════════════════════════════════════
|
||||||
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
|
table.users is the name admin.js already emits; table.grid is the same
|
||||||
.gate-box h2{ margin:0 0 4px; font-size:17px; }
|
object under the shared name. One rule set serves both, so existing markup
|
||||||
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
|
picks up the dense styling without being rewritten. border-collapse is
|
||||||
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:12px; }
|
separate rather than collapse because a collapsed border does not travel
|
||||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
|
with a sticky header. */
|
||||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:16px; }
|
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
|
||||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
|
font-size:13px; color:var(--text); background:var(--surface); }
|
||||||
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
|
||||||
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
|
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
|
||||||
border-radius:0; background:#fff; color:var(--text); }
|
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
|
||||||
.urow input{ flex:1; min-width:130px; }
|
box-shadow:inset 0 -1px 0 var(--border); }
|
||||||
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
|
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and
|
||||||
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
|
the table stops reading as rows. Anything genuinely long truncates (.ell)
|
||||||
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
or is exempted by name further down. */
|
||||||
table.users tr:last-child td{ border-bottom:none; }
|
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
|
||||||
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
|
vertical-align:middle; white-space:nowrap; }
|
||||||
.tag.admin{ background:#edf5ff; color:#0f62fe; } .tag.user{ background:#e8e8e8; color:#525252; }
|
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
|
||||||
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
|
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
|
||||||
button.mini{ padding:4px 9px; font-size:12px; }
|
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
|
||||||
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
|
projects" pill sitting on its own colour disappears the moment you hover it. */
|
||||||
select.role-select{ padding:4px 8px; font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); cursor:pointer; }
|
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:hover{ border-color:var(--accent); }
|
||||||
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:700; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -79,34 +222,34 @@
|
|||||||
<div class="wrap" id="admin-denied" style="display:none">
|
<div class="wrap" id="admin-denied" style="display:none">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Admins only</h2>
|
<h2>Admins only</h2>
|
||||||
<p class="sub" style="margin:0 0 12px">Your account doesn’t have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
|
<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 class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONSOLE -->
|
<!-- CONSOLE -->
|
||||||
<div class="wrap" id="admin-main" style="display:none">
|
<div class="wrap" id="admin-main" style="display:none">
|
||||||
<div class="row" style="justify-content:space-between">
|
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
|
||||||
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics & tests · talks to <code>/api</code> on this host</div></div>
|
<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 class="row"><a class="home" href="index.html">← Site</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONNECTIVITY -->
|
<!-- CONNECTIVITY -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>API connectivity</h2>
|
<h2>API connectivity</h2>
|
||||||
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
|
<div class="toolbar"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
|
||||||
<div class="banner" id="health-banner">—</div>
|
<div class="banner" id="health-banner">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- USER ADMINISTRATION -->
|
<!-- USER ADMINISTRATION -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>User administration</h2>
|
<h2>User administration</h2>
|
||||||
<div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
<div class="sub">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||||
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
|
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
|
||||||
<div id="users-banner"></div>
|
<div id="users-banner"></div>
|
||||||
<div id="users-table" style="margin-top:12px"></div>
|
<div id="users-table"></div>
|
||||||
|
|
||||||
<h2 style="margin-top:22px">Add a user</h2>
|
<h2 style="margin-top:var(--s6)">Add a user</h2>
|
||||||
<div class="urow">
|
<div class="urow">
|
||||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||||
@@ -123,17 +266,44 @@
|
|||||||
<div id="users-create-msg" class="note"></div>
|
<div id="users-create-msg" class="note"></div>
|
||||||
</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 -->
|
<!-- FEATURE FLAGS -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Features</h2>
|
<h2>Features</h2>
|
||||||
<div class="sub" style="margin-bottom:10px">Switches that change what the suite offers on every project.</div>
|
<div class="sub">Switches that change what the suite offers on every project.</div>
|
||||||
<div id="features-box" class="note">Loading…</div>
|
<div id="features-box" class="note">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NOTIFICATIONS / EMAIL -->
|
<!-- NOTIFICATIONS / EMAIL -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Notifications & email</h2>
|
<h2>Notifications & email</h2>
|
||||||
<div class="sub" style="margin-bottom:10px">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 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="settings-box" class="note">Loading…</div>
|
||||||
<div id="notif-box" class="note" style="margin-top:14px"></div>
|
<div id="notif-box" class="note" style="margin-top:14px"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,20 +311,20 @@
|
|||||||
<!-- ALL FEEDBACK / COMMENTS -->
|
<!-- ALL FEEDBACK / COMMENTS -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>All feedback & comments</h2>
|
<h2>All feedback & comments</h2>
|
||||||
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page & step) when they commented.</div>
|
<div class="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="row">
|
<div class="toolbar">
|
||||||
<button onclick="loadComments()">Refresh comments</button>
|
<button onclick="loadComments()">Refresh comments</button>
|
||||||
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
|
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
|
||||||
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
|
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()">
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
<div id="comments-admin" class="note">Click refresh to load.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
|
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Activity log</h2>
|
<h2>Activity log</h2>
|
||||||
<div class="sub" style="margin-bottom:10px">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
|
<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="row">
|
<div class="toolbar">
|
||||||
<button onclick="loadAudit()">Refresh</button>
|
<button onclick="loadAudit()">Refresh</button>
|
||||||
<select id="audit-type" onchange="renderAudit()">
|
<select id="audit-type" onchange="renderAudit()">
|
||||||
<option value="">All types</option>
|
<option value="">All types</option>
|
||||||
@@ -163,42 +333,42 @@
|
|||||||
<option value="project">Projects</option>
|
<option value="project">Projects</option>
|
||||||
<option value="user">User accounts</option>
|
<option value="user">User accounts</option>
|
||||||
</select>
|
</select>
|
||||||
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
|
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()">
|
||||||
</div>
|
</div>
|
||||||
<div id="audit-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
<div id="audit-admin" class="note">Click refresh to load.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- USAGE LOGS -->
|
<!-- USAGE LOGS -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Usage logs</h2>
|
<h2>Usage logs</h2>
|
||||||
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
|
<div class="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="row">
|
<div class="toolbar">
|
||||||
<button onclick="loadUsage()">Refresh</button>
|
<button onclick="loadUsage()">Refresh</button>
|
||||||
<button onclick="downloadUsage()">Download JSON</button>
|
<button onclick="downloadUsage()">Download JSON</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
<div id="usage-admin" class="note">Click refresh to load.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DB SNAPSHOT -->
|
<!-- DB SNAPSHOT -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Database snapshot</h2>
|
<h2>Database snapshot</h2>
|
||||||
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
|
<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 id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMOKE TEST -->
|
<!-- SMOKE TEST -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>End-to-end smoke test</h2>
|
<h2>End-to-end smoke test</h2>
|
||||||
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
|
<div class="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="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
|
<div class="toolbar"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
|
||||||
<pre class="out" id="smoke-out">Ready.</pre>
|
<pre class="out" id="smoke-out">Ready.</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DEMO DATA -->
|
<!-- DEMO DATA -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Demo data</h2>
|
<h2>Demo data</h2>
|
||||||
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
|
<div class="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="row">
|
<div class="toolbar">
|
||||||
<button class="primary" onclick="seedDemo()">Seed demo project</button>
|
<button class="primary" onclick="seedDemo()">Seed demo project</button>
|
||||||
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
|
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +378,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="wp-format.js"></script>
|
|
||||||
<script src="wp-chrome.js"></script>
|
<script src="wp-chrome.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
377
html/admin.js
377
html/admin.js
@@ -12,6 +12,8 @@ function reveal(){
|
|||||||
fillProjectRoleOptions();
|
fillProjectRoleOptions();
|
||||||
checkHealth();
|
checkHealth();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
loadProjects();
|
||||||
|
loadDefaultMembers();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
loadComments();
|
loadComments();
|
||||||
@@ -53,15 +55,18 @@ async function checkHealth(){
|
|||||||
// ── db snapshot ───────────────────────────────────────────────────────────────
|
// ── db snapshot ───────────────────────────────────────────────────────────────
|
||||||
async function snapshot(){
|
async function snapshot(){
|
||||||
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
||||||
|
// archived=all: /api/projects now hides archived projects by default, and a row
|
||||||
|
// count that silently drops them is not a snapshot of the database.
|
||||||
const [p,s,w,c] = await Promise.all([
|
const [p,s,w,c] = await Promise.all([
|
||||||
api('GET','/api/projects'), api('GET','/api/sops'),
|
api('GET','/api/projects?archived=all'), api('GET','/api/sops'),
|
||||||
api('GET','/api/wps'), api('GET','/api/comments')]);
|
api('GET','/api/wps'), api('GET','/api/comments')]);
|
||||||
if(p.status!==200){
|
if(p.status!==200){
|
||||||
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
|
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
|
||||||
}
|
}
|
||||||
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
|
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
|
||||||
|
const archived = Array.isArray(p.json) ? p.json.filter(x => x && x.archived).length : 0;
|
||||||
out.innerHTML = `<table class="kv">
|
out.innerHTML = `<table class="kv">
|
||||||
<tr><th>Projects</th><td>${n(p)}</td></tr>
|
<tr><th>Projects</th><td>${n(p)}${archived ? ` <span class="note">(${archived} archived)</span>` : ''}</td></tr>
|
||||||
<tr><th>SOPs</th><td>${n(s)}</td></tr>
|
<tr><th>SOPs</th><td>${n(s)}</td></tr>
|
||||||
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
|
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
|
||||||
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
|
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
|
||||||
@@ -95,6 +100,14 @@ async function runSmokeTest(){
|
|||||||
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
|
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
|
||||||
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
|
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
|
||||||
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
|
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
|
||||||
|
// Archive round-trip: out of the default list, still there with archived=all,
|
||||||
|
// frozen against writes, and all three undone by unarchiving.
|
||||||
|
r = await api('POST','/api/projects/'+pid+'/archive',{archived:true}); chk('archive project', r.status===200 && r.json.archived===true, 'status '+r.status);
|
||||||
|
r = await api('GET','/api/projects'); chk('archived project leaves the default list', r.status===200 && !r.json.some(p=>p.id===pid));
|
||||||
|
r = await api('GET','/api/projects?archived=all'); chk('archived project visible with archived=all', r.status===200 && r.json.some(p=>p.id===pid));
|
||||||
|
r = await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'edited while archived',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40'}});
|
||||||
|
chk('write to an archived project refused (409)', r.status===409, 'status '+r.status);
|
||||||
|
r = await api('POST','/api/projects/'+pid+'/archive',{archived:false}); chk('unarchive project', r.status===200 && r.json.archived===false, 'status '+r.status);
|
||||||
} catch(e){ chk('unexpected error', false, String(e)); }
|
} catch(e){ chk('unexpected error', false, String(e)); }
|
||||||
finally {
|
finally {
|
||||||
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
|
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
|
||||||
@@ -141,7 +154,9 @@ async function seedDemo(){
|
|||||||
async function cleanDemo(){
|
async function cleanDemo(){
|
||||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||||
const r = await api('GET','/api/projects');
|
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
|
||||||
|
// this button — the default list hides it and nothing else here can delete it.
|
||||||
|
const r = await api('GET','/api/projects?archived=all');
|
||||||
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
||||||
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
||||||
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||||||
@@ -153,6 +168,22 @@ async function cleanDemo(){
|
|||||||
// ── user administration ────────────────────────────────────────────────────────
|
// ── user administration ────────────────────────────────────────────────────────
|
||||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
|
||||||
|
// for the JS string literal it lands in, and again for the HTML attribute carrying
|
||||||
|
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
|
||||||
|
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
|
||||||
|
// survives, and the browser decodes the entities before the JS parser runs.
|
||||||
|
//
|
||||||
|
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
|
||||||
|
// value containing a backslash: the \ we add is itself escaped by the stored one,
|
||||||
|
// the quote closes the literal, and everything after it runs as code. Project names
|
||||||
|
// and full names are free text that any signed-in user can write, so that is a real
|
||||||
|
// path from a project_user to whatever an admin's session can do. Use jsq() for
|
||||||
|
// EVERY value that lands inside an inline handler.
|
||||||
|
function jsq(v){
|
||||||
|
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
|
||||||
|
}
|
||||||
|
|
||||||
async function currentUserId(){
|
async function currentUserId(){
|
||||||
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||||||
const { status, json } = await api('GET','/api/auth/me');
|
const { status, json } = await api('GET','/api/auth/me');
|
||||||
@@ -178,6 +209,9 @@ async function loadUsers(){
|
|||||||
banner.style.display='none';
|
banner.style.display='none';
|
||||||
const meId = await currentUserId();
|
const meId = await currentUserId();
|
||||||
renderUsers(json, meId);
|
renderUsers(json, meId);
|
||||||
|
// Fill in the project-access counts, then repaint that column.
|
||||||
|
await loadProjectCounts(json);
|
||||||
|
renderUsers(json, meId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
||||||
@@ -197,6 +231,42 @@ function fillProjectRoleOptions(){
|
|||||||
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user project access gets its own column: it was buried among the action
|
||||||
|
// buttons, which is exactly where you'd fail to find "which projects can this
|
||||||
|
// person see, and what may they do there".
|
||||||
|
let _userProjectCounts = {}; // user id -> number of assigned projects
|
||||||
|
|
||||||
|
function projAccessCell(u){
|
||||||
|
const uname = jsq(u.username);
|
||||||
|
if(normRole(u.role) === 'admin'){
|
||||||
|
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
|
||||||
|
}
|
||||||
|
const n = _userProjectCounts[u.id];
|
||||||
|
const label = (n === undefined) ? 'Projects…'
|
||||||
|
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
|
||||||
|
return '<button class="mini' + (n === 0 ? ' danger' : '') +
|
||||||
|
'" onclick="manageProjects(\'' + jsq(u.id) + '\',\'' + uname + '\')"' +
|
||||||
|
' title="Choose which projects this user can access, and their role on each">' +
|
||||||
|
label + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project's role dropdown only matters while that project is ticked.
|
||||||
|
function projRowToggled(cb){
|
||||||
|
const row = cb.closest('div');
|
||||||
|
const sel = row && row.querySelector('select');
|
||||||
|
if(sel) sel.disabled = !cb.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts for that column. One call per user, but only for non-admins and only on a
|
||||||
|
// refresh — the admin console is not a hot path.
|
||||||
|
async function loadProjectCounts(list){
|
||||||
|
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
|
||||||
|
await Promise.all(targets.map(async u => {
|
||||||
|
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
|
||||||
|
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function renderUsers(list, meId){
|
function renderUsers(list, meId){
|
||||||
const wrap=document.getElementById('users-table');
|
const wrap=document.getElementById('users-table');
|
||||||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||||
@@ -206,19 +276,20 @@ function renderUsers(list, meId){
|
|||||||
const active = u.is_active;
|
const active = u.is_active;
|
||||||
const disableBtn = me
|
const disableBtn = me
|
||||||
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||||||
: '<button class="mini" onclick="toggleActive(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
: '<button class="mini" onclick="toggleActive(\''+jsq(u.id)+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||||||
const delBtn = me
|
const delBtn = me
|
||||||
? ''
|
? ''
|
||||||
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
|
: '<button class="mini danger" onclick="deleteUser(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')">Delete</button>';
|
||||||
// Role can be changed at any time via an inline dropdown. Your own row is
|
// Role can be changed at any time via an inline dropdown. Your own row is
|
||||||
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
|
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
|
||||||
const escUname = uesc(u.username).replace(/'/g,"\\'");
|
const escUname = jsq(u.username);
|
||||||
|
const escUid = jsq(u.id);
|
||||||
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
|
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
|
||||||
// a tag) so an admin can't accidentally demote themselves.
|
// a tag) so an admin can't accidentally demote themselves.
|
||||||
const role = normRole(u.role);
|
const role = normRole(u.role);
|
||||||
const roleCell = me
|
const roleCell = me
|
||||||
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
|
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
|
||||||
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
|
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||||
PERM_ROLES.map(function(r){
|
PERM_ROLES.map(function(r){
|
||||||
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
|
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
|
||||||
}).join('')+
|
}).join('')+
|
||||||
@@ -226,7 +297,7 @@ function renderUsers(list, meId){
|
|||||||
// PROJECT role — the person's job function. Descriptive only; grants nothing.
|
// PROJECT role — the person's job function. Descriptive only; grants nothing.
|
||||||
const pr = u.project_role || '';
|
const pr = u.project_role || '';
|
||||||
const projRoleCell =
|
const projRoleCell =
|
||||||
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
|
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||||
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
||||||
PROJECT_ROLES.map(function(r){
|
PROJECT_ROLES.map(function(r){
|
||||||
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
|
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
|
||||||
@@ -237,24 +308,29 @@ function renderUsers(list, meId){
|
|||||||
return '<tr>'+
|
return '<tr>'+
|
||||||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||||
'<td>'+uesc(u.email||'')+'</td>'+
|
// The address is truncated with the full value on the title: a long one used
|
||||||
|
// to wrap mid-word and push the whole row onto three lines.
|
||||||
|
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
|
||||||
'<td>'+roleCell+'</td>'+
|
'<td>'+roleCell+'</td>'+
|
||||||
'<td>'+projRoleCell+'</td>'+
|
'<td>'+projRoleCell+'</td>'+
|
||||||
|
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
|
||||||
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||||
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
|
'<td><div class="cellactions">'+
|
||||||
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
|
'<button class="mini" onclick="resetPw(\''+escUid+'\',\''+escUname+'\')">Reset password</button>'+
|
||||||
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
|
|
||||||
disableBtn+delBtn+
|
disableBtn+delBtn+
|
||||||
'</div></td>'+
|
'</div></td>'+
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
wrap.innerHTML='<table class="users"><thead><tr>'+
|
// Nine columns outrun even the widened card, so the table scrolls inside .tscroll
|
||||||
|
// rather than forcing every cell to wrap. The explanatory note stays outside it.
|
||||||
|
wrap.innerHTML='<div class="tscroll"><table class="users grid"><thead><tr>'+
|
||||||
'<th>Username</th><th>Name</th><th>Email</th>'+
|
'<th>Username</th><th>Name</th><th>Email</th>'+
|
||||||
'<th title="What this account may do in the app">Permissions</th>'+
|
'<th title="What this account may do in the app">Permissions</th>'+
|
||||||
'<th title="Job function on the project — descriptive only">Project role</th>'+
|
'<th title="Job function on the project — descriptive only">Project role</th>'+
|
||||||
|
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
|
||||||
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||||
'</tr></thead><tbody>'+rows+'</tbody></table>'+
|
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||||
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||||||
'<em>Administrator</em>: manages users, settings and every project. '+
|
'<em>Administrator</em>: manages users, settings and every project. '+
|
||||||
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
|
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
|
||||||
@@ -333,25 +409,50 @@ async function deleteUser(id, username){
|
|||||||
async function manageProjects(id, username){
|
async function manageProjects(id, username){
|
||||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||||
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||||||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
|
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{});
|
||||||
}
|
}
|
||||||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||||
function openProjectModal(userId, username, projects, assigned, userObj){
|
function openProjectModal(userId, username, projects, assigned, userObj, roles){
|
||||||
closeProjectModal();
|
closeProjectModal();
|
||||||
const isAdmin = userObj && userObj.role==='admin';
|
const isAdmin = userObj && normRole(userObj.role)==='admin';
|
||||||
const items = projects.length ? projects.map(p =>
|
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
|
||||||
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
|
roles = roles || {};
|
||||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
|
// Each project row: access tick + the role ON THAT project. "Same as account"
|
||||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
// inherits the account's Permissions, so the common case needs no thought.
|
||||||
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
|
// Live jobs first — an archived one is still listed (an existing assignment has to
|
||||||
|
// stay removable) but it is finished work, so it doesn't belong at the top of a
|
||||||
|
// list you're using to staff someone.
|
||||||
|
projects = projects.slice().sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
|
||||||
|
const items = projects.length ? projects.map(p => {
|
||||||
|
const on = assigned.has(p.id);
|
||||||
|
const cur = roles[p.id] || '';
|
||||||
|
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
|
||||||
|
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
|
||||||
|
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
|
||||||
|
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||||
|
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||||
|
'</select>';
|
||||||
|
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
|
||||||
|
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
|
||||||
|
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
|
||||||
|
' onchange="projRowToggled(this)">'+
|
||||||
|
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||||
|
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
|
||||||
|
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+'</span>'+
|
||||||
|
'</label>'+ sel +
|
||||||
|
'</div>';
|
||||||
|
}).join('') : '<div class="note">No projects exist yet.</div>';
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.id = 'proj-modal';
|
modal.id = 'proj-modal';
|
||||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
||||||
modal.innerHTML =
|
modal.innerHTML =
|
||||||
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
'<div style="background:#fff;border-radius:10px;max-width:660px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
|
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access & permissions — '+uesc(username)+'</div>'+
|
||||||
'<div style="padding:14px 18px;overflow:auto;">'+
|
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||||
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
|
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can access every project regardless of assignment.</div>'
|
||||||
|
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role on each. '+
|
||||||
|
'<strong>Project Admin</strong> can delete work packages, change a completed SOP and delete that project; '+
|
||||||
|
'<strong>Project User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
|
||||||
'<div id="proj-list">'+items+'</div>'+
|
'<div id="proj-list">'+items+'</div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||||
@@ -364,12 +465,234 @@ function openProjectModal(userId, username, projects, assigned, userObj){
|
|||||||
const saveBtn = document.getElementById('proj-save');
|
const saveBtn = document.getElementById('proj-save');
|
||||||
if(saveBtn) saveBtn.onclick = async () => {
|
if(saveBtn) saveBtn.onclick = async () => {
|
||||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||||||
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
|
const roleMap = {};
|
||||||
if(status===200) closeProjectModal();
|
ids.forEach(pid => {
|
||||||
|
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
|
||||||
|
if(sel && sel.value) roleMap[pid] = sel.value;
|
||||||
|
});
|
||||||
|
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
|
||||||
|
if(status===200){ closeProjectModal(); loadUsers(); }
|
||||||
else alert('Save failed (HTTP '+status+').');
|
else alert('Save failed (HTTP '+status+').');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── projects: archive / unarchive ───────────────────────────────────────────────
|
||||||
|
// Archiving is the answer to "this job is over but I can't throw the data away".
|
||||||
|
// An archived project disappears from every picker, switcher and search in the
|
||||||
|
// suite and is frozen read-only; nothing is deleted. That makes this card the ONLY
|
||||||
|
// place an archived project is still visible, so it asks for archived=all and does
|
||||||
|
// the hiding itself — otherwise an admin could never find one to unarchive.
|
||||||
|
let _adminProjects = [];
|
||||||
|
|
||||||
|
async function loadProjects(){
|
||||||
|
const banner=document.getElementById('projects-banner');
|
||||||
|
const wrap=document.getElementById('projects-table');
|
||||||
|
if(!banner || !wrap) return;
|
||||||
|
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||||
|
const { status, json } = await api('GET','/api/projects?archived=all');
|
||||||
|
if(status===403){
|
||||||
|
banner.className='banner bad';
|
||||||
|
banner.textContent='❌ Your account is not an admin, so you can’t archive or delete projects here.';
|
||||||
|
wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
if(status===401){
|
||||||
|
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
if(status!==200 || !Array.isArray(json)){
|
||||||
|
banner.className='banner bad'; banner.textContent='❌ Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
banner.style.display='none';
|
||||||
|
_adminProjects = json;
|
||||||
|
renderProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects(){
|
||||||
|
const wrap=document.getElementById('projects-table');
|
||||||
|
if(!wrap) return;
|
||||||
|
const showArchived = !!(document.getElementById('proj-show-archived')||{}).checked;
|
||||||
|
const q = (((document.getElementById('proj-search')||{}).value)||'').trim().toLowerCase();
|
||||||
|
const total = _adminProjects.length;
|
||||||
|
if(!total){ wrap.innerHTML='<div class="note">No projects yet.</div>'; return; }
|
||||||
|
const list = _adminProjects.filter(p => {
|
||||||
|
if(!showArchived && p.archived) return false;
|
||||||
|
if(!q) return true;
|
||||||
|
return ((p.name||'')+' '+(p.number||'')+' '+(p.client||'')+' '+(p.site||'')).toLowerCase().indexOf(q) >= 0;
|
||||||
|
});
|
||||||
|
const count = '<div class="note">'+list.length+' of '+total+' project'+(total===1?'':'s')+
|
||||||
|
(showArchived ? '' : ' <span title="Tick “Show archived” to include them">· archived hidden</span>')+'</div>';
|
||||||
|
if(!list.length){
|
||||||
|
wrap.innerHTML = count + '<div class="note">Nothing matches'+
|
||||||
|
(showArchived ? '' : ' — archived projects are hidden. Tick “Show archived” to include them')+'.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||||
|
const rows = list.map(p => {
|
||||||
|
// Project names are free text written by whoever created the job — jsq(), not
|
||||||
|
// uesc(), is what makes them safe to bind into the handlers below.
|
||||||
|
const pid = jsq(p.id);
|
||||||
|
const pname = jsq(p.name||'(unnamed)');
|
||||||
|
const arch = !!p.archived;
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||||
|
(p.number ? ' <span class="note">'+uesc(p.number)+'</span>' : '')+'</td>'+
|
||||||
|
'<td class="ell" title="'+uesc(p.client||'')+'"><span>'+uesc(p.client||'—')+'</span></td>'+
|
||||||
|
'<td class="ell" title="'+uesc(p.site||'')+'"><span>'+uesc(p.site||'—')+'</span></td>'+
|
||||||
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(p.created_at)+'</td>'+
|
||||||
|
'<td>'+(arch
|
||||||
|
? '<span class="tag archived" title="Hidden everywhere and read-only until unarchived">archived</span>'
|
||||||
|
: '<span class="tag on">active</span>')+'</td>'+
|
||||||
|
'<td><div class="cellactions">'+
|
||||||
|
'<button class="mini" onclick="archiveProject(\''+pid+'\',\''+pname+'\','+(arch?'false':'true')+')">'+
|
||||||
|
(arch?'Unarchive':'Archive')+'</button>'+
|
||||||
|
'<button class="mini danger" onclick="deleteProjectAdmin(\''+pid+'\',\''+pname+'\')">Delete</button>'+
|
||||||
|
'</div></td>'+
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
wrap.innerHTML = count +
|
||||||
|
'<div class="tscroll"><table class="grid"><thead><tr>'+
|
||||||
|
'<th>Project</th><th>Client</th><th>Site</th><th>Created</th><th>Status</th><th>Actions</th>'+
|
||||||
|
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||||
|
'<div class="note"><strong>Delete</strong> is not archive: it removes the project, its SOP and every '+
|
||||||
|
'work package on it for good. Archive first if there is any doubt.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both directions are explained in full before anything happens: archiving makes a
|
||||||
|
// project vanish for everyone else in the company, and there is no undo prompt on
|
||||||
|
// the other side of that.
|
||||||
|
async function archiveProject(id, name, archived){
|
||||||
|
const ask = archived
|
||||||
|
? 'Archive “'+name+'”?\n\n'+
|
||||||
|
'• It disappears from every project picker, switcher and search across the suite.\n'+
|
||||||
|
'• It becomes read-only — nobody can add or change its SOP or work packages.\n'+
|
||||||
|
'• Nothing is deleted. Unarchive here at any time to bring it back.'
|
||||||
|
: 'Unarchive “'+name+'”?\n\n'+
|
||||||
|
'It becomes visible in the pickers again and can be edited as normal.';
|
||||||
|
if(!confirm(ask)) return;
|
||||||
|
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
|
||||||
|
if(status===200) loadProjects();
|
||||||
|
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
|
||||||
|
// global shared with the other scripts the page loads, and "deleteProject" is broad
|
||||||
|
// enough to collide with one of them later. The -Admin suffix also says which of the
|
||||||
|
// two project deletions this is — the console's, not a project member's.
|
||||||
|
async function deleteProjectAdmin(id, name){
|
||||||
|
if(!confirm('DELETE “'+name+'” permanently?\n\n'+
|
||||||
|
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
|
||||||
|
'(database cascade). This cannot be undone.\n\n'+
|
||||||
|
'If you only want it out of the way, cancel and use Archive instead.')) return;
|
||||||
|
const { status, json } = await api('DELETE','/api/projects/'+id);
|
||||||
|
if(status===200) loadProjects();
|
||||||
|
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── default members on new projects ─────────────────────────────────────────────
|
||||||
|
// A rule about the FUTURE: flagged users are auto-added to every project created
|
||||||
|
// from now on. It is not a bulk assignment — existing projects are untouched, which
|
||||||
|
// is what the note under the table is there to say.
|
||||||
|
let _defMemUsers = [];
|
||||||
|
|
||||||
|
async function loadDefaultMembers(){
|
||||||
|
const banner=document.getElementById('defmem-banner');
|
||||||
|
const wrap=document.getElementById('defmem-table');
|
||||||
|
if(!banner || !wrap) return;
|
||||||
|
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||||
|
const { status, json } = await api('GET','/api/auth/users');
|
||||||
|
if(status===403){
|
||||||
|
banner.className='banner bad';
|
||||||
|
banner.textContent='❌ Your account is not an admin, so you can’t change who is added to new projects.';
|
||||||
|
wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
if(status===401){
|
||||||
|
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
if(status!==200 || !Array.isArray(json)){
|
||||||
|
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
banner.style.display='none';
|
||||||
|
_defMemUsers = json;
|
||||||
|
renderDefaultMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same idea as projRowToggled() — the role only matters while the tick is on — but
|
||||||
|
// here the select sits in a sibling <td>, so the lookup is scoped to the row.
|
||||||
|
function defMemToggled(cb){
|
||||||
|
const row = cb.closest('tr');
|
||||||
|
const sel = row && row.querySelector('select');
|
||||||
|
if(sel) sel.disabled = !cb.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefaultMembers(){
|
||||||
|
const wrap=document.getElementById('defmem-table');
|
||||||
|
if(!wrap) return;
|
||||||
|
if(!_defMemUsers.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||||
|
const rows = _defMemUsers.map(u => {
|
||||||
|
const uid = jsq(u.id);
|
||||||
|
const uname = jsq(u.username);
|
||||||
|
const role = normRole(u.role);
|
||||||
|
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
|
||||||
|
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+
|
||||||
|
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'—')+'</span></td>';
|
||||||
|
// Admins reach every project already, so there is nothing to add them to —
|
||||||
|
// the same thing projAccessCell() says in the user table.
|
||||||
|
if(role === 'admin'){
|
||||||
|
return '<tr>'+who+
|
||||||
|
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
|
||||||
|
'<td colspan="2"><span class="tag admin" title="Admins can access every project">all projects</span>'+
|
||||||
|
' <span class="note">Administrators already reach every project.</span></td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
const on = !!u.auto_add_projects;
|
||||||
|
const cur = u.auto_add_role || '';
|
||||||
|
return '<tr>'+who+
|
||||||
|
'<td><span class="tag user">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||||
|
'<td><label class="chk">'+
|
||||||
|
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
|
||||||
|
' title="Add this user to every project created from now on"'+
|
||||||
|
' onchange="defMemToggled(this);setAutoAdd(\''+uid+'\',\''+uname+'\')"> Add automatically'+
|
||||||
|
'</label></td>'+
|
||||||
|
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
|
||||||
|
' title="The role this user gets on those projects"'+
|
||||||
|
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+
|
||||||
|
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[role]||role)+')</option>'+
|
||||||
|
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||||
|
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||||
|
'</select></td>'+
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
wrap.innerHTML =
|
||||||
|
'<div class="tscroll"><table class="grid"><thead><tr>'+
|
||||||
|
'<th>User</th><th>Email</th>'+
|
||||||
|
'<th title="What this account may do in the app">Account permissions</th>'+
|
||||||
|
'<th title="Add this user to every project created from now on">Add to new projects</th>'+
|
||||||
|
'<th title="Their role on those projects">Role on those projects</th>'+
|
||||||
|
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||||
|
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
|
||||||
|
'are untouched. Use <strong>Project access</strong> in the user table above to add someone to a project '+
|
||||||
|
'that already exists.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saves on every tick and every dropdown change — there is no Save button, so a
|
||||||
|
// failure must not leave a control showing something the server never accepted.
|
||||||
|
// On success we swap in the row the server returned (it clears the role whenever
|
||||||
|
// the flag is off); on failure we reload so the controls snap back to the truth.
|
||||||
|
async function setAutoAdd(id, username){
|
||||||
|
const cb = document.getElementById('defmem-cb-'+id);
|
||||||
|
if(!cb) return;
|
||||||
|
const sel = document.getElementById('defmem-role-'+id);
|
||||||
|
const auto_add = !!cb.checked;
|
||||||
|
const { status, json } = await api('POST','/api/auth/users/'+id+'/auto-add',
|
||||||
|
{ auto_add, role: auto_add ? ((sel && sel.value) || '') : '' });
|
||||||
|
if(status===200 && json && json.id){
|
||||||
|
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
|
||||||
|
renderDefaultMembers();
|
||||||
|
} else {
|
||||||
|
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||||
|
loadDefaultMembers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── all feedback / comments ─────────────────────────────────────────────────────
|
// ── all feedback / comments ─────────────────────────────────────────────────────
|
||||||
let _comments = [];
|
let _comments = [];
|
||||||
async function loadComments(){
|
async function loadComments(){
|
||||||
|
|||||||
@@ -185,10 +185,12 @@
|
|||||||
wrap.appendChild(who);
|
wrap.appendChild(who);
|
||||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||||
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
||||||
if (typeof window.wpPreferences === 'function') {
|
// Always offered; wp-format.js may still be parsing when the menu is built, so
|
||||||
wrap.appendChild(sep());
|
// the check happens at click time rather than once, up front.
|
||||||
wrap.appendChild(link('Language & time', function () { window.wpPreferences(); }));
|
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('Password', function () { window.wpChangePassword(); }));
|
||||||
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
||||||
return wrap;
|
return wrap;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Field View — Work Package Suite</title>
|
<title>Field View — Work Package Suite</title>
|
||||||
<script src="auth-guard.js"></script>
|
<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">
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
<meta name="theme-color" content="#161616">
|
<meta name="theme-color" content="#161616">
|
||||||
@@ -83,7 +86,6 @@
|
|||||||
<script src="project-data.js"></script>
|
<script src="project-data.js"></script>
|
||||||
<script src="help.js"></script>
|
<script src="help.js"></script>
|
||||||
<script src="field.js"></script>
|
<script src="field.js"></script>
|
||||||
<script src="wp-format.js"></script>
|
|
||||||
<script src="wp-chrome.js"></script>
|
<script src="wp-chrome.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').repla
|
|||||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||||
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||||||
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
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 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 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); }
|
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); }
|
||||||
@@ -57,8 +67,12 @@ function renderList() {
|
|||||||
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; }
|
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) {
|
box.innerHTML = rows.map(function (p) {
|
||||||
var open = openCount(p);
|
var open = openCount(p);
|
||||||
var cls = p.status === 'Issue' ? 'hold' : (open === 0 ? 'ready' : '');
|
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
|
||||||
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>' : (open ? '<span class="pill warn">' + open + ' open</span>' : '<span class="pill ok">Ready</span>');
|
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) + '\')">' +
|
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
||||||
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
||||||
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Work Package Suite — Prime Controls</title>
|
<title>Work Package Suite — Prime Controls</title>
|
||||||
<script src="auth-guard.js"></script>
|
<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">
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
<meta name="theme-color" content="#161616">
|
<meta name="theme-color" content="#161616">
|
||||||
@@ -289,6 +292,10 @@
|
|||||||
padding: 1.25rem; }
|
padding: 1.25rem; }
|
||||||
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||||
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
.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 { 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 { 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 label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||||
@@ -411,12 +418,34 @@
|
|||||||
_projects = list || [];
|
_projects = list || [];
|
||||||
// Reconcile the active project against the list; clear if it's gone.
|
// Reconcile the active project against the list; clear if it's gone.
|
||||||
const active = ProjectData.getActive();
|
const active = ProjectData.getActive();
|
||||||
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
|
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
|
||||||
|
if(dropped) ProjectData.setActive(null);
|
||||||
renderProjectPicker();
|
renderProjectPicker();
|
||||||
applyActiveProject();
|
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(){
|
function createFormHtml(){
|
||||||
return `<div class="proj-form" id="proj-form" style="display:none">
|
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||||
<div class="proj-form-grid">
|
<div class="proj-form-grid">
|
||||||
@@ -662,7 +691,6 @@
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="wp-format.js"></script>
|
|
||||||
<script src="wp-chrome.js"></script>
|
<script src="wp-chrome.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -80,6 +80,9 @@
|
|||||||
// ── sign in ────────────────────────────────────────────────────────────────
|
// ── sign in ────────────────────────────────────────────────────────────────
|
||||||
var form = byId('login-form');
|
var form = byId('login-form');
|
||||||
var submitBtn = byId('submit');
|
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) {
|
form.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearBanners();
|
clearBanners();
|
||||||
@@ -117,7 +120,7 @@
|
|||||||
.catch(function () { resetAvailable = false; return false; });
|
.catch(function () { resetAvailable = false; return false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
byId('forgot-link').addEventListener('click', function (e) {
|
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
view('forgot');
|
view('forgot');
|
||||||
// Prefill from the sign-in box so nobody types their username twice.
|
// Prefill from the sign-in box so nobody types their username twice.
|
||||||
@@ -131,13 +134,13 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
byId('back-to-login').addEventListener('click', function (e) {
|
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
view('login');
|
view('login');
|
||||||
});
|
});
|
||||||
|
|
||||||
var forgotForm = byId('forgot-form');
|
var forgotForm = byId('forgot-form') || document.createElement('form');
|
||||||
var forgotBtn = byId('forgot-submit');
|
var forgotBtn = byId('forgot-submit') || document.createElement('button');
|
||||||
forgotForm.addEventListener('submit', function (e) {
|
forgotForm.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearBanners();
|
clearBanners();
|
||||||
@@ -167,13 +170,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── set a new password (from the emailed link) ──────────────────────────────
|
// ── set a new password (from the emailed link) ──────────────────────────────
|
||||||
byId('reset-to-login').addEventListener('click', function (e) {
|
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
view('login');
|
view('login');
|
||||||
});
|
});
|
||||||
|
|
||||||
var resetForm = byId('reset-form');
|
var resetForm = byId('reset-form') || document.createElement('form');
|
||||||
var resetBtn = byId('reset-submit');
|
var resetBtn = byId('reset-submit') || document.createElement('button');
|
||||||
resetForm.addEventListener('submit', function (e) {
|
resetForm.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearBanners();
|
clearBanners();
|
||||||
|
|||||||
@@ -73,6 +73,10 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 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 ────────────────────────────────────────────────
|
// ── active project context ────────────────────────────────────────────────
|
||||||
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
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; } },
|
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||||
@@ -208,7 +212,8 @@
|
|||||||
qWrite(q);
|
qWrite(q);
|
||||||
}
|
}
|
||||||
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
|
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
|
||||||
// retrying, so a rejected write can't loop forever.
|
// 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) {
|
function markDead(opId, err) {
|
||||||
var q = qRead();
|
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; } }
|
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
|
||||||
@@ -229,7 +234,16 @@
|
|||||||
var status = r ? r.status : 0;
|
var status = r ? r.status : 0;
|
||||||
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
|
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; })); }
|
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
|
||||||
else if (status >= 400 && status < 500 && status !== 429) { markDead(op.opId, 'HTTP ' + status); }
|
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); }
|
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
|
||||||
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
|
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
|
||||||
});
|
});
|
||||||
@@ -250,12 +264,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── sync status (drives the indicator + any listeners) ──────────────────────
|
// ── 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() {
|
function syncCounts() {
|
||||||
var q = qRead(), pending = 0, failed = 0;
|
var q = qRead(), pending = 0, failed = 0, dead = 0, reason = '';
|
||||||
for (var i = 0; i < q.length; i++) {
|
for (var i = 0; i < q.length; i++) {
|
||||||
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
|
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, syncing: _flushing };
|
return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing };
|
||||||
}
|
}
|
||||||
ProjectData.syncStatus = syncCounts;
|
ProjectData.syncStatus = syncCounts;
|
||||||
function notifySync() {
|
function notifySync() {
|
||||||
@@ -281,7 +300,17 @@
|
|||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
}
|
}
|
||||||
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
|
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
|
||||||
if (c.failed) {
|
// 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.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';
|
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
|
||||||
} else if (c.pending) {
|
} else if (c.pending) {
|
||||||
|
|||||||
70
html/sw.js
70
html/sw.js
@@ -5,15 +5,16 @@
|
|||||||
worker only caches the static app shell so the pages open without a network.
|
worker only caches the static app shell so the pages open without a network.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
• /api/* and non-GET → never touched (pass straight to the network; offline
|
• /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).
|
reads fall back to the app's localStorage cache, writes queue in the outbox).
|
||||||
• same-origin GET → stale-while-revalidate (instant from cache, refreshed
|
• HTML / CSS / JS → network-first, cache as fallback. These reference each
|
||||||
in the background when online).
|
other, so a page must never run against a stale sibling.
|
||||||
|
• images / icons / manifest → stale-while-revalidate (instant from cache).
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
// Bumped when the shell file list changes, so clients fetch the new assets
|
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||||
// instead of serving a half-old shell from the previous cache.
|
// instead of serving a half-old shell from the previous cache.
|
||||||
const CACHE = 'wp-suite-shell-v2';
|
const CACHE = 'wp-suite-shell-v5';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||||
'/field.html', '/login.html', '/admin.html',
|
'/field.html', '/login.html', '/admin.html',
|
||||||
@@ -30,7 +31,10 @@ self.addEventListener('install', (e) => {
|
|||||||
// Cache each shell asset individually so one missing file doesn't abort install.
|
// Cache each shell asset individually so one missing file doesn't abort install.
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
caches.open(CACHE)
|
caches.open(CACHE)
|
||||||
.then((c) => Promise.all(SHELL.map((u) => c.add(u).catch(() => {}))))
|
// 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())
|
.then(() => self.skipWaiting())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -43,6 +47,17 @@ self.addEventListener('activate', (e) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
self.addEventListener('fetch', (e) => {
|
||||||
const req = e.request;
|
const req = e.request;
|
||||||
if (req.method !== 'GET') return; // outbox owns writes
|
if (req.method !== 'GET') return; // outbox owns writes
|
||||||
@@ -50,18 +65,43 @@ self.addEventListener('fetch', (e) => {
|
|||||||
if (url.origin !== self.location.origin) return; // third-party: default
|
if (url.origin !== self.location.origin) return; // third-party: default
|
||||||
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
||||||
|
|
||||||
e.respondWith(
|
const isCode = CODE_RE.test(url.pathname);
|
||||||
caches.match(req).then((cached) => {
|
|
||||||
const network = fetch(req)
|
// 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) => {
|
.then((res) => {
|
||||||
if (res && res.ok) {
|
// A 502/404 must not replace a page the cache could still serve.
|
||||||
const copy = res.clone();
|
if (!res || !res.ok) return fromCache().then((c) => c || res);
|
||||||
caches.open(CACHE).then((c) => c.put(req, copy));
|
return store(res);
|
||||||
}
|
|
||||||
return res;
|
|
||||||
})
|
})
|
||||||
.catch(() => cached); // offline → cached copy
|
.catch(() => fromCache()) // offline → last good copy
|
||||||
return cached || network; // cache-first, then refresh
|
.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());
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -291,10 +291,15 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
loadProjectUsers(); // team pickers: who's on this project
|
loadProjectUsers(); // team pickers: who's on this project
|
||||||
applyBimFlag(); // hide the BIM section unless an admin enabled it
|
applyBimFlag(); // hide the BIM section unless an admin enabled it
|
||||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id>. Consumed ONCE —
|
||||||
|
// leaving ?view=dashboard in the URL used to make the Work Package Creation tab
|
||||||
|
// keep opening the dashboard for the rest of the session.
|
||||||
const tab = params.get('tab');
|
const tab = params.get('tab');
|
||||||
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
_deepLinkWp = params.get('wp') || '';
|
||||||
|
const wantDashboard = params.get('view') === 'dashboard';
|
||||||
|
if(wantDashboard) switchTool('dashboard');
|
||||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||||
|
else if(_deepLinkWp) switchTool('wp');
|
||||||
}
|
}
|
||||||
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||||
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||||||
@@ -342,19 +347,25 @@ function loadSampleData(){
|
|||||||
document.getElementById('proj_division').value = 'Semiconductor';
|
document.getElementById('proj_division').value = 'Semiconductor';
|
||||||
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
||||||
|
|
||||||
// Populate Step 2
|
// Populate Step 2. The leadership slots are account pickers now, so the sample's
|
||||||
document.getElementById('proj_pm').value = 'Mariano Sanchez';
|
// fictional names can't be "selected" — setting .value on a <select> with no
|
||||||
document.getElementById('proj_apm').value = 'Assistant PM';
|
// matching option silently does nothing. Store them as names without an account,
|
||||||
document.getElementById('proj_cm').value = 'K. Boyd';
|
// which is exactly how the picker shows a person who isn't a suite user yet.
|
||||||
document.getElementById('proj_qm').value = 'D. Nguyen';
|
state.team.pm = 'Mariano Sanchez';
|
||||||
|
state.team.apm = 'Assistant PM';
|
||||||
|
state.team.cm = 'K. Boyd';
|
||||||
|
state.team.qm = 'D. Nguyen';
|
||||||
|
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||||
|
renderTeamPickers();
|
||||||
|
|
||||||
// Step 3 — standard required roles
|
// Step 3 — standard required roles
|
||||||
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
|
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
|
||||||
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
|
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
|
||||||
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
|
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
|
||||||
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
|
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
|
||||||
document.getElementById('role_super_name').value = 'John Smith';
|
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
|
||||||
document.getElementById('role_foreman_name').value = 'Mike Jones';
|
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
|
||||||
|
renderSignoffRolePickers();
|
||||||
|
|
||||||
// Populate Step 5
|
// Populate Step 5
|
||||||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
||||||
@@ -425,11 +436,12 @@ function repopulateForm(){
|
|||||||
set('proj_client', state.project.client);
|
set('proj_client', state.project.client);
|
||||||
set('proj_division', state.project.division);
|
set('proj_division', state.project.division);
|
||||||
set('proj_site', state.project.site);
|
set('proj_site', state.project.site);
|
||||||
// The four leadership slots are user-account pickers, not text inputs —
|
// The leadership slots and sign-off names are account pickers, not text inputs —
|
||||||
// renderTeamPickers() builds their options and marks the current selection.
|
// these build their options and mark the current selection.
|
||||||
renderTeamPickers();
|
renderTeamPickers();
|
||||||
if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); }
|
renderSignoffRolePickers();
|
||||||
if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); }
|
if(state.signoffRoles[0]) set('role_super_title', state.signoffRoles[0].role);
|
||||||
|
if(state.signoffRoles[1]) set('role_foreman_title', state.signoffRoles[1].role);
|
||||||
set('gov_woformat', state.governance.woformat);
|
set('gov_woformat', state.governance.woformat);
|
||||||
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
||||||
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
||||||
@@ -472,11 +484,81 @@ function switchTool(tool){
|
|||||||
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||||||
|
|
||||||
if(contentTool === 'wp') renderWPTab(isDash);
|
if(contentTool === 'wp') renderWPTab(isDash);
|
||||||
|
// Only go full-bleed when the creator is actually showing. With the SOP
|
||||||
|
// incomplete this tab shows a short 'complete the SOP first' gate; making the
|
||||||
|
// page unscrollable around it can clip its button off the bottom.
|
||||||
|
applyEmbedLayout(contentTool === 'wp' && sopComplete);
|
||||||
|
|
||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The embedded creator/dashboard fills the window below the app chrome, so there
|
||||||
|
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
|
||||||
|
// page — and the creator's sticky bars have a real viewport to stick to.
|
||||||
|
// A work package the global search asked us to open, handed to the creator on its
|
||||||
|
// next load and then cleared.
|
||||||
|
let _deepLinkWp = '';
|
||||||
|
|
||||||
|
function applyEmbedLayout(on){
|
||||||
|
// Below this, "fill the window" leaves nothing usable: the iframe would be shorter
|
||||||
|
// than the creator's own sticky bars while the page itself can't scroll. Fall back
|
||||||
|
// to normal page flow and let the page scroll instead.
|
||||||
|
const MIN_FILL_H = 460;
|
||||||
|
const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H;
|
||||||
|
const full = !!on && fits;
|
||||||
|
const area = document.querySelector('.content-area');
|
||||||
|
const frame = document.getElementById('wp-frame');
|
||||||
|
if(area) area.classList.toggle('embed-full', full);
|
||||||
|
if(frame) frame.classList.toggle('fill', full);
|
||||||
|
document.body.classList.toggle('embed-full', full);
|
||||||
|
sizeWPFrame(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size the frame with INLINE styles, not only CSS classes. Inline wins over any
|
||||||
|
// stylesheet — including a stale cached one — so the creator can't collapse to the
|
||||||
|
// 300x150 default iframe box if the CSS and the HTML are ever out of step.
|
||||||
|
function sizeWPFrame(on){
|
||||||
|
const frame = document.getElementById('wp-frame');
|
||||||
|
if(!frame) return;
|
||||||
|
frame.style.width = '100%';
|
||||||
|
frame.style.border = '0';
|
||||||
|
if(on){
|
||||||
|
const h = viewportMinusChrome();
|
||||||
|
document.documentElement.style.setProperty('--wp-chrome-h', (window.innerHeight - h) + 'px');
|
||||||
|
frame.style.height = h + 'px';
|
||||||
|
frame.style.minHeight = '0';
|
||||||
|
} else {
|
||||||
|
frame.style.height = '';
|
||||||
|
frame.style.minHeight = 'calc(100vh - 200px)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chromeHeight(){
|
||||||
|
const hdr = document.querySelector('.header');
|
||||||
|
const nav = document.querySelector('.main-nav');
|
||||||
|
return (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
|
||||||
|
}
|
||||||
|
// Whatever is left of the window below the app bar + tab strip. No floor: a floor
|
||||||
|
// taller than the remaining space pushes the frame (and the creator's fixed save bar)
|
||||||
|
// off a window that has scrolling disabled.
|
||||||
|
function viewportMinusChrome(){
|
||||||
|
return Math.max(0, window.innerHeight - chromeHeight());
|
||||||
|
}
|
||||||
|
// Re-measure on resize, and re-decide whether full-bleed still fits.
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if(currentTool === 'wp' || currentTool === 'dashboard') applyEmbedLayout(true);
|
||||||
|
}, {passive:true});
|
||||||
|
// The app bar grows when wp-chrome.js injects the project switcher and search, which
|
||||||
|
// happens after the frame has already been sized. Watch the chrome instead of relying
|
||||||
|
// on someone resizing the window.
|
||||||
|
try {
|
||||||
|
const _ro = new ResizeObserver(() => {
|
||||||
|
if(document.body.classList.contains('embed-full')) sizeWPFrame(true);
|
||||||
|
});
|
||||||
|
['.header', '.main-nav'].forEach(sel => { const el = document.querySelector(sel); if(el) _ro.observe(el); });
|
||||||
|
} catch(e) { /* no ResizeObserver: the resize handler above still covers it */ }
|
||||||
|
|
||||||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
||||||
// wantDash=true opens the creator straight to the dashboard view.
|
// wantDash=true opens the creator straight to the dashboard view.
|
||||||
function renderWPTab(wantDash){
|
function renderWPTab(wantDash){
|
||||||
@@ -486,12 +568,49 @@ function renderWPTab(wantDash){
|
|||||||
if(sopComplete){
|
if(sopComplete){
|
||||||
gate.style.display = 'none';
|
gate.style.display = 'none';
|
||||||
frame.style.display = 'block';
|
frame.style.display = 'block';
|
||||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
|
||||||
const sp = new URLSearchParams(window.location.search);
|
const sp = new URLSearchParams(window.location.search);
|
||||||
const dash = wantDash || sp.get('view') === 'dashboard';
|
|
||||||
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||||
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
|
const wantWp = _deepLinkWp; _deepLinkWp = '';
|
||||||
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
const dash = wantDash;
|
||||||
|
|
||||||
|
// The frame's identity is the PROJECT only. The view (form vs dashboard) and
|
||||||
|
// which package to open are applied by calling into the loaded document, so
|
||||||
|
// switching tabs never reloads it — reloading discarded unsaved form edits, made
|
||||||
|
// the creator unreachable offline, and stored a fresh copy in the SW cache each
|
||||||
|
// time. It's same-origin, so a direct call is fine.
|
||||||
|
const src = 'wp-creation-index.html?embedded=1'
|
||||||
|
+ (projId ? '&project=' + encodeURIComponent(projId) : '');
|
||||||
|
|
||||||
|
const applyNow = () => {
|
||||||
|
try {
|
||||||
|
const cw = frame.contentWindow;
|
||||||
|
if(!cw) return;
|
||||||
|
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
|
||||||
|
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
|
||||||
|
}
|
||||||
|
if(dash && typeof cw.showDashboard === 'function') cw.showDashboard();
|
||||||
|
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
|
||||||
|
} catch(e){ /* cross-document timing; nothing useful to do */ }
|
||||||
|
};
|
||||||
|
// Wait for the creator's data, not just its document. `load` fires before
|
||||||
|
// pullProject() resolves, so opening a specific package straight after load
|
||||||
|
// silently found nothing.
|
||||||
|
const whenReady = () => {
|
||||||
|
try {
|
||||||
|
const cw = frame.contentWindow;
|
||||||
|
if(cw && cw.wpCreatorReady) { applyNow(); return; }
|
||||||
|
if(cw && cw.document) { cw.document.addEventListener('wp-creator-ready', applyNow, {once:true}); return; }
|
||||||
|
} catch(e){}
|
||||||
|
applyNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
if(frame.getAttribute('data-src') === src && frame.contentWindow){
|
||||||
|
whenReady();
|
||||||
|
} else {
|
||||||
|
frame.setAttribute('data-src', src);
|
||||||
|
frame.addEventListener('load', whenReady, {once:true});
|
||||||
|
frame.src = src;
|
||||||
|
}
|
||||||
}else{
|
}else{
|
||||||
gate.style.display = 'block';
|
gate.style.display = 'block';
|
||||||
frame.style.display = 'none';
|
frame.style.display = 'none';
|
||||||
@@ -623,6 +742,7 @@ async function loadProjectUsers(){
|
|||||||
projectUsersLoaded = true;
|
projectUsersLoaded = true;
|
||||||
renderTeamPickers();
|
renderTeamPickers();
|
||||||
renderTeamMembers();
|
renderTeamMembers();
|
||||||
|
renderSignoffRolePickers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// One <select> per leadership slot. A name already on the SOP that no longer
|
// One <select> per leadership slot. A name already on the SOP that no longer
|
||||||
@@ -662,6 +782,62 @@ function renderTeamPickers(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
|
||||||
|
// name that has no matching account as a selected "(no account)" option so older
|
||||||
|
// SOPs — and the sample's fictional names — are never silently dropped.
|
||||||
|
function userSelectOptions(curId, curName){
|
||||||
|
let html = '<option value="">— not assigned —</option>' +
|
||||||
|
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
|
||||||
|
if(curName && !userById(curId)){
|
||||||
|
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign-off roles (step 3) name the people who must sign a package, so they use the
|
||||||
|
// same picker as the leadership slots — a signature belongs to an account.
|
||||||
|
function renderSignoffRolePickers(){
|
||||||
|
[['role_super_name', 0], ['role_foreman_name', 1]].forEach(([id, ix]) => {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
const r = state.signoffRoles[ix];
|
||||||
|
if(!sel || !r) return;
|
||||||
|
sel.innerHTML = userSelectOptions(r.userId || '', r.name || '');
|
||||||
|
sel.onchange = function(){
|
||||||
|
if(this.value === '__orphan__') return;
|
||||||
|
const u = userById(this.value);
|
||||||
|
r.userId = u ? u.id : '';
|
||||||
|
r.name = u ? (u.full_name || u.username) : '';
|
||||||
|
renderSignoffRolePickers();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
renderOptionalRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the four leadership pickers back into state. `state.team[key]` always holds
|
||||||
|
// a display NAME and `state.teamIds[key]` the account id; a name kept from an older
|
||||||
|
// SOP whose person has no account (the "(no account)" option) is left alone.
|
||||||
|
function syncTeamFromPickers(){
|
||||||
|
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||||
|
['pm','apm','cm','qm'].forEach(key => {
|
||||||
|
const sel = document.getElementById('proj_' + key);
|
||||||
|
if(!sel) return;
|
||||||
|
if(sel.value === '__orphan__') return; // legacy typed name — keep it
|
||||||
|
const u = userById(sel.value);
|
||||||
|
state.teamIds[key] = u ? u.id : '';
|
||||||
|
if(u) state.team[key] = u.full_name || u.username;
|
||||||
|
else if(sel.value === '') state.team[key] = ''; // explicitly unassigned
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptionalRolePerson(ix, value){
|
||||||
|
const r = state.signoffRoles[ix];
|
||||||
|
if(!r || value === '__orphan__') return;
|
||||||
|
const u = userById(value);
|
||||||
|
r.userId = u ? u.id : '';
|
||||||
|
r.name = u ? (u.full_name || u.username) : '';
|
||||||
|
renderOptionalRoles();
|
||||||
|
}
|
||||||
|
|
||||||
function setTeamLead(key, userId){
|
function setTeamLead(key, userId){
|
||||||
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
|
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
|
||||||
const u = userById(userId);
|
const u = userById(userId);
|
||||||
@@ -715,7 +891,7 @@ function renderOptionalRoles(){
|
|||||||
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
|
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
|
||||||
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
|
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
|
<select onchange="setOptionalRolePerson(${state.signoffRoles.indexOf(r)}, this.value)">${userSelectOptions(r.userId||'', r.name||'')}</select>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -788,7 +964,7 @@ function renderCustomConstraints(){
|
|||||||
<strong>${escAttr(c.name)}</strong>
|
<strong>${escAttr(c.name)}</strong>
|
||||||
<span style="display:flex; align-items:center; gap:0.75rem;">
|
<span style="display:flex; align-items:center; gap:0.75rem;">
|
||||||
${criticalToggle(c.name, true, !!c.critical)}
|
${criticalToggle(c.name, true, !!c.critical)}
|
||||||
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
|
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
||||||
}
|
}
|
||||||
@@ -935,6 +1111,13 @@ const DEFAULT_SOURCES = [
|
|||||||
// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >),
|
// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >),
|
||||||
// so attribute values must be escaped or a re-render corrupts the field.
|
// so attribute values must be escaped or a re-render corrupts the field.
|
||||||
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
// A value bound into an inline handler — onclick="fn('…')" — needs BOTH escapes, in
|
||||||
|
// this order: backslash, then quote (for the JS string literal), then escAttr (for
|
||||||
|
// the attribute carrying it). Quote-escaping alone breaks on a value containing a
|
||||||
|
// backslash — the backslash escapes the backslash, the quote closes the literal, and
|
||||||
|
// the rest runs as code. Constraint names travel with the SOP to everyone on the
|
||||||
|
// project, so they are not this browser's own input.
|
||||||
|
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
|
||||||
function renderSources(){
|
function renderSources(){
|
||||||
const container = document.getElementById('sources-list');
|
const container = document.getElementById('sources-list');
|
||||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
|
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
|
||||||
@@ -1019,17 +1202,19 @@ function collectStepData(){
|
|||||||
state.project.site = document.getElementById('proj_site').value;
|
state.project.site = document.getElementById('proj_site').value;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
state.team.pm = document.getElementById('proj_pm').value;
|
// These are user-account pickers now, so their .value is an ID, not a name.
|
||||||
state.team.apm = document.getElementById('proj_apm').value;
|
// Reading them straight into state.team would put an id where the display
|
||||||
state.team.cm = document.getElementById('proj_cm').value;
|
// name belongs (and it would then print on the SOP as `user_ab12…`).
|
||||||
state.team.qm = document.getElementById('proj_qm').value;
|
syncTeamFromPickers();
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
// The two required roles now have editable titles (default Superintendent/Foreman).
|
// The two required roles now have editable titles (default Superintendent/Foreman).
|
||||||
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
|
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
|
||||||
state.signoffRoles[0].name = document.getElementById('role_super_name').value;
|
// Titles are still free text; the NAMES are account pickers whose .value is
|
||||||
state.signoffRoles[1].role = (document.getElementById('role_foreman_title').value || 'Role 2').trim();
|
// an id, so they're maintained by their own onchange (see
|
||||||
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
|
// renderSignoffRolePickers) rather than read as text here.
|
||||||
|
state.signoffRoles[0].role = document.getElementById('role_super_title').value || 'Superintendent';
|
||||||
|
state.signoffRoles[1].role = document.getElementById('role_foreman_title').value || 'Foreman';
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
state.governance.woformat = document.getElementById('gov_woformat').value;
|
state.governance.woformat = document.getElementById('gov_woformat').value;
|
||||||
@@ -1113,7 +1298,10 @@ function completeSOP(){
|
|||||||
site: state.project.site,
|
site: state.project.site,
|
||||||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
|
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
|
||||||
},
|
},
|
||||||
roles: state.signoffRoles.filter(r=>r.role),
|
roles: state.signoffRoles.filter(r=>r.role).map(r=>({
|
||||||
|
role: r.role, name: r.name || '',
|
||||||
|
userId: r.userId || '' // who signs — an account, so it can be notified
|
||||||
|
})),
|
||||||
governance: {
|
governance: {
|
||||||
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
|
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
|
||||||
woSize: state.governance.wosize,
|
woSize: state.governance.wosize,
|
||||||
|
|||||||
@@ -167,15 +167,54 @@ body {
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -242,10 +281,15 @@ body {
|
|||||||
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; }
|
||||||
@@ -289,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;
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<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>
|
<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">
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
<meta name="theme-color" content="#161616">
|
<meta name="theme-color" content="#161616">
|
||||||
@@ -145,14 +148,14 @@
|
|||||||
<input type="checkbox" id="role_super" checked disabled>
|
<input type="checkbox" id="role_super" checked disabled>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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;">
|
||||||
@@ -372,7 +375,13 @@
|
|||||||
<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>
|
||||||
@@ -423,7 +432,6 @@
|
|||||||
<script src="project-data.js"></script>
|
<script src="project-data.js"></script>
|
||||||
<script src="help.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-format.js"></script>
|
|
||||||
<script src="wp-chrome.js"></script>
|
<script src="wp-chrome.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -35,6 +35,32 @@
|
|||||||
--wpc-accent: #78a9ff;
|
--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 ─────────────────────────────────────────────────────── */
|
/* ── project switcher ─────────────────────────────────────────────────────── */
|
||||||
.wpc-proj { position: relative; flex: 0 0 auto; }
|
.wpc-proj { position: relative; flex: 0 0 auto; }
|
||||||
.wpc-proj-btn {
|
.wpc-proj-btn {
|
||||||
|
|||||||
@@ -167,6 +167,58 @@
|
|||||||
.catch(function () {});
|
.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 ──────────────────────────────────────────────────────────
|
// ── global search ──────────────────────────────────────────────────────────
|
||||||
function buildSearch() {
|
function buildSearch() {
|
||||||
var wrap = el('div', 'wpc-search');
|
var wrap = el('div', 'wpc-search');
|
||||||
@@ -325,6 +377,7 @@
|
|||||||
if (m.before) m.host.insertBefore(chrome, m.before);
|
if (m.before) m.host.insertBefore(chrome, m.before);
|
||||||
else m.host.appendChild(chrome);
|
else m.host.appendChild(chrome);
|
||||||
loadProjects(switcher);
|
loadProjects(switcher);
|
||||||
|
checkArchived(m.host);
|
||||||
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
|
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1322,9 +1322,10 @@ function buildSectionNav(){
|
|||||||
function positionSectionNav(){
|
function positionSectionNav(){
|
||||||
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
|
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
|
||||||
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
|
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
|
||||||
// The WP navigator rail sticks below the header + section-nav chrome.
|
// The navigator drawer + its handle hang below the page header. Deliberately NOT
|
||||||
const top=(hdr?hdr.offsetHeight:0)+((nav && nav.style.display!=='none')?nav.offsetHeight:0);
|
// including the section-nav height: that bar is sticky, so at scroll 0 it sits
|
||||||
document.documentElement.style.setProperty('--rail-top', top+'px');
|
// further down the page and the handle would float over the chrome.
|
||||||
|
document.documentElement.style.setProperty('--rail-top', (hdr?hdr.offsetHeight:0)+'px');
|
||||||
}
|
}
|
||||||
let _snLastY=0, _snBound=false;
|
let _snLastY=0, _snBound=false;
|
||||||
function initSectionNavAutoHide(){
|
function initSectionNavAutoHide(){
|
||||||
@@ -1377,49 +1378,229 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
|||||||
// Every saved package on this project, grouped by status, filterable. Clicking a
|
// Every saved package on this project, grouped by status, filterable. Clicking a
|
||||||
// row opens it in the form (same path as the Saved table's "edit").
|
// row opens it in the form (same path as the Saved table's "edit").
|
||||||
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
||||||
function toggleWpNav(){
|
// The panel's ESSENTIAL layout ships with this script rather than living only in
|
||||||
document.body.classList.toggle('wp-nav-collapsed');
|
// wp-creation-styles.css. The two files are cached independently, so a browser can
|
||||||
try{ localStorage.setItem('wp_nav_collapsed', document.body.classList.contains('wp-nav-collapsed')?'1':''); }catch(e){}
|
// run new markup against an old stylesheet — and without these rules the panel is
|
||||||
|
// not merely unstyled, it's broken: its title, filter and buttons drop into the
|
||||||
|
// middle of the form as loose widgets (which is exactly what happened once).
|
||||||
|
// Injecting the floor here means the panel is always a positioned side panel; the
|
||||||
|
// stylesheet only refines its appearance.
|
||||||
|
const WP_NAV_CRITICAL_CSS = `
|
||||||
|
body{--nav-w:288px;}
|
||||||
|
body.wp-nav-collapsed{--nav-w:56px;}
|
||||||
|
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
|
||||||
|
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc;
|
||||||
|
border-right:1px solid #e0e0e0;}
|
||||||
|
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
|
||||||
|
.wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%;
|
||||||
|
background:none;border:0;text-align:left;cursor:pointer;font:inherit;}
|
||||||
|
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff;
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
|
||||||
|
.wp-nav-body{min-width:0;flex:1 1 auto;}
|
||||||
|
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.main{padding-left:calc(var(--nav-w) + 28px);}
|
||||||
|
body.wp-nav-collapsed .wp-nav-body,body.wp-nav-collapsed .wp-nav-state,
|
||||||
|
body.wp-nav-collapsed .wp-nav-link-label,body.wp-nav-collapsed .wp-nav-sect,
|
||||||
|
body.wp-nav-collapsed .wp-nav-filter,body.wp-nav-collapsed .wp-nav-group,
|
||||||
|
body.wp-nav-collapsed .wp-nav-cta-label,body.wp-nav-collapsed .wp-nav-cta-more{display:none;}
|
||||||
|
/* Modals are hidden by a stylesheet rule; without it their contents render inline
|
||||||
|
in the middle of the form. Same reasoning as the panel: this is a floor. */
|
||||||
|
.modal-overlay:not(.open),.cmt-overlay:not(.open){display:none!important;}
|
||||||
|
/* The jump bar's sticky offset is set inline by JS; give it a sane default so a
|
||||||
|
stale stylesheet can't park it behind the opaque header. */
|
||||||
|
.section-nav-bar{position:sticky;top:48px;z-index:30;background:#fff;}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function injectWpNavCriticalCss(){
|
||||||
|
if(document.getElementById('wp-nav-critical')) return;
|
||||||
|
const st = document.createElement('style');
|
||||||
|
st.id = 'wp-nav-critical';
|
||||||
|
st.textContent = WP_NAV_CRITICAL_CSS;
|
||||||
|
// First in <head> so the real stylesheet (loaded later) still wins on every
|
||||||
|
// property it defines — this is a floor, not an override.
|
||||||
|
const head = document.head || document.documentElement;
|
||||||
|
head.insertBefore(st, head.firstChild);
|
||||||
}
|
}
|
||||||
function renderWpNav(){
|
|
||||||
const list=document.getElementById('wp-nav-list'); if(!list) return;
|
// ── panel behaviour ────────────────────────────────────────────
|
||||||
const cnt=document.getElementById('wp-nav-count');
|
// The panel is always present. The toggle collapses it to a 56px icon rail (badges
|
||||||
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
|
// only) and back; that choice is remembered. No hover-to-open: a list you navigate
|
||||||
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
|
// by shouldn't appear and disappear under the pointer.
|
||||||
// Keep the original index — edit/view act on savedPackages by position.
|
let wpNavView = 'all'; // all | mine | open
|
||||||
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
|
|
||||||
if(!q) return true;
|
function toggleWpNav(){
|
||||||
const p=r.p;
|
const collapsed = document.body.classList.toggle('wp-nav-collapsed');
|
||||||
return [p.number,p.subject,p.type,p.location,p.system].some(v=>String(v||'').toLowerCase().includes(q));
|
try{ localStorage.setItem('wp_nav_collapsed', collapsed ? '1' : ''); }catch(e){}
|
||||||
|
const btn = document.getElementById('wp-nav-toggle');
|
||||||
|
if(btn){
|
||||||
|
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||||
|
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
|
||||||
|
}
|
||||||
|
closeWpNavMore();
|
||||||
|
track(collapsed ? 'wp_nav_collapsed' : 'wp_nav_expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept so older callers (and the suite shell) don't break.
|
||||||
|
function openWpNav(){ document.body.classList.remove('wp-nav-collapsed'); }
|
||||||
|
function closeWpNav(){ document.body.classList.add('wp-nav-collapsed'); }
|
||||||
|
|
||||||
|
function toggleWpNavMore(ev){
|
||||||
|
if(ev) ev.stopPropagation();
|
||||||
|
const m = document.getElementById('wp-nav-more');
|
||||||
|
const b = document.getElementById('wp-nav-more-btn');
|
||||||
|
if(!m) return;
|
||||||
|
const open = m.hidden;
|
||||||
|
m.hidden = !open;
|
||||||
|
if(b) b.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
function closeWpNavMore(){
|
||||||
|
const m = document.getElementById('wp-nav-more');
|
||||||
|
const b = document.getElementById('wp-nav-more-btn');
|
||||||
|
if(m) m.hidden = true;
|
||||||
|
if(b) b.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if(!e.target.closest || !e.target.closest('.wp-nav-primary')) closeWpNavMore();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNavMore(); });
|
||||||
|
|
||||||
|
function wpNavAction(what){
|
||||||
|
closeWpNavMore();
|
||||||
|
if(what === 'duplicate') duplicateWP();
|
||||||
|
else if(what === 'split') splitByDiscipline();
|
||||||
|
else if(what === 'export') exportPackages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three views are filters over the same list, like Planner's My tasks / My plans.
|
||||||
|
function setWpNavView(view){
|
||||||
|
wpNavView = view;
|
||||||
|
document.querySelectorAll('.wp-nav-link[data-view]').forEach(b => {
|
||||||
|
b.classList.toggle('is-current', b.getAttribute('data-view') === view);
|
||||||
});
|
});
|
||||||
|
renderWpNav();
|
||||||
|
track('wp_nav_view', {view});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stable colour per package so the same one is always the same swatch — the cue
|
||||||
|
// Planner gets from its plan avatars. Hashed from the WP number, not its index, so
|
||||||
|
// it doesn't shuffle when a package is added or deleted.
|
||||||
|
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00',
|
||||||
|
'#1192e8','#198038','#a56eff','#9f1853','#005d5d'];
|
||||||
|
function wpBadgeColor(p){
|
||||||
|
const key = (p.number || p.id || '') + '';
|
||||||
|
let h = 0;
|
||||||
|
for(let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 100000;
|
||||||
|
return WP_BADGE_COLORS[h % WP_BADGE_COLORS.length];
|
||||||
|
}
|
||||||
|
// Two characters for the badge: the package's own digits if it has them (WP-25 -> 25),
|
||||||
|
// otherwise the initials of its type, otherwise WP.
|
||||||
|
function wpBadgeText(p){
|
||||||
|
const digits = String(p.number || '').match(/(\d{1,2})(?!.*\d)/);
|
||||||
|
if(digits) return digits[1];
|
||||||
|
const t = String(p.type || '').trim();
|
||||||
|
if(t){
|
||||||
|
const words = t.split(/\s+/).filter(Boolean);
|
||||||
|
return (words.length > 1 ? words[0][0] + words[1][0] : t.slice(0, 2));
|
||||||
|
}
|
||||||
|
return 'WP';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWpNavDrawer(){
|
||||||
|
const nav = document.getElementById('wp-nav');
|
||||||
|
if(!nav || nav.dataset.bound) return;
|
||||||
|
nav.dataset.bound = '1';
|
||||||
|
injectWpNavCriticalCss();
|
||||||
|
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
|
||||||
|
const btn = document.getElementById('wp-nav-toggle');
|
||||||
|
if(btn){
|
||||||
|
const collapsed = document.body.classList.contains('wp-nav-collapsed');
|
||||||
|
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||||
|
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWpNav(){
|
||||||
|
const list = document.getElementById('wp-nav-list');
|
||||||
|
if(!list) return;
|
||||||
|
const q = ((document.getElementById('wp-nav-search') || {}).value || '').trim().toLowerCase();
|
||||||
|
const me = myUserId();
|
||||||
|
|
||||||
|
// Keep the original index — edit/view act on savedPackages by position.
|
||||||
|
const all = savedPackages.map((p, i) => ({p, i}));
|
||||||
|
const mine = all.filter(r => r.p.assigneeId && r.p.assigneeId === me);
|
||||||
|
const needs = all.filter(r => r.p.status === 'Issue' || wpReleaseBlocked(r.p));
|
||||||
|
|
||||||
|
const setN = (id, n) => { const e = document.getElementById(id); if(e) e.textContent = n || ''; };
|
||||||
|
setN('wp-nav-n-all', all.length);
|
||||||
|
setN('wp-nav-n-mine', mine.length);
|
||||||
|
setN('wp-nav-n-open', needs.length);
|
||||||
|
|
||||||
|
let rows = wpNavView === 'mine' ? mine : (wpNavView === 'open' ? needs : all);
|
||||||
|
const label = document.getElementById('wp-nav-sect-label');
|
||||||
|
if(label) label.textContent = wpNavView === 'mine' ? 'My packages'
|
||||||
|
: (wpNavView === 'open' ? 'Needs attention' : 'Work packages');
|
||||||
|
|
||||||
|
if(q){
|
||||||
|
rows = rows.filter(r => {
|
||||||
|
const p = r.p;
|
||||||
|
return [p.number, p.subject, p.type, p.location, p.system]
|
||||||
|
.some(v => String(v || '').toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnt = document.getElementById('wp-nav-count');
|
||||||
|
if(cnt) cnt.textContent = rows.length ? '(' + rows.length + ')' : '';
|
||||||
|
|
||||||
if(!rows.length){
|
if(!rows.length){
|
||||||
list.innerHTML='<div class="wp-nav-empty">'+(savedPackages.length?'No packages match “'+esc(q)+'”.':'No work packages saved yet. Fill the form and Save Draft.')+'</div>';
|
list.innerHTML = '<div class="wp-nav-empty">' + (
|
||||||
|
savedPackages.length
|
||||||
|
? (q ? 'Nothing matches “' + esc(q) + '”.' : 'No packages in this view.')
|
||||||
|
: 'No work packages yet. Fill the form and Save Draft.'
|
||||||
|
) + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const groups={};
|
|
||||||
rows.forEach(r=>{ const s=r.p.status||'Draft'; (groups[s]=groups[s]||[]).push(r); });
|
const groups = {};
|
||||||
const keys=Object.keys(groups).sort((a,b)=>{
|
rows.forEach(r => { const st = r.p.status || 'Draft'; (groups[st] = groups[st] || []).push(r); });
|
||||||
const ia=WPNAV_ORDER.indexOf(a), ib=WPNAV_ORDER.indexOf(b);
|
const keys = Object.keys(groups).sort((a, b) => {
|
||||||
return (ia<0?99:ia)-(ib<0?99:ib);
|
const ia = WPNAV_ORDER.indexOf(a), ib = WPNAV_ORDER.indexOf(b);
|
||||||
|
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
|
||||||
});
|
});
|
||||||
list.innerHTML=keys.map(k=>{
|
|
||||||
const label = k==='Issue' ? 'Issue (Hold)' : k;
|
list.innerHTML = keys.map(k => {
|
||||||
return '<div class="wp-nav-group">'+esc(label)+' · '+groups[k].length+'</div>'+groups[k].map(r=>{
|
const heading = k === 'Issue' ? 'Issue (Hold)' : k;
|
||||||
const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length;
|
return '<div class="wp-nav-group">' + esc(heading) + ' · ' + groups[k].length + '</div>' +
|
||||||
// Waiting on an unclosed predecessor is 'not ready' too, not just open constraints.
|
groups[k].map(r => {
|
||||||
const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length;
|
const p = r.p;
|
||||||
const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open');
|
const open = (p.constraints || []).filter(c => c.status === 'open').length;
|
||||||
const state = p.status==='Issue' ? 'on hold'
|
const waiting = wpWaitingOn(p).length;
|
||||||
: (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready'));
|
const dot = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ok' : 'open');
|
||||||
const active = (editingId && p.id===editingId) ? ' active' : '';
|
const state = p.status === 'Issue' ? 'on hold'
|
||||||
return '<button type="button" class="wp-nav-item'+active+'" onclick="wpNavOpen('+r.i+')" title="'+esc((p.number||'')+' — '+(p.subject||''))+'">'+
|
: (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready'));
|
||||||
'<span class="wp-nav-num">'+esc(p.number||'(unnumbered)')+'</span>'+
|
const active = (editingId && p.id === editingId) ? ' active' : '';
|
||||||
'<span class="wp-nav-subj">'+esc(p.subject||'untitled')+'</span>'+
|
const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state;
|
||||||
'<span class="wp-nav-meta"><span class="wp-nav-dot '+dot+'"></span>'+esc(state)+
|
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
|
||||||
(p.type?' · '+esc(p.type):'')+'</span></button>';
|
' title="' + esc(title) + '">' +
|
||||||
}).join('');
|
'<span class="wp-nav-badge" style="background:' + wpBadgeColor(p) + '">' + esc(wpBadgeText(p)) + '</span>' +
|
||||||
|
'<span class="wp-nav-body">' +
|
||||||
|
'<span class="wp-nav-num">' + esc(p.number || '(unnumbered)') + '</span>' +
|
||||||
|
'<span class="wp-nav-subj">' + esc(p.subject || 'untitled') + '</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="wp-nav-state"><span class="wp-nav-dot ' + dot + '"></span>' + esc(state) + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open a package by id. The suite shell calls this instead of reloading the whole
|
||||||
|
// document with a ?wp= parameter, so unsaved edits and scroll position survive.
|
||||||
|
function openWpById(id){
|
||||||
|
const ix = savedPackages.findIndex(x => x.id === id);
|
||||||
|
if(ix < 0) return false;
|
||||||
|
wpNavOpen(ix);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function wpNavOpen(i){
|
function wpNavOpen(i){
|
||||||
const p=savedPackages[i]; if(!p) return;
|
const p=savedPackages[i]; if(!p) return;
|
||||||
editingId=p.id;
|
editingId=p.id;
|
||||||
@@ -1930,13 +2111,25 @@ function bootData(){
|
|||||||
bootSOP();
|
bootSOP();
|
||||||
setRadio('status','Draft');
|
setRadio('status','Draft');
|
||||||
loadMembers();
|
loadMembers();
|
||||||
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
|
initWpNavDrawer();
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
positionSectionNav();
|
positionSectionNav();
|
||||||
cmtInit();
|
cmtInit();
|
||||||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
// Deep-link: a specific package (?wp=<id>, from the global search), or the dashboard.
|
||||||
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
const p=new URLSearchParams(location.search);
|
||||||
|
const wantWp=p.get('wp');
|
||||||
|
if(wantWp){
|
||||||
|
const ix=savedPackages.findIndex(x=>x.id===wantWp);
|
||||||
|
if(ix>=0) wpNavOpen(ix);
|
||||||
|
else toast('That work package is not on this project (it may have been deleted).');
|
||||||
|
}
|
||||||
|
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
||||||
track('app_open');
|
track('app_open');
|
||||||
|
// The embedding shell needs to know when the packages are actually in hand: the
|
||||||
|
// frame's `load` event fires long before pullProject() resolves, so anything that
|
||||||
|
// acts on a specific package has to wait for this instead.
|
||||||
|
window.wpCreatorReady = true;
|
||||||
|
try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){}
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
||||||
// Pull this project's shared SOP + Work Packages from the server first, then boot
|
// Pull this project's shared SOP + Work Packages from the server first, then boot
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Work Package (IWP) — Prime Controls</title>
|
<title>Work Package (IWP) — Prime Controls</title>
|
||||||
<script src="auth-guard.js"></script>
|
<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">
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
<meta name="theme-color" content="#161616">
|
<meta name="theme-color" content="#161616">
|
||||||
@@ -47,20 +50,63 @@
|
|||||||
|
|
||||||
<div class="wp-layout">
|
<div class="wp-layout">
|
||||||
|
|
||||||
<!-- WP NAVIGATOR (left rail — jump between every work package on this project) -->
|
<!-- 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">
|
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
|
||||||
<div class="wp-nav-head">
|
<div class="wp-nav-top">
|
||||||
<div class="wp-nav-title">Work Packages <span class="wp-nav-count" id="wp-nav-count"></span></div>
|
<button class="wp-nav-toggle" id="wp-nav-toggle" onclick="toggleWpNav()"
|
||||||
<button class="wp-nav-collapse" id="wp-nav-collapse" onclick="toggleWpNav()" title="Collapse list" aria-label="Collapse list">‹</button>
|
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>
|
||||||
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter by number, subject, type…" oninput="renderWpNav()">
|
|
||||||
<div class="wp-nav-list" id="wp-nav-list"></div>
|
<div class="wp-nav-list" id="wp-nav-list"></div>
|
||||||
<div class="wp-nav-foot">
|
|
||||||
<button class="add-btn" onclick="newPackage()">+ New</button>
|
|
||||||
<button class="add-btn" onclick="showDashboard()">📊 Dashboard</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
<button class="wp-nav-reopen" id="wp-nav-reopen" onclick="toggleWpNav()" title="Show work packages" aria-label="Show work packages">›</button>
|
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
@@ -351,6 +397,5 @@
|
|||||||
<script src="project-data.js"></script>
|
<script src="project-data.js"></script>
|
||||||
<script src="help.js"></script>
|
<script src="help.js"></script>
|
||||||
<script src="wp-creation-app.js"></script>
|
<script src="wp-creation-app.js"></script>
|
||||||
<script src="wp-format.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -100,10 +100,14 @@
|
|||||||
.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 ──
|
||||||
The form sits in a wide two-column shell: a sticky work-package navigator on
|
The form uses the full width it's given. The work-package navigator is an
|
||||||
the left and the form itself filling the rest of the screen. */
|
auto-hiding overlay drawer (see below) rather than a column, so it never takes
|
||||||
.wp-layout { display: flex; align-items: flex-start; gap: 0; max-width: 1760px; margin: 0 auto; }
|
width away from the form — which matters most when this page is embedded in the
|
||||||
.main { flex: 1 1 auto; min-width: 0; max-width: none; margin: 0; padding: 28px 32px 64px; }
|
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; }
|
||||||
@@ -435,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:1760px; 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; }
|
||||||
@@ -447,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:1760px; 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; }
|
||||||
@@ -471,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:1760px; 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; }
|
||||||
@@ -579,7 +583,7 @@
|
|||||||
|
|
||||||
/* Section nav (jump chips) */
|
/* Section nav (jump chips) */
|
||||||
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||||
padding:8px 12px; background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
|
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);
|
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
|
||||||
transition:transform .22s ease; }
|
transition:transform .22s ease; }
|
||||||
.section-nav-bar:empty{ display:none; }
|
.section-nav-bar:empty{ display:none; }
|
||||||
@@ -588,49 +592,6 @@
|
|||||||
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
|
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); }
|
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
|
||||||
|
|
||||||
/* ── WP NAVIGATOR (left rail) ─────────────────────────────────────────
|
|
||||||
Sticky list of every work package on the project. Click one to open it in
|
|
||||||
the form; the one being edited is highlighted. */
|
|
||||||
.wp-nav { flex:0 0 262px; width:262px; align-self:flex-start; position:sticky; top:var(--rail-top,0px);
|
|
||||||
height:calc(100vh - var(--rail-top,0px)); display:flex; flex-direction:column;
|
|
||||||
background:var(--surface); border-right:1px solid var(--border); }
|
|
||||||
.wp-nav-head { display:flex; align-items:center; gap:8px; padding:14px 14px 8px; }
|
|
||||||
.wp-nav-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); }
|
|
||||||
.wp-nav-count { color:var(--text-dim); font-weight:600; letter-spacing:0; }
|
|
||||||
.wp-nav-collapse, .wp-nav-reopen { background:transparent; border:1px solid var(--border); border-radius:4px;
|
|
||||||
color:var(--text-muted); cursor:pointer; font-size:14px; line-height:1; padding:2px 7px; margin-left:auto; }
|
|
||||||
.wp-nav-collapse:hover, .wp-nav-reopen:hover { border-color:var(--accent); color:var(--accent); }
|
|
||||||
.wp-nav-search { margin:0 14px 10px; padding:6px 9px; font-size:12px; font-family:inherit;
|
|
||||||
border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text); }
|
|
||||||
.wp-nav-search:focus { outline:none; border-color:var(--accent); }
|
|
||||||
.wp-nav-list { flex:1 1 auto; overflow-y:auto; padding:0 8px 8px; }
|
|
||||||
.wp-nav-group { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
|
|
||||||
color:var(--text-dim); padding:10px 6px 5px; }
|
|
||||||
.wp-nav-item { display:block; width:100%; text-align:left; background:transparent; border:0;
|
|
||||||
border-left:3px solid transparent; border-radius:4px; padding:6px 8px; cursor:pointer;
|
|
||||||
font-family:inherit; color:var(--text); }
|
|
||||||
.wp-nav-item:hover { background:var(--surface2); }
|
|
||||||
.wp-nav-item.active { background:var(--accent-dim); border-left-color:var(--accent); }
|
|
||||||
.wp-nav-num { display:block; font-family:var(--mono); font-size:11px; font-weight:600; color:var(--accent); }
|
|
||||||
.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-meta { display:flex; align-items:center; gap:6px; margin-top:3px; font-size:10px; color:var(--text-dim); }
|
|
||||||
.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:12px 8px; font-size:12px; color:var(--text-dim); }
|
|
||||||
.wp-nav-foot { border-top:1px solid var(--border); padding:9px 12px; display:flex; gap:8px; flex-wrap:wrap; }
|
|
||||||
.wp-nav-reopen { display:none; position:sticky; top:var(--rail-top,0px); margin:10px 0 0 8px; align-self:flex-start; z-index:20; }
|
|
||||||
/* Keep the rail's footer clear of the fixed save bar. */
|
|
||||||
body.has-sticky-save .wp-nav { height:calc(100vh - var(--rail-top,0px) - 56px); }
|
|
||||||
body.wp-nav-collapsed .wp-nav { display:none; }
|
|
||||||
body.wp-nav-collapsed .wp-nav-reopen { display:block; }
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.wp-nav { display:none; }
|
|
||||||
.wp-nav-reopen { display:none !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── SOP-inherited marker ───────────────────────────────────────────────────
|
/* ── SOP-inherited marker ───────────────────────────────────────────────────
|
||||||
The "from SOP types" subtext used to sit under the field. It's now a small
|
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).
|
chip on the label with the detail in a hover tooltip (site comment 8/3).
|
||||||
@@ -652,7 +613,7 @@
|
|||||||
.sop-chip:hover::after, .sop-chip:hover::before,
|
.sop-chip:hover::after, .sop-chip:hover::before,
|
||||||
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
|
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
|
||||||
|
|
||||||
/* ── people picker (Assignees / Distribution) ───────────────────────────────
|
/* ── people picker (Assignees / Distribution / Predecessors) ─────────────────
|
||||||
Multi-select over the SOP project team instead of a free-text list. */
|
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);
|
.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; }
|
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
|
||||||
@@ -689,8 +650,164 @@
|
|||||||
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
||||||
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
|
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 bar */
|
||||||
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
|
.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;
|
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); }
|
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-status{ font-size:13px; font-weight:600; }
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -40,6 +56,8 @@ server {
|
|||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
|
||||||
|
# Empty for anything that isn't code, in which case nginx omits the header.
|
||||||
|
add_header Cache-Control $wp_cache_control always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
# This container sits behind an external reverse proxy that handles SSL.
|
# This container sits behind an external reverse proxy that handles SSL.
|
||||||
# It listens on port 80 (plain HTTP on the internal Docker network).
|
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||||
|
|
||||||
|
# Cache-Control per file type, computed here rather than in a nested location.
|
||||||
|
# WHY A MAP: nginx's add_header is not inherited into a block that declares its own
|
||||||
|
# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have
|
||||||
|
# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly
|
||||||
|
# those files. Computing the value here keeps every header in ONE scope. An empty value
|
||||||
|
# means nginx omits the header entirely, so images and fonts stay freely cacheable.
|
||||||
|
#
|
||||||
|
# Code assets must revalidate on every load: with no Cache-Control at all the browser
|
||||||
|
# applies HEURISTIC freshness (~10% of the file's age), so the least recently changed
|
||||||
|
# file gets the LONGEST lifetime — which is how a page ends up running against a
|
||||||
|
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a cheap 304.
|
||||||
|
map $uri $wp_cache_control {
|
||||||
|
default "";
|
||||||
|
~*\.(?:html|css|js|webmanifest)$ "no-cache";
|
||||||
|
~*/$ "no-cache"; # directory index → index.html
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name wp.controls.dev;
|
server_name wp.controls.dev;
|
||||||
@@ -19,6 +36,8 @@ server {
|
|||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;
|
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;
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
291
server/app.py
291
server/app.py
@@ -137,16 +137,59 @@ def require_project_access(db: Session, user: "models.User", project_id: Optiona
|
|||||||
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
||||||
|
|
||||||
|
|
||||||
|
def effective_role(db: Session, user: "models.User", project_id: Optional[str]) -> str:
|
||||||
|
"""The user's permissions role ON THIS PROJECT.
|
||||||
|
|
||||||
|
An app admin is admin everywhere. Otherwise a membership row may carry its own
|
||||||
|
role — so a PM on one job can be a plain Project User on another — and an empty
|
||||||
|
membership role falls back to the account's own role."""
|
||||||
|
if auth.is_admin(user):
|
||||||
|
return auth.ROLE_ADMIN
|
||||||
|
if project_id:
|
||||||
|
row = db.scalars(
|
||||||
|
select(models.ProjectMember).where(
|
||||||
|
(models.ProjectMember.user_id == user.id)
|
||||||
|
& (models.ProjectMember.project_id == project_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row and (row.role or "").strip():
|
||||||
|
return auth.normalize_role(row.role)
|
||||||
|
return auth.normalize_role(user.role)
|
||||||
|
|
||||||
|
|
||||||
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
|
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
|
||||||
what: str = "this action") -> None:
|
what: str = "this action") -> None:
|
||||||
"""Destructive / baseline-changing operations: deleting a work package or a
|
"""Destructive / baseline-changing operations: deleting a work package or a
|
||||||
project, and editing a SOP that has already been completed. Requires project
|
project, and editing a SOP that has already been completed. Requires project
|
||||||
access AND the project_admin (or admin) permissions role."""
|
access AND Project Admin *on that project*."""
|
||||||
require_project_access(db, user, project_id)
|
require_project_access(db, user, project_id)
|
||||||
if not auth.is_project_admin(user):
|
if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail=f"{what} requires the Project Admin permissions role",
|
detail=f"{what} requires the Project Admin role on this project",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_project_writable(db: Session, user_or_none, project_id: Optional[str],
|
||||||
|
what: str = "This change") -> None:
|
||||||
|
"""An archived project is frozen: everything on it stays readable, nothing on it
|
||||||
|
may be written. Every mutating path that lands on a project goes through here —
|
||||||
|
saving a SOP or a package, issuing, status changes, deletes, comments.
|
||||||
|
|
||||||
|
It is 409 and not 403 on purpose. Nobody lacks a permission here: the state of
|
||||||
|
the project is the objection, and even an admin gets refused until they unarchive
|
||||||
|
it — hence the caller identity is accepted for symmetry with the other guards but
|
||||||
|
deliberately unused. 409 also matters offline: the browser outbox in
|
||||||
|
html/project-data.js retires any 4xx op instead of retrying it forever, so an edit
|
||||||
|
queued before the archive dies quietly rather than looping against a frozen job."""
|
||||||
|
if not project_id:
|
||||||
|
return
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if proj is not None and proj.archived_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(f"{what} — this project is archived (read-only). An administrator "
|
||||||
|
f"can unarchive it from the admin console."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,16 +201,50 @@ def scope_to_access(stmt, column, db: Session, user: "models.User"):
|
|||||||
return stmt.where(column.in_(ids))
|
return stmt.where(column.in_(ids))
|
||||||
|
|
||||||
|
|
||||||
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
|
def grant_project_access(db: Session, user_id: str, project_id: str, role: str = "") -> bool:
|
||||||
"""Add a (user, project) membership if it isn't already there."""
|
"""Add a (user, project) membership if it isn't already there, carrying an
|
||||||
|
optional per-project role override ('' = inherit the account's own). Returns True
|
||||||
|
only when a row was actually added, so a caller can report what it did. Does NOT
|
||||||
|
commit — the caller owns the transaction."""
|
||||||
exists = db.scalar(
|
exists = db.scalar(
|
||||||
select(models.ProjectMember.id).where(
|
select(models.ProjectMember.id).where(
|
||||||
(models.ProjectMember.user_id == user_id)
|
(models.ProjectMember.user_id == user_id)
|
||||||
& (models.ProjectMember.project_id == project_id)
|
& (models.ProjectMember.project_id == project_id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not exists:
|
if exists:
|
||||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
|
return False
|
||||||
|
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id, role=role))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def add_default_members(db: Session, project_id: str, actor) -> list[str]:
|
||||||
|
"""Put the standing default members onto a brand-new project.
|
||||||
|
|
||||||
|
Some people belong on every job the moment it exists — the PM who runs them all,
|
||||||
|
the QC lead — and flagging their account (users.auto_add_projects) is how an admin
|
||||||
|
says that once instead of remembering it at every project creation. App admins are
|
||||||
|
skipped because they already reach every project, an inactive account is not
|
||||||
|
revived by a new job, and an existing membership is never duplicated or
|
||||||
|
overwritten. Returns the usernames added; does NOT commit, matching
|
||||||
|
grant_project_access."""
|
||||||
|
rows = db.scalars(
|
||||||
|
select(models.User).where(
|
||||||
|
(models.User.auto_add_projects.is_(True)) & (models.User.is_active.is_(True))
|
||||||
|
).order_by(models.User.username)
|
||||||
|
).all()
|
||||||
|
added = []
|
||||||
|
for u in rows:
|
||||||
|
if auth.is_admin(u):
|
||||||
|
continue
|
||||||
|
if grant_project_access(db, u.id, project_id, (u.auto_add_role or "").strip()):
|
||||||
|
added.append(u.username)
|
||||||
|
if added:
|
||||||
|
# One line for the batch, not one per person — this is a single automatic act.
|
||||||
|
log_event(db, actor, "project_access_granted", "project", project_id,
|
||||||
|
project_id=project_id, summary=f"{len(added)} default member(s) added",
|
||||||
|
detail={"users": added, "reason": "auto_add_projects"})
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
# ── Audit trail ────────────────────────────────────────────────────────────────
|
# ── Audit trail ────────────────────────────────────────────────────────────────
|
||||||
@@ -356,6 +433,16 @@ class RoleIn(BaseModel):
|
|||||||
|
|
||||||
class ProjectAssignIn(BaseModel):
|
class ProjectAssignIn(BaseModel):
|
||||||
project_ids: list[str] = Field(default_factory=list)
|
project_ids: list[str] = Field(default_factory=list)
|
||||||
|
# Optional per-project permissions role, {project_id: role}. Omit or use '' to
|
||||||
|
# inherit the account's own role on that project.
|
||||||
|
roles: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoAddIn(BaseModel):
|
||||||
|
auto_add: bool
|
||||||
|
# Role to give this user on the projects they're auto-added to; '' inherits the
|
||||||
|
# account's own role, same value space as ProjectMember.role.
|
||||||
|
role: str = ""
|
||||||
|
|
||||||
|
|
||||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||||
@@ -667,8 +754,16 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
|
|||||||
raise HTTPException(status_code=400, detail="Can't remove the last admin account")
|
raise HTTPException(status_code=400, detail="Can't remove the last admin account")
|
||||||
old_role = u.role
|
old_role = u.role
|
||||||
u.role = body.role
|
u.role = body.role
|
||||||
log_event(db, admin, "role_changed", "user", u.id, summary=u.username,
|
detail = {"from": old_role, "to": body.role}
|
||||||
detail={"from": old_role, "to": body.role})
|
# Promoting someone to admin retires their default-member flag: an admin already
|
||||||
|
# reaches every project, so the flag would do nothing except sit there invisibly
|
||||||
|
# (the console shows admins no controls) and come back to life the day they are
|
||||||
|
# demoted. Same reasoning as clearing the role in set_user_auto_add.
|
||||||
|
if body.role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role):
|
||||||
|
u.auto_add_projects = False
|
||||||
|
u.auto_add_role = ""
|
||||||
|
detail["auto_add_cleared"] = True
|
||||||
|
log_event(db, admin, "role_changed", "user", u.id, summary=u.username, detail=detail)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(u)
|
db.refresh(u)
|
||||||
return u.to_dict()
|
return u.to_dict()
|
||||||
@@ -691,6 +786,33 @@ def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User
|
|||||||
return u.to_dict()
|
return u.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/users/{user_id}/auto-add")
|
||||||
|
def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
|
"""Flag a user as a default member of every project created from here on, with
|
||||||
|
an optional role on those projects. It only touches NEW projects — existing
|
||||||
|
assignments stay under the admin's hand (see set_user_projects), because
|
||||||
|
back-filling everyone onto historical jobs is never what this flag means."""
|
||||||
|
allowed = ("", auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||||
|
role = (body.role or "").strip()
|
||||||
|
if role not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"role must be '' (inherit) or one of {auth.ROLE_PROJECT_ADMIN}, {auth.ROLE_PROJECT_USER}",
|
||||||
|
)
|
||||||
|
u = db.get(models.User, user_id)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
u.auto_add_projects = bool(body.auto_add)
|
||||||
|
# A role left behind on a switched-off flag is a trap: it would quietly take
|
||||||
|
# effect the day someone switches the flag back on.
|
||||||
|
u.auto_add_role = role if u.auto_add_projects else ""
|
||||||
|
log_event(db, admin, "auto_add_changed", "user", u.id, summary=u.username,
|
||||||
|
detail={"auto_add": bool(u.auto_add_projects), "role": u.auto_add_role})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
return u.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/auth/users/{user_id}")
|
@app.delete("/api/auth/users/{user_id}")
|
||||||
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||||
u = db.get(models.User, user_id)
|
u = db.get(models.User, user_id)
|
||||||
@@ -711,12 +833,18 @@ def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_a
|
|||||||
u = db.get(models.User, user_id)
|
u = db.get(models.User, user_id)
|
||||||
if not u:
|
if not u:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
|
rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
|
||||||
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||||
return {
|
return {
|
||||||
"user": u.to_dict(),
|
"user": u.to_dict(),
|
||||||
"assigned": list(assigned),
|
"assigned": [r.project_id for r in rows],
|
||||||
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
# Per-project role overrides, keyed by project id ('' = inherit the account's).
|
||||||
|
"roles": {r.project_id: (r.role or "") for r in rows},
|
||||||
|
# Archived projects stay on this list on purpose — an existing assignment has
|
||||||
|
# to remain visible and removable — but they're flagged so the dialog can say
|
||||||
|
# so, rather than offering a finished job as though it were live work.
|
||||||
|
"projects": [{"id": p.id, "name": p.name, "number": p.number,
|
||||||
|
"archived": p.archived_at is not None} for p in projects],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -727,11 +855,19 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
|
|||||||
if not u:
|
if not u:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
||||||
|
# Only the two project-scoped roles make sense here: app admin is global, and
|
||||||
|
# anything unrecognised falls back to inheriting the account's own role.
|
||||||
|
allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||||
|
roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
|
||||||
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
||||||
for pid in valid:
|
for pid in valid:
|
||||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
|
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
|
||||||
|
role=roles.get(pid, "")))
|
||||||
|
log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username,
|
||||||
|
detail={"projects": len(valid),
|
||||||
|
"overrides": {p: r for p, r in roles.items() if p in valid}})
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"assigned": sorted(valid)}
|
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
|
||||||
|
|
||||||
|
|
||||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||||
@@ -742,6 +878,7 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
|
|||||||
is_new = proj is None
|
is_new = proj is None
|
||||||
if not is_new:
|
if not is_new:
|
||||||
require_project_access(db, user, proj.id)
|
require_project_access(db, user, proj.id)
|
||||||
|
require_project_writable(db, user, proj.id, "Editing a project")
|
||||||
if proj is None:
|
if proj is None:
|
||||||
proj = models.Project(id=body.id or gen_id("proj"))
|
proj = models.Project(id=body.id or gen_id("proj"))
|
||||||
db.add(proj)
|
db.add(proj)
|
||||||
@@ -756,18 +893,36 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
|
|||||||
log_event(db, user, "created" if is_new else "updated", "project", proj.id,
|
log_event(db, user, "created" if is_new else "updated", "project", proj.id,
|
||||||
project_id=proj.id, summary=(proj.name or proj.number or proj.id))
|
project_id=proj.id, summary=(proj.name or proj.number or proj.id))
|
||||||
db.commit()
|
db.commit()
|
||||||
# A project created by a non-admin auto-grants its creator access.
|
# A project created by a non-admin auto-grants its creator access. If that
|
||||||
|
# creator is ALSO a standing default member, this is the row that sticks —
|
||||||
|
# add_default_members below never overwrites an existing membership — so it has
|
||||||
|
# to carry the role they'd have been given, or someone whose flag says
|
||||||
|
# "Project Admin on every job" would silently land as a plain member on the one
|
||||||
|
# job they started themselves.
|
||||||
if is_new and not auth.is_admin(user):
|
if is_new and not auth.is_admin(user):
|
||||||
grant_project_access(db, user.id, proj.id)
|
creator_role = (user.auto_add_role or "").strip() if user.auto_add_projects else ""
|
||||||
|
grant_project_access(db, user.id, proj.id, creator_role)
|
||||||
|
db.commit()
|
||||||
|
# …and anyone flagged as a default member joins at creation time too.
|
||||||
|
if is_new:
|
||||||
|
add_default_members(db, proj.id, user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(proj)
|
db.refresh(proj)
|
||||||
return proj.to_dict()
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/projects")
|
@app.get("/api/projects")
|
||||||
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
def list_projects(
|
||||||
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
|
archived: str = Query("exclude", description="exclude (default) | only | all"),
|
||||||
rows = db.scalars(stmt).all()
|
user: models.User = Depends(auth.get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = scope_to_access(select(models.Project), models.Project.id, db, user)
|
||||||
|
if archived == "only":
|
||||||
|
stmt = stmt.where(models.Project.archived_at.is_not(None))
|
||||||
|
elif archived != "all":
|
||||||
|
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
|
||||||
|
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
|
||||||
return [p.summary() for p in rows]
|
return [p.summary() for p in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -794,14 +949,46 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
|
|||||||
return {"deleted": project_id}
|
return {"deleted": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/projects/{project_id}/archive")
|
||||||
|
def archive_project(project_id: str, body: ArchiveIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""Archive (or unarchive) a whole project — it drops out of every picker,
|
||||||
|
switcher and search, and freezes read-only, without losing a thing. This is how
|
||||||
|
a finished job gets out of everyone's way while staying on the record; delete is
|
||||||
|
still there for a job that should never have existed."""
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if not proj:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
# Takes the whole job out of circulation for everyone on it, so it sits at the
|
||||||
|
# same bar as deleting it. Note the deliberate absence of require_project_writable
|
||||||
|
# — unarchiving is the one write an archived project must still accept.
|
||||||
|
require_project_admin(db, user, proj.id, "Archiving a project")
|
||||||
|
was_archived = proj.archived_at is not None
|
||||||
|
if body.archived and not was_archived:
|
||||||
|
proj.archived_at = models.utcnow()
|
||||||
|
log_event(db, user, "archived", "project", proj.id, project_id=proj.id,
|
||||||
|
summary=(proj.name or proj.number or proj.id))
|
||||||
|
elif not body.archived and was_archived:
|
||||||
|
proj.archived_at = None
|
||||||
|
log_event(db, user, "unarchived", "project", proj.id, project_id=proj.id,
|
||||||
|
summary=(proj.name or proj.number or proj.id))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(proj)
|
||||||
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||||
@app.post("/api/sops")
|
@app.post("/api/sops")
|
||||||
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
check_id(body.id)
|
check_id(body.id)
|
||||||
require_project_access(db, user, body.project_id)
|
require_project_access(db, user, body.project_id)
|
||||||
|
# Both ends are checked: the project the SOP is being written INTO here, and the
|
||||||
|
# one it currently sits on below — so an archived job can be neither edited nor
|
||||||
|
# used as a source to move a SOP out of.
|
||||||
|
require_project_writable(db, user, body.project_id, "Saving a SOP")
|
||||||
sop = db.get(models.Sop, body.id) if body.id else None
|
sop = db.get(models.Sop, body.id) if body.id else None
|
||||||
if sop is not None:
|
if sop is not None:
|
||||||
require_project_access(db, user, sop.project_id)
|
require_project_access(db, user, sop.project_id)
|
||||||
|
require_project_writable(db, user, sop.project_id, "Saving a SOP")
|
||||||
# The SOP is the project's baseline: once it's been completed, changing it
|
# The SOP is the project's baseline: once it's been completed, changing it
|
||||||
# is a Project Admin action. Authoring and revising a draft is open to any
|
# is a Project Admin action. Authoring and revising a draft is open to any
|
||||||
# project member, including marking it complete the first time.
|
# project member, including marking it complete the first time.
|
||||||
@@ -866,6 +1053,7 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
|||||||
if not sop:
|
if not sop:
|
||||||
raise HTTPException(status_code=404, detail="SOP not found")
|
raise HTTPException(status_code=404, detail="SOP not found")
|
||||||
require_project_admin(db, user, sop.project_id, "Deleting a SOP")
|
require_project_admin(db, user, sop.project_id, "Deleting a SOP")
|
||||||
|
require_project_writable(db, user, sop.project_id, "Deleting a SOP")
|
||||||
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
|
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
|
||||||
summary=(sop.name or sop.number or sop.id))
|
summary=(sop.name or sop.number or sop.id))
|
||||||
db.delete(sop)
|
db.delete(sop)
|
||||||
@@ -1057,9 +1245,12 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
|
|||||||
check_id(body.parent_id)
|
check_id(body.parent_id)
|
||||||
check_id(body.assignee_id)
|
check_id(body.assignee_id)
|
||||||
require_project_access(db, user, body.project_id)
|
require_project_access(db, user, body.project_id)
|
||||||
|
# Destination and origin both have to be live — see upsert_sop.
|
||||||
|
require_project_writable(db, user, body.project_id, "Saving a work package")
|
||||||
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
||||||
if wp is not None:
|
if wp is not None:
|
||||||
require_project_access(db, user, wp.project_id)
|
require_project_access(db, user, wp.project_id)
|
||||||
|
require_project_writable(db, user, wp.project_id, "Saving a work package")
|
||||||
is_new = wp is None
|
is_new = wp is None
|
||||||
old_status = None if is_new else wp.status
|
old_status = None if is_new else wp.status
|
||||||
old_assignee = None if is_new else wp.assignee_id
|
old_assignee = None if is_new else wp.assignee_id
|
||||||
@@ -1243,6 +1434,7 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
|
|||||||
# Deleting a work package is irreversible — Project Admin only. A project_user
|
# Deleting a work package is irreversible — Project Admin only. A project_user
|
||||||
# who wants one out of the way can archive it instead (reversible).
|
# who wants one out of the way can archive it instead (reversible).
|
||||||
require_project_admin(db, user, wp.project_id, "Deleting a work package")
|
require_project_admin(db, user, wp.project_id, "Deleting a work package")
|
||||||
|
require_project_writable(db, user, wp.project_id, "Deleting a work package")
|
||||||
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
|
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
|
||||||
summary=(wp.number or wp.subject or wp.id))
|
summary=(wp.number or wp.subject or wp.id))
|
||||||
db.delete(wp)
|
db.delete(wp)
|
||||||
@@ -1258,6 +1450,7 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
|
|||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
require_project_access(db, user, wp.project_id)
|
require_project_access(db, user, wp.project_id)
|
||||||
|
require_project_writable(db, user, wp.project_id, "Issuing a work package")
|
||||||
enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status)
|
enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status)
|
||||||
wp.status = "Issued"
|
wp.status = "Issued"
|
||||||
wp.issued_at = models.utcnow()
|
wp.issued_at = models.utcnow()
|
||||||
@@ -1274,6 +1467,7 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
|
|||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
require_project_access(db, user, wp.project_id)
|
require_project_access(db, user, wp.project_id)
|
||||||
|
require_project_writable(db, user, wp.project_id, "Changing a work package's status")
|
||||||
old_status = wp.status
|
old_status = wp.status
|
||||||
# Same gates as /issue — this route must not be a way around them.
|
# Same gates as /issue — this route must not be a way around them.
|
||||||
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
|
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
|
||||||
@@ -1296,6 +1490,9 @@ def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get
|
|||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
require_project_access(db, user, wp.project_id)
|
require_project_access(db, user, wp.project_id)
|
||||||
|
# An archived project is frozen whole: its packages keep the archive state they
|
||||||
|
# had, and tidying inside it waits until the job is unarchived.
|
||||||
|
require_project_writable(db, user, wp.project_id, "Archiving a work package")
|
||||||
was_archived = wp.archived_at is not None
|
was_archived = wp.archived_at is not None
|
||||||
if body.archived and not was_archived:
|
if body.archived and not was_archived:
|
||||||
wp.archived_at = models.utcnow()
|
wp.archived_at = models.utcnow()
|
||||||
@@ -1366,12 +1563,23 @@ def global_search(
|
|||||||
pat = _like_term(term)
|
pat = _like_term(term)
|
||||||
esc = "\\"
|
esc = "\\"
|
||||||
|
|
||||||
|
# An archived project is meant to be gone from view, and search is the one place
|
||||||
|
# it would otherwise come back — as the project itself, or as one of its packages
|
||||||
|
# or SOPs. So the archived ids are read once here and filtered out of all three
|
||||||
|
# result sets. Rows with no project at all (orphan/legacy, admin-only) survive
|
||||||
|
# explicitly: SQL's NOT IN drops NULLs, and they aren't on an archived job.
|
||||||
|
archived_pids = set(db.scalars(
|
||||||
|
select(models.Project.id).where(models.Project.archived_at.is_not(None))
|
||||||
|
).all())
|
||||||
|
|
||||||
proj_stmt = select(models.Project).where(
|
proj_stmt = select(models.Project).where(
|
||||||
func.lower(models.Project.name).like(pat, escape=esc)
|
func.lower(models.Project.name).like(pat, escape=esc)
|
||||||
| func.lower(models.Project.number).like(pat, escape=esc)
|
| func.lower(models.Project.number).like(pat, escape=esc)
|
||||||
| func.lower(models.Project.client).like(pat, escape=esc)
|
| func.lower(models.Project.client).like(pat, escape=esc)
|
||||||
| func.lower(models.Project.site).like(pat, escape=esc)
|
| func.lower(models.Project.site).like(pat, escape=esc)
|
||||||
)
|
)
|
||||||
|
if archived_pids:
|
||||||
|
proj_stmt = proj_stmt.where(models.Project.id.not_in(archived_pids))
|
||||||
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
|
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
|
||||||
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
|
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
|
||||||
|
|
||||||
@@ -1384,6 +1592,11 @@ def global_search(
|
|||||||
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
|
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if archived_pids:
|
||||||
|
wp_stmt = wp_stmt.where(
|
||||||
|
models.WorkPackage.project_id.is_(None)
|
||||||
|
| models.WorkPackage.project_id.not_in(archived_pids)
|
||||||
|
)
|
||||||
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
|
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
|
||||||
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
|
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
|
||||||
|
|
||||||
@@ -1391,6 +1604,10 @@ def global_search(
|
|||||||
func.lower(models.Sop.name).like(pat, escape=esc)
|
func.lower(models.Sop.name).like(pat, escape=esc)
|
||||||
| func.lower(models.Sop.number).like(pat, escape=esc)
|
| func.lower(models.Sop.number).like(pat, escape=esc)
|
||||||
)
|
)
|
||||||
|
if archived_pids:
|
||||||
|
sop_stmt = sop_stmt.where(
|
||||||
|
models.Sop.project_id.is_(None) | models.Sop.project_id.not_in(archived_pids)
|
||||||
|
)
|
||||||
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
|
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
|
||||||
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
|
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
|
||||||
|
|
||||||
@@ -1494,7 +1711,7 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
|
|||||||
seen.add(u.id)
|
seen.add(u.id)
|
||||||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
|
out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
|
||||||
"email": u.email, "project_role": u.project_role or "",
|
"email": u.email, "project_role": u.project_role or "",
|
||||||
"role": auth.normalize_role(u.role)})
|
"role": effective_role(db, u, project_id)})
|
||||||
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
|
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -1502,17 +1719,28 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
|
|||||||
# ── Comments / feedback ──────────────────────────────────────────────────────
|
# ── Comments / feedback ──────────────────────────────────────────────────────
|
||||||
def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
|
def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
|
||||||
# A comment tied to a WP/SOP requires access to that resource's project, so
|
# A comment tied to a WP/SOP requires access to that resource's project, so
|
||||||
# a user can't write into another project's review thread.
|
# a user can't write into another project's review thread. A review thread on an
|
||||||
|
# archived project is frozen with the rest of it; general app feedback isn't tied
|
||||||
|
# to a project at all and keeps working regardless.
|
||||||
|
#
|
||||||
|
# Both ids are checked independently — NOT if/elif. The row stores whichever ids
|
||||||
|
# the payload carried, so a body naming a WP you may touch AND a SOP you may not
|
||||||
|
# would, under an elif, be authorised on the WP alone and still land in the other
|
||||||
|
# project's SOP thread.
|
||||||
|
check_id(body.wp_id)
|
||||||
|
check_id(body.sop_id)
|
||||||
if body.wp_id:
|
if body.wp_id:
|
||||||
wp = db.get(models.WorkPackage, body.wp_id)
|
wp = db.get(models.WorkPackage, body.wp_id)
|
||||||
if not wp:
|
if not wp:
|
||||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||||
require_project_access(db, user, wp.project_id)
|
require_project_access(db, user, wp.project_id)
|
||||||
elif body.sop_id:
|
require_project_writable(db, user, wp.project_id, "Commenting on a work package")
|
||||||
|
if body.sop_id:
|
||||||
sop = db.get(models.Sop, body.sop_id)
|
sop = db.get(models.Sop, body.sop_id)
|
||||||
if not sop:
|
if not sop:
|
||||||
raise HTTPException(status_code=404, detail="SOP not found")
|
raise HTTPException(status_code=404, detail="SOP not found")
|
||||||
require_project_access(db, user, sop.project_id)
|
require_project_access(db, user, sop.project_id)
|
||||||
|
require_project_writable(db, user, sop.project_id, "Commenting on a SOP")
|
||||||
extra = body.model_extra or {}
|
extra = body.model_extra or {}
|
||||||
c = models.Comment(
|
c = models.Comment(
|
||||||
id=gen_id("c"),
|
id=gen_id("c"),
|
||||||
@@ -1592,4 +1820,21 @@ def list_comments(
|
|||||||
# Mounted LAST so the /api/* routes above always match first.
|
# Mounted LAST so the /api/* routes above always match first.
|
||||||
_html_dir = os.path.join(os.path.dirname(__file__), "..", "html")
|
_html_dir = os.path.join(os.path.dirname(__file__), "..", "html")
|
||||||
if os.path.isdir(_html_dir):
|
if os.path.isdir(_html_dir):
|
||||||
app.mount("/", StaticFiles(directory=_html_dir, html=True), name="site")
|
|
||||||
|
class _NoCacheCode(StaticFiles):
|
||||||
|
"""Serve code assets with Cache-Control: no-cache.
|
||||||
|
|
||||||
|
Production runs behind NGINX (which now sets this itself), but the dev server
|
||||||
|
is what people actually click around in — and with no header at all the
|
||||||
|
browser applies HEURISTIC freshness per file (~10% of the file's age), so the
|
||||||
|
least recently changed file gets the longest lifetime and HTML/CSS/JS drift
|
||||||
|
apart between reloads. ETag/Last-Modified still make revalidation a cheap 304.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_response(self, path, scope):
|
||||||
|
res = await super().get_response(path, scope)
|
||||||
|
if path.endswith((".html", ".css", ".js", ".webmanifest")) or path in ("", "/", "."):
|
||||||
|
res.headers["Cache-Control"] = "no-cache"
|
||||||
|
return res
|
||||||
|
|
||||||
|
app.mount("/", _NoCacheCode(directory=_html_dir, html=True), name="site")
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class Project(Base):
|
|||||||
division: Mapped[str] = mapped_column(String(200), default="")
|
division: Mapped[str] = mapped_column(String(200), default="")
|
||||||
site: Mapped[str] = mapped_column(String(300), default="")
|
site: Mapped[str] = mapped_column(String(300), default="")
|
||||||
sample: Mapped[bool] = mapped_column(Boolean, default=False)
|
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)
|
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)
|
||||||
@@ -42,7 +47,9 @@ class Project(Base):
|
|||||||
return {
|
return {
|
||||||
"id": self.id, "name": self.name, "number": self.number,
|
"id": self.id, "name": self.name, "number": self.number,
|
||||||
"client": self.client, "division": self.division, "site": self.site,
|
"client": self.client, "division": self.division, "site": self.site,
|
||||||
"sample": self.sample, "created_by": self.created_by,
|
"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),
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +147,13 @@ class User(Base):
|
|||||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||||
# Job function on the project — free text, offered from a suggested list.
|
# Job function on the project — free text, offered from a suggested list.
|
||||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
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
|
# 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
|
# browser". A stored value follows the person between devices, which matters on
|
||||||
# shared field tablets where the browser locale isn't theirs.
|
# shared field tablets where the browser locale isn't theirs.
|
||||||
@@ -162,15 +176,21 @@ class User(Base):
|
|||||||
"id": self.id, "username": self.username, "email": self.email,
|
"id": self.id, "username": self.username, "email": self.email,
|
||||||
"full_name": self.full_name, "role": self.role,
|
"full_name": self.full_name, "role": self.role,
|
||||||
"project_role": self.project_role or "", "is_active": self.is_active,
|
"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 "",
|
"locale": self.locale or "", "timezone": self.timezone or "",
|
||||||
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ProjectMember(Base):
|
class ProjectMember(Base):
|
||||||
"""Which users may access which projects. A user sees/operates on a project
|
"""Which users may access which projects, and what they may do there. A user
|
||||||
only if a row links them to it (admins bypass this entirely). One row per
|
sees/operates on a project only if a row links them to it (admins bypass this
|
||||||
(user, project) pair."""
|
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"
|
__tablename__ = "project_members"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||||
|
|
||||||
@@ -181,6 +201,7 @@ class ProjectMember(Base):
|
|||||||
project_id: Mapped[str] = mapped_column(
|
project_id: Mapped[str] = mapped_column(
|
||||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,11 @@ def main():
|
|||||||
if st != 200:
|
if st != 200:
|
||||||
print(f"ABORT: /api/health returned {st}"); return 1
|
print(f"ABORT: /api/health returned {st}"); return 1
|
||||||
|
|
||||||
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
|
# --clean: remove any prior demo projects (cascade removes their SOP + WPs).
|
||||||
st, projects = call("GET", "/api/projects")
|
# 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-")]
|
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
|
||||||
if args.clean:
|
if args.clean:
|
||||||
for p in demos:
|
for p in demos:
|
||||||
|
|||||||
@@ -168,8 +168,32 @@ def main():
|
|||||||
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
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}")
|
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:
|
finally:
|
||||||
# 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
# 13) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
||||||
if project_id and not args.keep:
|
if project_id and not args.keep:
|
||||||
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
||||||
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
||||||
|
|||||||
Reference in New Issue
Block a user