Compare commits

..

1 Commits

Author SHA1 Message Date
C-West8
e51024466b first pass at storing data in DB instead of client only 2026-06-30 16:35:39 -05:00
48 changed files with 533 additions and 3136 deletions

11
.gitignore vendored
View File

@@ -15,16 +15,5 @@ wpsuite.db
# Runtime directories (created by containers) # Runtime directories (created by containers)
logs/ logs/
# Database backup dumps (large + sensitive) — keep the folder, ignore contents
/backups/*
!/backups/.gitkeep
# Local server logs # Local server logs
*.log *.log
# Local scratch / test artifacts (curl cookie jars hold live session tokens)
_*.txt
cookies.txt
# Claude Code local workspace (agent memory, session data)
.claude/

View File

@@ -51,25 +51,9 @@ Create a file named `.env` in the **project root** (same folder as
POSTGRES_DB=wpsuite POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password> POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
# out and the API refuses to start. Generate once and keep it stable:
# openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email notifications. Email is OFF
# by default and enabled from the Admin console; the host/port/from-address are
# configured there, but the password is only ever read from this variable (never
# stored in the DB or shown in the UI). Leave unset until you have SMTP details.
# SMTP_PASSWORD=<smtp-app-password>
``` ```
The API builds its own DB connection string from the `POSTGRES_*` That's it — the API now builds its own connection string from these three
values and **encodes the password automatically**, so a password with special values and **encodes the password automatically**, so a password with special
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL` characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
is **optional** and only needed if you want to point the API at some other is **optional** and only needed if you want to point the API at some other
@@ -80,8 +64,7 @@ Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's > **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`). > **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` / > Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
These are the only credentials in the system, and they never appear in the These are the only credentials in the system, and they never appear in the
compose file or in git. compose file or in git.
@@ -94,14 +77,6 @@ chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
container on that network. The container already proxies `/api/` to the `api` container on that network. The container already proxies `/api/` to the `api`
service internally — no extra app config needed. service internally — no extra app config needed.
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
> reached over HTTPS.
## 4. Bring it up ## 4. Bring it up
From the project root: From the project root:
@@ -170,10 +145,12 @@ python3 server/seed_demo.py https://wp-suite.company.local --insecure
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
``` ```
> **What shows where:** the DEMO **project**, its **SOP**, and its **Work > **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
> Packages** are all API/SQL-backed, so they appear in the home-page project > the home-page project picker right away (this is the visible proof that the
> picker and render in the Creator/Dashboard as soon as any user opens the > projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
> project. Inspect them at the SQL layer with `smoketest.py` or: > written to SQL too, but the current front end still reads SOPs/WPs from the
> browser, so they won't render in the Creator/Dashboard until the Phase 2
> wiring. Inspect them at the SQL layer with `smoketest.py` or:
> ```bash > ```bash
> docker compose exec db psql -U wpsuite -d wpsuite \ > docker compose exec db psql -U wpsuite -d wpsuite \
> -c "select number, subject, status from work_packages order by number;" > -c "select number, subject, status from work_packages order by number;"
@@ -183,21 +160,20 @@ python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it
## What is stored in SQL today ## What is stored in SQL today
The API + Postgres are the system of record. Everything below is server-stored Be aware of the current persistence split — the API + Postgres are fully
and shared across every user who opens the project: deployed, and:
| Data | Stored in PostgreSQL today? | | Data | Stored in PostgreSQL today? |
|------|------------------------------| |------|------------------------------|
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. | | **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. | | **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
| **SOPs** | **Yes** — pulled from `/api/sops` on load and written through on every save. | | **SOPs** | Endpoints exist (`/api/sops`); the front end still keeps the SOP in the browser (namespaced per project). Wiring it to the API is the remaining **Phase 2** step. |
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). | | **Work Packages** | Same — `/api/wps` (+ issue/status/metrics) exist and are ready; the creator still saves to the browser per project. |
Saves go through a **durable client-side sync outbox**: edits are written to the So a fresh deployment gives you **shared, server-stored projects and comments
API immediately, and if the device is offline they queue and retry when it immediately**. Moving SOPs and Work Packages off the browser and onto the API
reconnects (4xx rejections are dropped rather than retried forever). The browser (so they're shared across users too) is a front-end change only — the database
cache is only an offline fallback that reconciles through that outbox — so two and endpoints are already in place.
users on the same project see the same server-stored SOP and Work Packages.
## Data model (PostgreSQL) ## Data model (PostgreSQL)
@@ -205,13 +181,8 @@ users on the same project see the same server-stored SOP and Work Packages.
|-------|-------|-------------| |-------|-------|-------------|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` | | `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `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`, `issued_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 |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
The complete client document is stored verbatim in each row's `data` JSON The complete client document is stored verbatim in each row's `data` JSON
column; frequently-listed fields are promoted to real columns for filtering. column; frequently-listed fields are promoted to real columns for filtering.
@@ -221,13 +192,8 @@ column; frequently-listed fields are promoted to real columns for filtering.
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` · Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
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`, `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
under `/api/auth/users` · Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`.
List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
and request shapes: `/api/docs` and [`server/README.md`](server/README.md). and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
@@ -243,77 +209,27 @@ docker compose up -d --build api # backend change (server/)
## Backups & retention ## Backups & retention
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and The whole dataset is in the `pgdata` volume — back it up on a schedule:
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
stack — no cron to set up.
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env`
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many
to keep).
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
the passphrase somewhere other than this host; if you lose it the backups can't
be restored.
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
- **Restore (destructive — overwrites current data):**
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
`internal` network on purpose, so offsite must be pushed from the host.
- **Test restores quarterly:** load the latest dump into a throwaway database and
confirm it applies. An untested backup is not a backup.
## Field devices & data at rest
The field view (PWA) caches a project's Work Packages/SOP in the browser's
localStorage so it works offline — i.e. **customer IP sits on the device**.
localStorage is not encrypted and is not a security boundary. Signing out clears
the cached project data, but for any tablet/phone that opens customer-IP projects:
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is
encrypted by default) and a device passcode.
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep
the browser profile per-user on shared devices.
- Users should **sign out** when handing off a shared device (clears the cache).
## Email notifications (optional)
Work-package **owner assignment** works out of the box (in-app only). Optional
**email** on assignment is **OFF by default** and is turned on from the **Admin
console → Notifications & email** card, where an admin sets the SMTP host / port /
TLS / From address and flips the master toggle.
- The **SMTP password is never stored in the database.** It is read only from the
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
still recorded in-app (status `skipped`); nothing is sent.
- Notification emails carry only a **WP number and a deep link** — never the work
package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling.
## Schema migrations (Alembic)
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
pending migrations automatically**.
- The **baseline** migration is idempotent: on a fresh database it creates every
table; on a database whose tables already exist (made by the old `create_all`)
it adopts the schema as-is — no manual `alembic stamp` needed.
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
migrations-only.
- **To change the schema:** edit `server/models.py`, then generate and review a
migration before committing:
```bash ```bash
# from the project root (against your dev SQLite or a staging DB) # Backup (run from project root)
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change" docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
# Restore
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
``` ```
The next `docker compose up -d --build api` applies it in production on startup.
## Schema migrations (important)
Tables are auto-created on API startup (`Base.metadata.create_all`). This
creates **missing tables**, but it does **not** alter existing ones. The
multi-project work added the `projects` table and new columns
(`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
- On a **fresh** database these appear automatically — nothing to do.
- On a database that **already has data** from an older schema, add the new
columns with a migration (introduce **Alembic**) or apply them manually with
`ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
## Local trial without Postgres ## Local trial without Postgres

View File

@@ -4,8 +4,7 @@ COPY server/requirements.txt ./server/
RUN pip install --no-cache-dir -r server/requirements.txt RUN pip install --no-cache-dir -r server/requirements.txt
COPY server/ ./server/ COPY server/ ./server/
EXPOSE 8000 EXPOSE 8000
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is # --preload imports the app once in the master (so create_all runs a single time)
# safe on both fresh and existing databases (the baseline migration adopts an # before forking workers, preventing a table-creation race on first startup.
# existing schema, so no manual stamp is needed). `exec` hands PID 1 to gunicorn CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
# for correct signal handling; --preload imports the app once before forking. "-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
CMD ["sh", "-c", "alembic -c server/alembic.ini upgrade head && exec gunicorn -k uvicorn.workers.UvicornWorker --preload -b 0.0.0.0:8000 --workers 2 server.app:app"]

View File

View File

@@ -27,14 +27,9 @@ services:
POSTGRES_HOST: db POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used). # Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-} DATABASE_URL: ${DATABASE_URL:-}
# Signs login session cookies. REQUIRED — compose fails fast if it's unset, # Signs login session cookies. MUST be set (see server/.env.example).
# and the API refuses to start in production without it (see server/auth.py). AUTH_SECRET_KEY: ${AUTH_SECRET_KEY}
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12} AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
# Optional — SMTP password for WP-assignment emails. Email is off by
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -60,36 +55,6 @@ services:
networks: networks:
- internal - internal
# Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on
# the host (sync that folder offsite from the host — this container has no
# internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups.
backup:
build:
context: .
dockerfile: scripts/backup.Dockerfile # postgres client + openssl
container_name: wp_db_backup
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
PGHOST: db
BACKUP_DIR: /backups
BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily
# Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256).
# Required once the DB holds customer IP. Keep the passphrase off this host.
BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-}
volumes:
- ./scripts:/scripts:ro
- ./backups:/backups
entrypoint: ["/bin/sh", "/scripts/backup-cron.sh"]
restart: unless-stopped
depends_on:
db:
condition: service_healthy
networks:
- internal
volumes: volumes:
pgdata: pgdata:
nginx_logs: nginx_logs:

View File

@@ -6,21 +6,18 @@
<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>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<style> <style>
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616; :root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6; --muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd; --mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace; } --red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
*{ 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:-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; } .wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
h1{ font-size:20px; margin:0 0 2px; } h1{ font-size:20px; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; } .sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:18px 20px; margin-bottom:16px; } .card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); } .card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; padding:8px 14px; cursor:pointer; button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); } 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.primary{ background:var(--accent); border-color:var(--accent); color:#fff; } button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
@@ -28,10 +25,10 @@
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); }
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; } .row{ display:flex; gap:10px; 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); } .banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); } .banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); } .banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:12px 14px; font-family:var(--mono); pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; } font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; } 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:8px; }
@@ -39,41 +36,30 @@
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; } table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; } .note{ font-size:12px; color:var(--dim); margin-top:10px; }
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; } .gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); } .gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 4px; font-size:17px; } .gate-box h2{ margin:0 0 4px; font-size:17px; }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; } .gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:12px; } .gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; } .gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:16px; } .secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; } a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; } .urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong); .urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
border-radius:0; background:#fff; color:var(--text); } border-radius:6px; background:#fff; color:var(--text); }
.urow input{ flex:1; min-width:130px; } .urow input{ flex:1; min-width:130px; }
table.users{ border-collapse:collapse; width:100%; font-size:13px; } table.users{ border-collapse:collapse; width:100%; font-size:13px; }
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; } table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; } table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
table.users tr:last-child td{ border-bottom:none; } table.users tr:last-child td{ border-bottom:none; }
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; } .tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
.tag.admin{ background:#edf5ff; color:#0f62fe; } .tag.user{ background:#e8e8e8; color:#525252; } .tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); } .tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
button.mini{ padding:4px 9px; font-size:12px; } button.mini{ padding:4px 9px; font-size:12px; }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; } .me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
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; }
select.role-select:hover{ border-color:var(--accent); }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:700; }
</style> </style>
</head> </head>
<body> <body>
<!-- SHARED DARK APP BAR -->
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Back to site">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
</a>
</header>
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) --> <!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
<div class="wrap" id="admin-denied" style="display:none"> <div class="wrap" id="admin-denied" style="display:none">
<div class="card"> <div class="card">
@@ -86,7 +72,7 @@
<!-- 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">
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div> <div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div> <div class="row"><a class="home" href="index.html">← Site</a></div>
</div> </div>
@@ -117,14 +103,6 @@
<div id="users-create-msg" class="note"></div> <div id="users-create-msg" class="note"></div>
</div> </div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div>
</div>
<!-- ALL FEEDBACK / COMMENTS --> <!-- ALL FEEDBACK / COMMENTS -->
<div class="card"> <div class="card">
<h2>All feedback &amp; comments</h2> <h2>All feedback &amp; comments</h2>
@@ -132,29 +110,11 @@
<div class="row"> <div class="row">
<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()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
</div> </div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div> <div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div> </div>
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
<div class="card">
<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="row">
<button onclick="loadAudit()">Refresh</button>
<select id="audit-type" onchange="renderAudit()">
<option value="">All types</option>
<option value="wp">Work packages</option>
<option value="sop">SOPs</option>
<option value="project">Projects</option>
<option value="user">User accounts</option>
</select>
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
</div>
<div id="audit-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- USAGE LOGS --> <!-- USAGE LOGS -->
<div class="card"> <div class="card">
<h2>Usage logs</h2> <h2>Usage logs</h2>

View File

@@ -11,10 +11,7 @@ function reveal(){
document.getElementById('admin-main').style.display=''; document.getElementById('admin-main').style.display='';
checkHealth(); checkHealth();
loadUsers(); loadUsers();
loadSettings();
loadNotifications();
loadComments(); loadComments();
loadAudit();
loadUsage(); loadUsage();
} }
function showDenied(){ function showDenied(){
@@ -192,20 +189,11 @@ function renderUsers(list, meId){
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(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
// 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.
const escUname = uesc(u.username).replace(/'/g,"\\'");
const roleCell = me
? '<span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(u.role==='admin'?' is-admin':'')+'" title="Change this users role" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value="user"'+(u.role==='user'?' selected':'')+'>user</option>'+
'<option value="admin"'+(u.role==='admin'?' selected':'')+'>admin</option>'+
'</select>';
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>'+ '<td>'+uesc(u.email||'')+'</td>'+
'<td>'+roleCell+'</td>'+ '<td><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+ '<td><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 style="white-space:nowrap"><div class="row" style="gap:6px">'+
@@ -256,18 +244,6 @@ async function toggleActive(id, makeActive){
else alert('Failed: '+((json && json.detail)||('HTTP '+status))); else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
} }
// Change a user's role (user ↔ admin) at any time. The server enforces the same
// admin-only rule as every other user-management call, and refuses to remove the
// last admin. On any failure we reload so the dropdown snaps back to the truth.
async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status===200){ loadUsers(); }
else {
alert('Could not change role for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function deleteUser(id, username){ async function deleteUser(id, username){
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return; if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id); const { status, json } = await api('DELETE','/api/auth/users/'+id);
@@ -359,118 +335,6 @@ function renderComments(){
'</tr>').join('')+'</tbody></table>'; '</tr>').join('')+'</tbody></table>';
} }
// ── activity log (audit trail) ──────────────────────────────────────────────────
let _audit = [];
async function loadAudit(){
const box = document.getElementById('audit-admin');
box.textContent = 'Loading…';
const { status, json } = await api('GET','/api/audit?limit=500');
if(status!==200 || !Array.isArray(json)){
box.innerHTML = '<div class="banner bad">Could not load activity (HTTP '+status+').</div>'; return;
}
_audit = json;
renderAudit();
}
function renderAudit(){
const box = document.getElementById('audit-admin');
const type = document.getElementById('audit-type').value;
const q = (document.getElementById('audit-search').value||'').toLowerCase();
let rows = _audit.filter(e => (!type || e.entity_type===type) &&
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const det = e => {
const d = e.detail || {};
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
return uesc(Object.keys(d).map(k=>k+': '+d[k]).join(', '));
};
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Type</th><th>Item</th><th>Detail</th></tr></thead><tbody>'+
rows.map(e => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(e.at)+'</td>'+
'<td><strong>'+uesc(e.actor||'—')+'</strong></td>'+
'<td>'+uesc((e.action||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(e.entity_type||'')+'</td>'+
'<td>'+uesc(e.summary||e.entity_id||'')+'</td>'+
'<td style="color:var(--muted)">'+det(e)+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── notifications / email settings ──────────────────────────────────────────────
let _settings = {};
async function loadSettings(){
const box = document.getElementById('settings-box');
const { status, json } = await api('GET','/api/settings');
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
_settings = json; renderSettings();
}
function renderSettings(){
const s = _settings, box = document.getElementById('settings-box');
const on = !!s.email_enabled;
const pwOk = !!s.smtp_password_set;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;margin-bottom:12px">'+
'<input type="checkbox" id="set-enabled"'+(on?' checked':'')+'> Email notifications are <span style="color:'+(on?'var(--green)':'var(--muted)')+'">'+(on?'ON':'OFF')+'</span></label>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-host" placeholder="SMTP host (e.g. smtp.company.local)" value="'+uesc(s.smtp_host||'')+'">'+
'<input id="set-port" style="flex:0 0 90px;min-width:70px" placeholder="Port" value="'+uesc(s.smtp_port||587)+'">'+
'<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;white-space:nowrap"><input type="checkbox" id="set-tls"'+(s.smtp_use_tls?' checked':'')+'> STARTTLS</label>'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-from" placeholder="From address (e.g. wp-suite@company.com)" value="'+uesc(s.from_addr||'')+'">'+
'<input id="set-fromname" placeholder="From name" value="'+uesc(s.from_name||'')+'">'+
'<input id="set-user" placeholder="SMTP username (optional)" value="'+uesc(s.smtp_username||'')+'">'+
'</div>'+
'<div class="urow" style="margin-bottom:8px">'+
'<input id="set-baseurl" placeholder="App base URL for email links (e.g. https://wp.controls.dev)" value="'+uesc(s.app_base_url||'')+'">'+
'</div>'+
'<div class="note" style="margin-bottom:10px">SMTP password: '+(pwOk?'<span style="color:var(--green);font-weight:600">set via SMTP_PASSWORD env ✓</span>':'<span style="color:var(--amber);font-weight:600">not set — add SMTP_PASSWORD to the environment before enabling</span>')+'</div>'+
'<div class="row">'+
'<button class="primary" onclick="saveSettings()">Save settings</button>'+
'<button onclick="testEmail()">Send test email to me</button>'+
'<span id="set-msg" class="note" style="margin:0"></span>'+
'</div>';
}
async function saveSettings(){
const v = id => document.getElementById(id);
const patch = {
email_enabled: v('set-enabled').checked,
smtp_host: v('set-host').value.trim(),
smtp_port: parseInt(v('set-port').value, 10) || 587,
smtp_use_tls: v('set-tls').checked,
from_addr: v('set-from').value.trim(),
from_name: v('set-fromname').value.trim(),
smtp_username: v('set-user').value.trim(),
app_base_url: v('set-baseurl').value.trim(),
};
const msg = v('set-msg'); msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('PUT','/api/settings', patch);
if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('set-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } }
else { msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; }
}
async function testEmail(){
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('POST','/api/settings/test-email', {});
if(status===200) { msg.textContent = '✅ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
}
async function loadNotifications(){
const box = document.getElementById('notif-box'); if(!box) return;
const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
json.map(n => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(n.created_at)+'</td>'+
'<td>'+uesc(n.email||n.user_id)+'</td>'+
'<td>'+uesc((n.kind||'').replace(/_/g,' '))+'</td>'+
'<td>'+uesc(n.subject||'')+'</td>'+
'<td style="color:'+stColor(n.status)+';font-weight:600">'+uesc(n.status)+(n.error?' <span title="'+uesc(n.error)+'">ⓘ</span>':'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ────────────────────────── // ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1'; const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } } function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }

View File

@@ -14,12 +14,6 @@
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })(); var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
// Register the PWA service worker (caches the app shell for offline use). Only
// from the top window; the API and writes are never cached (see sw.js).
if (!inIframe && 'serviceWorker' in navigator) {
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
}
// Hide the page until we know the user is allowed, to avoid a flash of the app // Hide the page until we know the user is allowed, to avoid a flash of the app
// before a redirect. A safety timer reveals it even if the check hangs. // before a redirect. A safety timer reveals it even if the check hangs.
var root = document.documentElement; var root = document.documentElement;
@@ -40,19 +34,6 @@
} }
window.wpLogout = function () { window.wpLogout = function () {
try {
// Clear the auth cache AND all cached project data (customer IP) from this
// device on sign-out — important on shared/field tablets. The outbox
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
// (localStorage is not a security boundary; field devices still need
// full-disk encryption / MDM — see DEPLOYMENT.md.)
localStorage.removeItem('wp_auth_cache');
Object.keys(localStorage).forEach(function (k) {
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
localStorage.removeItem(k);
}
});
} catch (e) {}
fetch('/api/auth/logout', { method: 'POST' }) fetch('/api/auth/logout', { method: 'POST' })
.catch(function () {}) .catch(function () {})
.then(function () { window.location.replace('login.html'); }); .then(function () { window.location.replace('login.html'); });
@@ -115,99 +96,58 @@
}; };
}; };
function isDarkBg(el) {
try {
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return true; }
}
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
// bar it sits in (light links on a dark bar, blue links on a light bar).
function buildUserMenu(user, dark) {
var wrap = document.createElement('div');
wrap.id = 'wp-usermenu';
var linkColor = dark ? '#ffffff' : '#0f62fe';
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
function link(text, onClick, href) {
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
return a;
}
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
who.style.color = dark ? '#ffffff' : '#161616';
wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap;
}
function addLogoutPill(user) { function addLogoutPill(user) {
if (inIframe) return; // the parent page already shows it if (inIframe) return; // the parent page already shows it
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return; if (document.getElementById('wp-logout-pill')) return;
// Preferred: drop the menu INTO the top bar so it never floats over the
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
// the older .header bars alike.
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
if (host) {
var menu = buildUserMenu(user, isDarkBg(host));
// The older .header bars already right-align their own toolbar (via flex:1
// or a button's margin-left:auto). A second auto-margin would split the free
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
host.appendChild(menu);
return;
}
// Fallback for any page with no header bar: a floating pill (as before).
var pill = document.createElement('div'); var pill = document.createElement('div');
pill.id = 'wp-logout-pill'; pill.id = 'wp-logout-pill';
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' + pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' + 'display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;'; 'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
pill.appendChild(buildUserMenu(user, false)); 'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
pill.appendChild(who);
// Admins get a link to the Admin Console (hidden when already on it).
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) {
var adm = document.createElement('a');
adm.href = 'admin.html'; adm.textContent = 'Admin';
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pill.appendChild(sep()); pill.appendChild(adm);
}
var pw = document.createElement('a');
pw.href = '#'; pw.textContent = 'Password';
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
pill.appendChild(sep()); pill.appendChild(pw);
var out = document.createElement('a');
out.href = '#'; out.textContent = 'Sign out';
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
pill.appendChild(sep()); pill.appendChild(out);
document.body.appendChild(pill); document.body.appendChild(pill);
} }
function proceed(user) { fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
.then(function (r) {
if (r.status === 401 || r.status === 403) { goToLogin(); return; }
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
return r.json().then(function (data) {
clearTimeout(safety); clearTimeout(safety);
window.WP_USER = user; window.WP_USER = data && data.user;
reveal(); reveal();
if (window.WP_USER) { if (window.WP_USER) {
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {} try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER); if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); }); else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
} }
}
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
.then(function (r) {
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
return r.json().then(function (data) {
var user = data && data.user;
// Remember the last good auth so the PWA can open offline. The server is
// still the real gate; offline writes queue in the outbox until reconnect.
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
proceed(user);
}); });
}) })
.catch(function () { .catch(function () { goToLogin(); }); // API unreachable → send to login
// Offline / API unreachable: fall back to a recent cached auth if present,
// so the app (and the field view) still open without a network.
try {
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
} catch (e) {}
goToLogin();
});
})(); })();

View File

@@ -1,86 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field View — Work Package Suite</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<style>
* { box-sizing: border-box; }
body { -webkit-text-size-adjust: 100%; }
.field-wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 40px; }
.fld-ctx { font-size: 13px; color: var(--cds-text-secondary); margin-bottom: 12px; }
.fld-ctx b { color: var(--cds-text-primary); }
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: #fff; margin-bottom: 14px; }
.fld-search:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.wp-card { display: block; width: 100%; text-align: left; background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); border-left: 4px solid var(--cds-border-strong); padding: 14px 16px; margin-bottom: 10px; cursor: pointer; font-family: inherit; }
.wp-card:active { background: var(--cds-layer-hover); }
.wp-card.ready { border-left-color: var(--cds-support-success); }
.wp-card.hold { border-left-color: var(--cds-support-error); }
.wp-card .num { font-weight: 600; font-size: 16px; color: var(--cds-text-primary); }
.wp-card .subj { color: var(--cds-text-secondary); font-size: 13px; margin-top: 2px; }
.wp-card .meta { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.pill.ok { background: #defbe6; color: #0e6027; }
.pill.warn { background: #fdf6dd; color: #8a6d00; }
.pill.bad { background: #fff1f1; color: #da1e28; }
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: #fff; }
.fld-empty a { color: var(--cds-link-primary); }
.fld-back { background: none; border: none; color: var(--cds-link-primary); font-size: 15px; padding: 8px 0; cursor: pointer; font-family: inherit; }
.fld-h1 { font-size: 20px; font-weight: 600; margin: 4px 0 2px; }
.fld-sub { color: var(--cds-text-secondary); font-size: 14px; margin-bottom: 16px; }
.fld-sec { background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); padding: 14px 16px; margin-bottom: 14px; }
.fld-sec h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--cds-text-helper); margin-bottom: 10px; }
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.st-btn.hold.on { background: var(--cds-support-error); border-color: var(--cds-support-error); }
.cx-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--cds-border-subtle); }
.cx-row:last-child { border-bottom: none; }
.cx-name { flex: 1; font-size: 15px; }
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; text-align: center; font-family: inherit; }
.cx-state.cleared { background: #defbe6; color: #0e6027; border-color: #a7f0ba; }
.cx-state.na { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.cx-state.open { background: #fff1f1; color: #da1e28; border-color: #ffd7d9; }
.fld-note { width: 100%; padding: 12px; font-size: 16px; border: 1px solid var(--cds-border-strong); min-height: 84px; font-family: inherit; resize: vertical; }
.fld-photo-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; font-family: inherit; }
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
.log-item { border: 1px solid var(--cds-border-subtle); padding: 10px 12px; margin-bottom: 8px; font-size: 14px; color: var(--cds-text-primary); white-space: pre-wrap; }
.log-item .lm { color: var(--cds-text-helper); font-size: 11px; margin-bottom: 4px; }
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: #161616; color: #fff; padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
.fld-toast.show { opacity: 1; }
</style>
</head>
<body>
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Field View</span>
</a>
<div class="wp-appbar-spacer"></div>
<a class="wp-appbar-link" href="index.html">Home</a>
</header>
<div class="field-wrap">
<div class="fld-ctx" id="fld-ctx"></div>
<section id="screen-list">
<input class="fld-search" id="fld-search" type="search" placeholder="Search work packages…" oninput="renderList()" aria-label="Search work packages">
<div id="wp-list"></div>
</section>
<section id="screen-detail" style="display:none"></section>
</div>
<div id="toast" class="fld-toast"></div>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="field.js"></script>
</body>
</html>

View File

@@ -1,164 +0,0 @@
/* Field view — a touch-optimized screen for updating a Work Package's status,
constraints, and a photo/note log from the work face. Reads the same shared
data as the desktop creator (via project-data.js) and saves through the sync
outbox, so it works offline and syncs when the network returns. */
'use strict';
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function nsKey(id) { return 'wp_iwp_v1__' + id; }
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
function fmtTs(s) { try { return new Date(s).toLocaleString(); } catch (e) { return s || ''; } }
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
// ── boot / data ──────────────────────────────────────────────────────────────
function boot() {
var params = new URLSearchParams(location.search);
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
if (!PID) { showNoProject(); return; }
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; renderCtx(); }).catch(function () {}); }
loadWPs();
}
function renderCtx() {
var el = document.getElementById('fld-ctx'); if (!el) return;
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
else el.textContent = 'Project: ' + PID;
}
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
function loadWPs() {
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
renderList();
if (ProjectData.pullProject) {
ProjectData.pullProject(PID).then(function () {
WPS = activePkgs(readCache());
if (!curId) renderList(); else renderDetail();
}).catch(function () {});
}
}
function showNoProject() {
var s = document.getElementById('screen-list');
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
}
// ── list ───────────────────────────────────────────────────────────────────
function renderList() {
var box = document.getElementById('wp-list'); if (!box) return;
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
box.innerHTML = rows.map(function (p) {
var open = openCount(p);
var cls = p.status === 'Issue' ? 'hold' : (open === 0 ? 'ready' : '');
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>');
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
'<div class="subj">' + esc(p.subject || '') + '</div>' +
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
}).join('');
}
// ── detail ─────────────────────────────────────────────────────────────────
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
function backToList() {
curId = null; pendingPhoto = ''; draftNote = '';
document.getElementById('screen-detail').style.display = 'none';
document.getElementById('screen-list').style.display = '';
renderList();
}
function renderDetail() {
var p = curWP(); if (!p) { backToList(); return; }
document.getElementById('screen-list').style.display = 'none';
var d = document.getElementById('screen-detail'); d.style.display = '';
var stBtns = STATUSES.map(function (s) {
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
}).join('');
var cx = (p.constraints) || [];
var cxRows = cx.length ? cx.map(function (c, i) {
var st = c.status || 'open';
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
d.innerHTML =
'<button class="fld-back" onclick="backToList()"> All packages</button>' +
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
'<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
'</div>' +
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
}
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
function saveWP(p) {
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
if (ix >= 0) WPS[ix] = p;
writeCache();
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
}
function setStatus(s) {
var p = curWP(); if (!p) return;
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
if (p.status === s) return;
p.status = s;
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
}
function cycleConstraint(i) {
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
var order = ['open', 'cleared', 'na'];
var cur = p.constraints[i].status || 'open';
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
saveWP(p); renderDetail();
}
function onPhoto(ev) {
var f = ev.target.files && ev.target.files[0]; if (!f) return;
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
var url = URL.createObjectURL(f);
var img = new Image();
img.onload = function () {
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
var cv = document.createElement('canvas');
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
URL.revokeObjectURL(url);
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
};
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
img.src = url;
}
function addUpdate() {
var p = curWP(); if (!p) return;
var note = (draftNote || '').trim();
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
if (!p.fieldLog) p.fieldLog = [];
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
pendingPhoto = ''; draftNote = '';
saveWP(p); renderDetail(); toast('Update added to log');
}
boot();

View File

@@ -15,64 +15,64 @@
// ── styles ──────────────────────────────────────────────────────────────── // ── styles ────────────────────────────────────────────────────────────────
var css = ` var css = `
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; .help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700; margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; } font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%); .help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400; background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999; line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); } opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%); .help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; } border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; }
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; } .help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center; .ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
justify-content:center; z-index:10000; padding:4vh 16px; } justify-content:center; z-index:10000; padding:4vh 16px; }
.ui-help-overlay.open{ display:flex; } .ui-help-overlay.open{ display:flex; }
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px; .ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden; border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; } font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; } .ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; } .ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.ui-help-search{ flex:1; position:relative; max-width:420px; } .ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0; .ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
font-size:13px; outline:none; background:#f7f8fa; } font-size:13px; outline:none; background:#f7f8fa; }
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); } .ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#525252; line-height:1; } .ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; line-height:1; }
.ui-help-wrap{ display:flex; flex:1; min-height:0; } .ui-help-wrap{ display:flex; flex:1; min-height:0; }
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; } .ui-help-nav{ width:230px; flex:none; border-right:1px solid #e3e6ec; overflow:auto; padding:10px 8px; background:#fafbfc; }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px; .ui-help-nav a{ display:block; padding:7px 10px; border-radius:6px; color:#27313f; text-decoration:none; font-size:13px;
cursor:pointer; margin-bottom:1px; } cursor:pointer; margin-bottom:1px; }
.ui-help-nav a:hover{ background:#eef1f6; } .ui-help-nav a:hover{ background:#eef1f6; }
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; } .ui-help-nav a.active{ background:#e7effe; color:#1d4ed8; font-weight:600; }
.ui-help-nav a.nohit{ display:none; } .ui-help-nav a.nohit{ display:none; }
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; } .ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
.ui-help-sec{ margin-bottom:30px; } .ui-help-sec{ margin-bottom:30px; }
.ui-help-sec.hide{ display:none; } .ui-help-sec.hide{ display:none; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; } .ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#16213a; scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; } .ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#2563d6; }
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; } .ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; } .ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
.ui-help-content li{ margin-bottom:5px; } .ui-help-content li{ margin-bottom:5px; }
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; } .ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; } .ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; } .ui-help-content th, .ui-help-content td{ border:1px solid #e3e6ec; padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th{ background:#f4f6f9; font-weight:600; } .ui-help-content th{ background:#f4f6f9; font-weight:600; }
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; } .ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; } .pill-draft{ background:#eef1f6; color:#5a6675; } .pill-sched{ background:#e7effe; color:#1d4ed8; }
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; } .pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; } .pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
.pill-hold{ background:#fde8e8; color:#c0392b; } .pill-hold{ background:#fde8e8; color:#c0392b; }
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0; .ui-help-callout{ background:#f4f8ff; border-left:3px solid #2563d6; padding:10px 14px; border-radius:0 6px 6px 0;
font-size:13px; line-height:1.55; margin:10px 0; } font-size:13px; line-height:1.55; margin:10px 0; }
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; } .ui-help-noresult{ display:none; color:#5a6675; font-size:14px; padding:10px 2px; }
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; } .ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%; .ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer; border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
box-shadow:0 2px 10px rgba(20,30,50,.28); } box-shadow:0 2px 10px rgba(20,30,50,.28); }
.ui-help-fab:hover{ background:#0353e9; } .ui-help-fab:hover{ background:#1d4ed8; }
@media (max-width:760px){ @media (max-width:760px){
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; } .ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; } .ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e3e6ec; }
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; } .ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
.ui-help-head{ flex-wrap:wrap; } .ui-help-head{ flex-wrap:wrap; }
}`; }`;
@@ -94,13 +94,6 @@
</ol> </ol>
<h4>Moving around</h4> <h4>Moving around</h4>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p> <p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<h4>Quick start</h4>
<ol>
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
</ol>
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` }, <div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
{ id: 'projects', title: 'Projects', body: ` { id: 'projects', title: 'Projects', body: `

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -6,58 +6,114 @@
<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>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--cds-background); background: var(--cds-background);
color: var(--cds-text-primary); color: var(--cds-text-primary);
line-height: 1.5; line-height: 1.5;
} }
/* HEADER */
.header {
background: var(--cds-layer);
padding: 1.5rem 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-bottom: 1px solid var(--cds-border-subtle);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1.5rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 16px;
text-decoration: none;
color: var(--cds-text-primary);
background: white;
padding: 0.5rem 0.75rem;
border-radius: 6px;
}
.logo img {
height: 32px;
width: auto;
}
.logo:hover { opacity: 0.9; }
.header-spacer { flex: 1; }
.header-nav {
display: flex;
gap: 1.5rem;
align-items: center;
}
.header-nav a {
color: var(--cds-text-secondary);
text-decoration: none;
font-size: 13px;
transition: color 0.2s;
}
.header-nav a:hover { color: var(--cds-text-primary); }
/* CONTAINER */ /* CONTAINER */
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 2.5rem 2rem 3rem; padding: 3rem 2rem;
} }
/* HERO */ /* HERO */
.hero { .hero {
margin-bottom: 2.5rem; text-align: center;
margin-bottom: 4rem;
} }
.hero h1 { .hero h1 {
font-size: 2.25rem; font-size: 2.625rem;
font-weight: 300; font-weight: 300;
letter-spacing: -0.01em; margin-bottom: 1rem;
margin-bottom: 0.5rem;
color: var(--cds-text-primary); color: var(--cds-text-primary);
} }
.hero p { .hero p {
font-size: 1rem; font-size: 1.125rem;
color: var(--cds-text-secondary); color: var(--cds-text-secondary);
max-width: 760px; margin-bottom: 2rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
} }
/* CARDS */ /* CARDS */
.cards-grid { .cards-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 1rem; gap: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 3rem;
} }
.card { .card {
background: var(--cds-layer); background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
border-left: 4px solid var(--cds-border-strong); border-radius: 4px;
padding: 1.5rem; padding: 1.5rem;
transition: border-color 0.15s, background 0.15s; box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: all 0.2s;
text-decoration: none; text-decoration: none;
color: var(--cds-text-primary); color: var(--cds-text-primary);
display: flex; display: flex;
@@ -65,8 +121,9 @@
} }
.card:hover { .card:hover {
border-left-color: var(--cds-interactive-01); box-shadow: 0 4px 8px rgba(0,0,0,0.4);
background: var(--cds-layer-hover); transform: translateY(-2px);
border-color: var(--cds-button-primary);
} }
.card-badge { .card-badge {
@@ -96,10 +153,10 @@
.card-button { .card-button {
display: inline-block; display: inline-block;
align-self: flex-start;
background: var(--cds-button-primary); background: var(--cds-button-primary);
color: white; color: white;
padding: 0.7rem 1.25rem; padding: 0.75rem 1.5rem;
border-radius: 3px;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
@@ -115,15 +172,16 @@
/* COMPLETE STATE (SOP done) */ /* COMPLETE STATE (SOP done) */
.card.complete { .card.complete {
border-left-color: var(--cds-support-success); background: #ecfdf5;
border-color: #16a34a;
} }
.card.complete .card-button { background: var(--cds-support-success); } .card.complete .card-button { background: #16a34a; }
.card.complete .card-button:hover { background: #0e6027; } .card.complete .card-button:hover { background: #15803d; }
.card-status { .card-status {
display: inline-block; display: inline-block;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--cds-support-success); color: #16a34a;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.card.disabled { .card.disabled {
@@ -134,8 +192,9 @@
/* SECTION */ /* SECTION */
.section { .section {
background: var(--cds-layer); background: var(--cds-layer);
padding: 1.75rem; border-radius: 4px;
margin-bottom: 1.5rem; padding: 2rem;
margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
} }
@@ -158,6 +217,16 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.quick-start {
background: var(--cds-button-primary);
color: white;
padding: 2rem;
}
.quick-start h2 { color: white; }
.quick-start ol { margin-left: 1.5rem; line-height: 2; }
.quick-start li { margin-bottom: 0.5rem; }
/* FOOTER */ /* FOOTER */
.footer { .footer {
background: var(--cds-ui-01); background: var(--cds-ui-01);
@@ -178,16 +247,18 @@
/* COMMENTS SECTION */ /* COMMENTS SECTION */
.comments-section { .comments-section {
background: var(--cds-layer); background: var(--cds-layer);
border-radius: 4px;
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
} }
.comments-toggle { .comments-toggle {
padding: 0.7rem 1.25rem; padding: 0.75rem 1.5rem;
background: var(--cds-button-primary); background: var(--cds-button-primary);
color: white; color: white;
border: none; border: none;
border-radius: 3px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -200,7 +271,8 @@
display: none; display: none;
margin-top: 1rem; margin-top: 1rem;
padding: 1rem; padding: 1rem;
background: var(--cds-layer-accent); background: var(--cds-ui-01);
border-radius: 3px;
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
} }
@@ -209,17 +281,15 @@
.comments-panel input, .comments-panel input,
.comments-panel textarea { .comments-panel textarea {
width: 100%; width: 100%;
padding: 0.7rem; padding: 0.75rem;
border: 1px solid var(--cds-border-strong); border: 1px solid var(--cds-border-subtle);
background: var(--cds-field); border-radius: 3px;
background: var(--cds-ui-02);
color: var(--cds-text-primary); color: var(--cds-text-primary);
font-family: inherit; font-family: inherit;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.comments-panel input:focus,
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.comments-panel textarea { .comments-panel textarea {
resize: vertical; resize: vertical;
min-height: 80px; min-height: 80px;
@@ -228,12 +298,12 @@
.comment-buttons { .comment-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
} }
.comment-buttons button { .comment-buttons button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 3px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -248,12 +318,11 @@
.submit-btn:hover { background: var(--cds-hover-primary); } .submit-btn:hover { background: var(--cds-hover-primary); }
.close-btn { .close-btn {
background: var(--cds-layer-selected); background: var(--cds-border-subtle);
color: var(--cds-text-primary); color: var(--cds-text-primary);
border: 1px solid var(--cds-border-strong);
} }
.close-btn:hover { background: var(--cds-layer-selected-hover); } .close-btn:hover { background: var(--cds-hover-ui); }
.comments-list { .comments-list {
margin-top: 1rem; margin-top: 1rem;
@@ -265,6 +334,7 @@
padding: 0.75rem; padding: 0.75rem;
background: var(--cds-background); background: var(--cds-background);
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
border-radius: 3px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 12px; font-size: 12px;
} }
@@ -283,20 +353,22 @@
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; } .proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; } .proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px; .proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; } border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d); .proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
padding: 1.25rem; } border-radius: 6px; 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; }
.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); border-radius: 6px; background: var(--cds-ui-01, #fff); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; } .proj-form-grid { 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); }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); } .proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); } .proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; } .link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { flex-direction: column; text-align: center; }
.header-spacer { display: none; }
.hero h1 { font-size: 1.75rem; } .hero h1 { font-size: 1.75rem; }
.cards-grid { grid-template-columns: 1fr; } .cards-grid { grid-template-columns: 1fr; }
.container { padding: 1.5rem; } .container { padding: 1.5rem; }
@@ -305,17 +377,19 @@
</head> </head>
<body> <body>
<!-- HEADER --> <!-- HEADER -->
<header class="wp-appbar"> <header class="header">
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home"> <div class="header-content">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span> <a href="index.html" class="logo">
<span class="wp-appbar-title">Work Package Suite</span> <img src="prime-controls-logo.jpg" alt="Prime Controls">
<div>Work Package Suite</div>
</a> </a>
<div class="wp-appbar-spacer"></div> <div class="header-spacer"></div>
<nav class="wp-appbar-actions"> <nav class="header-nav">
<a class="wp-appbar-link" href="#overview">Overview</a> <a href="#overview">Overview</a>
<a class="wp-appbar-link" href="#comments">Feedback</a> <a href="#comments">Feedback</a>
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a> <a href="#" onclick="openHelp();return false;">Help</a>
</nav> </nav>
</div>
</header> </header>
<!-- MAIN CONTENT --> <!-- MAIN CONTENT -->
@@ -358,13 +432,17 @@
<button class="card-button" id="card-dash-btn">Open Dashboard</button> <button class="card-button" id="card-dash-btn">Open Dashboard</button>
</a> </a>
<!-- FIELD VIEW --> </div>
<a href="field.html" class="card" id="card-field">
<h3>Field View</h3>
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
<button class="card-button" id="card-field-btn">Open Field View</button>
</a>
<!-- QUICK START -->
<div class="section quick-start">
<h2>Getting Started</h2>
<ol>
<li><strong>Open "SOP Configuration"</strong> and complete the 10 steps for your project (~15 minutes)</li>
<li><strong>Finish the SOP</strong> — this card turns green and unlocks the Work Package Creator</li>
<li><strong>Open "Work Package Creator"</strong> to author Work Packages with your SOP defaults pre-populated</li>
<li><strong>Leave feedback</strong> on any page using the feedback button below</li>
</ol>
</div> </div>
<!-- COMMENTS SECTION --> <!-- COMMENTS SECTION -->
@@ -510,7 +588,6 @@
setHref('card-sop', 'work-package-suite.html?tab=sop'); setHref('card-sop', 'work-package-suite.html?tab=sop');
setHref('card-wp', 'work-package-suite.html?tab=wp'); setHref('card-wp', 'work-package-suite.html?tab=wp');
setHref('card-dash', 'work-package-suite.html?view=dashboard'); setHref('card-dash', 'work-package-suite.html?view=dashboard');
setHref('card-field', 'field.html?src=home');
cards.style.display = ''; cards.style.display = '';
heroTitle.textContent = active.name || 'Work Package Suite'; heroTitle.textContent = active.name || 'Work Package Suite';
@@ -518,8 +595,8 @@
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''} if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
&nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`; &nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
// Pull the project's shared SOP/WPs from the server into the local cache // Pull the project's shared SOP from the server into the local cache first,
// first, so the SOP "Complete / Review" status reflects what other users did. // so the SOP "Complete / Review" status reflects what other users have done.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); } if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
else reflectSOPStatus(active); else reflectSOPStatus(active);
} }

View File

@@ -21,7 +21,7 @@
max-width: 400px; max-width: 400px;
background: var(--cds-layer); background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle);
border-top: 3px solid var(--cds-interactive-01); box-shadow: 0 2px 6px var(--cds-shadow);
padding: 2.5rem 2rem; padding: 2.5rem 2rem;
} }
.brand { .brand {
@@ -97,7 +97,7 @@
<p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;"> <p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a> <a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
</p> </p>
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem;"> <div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem; border-radius:0 6px 6px 0;">
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner. Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
</div> </div>

View File

@@ -1,18 +0,0 @@
{
"name": "Prime Work Package Suite",
"short_name": "WP Suite",
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
"start_url": "/index.html",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f4f4f4",
"theme_color": "#161616",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
]
}

View File

@@ -12,7 +12,7 @@
var LS_ACTIVE_OBJ = 'wp_active_project_obj'; var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); } function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } } function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} } function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
@@ -87,8 +87,6 @@
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact // localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
// keys from the API on page load, and the push* helpers write through to the // keys from the API on page load, and the push* helpers write through to the
// API whenever the apps save. The apps' own (synchronous) reads are unchanged. // API whenever the apps save. The apps' own (synchronous) reads are unchanged.
// (Original author: C-West8, "storing data in DB instead of client only";
// reintegrated on top of the BIM/per-package work.)
function nsKey(base, id) { return id ? base + '__' + id : base; } function nsKey(base, id) { return id ? base + '__' + id : base; }
function currentUser() { function currentUser() {
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; } try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
@@ -96,8 +94,7 @@
// A saved Work Package is a flat object in the browser; the API splits it into // A saved Work Package is a flat object in the browser; the API splits it into
// promoted columns + a `data` blob. We store the whole flat object in `data` // promoted columns + a `data` blob. We store the whole flat object in `data`
// for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all // for perfect round-tripping, and mirror the few fields the API promotes.
// survive), and mirror the few fields the API promotes to columns.
function pkgToServer(p, projectId) { function pkgToServer(p, projectId) {
return { return {
id: p.id, id: p.id,
@@ -107,7 +104,6 @@
subject: p.subject || '', subject: p.subject || '',
type: p.type || '', type: p.type || '',
status: p.status || 'Draft', status: p.status || 'Draft',
assignee_id: p.assigneeId || null,
created_by: p.createdBy || currentUser(), created_by: p.createdBy || currentUser(),
data: p data: p
}; };
@@ -121,8 +117,6 @@
if (row.type != null) p.type = row.type; if (row.type != null) p.type = row.type;
if (row.status) p.status = row.status; // honor server-side status changes if (row.status) p.status = row.status; // honor server-side status changes
if (row.parent_id) p.instanceOf = row.parent_id; if (row.parent_id) p.instanceOf = row.parent_id;
p.archived = !!row.archived_at;
p.assigneeId = row.assignee_id || '';
return p; return p;
} }
@@ -155,200 +149,37 @@
return Promise.all(jobs).then(function () {}); return Promise.all(jobs).then(function () {});
}; };
// ── Durable write-through outbox ─────────────────────────────────────────── // Write a completed SOP (plus the builder's raw state) to the API. Uses a
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a // deterministic id per project so re-completing updates the same row.
// silently-failed POST leaves the browser and server divergent. Instead of a
// fire-and-forget request, each mutation is appended to a localStorage-backed
// queue and flushed to the API with retry + backoff. The API upserts by id and
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
// local cache still updates immediately, so rendering never waits on the network.
var OUTBOX_KEY = 'wp_sync_outbox_v1';
var _flushTimer = null, _backoff = 0, _flushing = false;
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
// Append an op, coalescing by (kind,key) so only the latest write per entity is
// queued. A delete supersedes any pending upsert for the same id.
function enqueue(op) {
var q = qRead();
if (op.kind === 'wp-del') {
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
} else {
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
}
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
op.tries = 0;
q.push(op);
qWrite(q);
notifySync();
scheduleFlush(0);
}
function opRequest(op) {
if (op.kind === 'wp-del') {
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
}
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
});
}
function bumpTries(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever.
function markDead(opId, err) {
var q = qRead();
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
qWrite(q);
}
// Attempt every live op; successes are removed, 4xx client errors are marked
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
function flush() {
if (_flushing) return Promise.resolve();
var q = qRead().filter(function (o) { return !o.dead; });
if (!q.length) { notifySync(); return Promise.resolve(); }
_flushing = true; notifySync();
var chain = Promise.resolve(), anyFail = false;
q.forEach(function (op) {
chain = chain.then(function () {
return opRequest(op).then(function (r) {
var status = r ? r.status : 0;
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
else if (status >= 400 && status < 500 && status !== 429) { markDead(op.opId, 'HTTP ' + status); }
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
});
return chain.then(function () {
_flushing = false;
notifySync();
if (qRead().filter(function (o) { return !o.dead; }).length) {
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
scheduleFlush(_backoff || 15000);
} else { _backoff = 0; }
});
}
function scheduleFlush(delay) {
if (_flushTimer) return; // one pending flush at a time
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
function syncCounts() {
var q = qRead(), pending = 0, failed = 0;
for (var i = 0; i < q.length; i++) {
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
}
return { pending: pending, failed: failed, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
var c = syncCounts();
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
renderSyncBadge(c);
}
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
// isn't duplicated inside the embedded creator iframe; the top window still sees
// the iframe's queue changes via the 'storage' event below.
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
var _badgeHideTimer = null;
function renderSyncBadge(c) {
if (!_isTop || !document.body) return;
var el = document.getElementById('wp-sync-badge');
if (!el) {
el = document.createElement('div');
el.id = 'wp-sync-badge';
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ All changes saved';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
}
}
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
// a periodic backstop. Anything left from a previous session flushes on load.
try {
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
} catch (e) {}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
// ── public write API (now durable via the outbox) ───────────────────────────
// Write a completed SOP (plus the builder's raw state). Deterministic id per
// project so re-completing updates the same row.
ProjectData.pushSOP = function (projectId, sop, state) { ProjectData.pushSOP = function (projectId, sop, state) {
if (!projectId) return Promise.resolve(null); if (!projectId) return Promise.resolve(null);
enqueue({ var body = {
kind: 'sop', key: 'sop__' + projectId, id: 'sop__' + projectId,
body: { project_id: projectId,
id: 'sop__' + projectId, project_id: projectId,
name: (sop && sop.project && sop.project.name) || 'SOP', name: (sop && sop.project && sop.project.name) || 'SOP',
number: (sop && sop.project && sop.project.number) || '', number: (sop && sop.project && sop.project.number) || '',
complete: true, created_by: currentUser(), data: { sop: sop, state: state } complete: true,
} created_by: currentUser(),
}); data: { sop: sop, state: state }
return Promise.resolve(true); };
return fetch(API + '/sops', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}; };
// Upsert a single Work Package. The local cache stays the source of truth for // Upsert a single Work Package to the API (fire-and-forget from the caller's
// immediate rendering; the outbox guarantees the write reaches the server. // perspective; the local cache is the source of truth for immediate rendering).
ProjectData.pushWP = function (p, projectId) { ProjectData.pushWP = function (p, projectId) {
if (!p || !p.id) return Promise.resolve(null); if (!p || !p.id) return Promise.resolve(null);
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) }); return fetch(API + '/wps', {
return Promise.resolve(true); method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}; };
ProjectData.removeWP = function (id) { ProjectData.removeWP = function (id) {
if (!id) return Promise.resolve(); if (!id) return Promise.resolve();
enqueue({ kind: 'wp-del', key: id }); return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
return Promise.resolve(true); .then(function () {}).catch(function () {});
};
// Force a flush now and resolve when the queue drains (or a round-trip is done).
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
// Archive / unarchive a Work Package (hide from active lists without deleting).
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
// the caller updates the view on the returned result.
ProjectData.archiveWP = function (id, archived) {
if (!id) return Promise.resolve(null);
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
// "show archived" view. Returns app-shaped package objects (p.archived === true).
ProjectData.listArchived = function (projectId) {
if (!projectId) return Promise.resolve([]);
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
.catch(function () { return []; });
}; };
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale // One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale

View File

@@ -1,63 +0,0 @@
/* Service worker for the Work Package Suite PWA.
Goal: let the app (and especially the field view) load and run offline. Data
durability is already handled by the sync outbox in project-data.js — this
worker only caches the static app shell so the pages open without a network.
Strategy:
• /api/* and non-GET → never touched (pass straight to the network; offline
reads fall back to the app's localStorage cache, writes queue in the outbox).
• same-origin GET → stale-while-revalidate (instant from cache, refreshed
in the background when online).
*/
'use strict';
const CACHE = 'wp-suite-shell-v1';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html',
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/prime-controls-logo.jpg', '/favicon.ico',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
];
self.addEventListener('install', (e) => {
// Cache each shell asset individually so one missing file doesn't abort install.
e.waitUntil(
caches.open(CACHE)
.then((c) => Promise.all(SHELL.map((u) => c.add(u).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return; // outbox owns writes
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: default
if (url.pathname.startsWith('/api/')) return; // never cache the API
e.respondWith(
caches.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res && res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
.catch(() => cached); // offline → cached copy
return cached || network; // cache-first, then refresh
})
);
});

View File

@@ -157,93 +157,3 @@ input, textarea, select {
font-family: inherit; font-family: inherit;
color: var(--cds-text-primary); color: var(--cds-text-primary);
} }
/* ============================================================================
App shell — shared "UI Shell" chrome (Prime Controls, IBM Carbon styling)
----------------------------------------------------------------------------
One dark top bar across every page so the suite reads as a single product.
The Prime Controls logo is a white-background wordmark, so it sits inside a
white "chip" on the near-black bar (reads as intentional, not a stray box).
Flip --wp-appbar-bg to a light value if a light header is ever preferred.
============================================================================ */
:root {
--wp-appbar-bg: #161616; /* near-black UI Shell bar */
--wp-appbar-fg: #ffffff;
--wp-appbar-fg-dim: #c6c6c6;
--wp-appbar-border: #6f6f6f; /* outline for ghost buttons on the bar */
--wp-appbar-hover: #353535;
--wp-appbar-height: 48px;
}
.wp-appbar {
background: var(--wp-appbar-bg);
color: var(--wp-appbar-fg);
display: flex;
align-items: center;
gap: 16px;
height: var(--wp-appbar-height);
padding: 0 16px;
position: sticky;
top: 0;
z-index: 100;
}
.wp-appbar-brand {
display: flex;
align-items: center;
gap: 12px;
height: 100%;
text-decoration: none;
color: var(--wp-appbar-fg);
}
.wp-appbar-brand:hover { text-decoration: none; opacity: .92; }
.wp-logo-chip {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 4px;
padding: 4px 8px;
}
.wp-logo-chip img { height: 24px; width: auto; display: block; }
.wp-appbar-title {
font-size: 15px;
font-weight: 600;
color: var(--wp-appbar-fg);
white-space: nowrap;
letter-spacing: .01em;
}
.wp-appbar-title .wp-appbar-sub { font-weight: 400; color: var(--wp-appbar-fg-dim); }
.wp-appbar-spacer { flex: 1 1 auto; }
.wp-appbar-meta { font-size: 13px; color: var(--wp-appbar-fg-dim); white-space: nowrap; }
.wp-appbar-actions { display: flex; align-items: center; gap: 8px; }
/* Buttons and links that live on the dark bar */
.wp-appbar-btn {
background: transparent;
color: var(--wp-appbar-fg);
border: 1px solid var(--wp-appbar-border);
border-radius: 0;
padding: 7px 14px;
font-size: 14px;
font-family: inherit;
line-height: 1.2;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background .15s, border-color .15s;
}
.wp-appbar-btn:hover { background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); text-decoration: none; }
.wp-appbar-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); }
.wp-appbar-btn.primary:hover { background: var(--cds-hover-primary); border-color: var(--cds-hover-primary); }
.wp-appbar-btn:focus-visible { outline: 2px solid var(--wp-appbar-fg); outline-offset: 1px; }
.wp-appbar-count { font-size: 13px; color: var(--wp-appbar-fg-dim); padding: 0 2px; white-space: nowrap; }
/* Plain text links on the dark bar (Overview / Feedback / Help, Admin, etc.) */
.wp-appbar-link { color: var(--wp-appbar-fg-dim); text-decoration: none; font-size: 14px; white-space: nowrap; }
.wp-appbar-link:hover { color: var(--wp-appbar-fg); text-decoration: none; }
@media (max-width: 720px) {
.wp-appbar { height: auto; flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
.wp-appbar-actions { flex-wrap: wrap; }
.wp-appbar-meta { width: 100%; order: 5; }
}

View File

@@ -24,7 +24,6 @@ function onSizePresetChange(){
} }
let state = { let state = {
bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only.
project: {name:'', number:'', client:'', division:'', site:''}, project: {name:'', number:'', client:'', division:'', site:''},
team: {pm:'', apm:'', cm:'', qm:''}, team: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [], teamMembers: [],
@@ -32,7 +31,7 @@ let state = {
wpTypes: [], wpTypes: [],
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''}, governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
quality: {qcreq:'', photo:'', hold:''}, quality: {qcreq:'', photo:'', hold:''},
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''}, platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
sequence: [], sequence: [],
constraints: [], constraints: [],
sources: [] sources: []
@@ -93,14 +92,7 @@ const OPTIONAL_ROLES = [
'Quality Representative', 'Quality Representative',
'Planner', 'Planner',
'Safety Manager', 'Safety Manager',
'Project Controls Manager', 'Project Controls Manager'
// BIM / VDC roles (used by the BIM template; also selectable on any SOP)
'BIM Coordinator',
'BIM Modeler / Detailer',
'VDC Manager',
'Construction Lead (CRS)',
'Field Lead',
'General Contractor'
]; ];
const LABOR_COST_CODES = [ const LABOR_COST_CODES = [
@@ -145,100 +137,6 @@ const LABOR_COST_CODES = [
'9000|Administration' '9000|Administration'
]; ];
// ── BIM / VDC TEMPLATE ──────────────────────────────────────────────────────────
// The BIM/VDC department also produces work packages under Advanced Work Packaging —
// model & engineering deliverables (EWPs) that feed the field's install packages.
// These defaults are drawn from the EN06 SOP + work instructions and are added by
// enableBIM() when the "Include BIM / VDC work packages" box is ticked on Step 4
// (each is flagged bim so the Creator can offer them under the EWP kind). Everything
// remains editable afterward.
const BIM_WP_TYPES = [
'Model Area Package', 'Conduit Routing Package', 'Coordination / Clash Package',
'First-of-Kind (FOK) Package', '2D Installation Sheet Set', 'Field Detailing / Markup Package',
'Laser Scan Package', 'As-Built Model / Drawings'
];
// BIM "disciplines" are the install phases work is broken into (EN06-SOP §4.1.2.5).
const BIM_PHASES = [
'Cable Tray & Hangers', 'Conduits & Hangers', 'Wall & Slab Penetrations',
'Panels & Instrument Racks', 'In-wall Instruments & Stud-ups'
];
const BIM_TEMPLATE_ROLES = [
'BIM Coordinator', 'BIM Modeler / Detailer', 'VDC Manager',
'Construction Lead (CRS)', 'Field Lead', 'General Contractor'
];
// Release-gate constraints for a BIM/model package (the BIM equivalent of the field's
// AWP constraints). Citations point back to the EN06 documents.
const BIM_CONSTRAINTS = [
{name:'Required Docs Received (IO list, P&IDs, drawings, models, specs)', description:'Project-start inputs available — EN06-SOP §3.1'},
{name:'Conduit Schedule & Schematic Redlines Received', description:'Hard gate: no conduit modeled without these — EN06-SOP §3'},
{name:'LOD Defined & Agreed', description:'Level of detail set at kick-off — EN06-G-01'},
{name:'Field Coordination / Laser Scan Complete', description:'Field walk or scan done — EN06-WI-01 / WI-03'},
{name:'Clash-Free / Coordinated with GC & Trades', description:'Coordination complete — EN06-SOP §8.1'},
{name:'Constructability Review (CRS) Signed', description:'Internal construction-lead sign-off before GC — EN06-SOP §8.4'},
{name:'GC / Trade Sign-Off', description:'GC review and approval — EN06-SOP §8.2'},
{name:'Issued-For-Fabrication (IFF) Granted', description:'Model approved for field use — EN06-SOP §9.4'}
];
const BIM_SEQUENCE = [
'Kick-off (LOD, schedule, cost code)', 'Project start — gather required docs',
'Field coordination / laser scan', 'Model racks, instruments & panels',
'Model conduit (after schedule + redlines)', 'BIM coordination / clash with GC & trades',
'Constructability review (CRS)', 'GC submission & sign-off (IFF)',
// BIM deliverable that hands off to the field — only present when BIM is enabled.
'2D installation sheets / Spool Drawings'
];
const BIM_SOURCES = [
{label:'IO List (Point Matrix DB)', ph:'controls.dev / SharePoint'},
{label:'P&IDs', ph:'Procore / SharePoint'},
{label:'Contract / Design Drawings', ph:'Procore / Bluebeam'},
{label:'Navisworks / Revit Models', ph:'BIM360 / SharePoint'},
{label:'Specs & Submittals', ph:'client portal'},
{label:'Conduit Schedule', ph:'Excel on SharePoint'},
{label:'Bluebeam Project', ph:'Bluebeam Studio'},
{label:'Pre-Construction Tracker', ph:'SharePoint'},
{label:'Constructability Review Sheet (CRS)', ph:'SharePoint'}
];
// Toggle BIM/VDC capability on the project. ON augments the SOP with BIM package
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the
// project produces both install (IWP) and BIM (EWP) packages. OFF strips the
// bim-flagged items. Everything stays editable.
function setBimEnabled(on){
state.bimEnabled = !!on;
if(on) enableBIM(); else disableBIM();
const cb = document.getElementById('bim_enabled'); if(cb) cb.checked = !!on;
track(on ? 'bim_enabled' : 'bim_disabled');
}
function enableBIM(){
// Package types (flagged bim so the Creator can offer them under "BIM (EWP)").
BIM_WP_TYPES.forEach(n => {
const t = state.wpTypes.find(x => x.name === n);
if(t){ t.bim = true; t.enabled = true; }
else state.wpTypes.push({name:n, enabled:true, notes:'', approval:'', bim:true});
});
renderWPTypes();
// Release-gate constraints (seed standard 10 first if empty, then add BIM gates).
if(!state.constraints || !state.constraints.length) state.constraints = STANDARD_10_CONSTRAINTS.map(c => ({...c}));
_constraintsSeeded = true;
BIM_CONSTRAINTS.forEach(c => { if(!state.constraints.some(x => x.name === c.name)) state.constraints.push({...c, bim:true}); });
renderStandardConstraints();
// BIM sign-off roles (optional), reference sources, and process steps (idempotent).
BIM_TEMPLATE_ROLES.forEach(r => { if(!state.signoffRoles.some(x => x.role === r)) state.signoffRoles.push({role:r, name:'', bim:true}); });
renderOptionalRoles();
// BIM work precedes construction, so put the BIM steps at the FRONT of the sequence.
const bimSteps = BIM_SEQUENCE.filter(lbl => !state.sequence.some(s => s.label === lbl)).map(lbl => ({label:lbl, kind:'step', bim:true}));
state.sequence = [...bimSteps, ...state.sequence];
renderSequenceSteps();
BIM_SOURCES.forEach(s => { if(!state.sources.some(x => x.label === s.label)) state.sources.push({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true, bim:true}); });
renderSources();
}
function disableBIM(){
state.wpTypes = state.wpTypes.filter(t => !t.bim); renderWPTypes();
state.constraints = (state.constraints || []).filter(c => !c.bim); renderStandardConstraints();
state.signoffRoles = state.signoffRoles.filter((r,i) => i < 2 || !r.bim); renderOptionalRoles();
state.sequence = (state.sequence || []).filter(s => !s.bim); renderSequenceSteps();
state.sources = (state.sources || []).filter(s => !s.bim); renderSources();
}
// ── INITIALIZATION ──────────────────────────────────────────────────────────── // ── INITIALIZATION ────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded',()=>{ window.addEventListener('DOMContentLoaded',()=>{
initializeWPTypes(); initializeWPTypes();
@@ -316,11 +214,7 @@ function loadSampleData(){
document.getElementById('proj_cm').value = 'K. Boyd'; document.getElementById('proj_cm').value = 'K. Boyd';
document.getElementById('proj_qm').value = 'D. Nguyen'; document.getElementById('proj_qm').value = 'D. Nguyen';
// Step 3 — standard required roles // Step 3 already has defaults
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
document.getElementById('role_super_name').value = 'John Smith'; document.getElementById('role_super_name').value = 'John Smith';
document.getElementById('role_foreman_name').value = 'Mike Jones'; document.getElementById('role_foreman_name').value = 'Mike Jones';
@@ -338,12 +232,6 @@ function loadSampleData(){
// Step 7 already has defaults // Step 7 already has defaults
// The Micron FMCS sample includes BIM/VDC — enable it so the sequence shows the
// full BIM → construction flow (BIM steps first) and the Creator offers IWP/EWP.
state.bimEnabled = true;
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = true;
enableBIM();
// Collect all data // Collect all data
collectStepData(); collectStepData();
track('sample_loaded'); track('sample_loaded');
@@ -394,8 +282,8 @@ function repopulateForm(){
set('proj_apm', state.team.apm); set('proj_apm', state.team.apm);
set('proj_cm', state.team.cm); set('proj_cm', state.team.cm);
set('proj_qm', state.team.qm); set('proj_qm', state.team.qm);
if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); } if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); } if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
set('gov_woformat', state.governance.woformat); set('gov_woformat', state.governance.woformat);
// gov_wosize is now a <select>; if a saved value isn't one of the presets // 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.
@@ -412,9 +300,6 @@ function repopulateForm(){
set('qual_hold', state.quality.hold); set('qual_hold', state.quality.hold);
set('plat_tracking', state.platforms.tracking); set('plat_tracking', state.platforms.tracking);
set('plat_commissioning', state.platforms.commissioning); set('plat_commissioning', state.platforms.commissioning);
set('plat_tracking_url', state.platforms.trackingUrl);
set('plat_commissioning_url', state.platforms.commissioningUrl);
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = !!state.bimEnabled;
} }
// ── TOOL SWITCHING ──────────────────────────────────────────────────────────── // ── TOOL SWITCHING ────────────────────────────────────────────────────────────
@@ -586,8 +471,7 @@ function removeTeamMember(i){
function renderOptionalRoles(){ function renderOptionalRoles(){
const container = document.getElementById('optional-roles-list'); const container = document.getElementById('optional-roles-list');
// The first two entries are the required (editable-title) roles; the rest are optional. const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
const current = state.signoffRoles.slice(2);
container.innerHTML = current.map((r,i)=>` container.innerHTML = current.map((r,i)=>`
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);"> <div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value"> <select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
@@ -691,26 +575,13 @@ function addCustomConstraintText(){
renderStandardConstraints(); renderStandardConstraints();
} }
// Default construction flow (used when BIM is off; the BIM steps are prepended when const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
// the project includes BIM/VDC). Includes QC-hold gates. Items may be strings or
// {label, kind} objects.
const DEFAULT_SEQUENCE = [
{label:'Conduit Install', kind:'step'},
{label:'Tray Install', kind:'step'},
{label:'QC Hold', kind:'gate'},
{label:'Wire Pull', kind:'step'},
{label:'Device Install', kind:'step'},
{label:'Termination', kind:'step'},
{label:'QC Hold', kind:'gate'},
{label:'Commissioning', kind:'step'},
{label:'As-built (scan / redlines)', kind:'step'}
];
let seqDragIndex = null; let seqDragIndex = null;
function renderSequenceSteps(){ function renderSequenceSteps(){
const container = document.getElementById('sequence-list'); const container = document.getElementById('sequence-list');
if(!container) return; if(!container) return;
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=> typeof s==='string' ? {label:s,kind:'step'} : {label:s.label, kind:s.kind||'step'}); if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label:s,kind:'step'}));
container.innerHTML = ''; container.innerHTML = '';
let stepNo = 0; let stepNo = 0;
state.sequence.forEach((item,i)=>{ state.sequence.forEach((item,i)=>{
@@ -871,10 +742,7 @@ function collectStepData(){
state.team.qm = document.getElementById('proj_qm').value; state.team.qm = document.getElementById('proj_qm').value;
break; break;
case 3: case 3:
// 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].name = document.getElementById('role_super_name').value; state.signoffRoles[0].name = document.getElementById('role_super_name').value;
state.signoffRoles[1].role = (document.getElementById('role_foreman_title').value || 'Role 2').trim();
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value; state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
break; break;
case 5: case 5:
@@ -895,8 +763,6 @@ function collectStepData(){
case 7: case 7:
state.platforms.tracking = document.getElementById('plat_tracking').value; state.platforms.tracking = document.getElementById('plat_tracking').value;
state.platforms.commissioning = document.getElementById('plat_commissioning').value; state.platforms.commissioning = document.getElementById('plat_commissioning').value;
state.platforms.trackingUrl = (document.getElementById('plat_tracking_url').value || '').trim();
state.platforms.commissioningUrl = (document.getElementById('plat_commissioning_url').value || '').trim();
break; break;
} }
} }
@@ -927,7 +793,6 @@ function completeSOP(){
sop = { sop = {
meta: {tool:'Work Package Configuration', sample:false}, meta: {tool:'Work Package Configuration', sample:false},
bimEnabled: !!state.bimEnabled, // project also produces BIM (EWP) packages → Creator offers per-package IWP/EWP kind
project: { project: {
name: state.project.name, name: state.project.name,
number: state.project.number, number: state.project.number,
@@ -955,18 +820,11 @@ function completeSOP(){
name: t.name.trim(), name: t.name.trim(),
enabled: true, enabled: true,
notes: t.notes || '', notes: t.notes || '',
approval: t.approval || '', approval: t.approval || ''
bim: !!t.bim
})), })),
sources: state.sources.filter(s=>s.label), sources: state.sources.filter(s=>s.label),
field: {trackPlatform: state.platforms.tracking, trackPlatformUrl: state.platforms.trackingUrl || ''}, field: {trackPlatform: state.platforms.tracking},
commissioning: {tool: state.platforms.commissioning, toolUrl: state.platforms.commissioningUrl || ''}, commissioning: {tool: state.platforms.commissioning},
// Project homepage links in the tracking / commissioning systems. The Creator
// copies these onto every Work Package created for this project.
projectLinks: [
state.platforms.trackingUrl ? {label:'Tracking — '+state.platforms.tracking, system:state.platforms.tracking, url:state.platforms.trackingUrl} : null,
state.platforms.commissioningUrl ? {label:'Commissioning — '+state.platforms.commissioning, system:state.platforms.commissioning, url:state.platforms.commissioningUrl} : null
].filter(Boolean),
quality: { quality: {
qcReq: state.quality.qcreq, qcReq: state.quality.qcreq,
photo: state.quality.photo, photo: state.quality.photo,
@@ -978,7 +836,7 @@ function completeSOP(){
kind: s.kind || 'step' kind: s.kind || 'step'
})), })),
costCodes: LABOR_COST_CODES, costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({name: c.name, description: c.description || '', bim: !!c.bim})) constraints: state.constraints.map(c=>({name: c.name, description: c.description || ''}))
}; };
sopComplete = true; sopComplete = true;
@@ -1095,8 +953,8 @@ function loadStepComments(){
}else{ }else{
list.innerHTML = stepComments.map(c=>` list.innerHTML = stepComments.map(c=>`
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;"> <div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${escAttr(c.name)}</strong> • ${escAttr(c.timestamp)}</div> <div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
<div style="font-size:12px; color:var(--text);">${escAttr(c.text)}</div> <div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
</div> </div>
`).join(''); `).join('');
} }

View File

@@ -1,25 +1,22 @@
:root { :root {
--primary: #0f62fe; --primary: #2563eb;
--primary-light: #edf5ff; --primary-light: #dbeafe;
--success: #198038; --success: #16a34a;
--warning: #8e6a00; --warning: #ea580c;
--warning-bg: #fdf6dd; --danger: #dc2626;
--danger: #da1e28; --text: #1f2937;
--text: #161616; --text-light: #6b7280;
--text-light: #525252; --text-dim: #9ca3af;
--text-dim: #8d8d8d; --border: #e5e7eb;
--border: #e0e0e0; --bg: #f9fafb;
--border-strong: #8d8d8d;
--bg: #f4f4f4;
--bg-card: #ffffff; --bg-card: #ffffff;
--appbar: #161616; --shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow: none; --shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
color: var(--text); color: var(--text);
background: var(--bg); background: var(--bg);
line-height: 1.5; line-height: 1.5;
@@ -31,46 +28,43 @@ body {
min-height: 100vh; min-height: 100vh;
} }
/* HEADER — dark UI Shell bar */ /* HEADER */
.header { .header {
background: var(--appbar); background: #ffffff;
color: #fff; color: var(--text);
padding: 0 16px; padding: 1.5rem 2rem;
height: 48px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: var(--shadow);
border-bottom: 1px solid var(--border);
} }
.header-left { .header-left {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 1.5rem;
min-width: 0;
} }
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
.logo { .logo {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 0.5rem;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
text-decoration: none; text-decoration: none;
color: var(--text);
font-weight: 700;
font-size: 14px;
transition: opacity 0.2s; transition: opacity 0.2s;
flex-shrink: 0;
} }
.logo:hover { opacity: 0.92; } .logo:hover { opacity: 0.7; }
.logo img { height: 24px; width: auto; display: block; }
.logo-icon { .logo-icon {
width: 36px; width: 36px;
height: 36px; height: 36px;
background: var(--primary-light); background: var(--primary-light);
border-radius: 0; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -79,90 +73,84 @@ body {
} }
.header-title { .header-title {
font-size: 15px; font-size: 24px;
font-weight: 600; font-weight: 700;
margin-bottom: 0; margin-bottom: 0;
color: #fff; color: var(--text);
white-space: nowrap;
} }
.header-subtitle { .header-subtitle {
font-size: 12px; font-size: 13px;
color: #c6c6c6; color: var(--text-light);
min-height: 16px; min-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 1.5rem;
} }
.header-button { .header-button {
padding: 7px 14px; padding: 0.5rem 1rem;
background: transparent; background: var(--bg);
color: #fff; color: var(--primary);
border: 1px solid #6f6f6f; border: 1px solid var(--border);
border-radius: 0; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 13px;
font-weight: 400; font-weight: 600;
transition: background 0.15s, border-color 0.15s; transition: all 0.2s;
} }
.header-button:hover { .header-button:hover {
background: #353535; background: var(--primary-light);
border-color: #6f6f6f; border-color: var(--primary);
} }
.step-counter { .step-counter {
background: transparent; background: var(--bg);
border: 1px solid #6f6f6f; border: 1px solid var(--border);
color: #c6c6c6; color: var(--text-light);
padding: 4px 10px; padding: 0.4rem 0.8rem;
border-radius: 20px; border-radius: 20px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
/* MAIN NAVIGATION — underline tabs */ /* MAIN NAVIGATION */
.main-nav { .main-nav {
display: flex; display: flex;
gap: 0; gap: 0.5rem;
padding: 0 16px; padding: 1rem 2rem;
background: var(--bg-card); background: var(--bg-card);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
} }
.nav-tab { .nav-tab {
padding: 13px 18px; padding: 0.75rem 1.5rem;
background: none; background: var(--bg);
border: none; border: 2px solid var(--border);
border-bottom: 3px solid transparent; border-radius: 6px;
border-radius: 0;
cursor: pointer; cursor: pointer;
font-size: 15px; font-size: 14px;
font-weight: 400; font-weight: 600;
color: var(--text-light);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
transition: background 0.15s, color 0.15s; transition: all 0.2s;
} }
.nav-tab:hover { .nav-tab:hover {
background: var(--bg); border-color: var(--primary);
color: var(--text); color: var(--primary);
} }
.nav-tab.active { .nav-tab.active {
background: none; background: var(--primary);
color: var(--text); color: white;
border-bottom-color: var(--primary); border-color: var(--primary);
font-weight: 600;
} }
.tab-icon { font-size: 16px; } .tab-icon { font-size: 16px; }
@@ -198,37 +186,35 @@ body {
} }
.step-item { .step-item {
padding: 0.6rem 0.9rem; padding: 0.75rem 1rem;
border-radius: 0; border-radius: 6px;
background: var(--bg-card); background: var(--bg);
border: 1px solid var(--border); border: 2px solid var(--border);
color: var(--text-light);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 600;
white-space: nowrap; white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s; transition: all 0.2s;
} }
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); } .step-item:hover { background: var(--primary-light); border-color: var(--primary); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; } .step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
/* STEP CONTENT */ /* STEP CONTENT */
.step-content { .step-content {
background: var(--bg-card); background: var(--bg-card);
padding: 2rem; padding: 2rem;
border: 1px solid var(--border); border-radius: 8px;
border-radius: 0; box-shadow: var(--shadow);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.step { display: none; } .step { display: none; }
.step h2 { .step h2 {
font-size: 22px; font-size: 20px;
font-weight: 400; font-weight: 700;
letter-spacing: -0.01em; margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
color: var(--text); color: var(--text);
} }
@@ -237,7 +223,7 @@ body {
color: var(--text-light); color: var(--text-light);
background: var(--primary-light); background: var(--primary-light);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 0; border-radius: 6px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border-left: 4px solid var(--primary); border-left: 4px solid var(--primary);
} }
@@ -267,7 +253,7 @@ body {
.field textarea { .field textarea {
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-family: inherit; font-family: inherit;
color: var(--text); color: var(--text);
@@ -301,7 +287,7 @@ body {
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
background: var(--bg); background: var(--bg);
border-radius: 0; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
} }
@@ -333,7 +319,7 @@ body {
align-items: center; align-items: center;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--bg); background: var(--bg);
border-radius: 0; border-radius: 6px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
border: 1px solid var(--border); border: 1px solid var(--border);
} }
@@ -353,32 +339,31 @@ body {
/* BUTTONS */ /* BUTTONS */
.add-btn { .add-btn {
padding: 0.7rem 1.25rem; padding: 0.75rem 1.25rem;
background: var(--primary); background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 0; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); } .add-btn:hover { background: #1d4ed8; }
.nav-btn { .nav-btn {
padding: 0.7rem 1.4rem; padding: 0.75rem 1.5rem;
background: var(--bg-card); background: var(--bg);
border: 1px solid var(--border-strong); border: 2px solid var(--border);
border-radius: 0; border-radius: 6px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.2s;
} }
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); } .nav-btn:hover { border-color: var(--primary); color: var(--primary); }
.nav-btn.primary { .nav-btn.primary {
background: var(--success); background: var(--success);
@@ -386,7 +371,7 @@ body {
border-color: var(--success); border-color: var(--success);
} }
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; } .nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; } .nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -397,7 +382,7 @@ body {
justify-content: space-between; justify-content: space-between;
padding: 1.5rem; padding: 1.5rem;
background: var(--bg-card); background: var(--bg-card);
border-radius: 0; border-radius: 8px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
@@ -413,7 +398,7 @@ body {
background: var(--primary-light); background: var(--primary-light);
color: var(--primary); color: var(--primary);
border: 1px solid var(--primary); border: 1px solid var(--primary);
border-radius: 0; border-radius: 6px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -426,7 +411,7 @@ body {
#sequence-list { display: flex; flex-direction: column; gap: 8px; } #sequence-list { display: flex; flex-direction: column; gap: 8px; }
.seq-step { .seq-step {
display: flex; align-items: center; gap: 12px; padding: 11px 14px; display: flex; align-items: center; gap: 12px; padding: 11px 14px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s; box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
} }
.seq-step:hover { border-color: var(--primary); } .seq-step:hover { border-color: var(--primary); }
@@ -447,7 +432,7 @@ body {
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0; width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
} }
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; } .seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; } .seq-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; } .seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
.seq-gate-badge { .seq-gate-badge {
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff; flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
@@ -463,7 +448,7 @@ body {
max-width: calc(100vw - 2rem); max-width: calc(100vw - 2rem);
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0; border-radius: 8px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
padding: 1.25rem; padding: 1.25rem;
z-index: 1200; z-index: 1200;
@@ -503,7 +488,7 @@ body {
.modal-content { .modal-content {
background: var(--bg-card); background: var(--bg-card);
border-radius: 0; border-radius: 8px;
padding: 2rem; padding: 2rem;
max-width: 600px; max-width: 600px;
max-height: 80vh; max-height: 80vh;
@@ -543,7 +528,7 @@ body {
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 768px) { @media (max-width: 768px) {
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; } .header { flex-direction: column; text-align: center; gap: 1rem; }
.main-nav { flex-wrap: wrap; } .main-nav { flex-wrap: wrap; }
.content-area { padding: 1rem; } .content-area { padding: 1rem; }
.step-content { padding: 1rem; } .step-content { padding: 1rem; }

View File

@@ -6,8 +6,6 @@
<title>Work Package Suite</title> <title>Work Package Suite</title>
<script src="auth-guard.js"></script> <script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="work-package-suite-styles.css"> <link rel="stylesheet" href="work-package-suite-styles.css">
</head> </head>
@@ -17,9 +15,9 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<a href="index.html" class="logo" title="Back to Home"> <a href="index.html" class="logo" title="Back to Home">
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;"> <img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
</a> </a>
<div style="min-width:0;overflow:hidden"> <div>
<div class="header-title">Work Package Suite</div> <div class="header-title">Work Package Suite</div>
<div class="header-subtitle" id="project-display"></div> <div class="header-subtitle" id="project-display"></div>
</div> </div>
@@ -132,19 +130,19 @@
<!-- STEP 3: SIGN-OFF ROLES --> <!-- STEP 3: SIGN-OFF ROLES -->
<div class="step" id="sop-step-3" style="display: none;"> <div class="step" id="sop-step-3" style="display: none;">
<h2>3. Required Sign-Off Roles</h2> <h2>3. Required Sign-Off Roles</h2>
<div class="notice">Two roles are required on every package. They default to <strong>Superintendent</strong> and <strong>Foreman</strong> — rename either to fit your project (e.g. a BIM SOP uses <em>BIM Coordinator</em> and <em>Construction Lead</em>). Add more below.</div> <div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
<div class="required-roles"> <div class="required-roles">
<div class="role-required"> <div class="role-required">
<div class="role-checkbox"> <div class="role-checkbox">
<input type="checkbox" id="role_super" checked disabled> <input type="checkbox" id="role_super" checked disabled>
<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> <label>Superintendent *</label>
</div> </div>
<input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;"> <input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
</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> <label>Foreman *</label>
</div> </div>
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;"> <input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
</div> </div>
@@ -160,11 +158,6 @@
<div class="step" id="sop-step-4" style="display: none;"> <div class="step" id="sop-step-4" style="display: none;">
<h2>4. Work Package Types</h2> <h2>4. Work Package Types</h2>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div> <div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
<label style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
<span><strong>Include BIM / VDC work packages on this project</strong><br>
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
</label>
<div id="wp-types-table" style="margin-top: 1.5rem;"></div> <div id="wp-types-table" style="margin-top: 1.5rem;"></div>
</div> </div>
@@ -294,16 +287,6 @@
</select> </select>
</div> </div>
</div> </div>
<div class="field" style="margin-top:1rem;">
<label>Tracking platform — project homepage link</label>
<input type="url" id="plat_tracking_url" placeholder="Paste the project's URL in the tracking platform (e.g. its Procore / CxAlloy project home)">
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
</div>
<div class="field" style="margin-top:0.75rem;">
<label>Commissioning tool — project homepage link</label>
<input type="url" id="plat_commissioning_url" placeholder="Paste the project's URL in the commissioning tool">
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
</div>
</div> </div>
<!-- STEP 8: SEQUENCE --> <!-- STEP 8: SEQUENCE -->

View File

@@ -43,7 +43,6 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":
// ── STATE ──────────────────────────────────────────────────────────────────── // ── STATE ────────────────────────────────────────────────────────────────────
let SOP=null, editingId=null, numberDirty=false; let SOP=null, editingId=null, numberDirty=false;
let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[]; let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
@@ -58,11 +57,7 @@ let currentView='Work Package Form';
// ── HELPERS ────────────────────────────────────────────────────────────────── // ── HELPERS ──────────────────────────────────────────────────────────────────
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; } function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
// Attribute-safe HTML escaper (also escapes " and ' so values are safe inside function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// href="…" / src="…" attributes, not just element text).
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
// Only allow http(s) URLs into an href; anything else (e.g. javascript:) → '#'.
function hrefAttr(u){ return /^https?:\/\//i.test(String(u||'')) ? esc(u) : '#'; }
function ns(){ return '<span style="color:var(--text-dim)">—</span>'; } function ns(){ return '<span style="color:var(--text-dim)">—</span>'; }
function cell(v){ return v ? esc(v) : ns(); } function cell(v){ return v ? esc(v) : ns(); }
function pad2(n){ return n<10?'0'+n:''+n; } function pad2(n){ return n<10?'0'+n:''+n; }
@@ -72,12 +67,7 @@ function toast(msg){ let t=document.getElementById('toast'); if(!t){ t=document.
function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; } function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; }
function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); } function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); }
function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); } function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
function constraintNames(){ function constraintNames(){ return (SOP&&Array.isArray(SOP.constraints)&&SOP.constraints.length)?SOP.constraints:DEFAULT_CONSTRAINTS; }
let cs = (SOP&&Array.isArray(SOP.constraints)&&SOP.constraints.length)?SOP.constraints:DEFAULT_CONSTRAINTS;
// On a BIM-enabled project, EWPs use the BIM gates and IWPs use the install gates.
if(bimSOP()) cs = cs.filter(c => (c && typeof c==='object') ? (isEwp() ? c.bim : !c.bim) : !isEwp());
return cs;
}
function nextSeq(){ return savedPackages.length+1; } function nextSeq(){ return savedPackages.length+1; }
// ── SOP LOADING ────────────────────────────────────────────────────────────── // ── SOP LOADING ──────────────────────────────────────────────────────────────
@@ -102,39 +92,12 @@ function applySOP(){
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
const hint=document.getElementById('wp_number_hint'); hint.textContent = SOP.governance&&SOP.governance.woFormat ? 'auto-built · format: '+SOP.governance.woFormat : ''; const hint=document.getElementById('wp_number_hint'); hint.textContent = SOP.governance&&SOP.governance.woFormat ? 'auto-built · format: '+SOP.governance.woFormat : '';
buildConstraints(); buildSignoffs(); buildConstraints(); buildSignoffs();
applyKind();
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); } if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); } if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); } if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
updateNumber(); updateReleaseBanner(); updateNumber(); updateReleaseBanner();
} }
// Per-package kind. A project whose SOP has bimEnabled produces both install (IWP)
// and BIM (EWP) packages; the kind selector tailors which fields, WP types, and
// release gates apply. Install-only projects never show the selector.
function bimSOP(){ return !!(SOP && SOP.bimEnabled); }
function isEwp(){ return bimSOP() && pkgKind === 'ewp'; }
function setKind(k){
if(pkgKind === k) return;
pkgKind = (k === 'ewp') ? 'ewp' : 'iwp';
const tEl = document.getElementById('wp_type'); if(tEl) tEl.value = ''; // type list changes with kind
applyKind();
numberDirty = false; updateNumber(); updateReleaseBanner();
track('kind_changed', {kind: pkgKind});
}
function applyKind(){
const bimProj = bimSOP(), ewp = isEwp();
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
show('kind-row', bimProj);
show('bim-card', ewp); // LOD / model area / clash / scan
show('asset-card', !ewp); // controls.dev assets
show('material-card', !ewp); // bill of materials
show('mimo-card', !ewp); // kitting / MIMO
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
if(bimProj) setRadio('pkgkind', pkgKind);
buildTypePicker(); // filtered by kind
buildConstraints(); // filtered by kind
}
function buildCostCodes(){ function buildCostCodes(){
const sel=document.getElementById('wp_cost'); const cur=sel.value; const sel=document.getElementById('wp_cost'); const cur=sel.value;
sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code}${esc(desc)}</option>`;}).join(''); sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code}${esc(desc)}</option>`;}).join('');
@@ -201,20 +164,14 @@ function renderSopRefLinks(){
const srcs=sopLinkedSources(); const srcs=sopLinkedSources();
if(!srcs.length){ box.innerHTML=''; return; } if(!srcs.length){ box.innerHTML=''; return; }
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+ box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`; `<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
} }
function renderSpecFolderLink(){ function renderSpecFolderLink(){
const el=document.getElementById('spec-folder-link'); if(!el) return; const el=document.getElementById('spec-folder-link'); if(!el) return;
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label)); const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
el.innerHTML = spec ? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : ''; el.innerHTML = spec ? `<a href="${esc(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
}
function buildTypePicker(){
let types = enabledTypes();
if(bimSOP()) types = types.filter(t => isEwp() ? t.bim : !t.bim); // BIM types only for EWP, install types only for IWP
const sel=document.getElementById('wp_type'); const cur=sel.value;
sel.innerHTML=`<option value="">Select…</option>`+types.map(t=>`<option>${esc(t.name)}</option>`).join('');
if(types.some(t=>t.name===cur)) sel.value=cur;
} }
function buildTypePicker(){ document.getElementById('wp_type').innerHTML=`<option value="">Select…</option>`+enabledTypes().map(t=>`<option>${esc(t.name)}</option>`).join(''); }
function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); } function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); }
function onTypeChange(){ function onTypeChange(){
updateNumber(); track('type_selected'); updateNumber(); track('type_selected');
@@ -528,7 +485,7 @@ function renderSopFileFolders(){
const srcs=sopLinkedSources(); const srcs=sopLinkedSources();
box.innerHTML = srcs.length box.innerHTML = srcs.length
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+ ? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>` `<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`; : `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
} }
function toggleSopFilePanel(){ function toggleSopFilePanel(){
@@ -623,7 +580,7 @@ function openHoldModal(preselect, fromConstraint){
} }
function holdPhotoChange(ev){ function holdPhotoChange(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const f=ev.target.files&&ev.target.files[0]; if(!f) return;
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; const ok=/^data:image\//.test(holdPhotoData); document.getElementById('hold-photo-preview').innerHTML= ok?`<img src="${esc(holdPhotoData)}" alt="supporting photo">`:''; }; r.readAsDataURL(f); const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; document.getElementById('hold-photo-preview').innerHTML=`<img src="${holdPhotoData}" alt="supporting photo">`; }; r.readAsDataURL(f);
} }
function submitHold(){ function submitHold(){
const constraint=document.getElementById('hold-constraint').value; const constraint=document.getElementById('hold-constraint').value;
@@ -703,7 +660,7 @@ function collectPackage(){
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined, parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'), number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'), type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'), cost:gv('wp_cost'), wbs:gv('wp_wbs'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'), due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims}, work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
disciplines:[...pkgDisciplines], disciplines:[...pkgDisciplines],
@@ -720,15 +677,7 @@ function collectPackage(){
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})), signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
holds:pkgHolds.map(h=>({...h})), holds:pkgHolds.map(h=>({...h})),
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'), actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
// AWP traceability: which BIM/model package(s) enabled this install package.
bimlink:gv('wp_bimlink'),
// BIM/VDC package details (only meaningful on a BIM SOP).
lod:gv('wp_lod'), modelArea:gv('wp_model_area'), clash:gv('wp_clash'), scanLink:gv('wp_scan_link'),
project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'', project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'',
// Project homepage links in the tracking / commissioning systems, copied from the
// SOP so they travel with every Work Package created for this project.
projectLinks:(SOP&&SOP.projectLinks)?SOP.projectLinks.map(l=>({...l})):[],
updatedAt:new Date().toISOString() updatedAt:new Date().toISOString()
}; };
} }
@@ -745,12 +694,9 @@ function savePackage(view){
function renderPackage(pkg){ function renderPackage(pkg){
const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0}; const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0};
const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS')); const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS'));
const kindLbl = pkg.kind==='ewp' ? 'BIM (EWP)' : 'IWP';
let h=`<h1>${esc(pkg.number||'(no number)')} — Work Package</h1> let h=`<h1>${esc(pkg.number||'(no number)')} — Work Package</h1>
<div class="doc-subtitle">${esc(pkg.project)} · ${kindLbl} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}</div>`; <div class="doc-subtitle">${esc(pkg.project)} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}</div>`;
const costDesc = (COST_CODES.find(c=>c.split('|')[0]===pkg.cost)||'').split('|')[1]; const costDesc = (COST_CODES.find(c=>c.split('|')[0]===pkg.cost)||'').split('|')[1];
// Project system links travel with the WP; fall back to the live SOP for older packages.
const plinks = (pkg.projectLinks&&pkg.projectLinks.length) ? pkg.projectLinks : ((SOP&&SOP.projectLinks)||[]);
h+=`<h2>1.0 General Information</h2><table><tbody> h+=`<h2>1.0 General Information</h2><table><tbody>
<tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr> <tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr>
<tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr> <tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr>
@@ -765,9 +711,6 @@ function renderPackage(pkg){
<tr><th>Due Date</th><td>${cell(pkg.due)}</td></tr> <tr><th>Due Date</th><td>${cell(pkg.due)}</td></tr>
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr> <tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr> <tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
${plinks.length?`<tr><th>Project Systems</th><td>${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('<br>')}</td></tr>`:''}
${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''}
${(pkg.lod||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.lod?'LOD: '+esc(pkg.lod):'', pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):''].filter(Boolean).join('<br>')}</td></tr>`:''}
</tbody></table>`; </tbody></table>`;
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`; if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; } pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
@@ -843,7 +786,7 @@ function setFormChrome(on){
if(nav) nav.style.display = on ? '' : 'none'; if(nav) nav.style.display = on ? '' : 'none';
if(save) save.style.display = on ? 'flex' : 'none'; if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on); document.body.classList.toggle('has-sticky-save', !!on);
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); } if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); }
} }
// Make each form card collapsible by clicking its heading (idempotent). // Make each form card collapsible by clicking its heading (idempotent).
function makeCollapsible(){ function makeCollapsible(){
@@ -875,27 +818,6 @@ function buildSectionNav(){
}); });
nav.innerHTML=chips.join(''); nav.innerHTML=chips.join('');
} }
// Keep the section-nav pinned just below the sticky header (so it stays put while
// scrolling instead of hiding behind the header), and let it slide out of the way
// while reading (scroll down), snapping back the moment you scroll up.
function positionSectionNav(){
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
}
let _snLastY=0, _snBound=false;
function initSectionNavAutoHide(){
positionSectionNav();
if(_snBound) return; _snBound=true;
window.addEventListener('resize', positionSectionNav, {passive:true});
window.addEventListener('scroll', ()=>{
const nav=document.getElementById('section-nav');
if(!nav || nav.style.display==='none') return;
const y=window.scrollY||document.documentElement.scrollTop||0;
if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down
else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up
_snLastY=y;
}, {passive:true});
}
function updateStickyStatus(){ function updateStickyStatus(){
const el=document.getElementById('sticky-status'); if(!el) return; const el=document.getElementById('sticky-status'); if(!el) return;
const r=readiness(); const st=getRadio('status'); const r=readiness(); const st=getRadio('status');
@@ -921,58 +843,21 @@ function renderSavedList(){
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:''; const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td> return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
<td>${statusPill(p.status)}</td><td>${ready}</td> <td>${statusPill(p.status)}</td><td>${ready}</td>
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="link-btn" onclick="showHistoryRow(${i})">history</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`; <td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
}).join(''); }).join('');
} }
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// ── WP HISTORY (audit trail) ─────────────────────────────────────────────────
function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); }
function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); }
function closeHistory(){ const m=document.getElementById('wp-history-modal'); if(m) m.remove(); }
const HIST_LABEL={created:'Created',updated:'Updated',status_changed:'Status changed',issued:'Issued',deleted:'Deleted'};
async function showHistory(wpId, label){
if(!wpId){ toast('Save the package first — history is recorded as it changes.'); return; }
if(!label){ const _p=savedPackages.find(x=>x.id===wpId)||(typeof dashArchived!=='undefined'&&dashArchived.find(x=>x.id===wpId)); label=_p?(_p.number||_p.subject):''; }
closeHistory();
const ov=document.createElement('div');
ov.id='wp-history-modal'; ov.className='modal-overlay open';
ov.innerHTML='<div class="modal" style="max-width:640px"><div class="modal-head"><div class="modal-title">History — '+esc(label||wpId)+'</div>'+
'<button class="cmt-x" onclick="closeHistory()" title="Close">✕</button></div>'+
'<div class="modal-body" id="wp-history-body"><div class="empty-hint">Loading…</div></div>'+
'<div class="modal-foot"><button class="btn btn-primary" onclick="closeHistory()">Close</button></div></div>';
ov.addEventListener('click', e=>{ if(e.target===ov) closeHistory(); });
document.body.appendChild(ov);
let rows=[];
try{ const r=await fetch('/api/audit?entity_type=wp&entity_id='+encodeURIComponent(wpId), {headers:{'Accept':'application/json'}}); if(r.ok) rows=await r.json(); }catch(e){}
const body=document.getElementById('wp-history-body'); if(!body) return;
if(!rows || !rows.length){
body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>';
return;
}
const fmt=s=>{ try{ return new Date(s).toLocaleString(); }catch(e){ return s||''; } };
const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; };
body.innerHTML='<div class="hist-list">'+rows.map(e=>
'<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+
'<div class="hist-main"><span class="hist-action">'+esc(HIST_LABEL[e.action]||(e.action||'').replace(/_/g,' '))+'</span> '+
'<span class="hist-detail">'+det(e.detail)+'</span></div>'+
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>';
}
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); } function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
function loadPackageIntoForm(p){ function loadPackageIntoForm(p){
pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location); set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution); set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
set('wp_assignee',p.assigneeId);
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours); set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc); set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons); set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
set('wp_bimlink',p.bimlink); set('wp_lod',p.lod); set('wp_model_area',p.modelArea); set('wp_clash',p.clash); set('wp_scan_link',p.scanLink);
applyKind();
buildTypePicker(); document.getElementById('wp_type').value=p.type||''; buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||''; buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
set('wp_wbs',p.wbs); set('wp_wbs',p.wbs);
@@ -1040,10 +925,8 @@ function duplicateWP(){
function newPackage(){ function newPackage(){
editingId=null; editingId=null;
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); ['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value=''; document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
['wp_lod','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
pkgKind='iwp'; applyKind();
setRadio('status','Draft'); setRadio('status','Draft');
numberDims={}; buildNumberDims(); numberDims={}; buildNumberDims();
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange(); pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
@@ -1070,8 +953,8 @@ function exportPackages(){
// Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus() // Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus()
// bodies for fetch() calls to /api/wps — the dashboard UI doesn't change. // bodies for fetch() calls to /api/wps — the dashboard UI doesn't change.
const WPData = { const WPData = {
list(){ return savedPackages.slice(); }, // GET /api/wps list(){ return savedPackages.slice(); }, // hydrated from GET /api/wps on boot
get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id} get(id){ return savedPackages.find(p=>p.id===id); },
issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false; issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore();
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; }, if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
@@ -1084,43 +967,15 @@ let dashFilter={status:'',discipline:'',q:'',flag:''};
function dashToggleFlag(f){ function dashToggleFlag(f){
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; } if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
else { dashFilter.flag = dashFilter.flag===f ? '' : f; } else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
dashPage=0; renderDashboard(); renderDashboard();
}
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
// ── Phase 2: pagination, progress %, and archived view ──────────────────────
let dashPage=0, dashShowArchived=false, dashArchived=[];
const DASH_PAGE_SIZE=25;
// Weighted completion by status (0..1) so progress is smoother than done/not-done.
const PROGRESS_W={'Draft':0,'Scheduled':0.25,'Issue':0.4,'Issued':0.5,'In Progress':0.75,'QC':0.9,'Closed':1};
function wpProgress(p){ const w=PROGRESS_W[p.status]; return w==null?0:w; }
function dashGo(pg){ dashPage=pg; renderDashboard(); }
function dashToggleArchived(on){
dashShowArchived=!!on; dashPage=0;
if(dashShowArchived && !dashArchived.length && typeof ProjectData!=='undefined' && ProjectData.listArchived){
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
} else { renderDashboard(); }
}
function dashArchive(id){
const p=savedPackages.find(x=>x.id===id); if(!p) return;
if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return;
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
saveStore(); renderSavedList(); renderDashboard(); toast('Archived '+(p.number||''));
}
function dashUnarchive(id){
const ix=dashArchived.findIndex(x=>x.id===id); const p=ix>=0?dashArchived[ix]:null; if(!p) return;
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,false);
p.archived=false; dashArchived.splice(ix,1); if(!savedPackages.some(x=>x.id===id)) savedPackages.push(p);
saveStore(); renderSavedList(); renderDashboard(); toast('Restored '+(p.number||''));
} }
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
// Consistent colored status pill, reused by the dashboard board and the saved list. // Consistent colored status pill, reused by the dashboard board and the saved list.
function statusPill(s){ function statusPill(s){
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'}; const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—'); const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`; return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
} }
function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } }
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); } function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted. // Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
@@ -1137,12 +992,11 @@ function showDashboard(){
function renderDashboard(){ function renderDashboard(){
const all=countableWPs(); const all=countableWPs();
const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0); const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0);
let estH=0, actH=0, ready=0, hold=0, overdue=0, mine=0; const byDisc={}; const meId=myUserId(); let estH=0, actH=0, ready=0, hold=0, overdue=0; const byDisc={};
all.forEach(p=>{ all.forEach(p=>{
byStatus[p.status]=(byStatus[p.status]||0)+1; byStatus[p.status]=(byStatus[p.status]||0)+1;
estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0; estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0;
if(p.status==='Issue') hold++; if(p.status==='Issue') hold++;
if(meId && p.assigneeId===meId) mine++;
if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++; if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++;
if(isOverdue(p)) overdue++; if(isOverdue(p)) overdue++;
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1); (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
@@ -1155,7 +1009,6 @@ function renderDashboard(){
}; };
let h=`<div class="dash-metrics"> let h=`<div class="dash-metrics">
${card('Total WPs', all.length, '', 'all')} ${card('Total WPs', all.length, '', 'all')}
${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''}
${card('Release-ready', ready, ready?'dm-green':'', 'ready')} ${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
${card('On hold', hold, hold?'dm-red':'', 'onhold')} ${card('On hold', hold, hold?'dm-red':'', 'onhold')}
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')} ${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
@@ -1171,17 +1024,6 @@ function renderDashboard(){
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div> h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`; <div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
// progress by phase (discipline), weighted by status; archived excluded
const overallPct = all.length ? Math.round(all.reduce((s,p)=>s+wpProgress(p),0)/all.length*100) : 0;
const phaseGroups={};
all.forEach(p=>{ (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>{ (phaseGroups[d]=phaseGroups[d]||[]).push(p); }); });
let prog=`<div class="dash-panel"><div class="dash-panel-title">Progress by phase</div>`;
prog+=`<div class="prog-row"><div class="prog-name"><strong>Overall</strong></div><div class="prog-bar"><div class="prog-fill" style="width:${overallPct}%"></div></div><div class="prog-pct">${overallPct}%</div></div>`;
Object.keys(phaseGroups).sort().forEach(d=>{ const g=phaseGroups[d]; const pct=g.length?Math.round(g.reduce((s,p)=>s+wpProgress(p),0)/g.length*100):0; const done=g.filter(p=>p.status==='Closed').length;
prog+=`<div class="prog-row"><div class="prog-name">${esc(d)}</div><div class="prog-bar"><div class="prog-fill" style="width:${pct}%"></div></div><div class="prog-pct">${pct}% <span class="prog-sub">${done}/${g.length}</span></div></div>`; });
prog+=`<div class="field-hint" style="margin-top:8px">Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In&nbsp;Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
h+=prog;
// gating panel — what's blocking release // gating panel — what's blocking release
const gated=all.filter(p=>wpOpenConstraints(p).length>0); const gated=all.filter(p=>wpOpenConstraints(p).length>0);
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`; h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
@@ -1196,60 +1038,39 @@ function renderDashboard(){
const discList=Object.keys(byDisc); const discList=Object.keys(byDisc);
const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join(''); const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join('');
h+=`<div class="dash-filters"> h+=`<div class="dash-filters">
<input type="search" placeholder="Search WP # / subject / type…" value="${(dashFilter.q||'').replace(/"/g,'&quot;')}" oninput="dashFilter.q=this.value;dashPage=0;renderDashboard()"> <input type="search" placeholder="Search WP # / subject…" value="${(dashFilter.q||'').replace(/"/g,'&quot;')}" oninput="dashFilter.q=this.value;renderDashboard()">
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select> <select onchange="dashFilter.status=this.value;renderDashboard()">${statusOpts}</select>
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select> <select onchange="dashFilter.discipline=this.value;renderDashboard()">${discOpts}</select>
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
</div>`; </div>`;
// main board (includes masters, marked; archived only when toggled on) // main board (includes masters, marked)
const q=(dashFilter.q||'').toLowerCase(); const q=(dashFilter.q||'').toLowerCase();
const boardSource = dashShowArchived ? WPData.list().concat(dashArchived) : WPData.list(); const rows=WPData.list().filter(p=>{
const rows=boardSource.filter(p=>{
if(dashFilter.status && p.status!==dashFilter.status) return false; if(dashFilter.status && p.status!==dashFilter.status) return false;
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false; if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false; if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false;
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false; if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false; if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false; if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
return true; return true;
}); });
const totalRows=rows.length; h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE));
if(dashPage>=pages) dashPage=pages-1;
if(dashPage<0) dashPage=0;
const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE);
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${totalRows})</div>
<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`; <table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`;
if(!totalRows) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`; if(!rows.length) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
pageRows.forEach(p=>{ rows.forEach(p=>{
const ix=savedPackages.findIndex(x=>x.id===p.id); const ix=savedPackages.findIndex(x=>x.id===p.id);
const open=wpOpenConstraints(p).length; const open=wpOpenConstraints(p).length;
const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`); const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`);
const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns(); const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns();
const pid=esc(p.id);
let actions;
if(p.archived){
actions=`<button class="link-btn" onclick="showHistory('${pid}')">history</button> <button class="link-btn" onclick="dashUnarchive('${pid}')">restore</button>`;
} else {
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue'; const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">issue</button> `:''; const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${p.id}')">issue</button>`:'';
actions=`${issueBtn}<button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button> <button class="link-btn" onclick="dashArchive('${pid}')">archive</button>`; h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
}
h+=`<tr${p.archived?' style="opacity:.6"':''}><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}${p.archived?' <span class="badge badge-NA">archived</span>':''}</td>
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td> <td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td> <td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td> <td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
<td class="center" style="white-space:nowrap">${actions}</td></tr>`; <td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
}); });
h+=`</tbody></table>`; h+=`</tbody></table></div>`;
if(pages>1){
h+=`<div class="dash-pager"><span>Page ${dashPage+1} of ${pages} · ${totalRows} packages</span>
<span class="dash-pager-btns"><button class="btn btn-ghost" ${dashPage===0?'disabled':''} onclick="dashGo(${dashPage-1})"> Prev</button>
<button class="btn btn-ghost" ${dashPage>=pages-1?'disabled':''} onclick="dashGo(${dashPage+1})">Next </button></span></div>`;
}
h+=`</div>`;
document.getElementById('dash-body').innerHTML=h; document.getElementById('dash-body').innerHTML=h;
} }
function dashIssue(id){ function dashIssue(id){
@@ -1346,27 +1167,10 @@ function bootSOP(){
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); } if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
else { loadSampleSOP(); } else { loadSampleSOP(); }
} }
// Populate the Owner picker with this project's members (+ admins). The list is
// only used to pick an assignee; the server re-validates on save.
async function loadMembers(){
const sel=document.getElementById('wp_assignee');
if(!sel || !activeProjectId) return;
try {
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
if(!r.ok) return;
const list=await r.json();
const cur=sel.value;
sel.innerHTML='<option value="">— Unassigned —</option>'+
list.map(u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}</option>`).join('');
if(cur) sel.value=cur;
} catch(e){}
}
function bootData(){ function bootData(){
loadStore(); // reads the localStorage cache (hydrated from the server below) loadStore(); // reads the localStorage cache (hydrated from the server below)
bootSOP(); bootSOP();
setRadio('status','Draft'); setRadio('status','Draft');
loadMembers();
renderSavedList(); renderSavedList();
cmtInit(); cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard). // Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).

View File

@@ -6,8 +6,6 @@
<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>
<link rel="icon" href="favicon.ico" sizes="any"> <link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-creation-styles.css"> <link rel="stylesheet" href="wp-creation-styles.css">
</head> </head>
@@ -30,7 +28,6 @@
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button> <button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button> <button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button>
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button> <button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button> <button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
</div> </div>
@@ -47,16 +44,6 @@
<div class="main"> <div class="main">
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
<div class="card" id="kind-row" style="display:none">
<div class="sub-heading">Package Type</div>
<div class="notice">This project includes BIM/VDC packages. Choose what this one is — it tailors the fields below and the WP types / release gates offered.</div>
<div class="radio-group" id="kind-group" style="margin-bottom:0">
<label class="radio-pill" data-val="iwp"><input type="radio" name="pkgkind" onclick="setKind('iwp')"><span class="dot"></span>Install package (IWP)</label>
<label class="radio-pill" data-val="ewp"><input type="radio" name="pkgkind" onclick="setKind('ewp')"><span class="dot"></span>BIM package (EWP)</label>
</div>
</div>
<!-- GENERAL INFORMATION --> <!-- GENERAL INFORMATION -->
<div class="card"> <div class="card">
<div class="section-header"><div class="section-title">General Information</div> <div class="section-header"><div class="section-title">General Information</div>
@@ -87,32 +74,16 @@
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div> <div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
</div> </div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div> <div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div> <div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div> <div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div> <div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
</div> </div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div> <div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
</div>
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
<div class="card" id="bim-card" style="display:none">
<div class="sub-heading">BIM / Model Details</div>
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
<div class="field-grid">
<div class="field"><label>Level of Detail (LOD)</label>
<select id="wp_lod"><option value=""></option><option>LOD 100 — Conceptual</option><option>LOD 200 — Approximate</option><option>LOD 300 — Precise</option><option>LOD 350 — Precise + interfaces</option><option>LOD 400 — Fabrication</option><option>LOD 500 — As-built</option></select></div>
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
<div class="field"><label>Clash / Coordination Status</label>
<select id="wp_clash"><option value=""></option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
</div>
</div> </div>
<!-- ASSETS (controls.dev) --> <!-- ASSETS (controls.dev) -->
<div class="card" id="asset-card"> <div class="card">
<div class="sub-heading">Assets</div> <div class="sub-heading">Assets</div>
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div> <div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
@@ -145,7 +116,7 @@
</div> </div>
<!-- MATERIAL LIST --> <!-- MATERIAL LIST -->
<div class="card" id="material-card"> <div class="card">
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div> <div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div> <div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div> <div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
@@ -176,7 +147,7 @@
</div> </div>
<!-- KITTING & MIMO --> <!-- KITTING & MIMO -->
<div class="card" id="mimo-card"> <div class="card">
<div class="sub-heading">Kitting & Material Movement (MIMO)</div> <div class="sub-heading">Kitting & Material Movement (MIMO)</div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>Kitting Status</label> <div class="field"><label>Kitting Status</label>

View File

@@ -7,25 +7,25 @@
body.embedded .embed-first { margin-left: auto; } body.embedded .embed-first { margin-left: auto; }
:root { :root {
--bg: #f4f4f4; --bg: #f4f5f7;
--surface: #ffffff; --surface: #ffffff;
--surface2: #f4f4f4; --surface2: #f7f8fa;
--border: #e0e0e0; --border: #e3e6ec;
--border-strong: #8d8d8d; --border-strong: #d0d5de;
--text: #161616; --text: #1a2230;
--text-muted: #525252; --text-muted: #5a6675;
--text-dim: #8d8d8d; --text-dim: #9aa3b2;
--accent: #0f62fe; --accent: #2563d6;
--accent-dim: #edf5ff; --accent-dim: #e8f0fe;
--accent-green: #198038; --accent-green: #15924f;
--accent-green-dim: #defbe6; --accent-green-dim: #e4f6ec;
--accent-amber: #8e6a00; --accent-amber: #b87100;
--accent-amber-dim: #fdf6dd; --accent-amber-dim: #fdf2e0;
--red: #da1e28; --red: #cf3b3b;
--red-dim: #fff1f1; --red-dim: #fbeaea;
--radius: 0; --radius: 5px;
--shadow: none; --shadow: 0 1px 2px rgba(20,30,50,.04), 0 1px 3px rgba(20,30,50,.06);
--shadow-lg: 0 4px 16px rgba(20,30,50,.12); --shadow-lg: 0 4px 16px rgba(20,30,50,.08);
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace; --mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; --sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
} }
@@ -213,7 +213,7 @@
.notice { .notice {
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius); background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono); padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
} }
/* ── DELIVERABLES ── */ /* ── DELIVERABLES ── */
@@ -245,9 +245,9 @@
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); } .btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); } .btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); } .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
.btn-primary:hover { background: #0353e9; } .btn-primary:hover { background: #1d52b8; }
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); } .btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
.btn-generate:hover { background: #0e6027; } .btn-generate:hover { background: #117a42; }
/* ── OUTPUT ── */ /* ── OUTPUT ── */
#output-section { display: none; } #output-section { display: none; }
@@ -419,7 +419,7 @@
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); } .ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700; .use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; } color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
.use-btn:hover { background:#0e6027; } .use-btn:hover { background:#0f7a40; }
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; } .sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px; .sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
padding:3px 10px; font-family:var(--mono); font-size:10px; } padding:3px 10px; font-family:var(--mono); font-size:10px; }
@@ -495,7 +495,7 @@
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; } .modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
.modal-overlay.open { display:flex; } .modal-overlay.open { display:flex; }
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; } .modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); } .modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-title { font-weight:700; font-size:15px; color:var(--text); } .modal-title { font-weight:700; font-size:15px; color:var(--text); }
.modal-body { padding:18px 20px; overflow-y:auto; } .modal-body { padding:18px 20px; overflow-y:auto; }
@@ -571,11 +571,9 @@
/* 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; background:rgba(255,255,255,.92); 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); }
transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; } .section-nav-bar:empty{ display:none; }
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2); .sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; } 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); }
@@ -608,12 +606,11 @@
/* Dashboard */ /* Dashboard */
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; } .dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; } .dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; } .dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; } .dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
.dash-metric.dm-green .dm-val { color:var(--accent-green); } .dash-metric.dm-green .dm-val { color:var(--accent-green); }
.dash-metric.dm-red .dm-val { color:var(--red); } .dash-metric.dm-red .dm-val { color:var(--red); }
.dash-metric.dm-blue .dm-val { color:var(--accent, #0f62fe); }
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; } .dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
.dash-metric[onclick]:hover { border-color:var(--accent); } .dash-metric[onclick]:hover { border-color:var(--accent); }
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); } .dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
@@ -623,7 +620,7 @@
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; } .dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; } .dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); } .dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; } .dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; } .dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; } .dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); } .dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
@@ -632,27 +629,3 @@
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; } .dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
.dash-filters input[type=search] { flex:1; min-width:200px; } .dash-filters input[type=search] { flex:1; min-width:200px; }
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } } @media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
/* ── WP history (audit trail) modal ──────────────────────────────────────── */
.hist-list { display:flex; flex-direction:column; }
.hist-item { display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:baseline;
padding:9px 2px; border-bottom:1px solid var(--border); }
.hist-item:last-child { border-bottom:none; }
.hist-when { font-family:var(--mono); font-size:11px; color:var(--text-muted); white-space:nowrap; }
.hist-action { font-weight:600; color:var(--text); }
.hist-detail { color:var(--accent); font-size:13px; }
.hist-actor { font-size:12px; color:var(--text-muted); white-space:nowrap; }
@media (max-width:560px){ .hist-item { grid-template-columns:1fr; gap:2px; } }
/* ── Dashboard progress bars + pager + archived toggle (Phase 2) ──────────── */
.prog-row { display:grid; grid-template-columns:150px 1fr 96px; gap:10px; align-items:center; margin-bottom:7px; }
.prog-name { font-size:12.5px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.prog-bar { height:10px; background:var(--surface2); border:1px solid var(--border); overflow:hidden; }
.prog-fill { height:100%; background:var(--accent); transition:width .3s ease; }
.prog-pct { font-size:12px; font-weight:600; color:var(--text); text-align:right; white-space:nowrap; }
.prog-sub { font-weight:400; color:var(--text-muted); font-size:11px; }
.dash-arch-toggle { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--text-muted); white-space:nowrap; cursor:pointer; }
.dash-pager { display:flex; align-items:center; gap:12px; margin-top:12px; font-size:12.5px; color:var(--text-muted); }
.dash-pager-btns { margin-left:auto; display:flex; gap:8px; }
.dash-pager .btn { padding:5px 12px; }
@media (max-width:560px){ .prog-row { grid-template-columns:110px 1fr 74px; } }

View File

@@ -34,13 +34,6 @@ server {
root /var/www/wp-suite; # <-- web root root /var/www/wp-suite; # <-- web root
index index.html; index index.html;
# ── Security response headers (defense-in-depth) ─────────────────────────
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }

View File

@@ -9,17 +9,6 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# ── Security response headers (defense-in-depth) ─────────────────────────
# CSP keeps 'unsafe-inline' for now because the app uses inline handlers/styles
# heavily; even so, connect-src/img-src/object-src/base-uri/frame-ancestors
# sharply limit what injected script could load or exfiltrate. Tighten toward
# nonce-based scripts once inline handlers are refactored.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
@@ -30,10 +19,7 @@ server {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
# This container is only ever reached via the TLS-terminating external proxy_set_header X-Forwarded-Proto $scheme;
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
# here is always "http") so the API marks the session cookie Secure.
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 5m; client_max_body_size 5m;
} }
} }

View File

@@ -1,16 +0,0 @@
#!/bin/sh
# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed
# interval (default: daily). Kept deliberately simple — a sleep loop instead of a
# cron daemon — so it works in a bare postgres:16-alpine image.
set -eu
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
# Take one backup shortly after start so a freshly-deployed stack has an
# immediate restore point instead of waiting a whole interval.
sleep 20
while true; do
sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2
sleep "$INTERVAL"
done

View File

@@ -1,5 +0,0 @@
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime
# (see the `backup` service in docker-compose.yml), so they're not COPYed here.
FROM postgres:16-alpine
RUN apk add --no-cache openssl

View File

@@ -1,58 +0,0 @@
#!/bin/sh
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
#
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
# the database holds customer IP — otherwise the dump (and every offsite copy) is
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
# possible); losing it means the backups are unrecoverable.
#
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
set -eu
BACKUP_DIR="${BACKUP_DIR:-/backups}"
KEEP="${BACKUP_KEEP:-14}"
PGHOST="${PGHOST:-db}"
PGPORT="${PGPORT:-5432}"
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
ENC="${BACKUP_ENC_PASSPHRASE:-}"
mkdir -p "$BACKUP_DIR"
ts="$(date -u +%Y%m%d-%H%M%SZ)"
if [ -n "$ENC" ]; then
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
else
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
fi
tmp="$out.partial"
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
if [ -n "$ENC" ]; then
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
| gzip -c \
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
mv "$tmp" "$out"
else
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
fi
else
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
mv "$tmp" "$out"
else
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
fi
fi
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
if [ "$count" -gt "$KEEP" ]; then
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
echo "[db-backup] pruning $f"
rm -f "$f"
done
fi

View File

@@ -1,33 +0,0 @@
#!/bin/sh
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
#
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
# recreates objects before loading. Take a fresh backup first if in doubt.
#
# Usage (from the project root):
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
# backup container's environment).
set -eu
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
PGHOST="${PGHOST:-db}"
PGPORT="${PGPORT:-5432}"
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
case "$FILE" in
*.enc)
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
;;
*)
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
;;
esac
echo "[db-restore] done."

View File

@@ -20,13 +20,3 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12. # How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12 # AUTH_SESSION_HOURS=12
# ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address
# live. The one secret that must NOT be stored in the database — the SMTP
# password — is read from this environment variable instead. Leave it unset
# until you have the SMTP details; the toggle stays effectively off (queued
# notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password

View File

@@ -1,43 +0,0 @@
# Alembic configuration for the Work Package Suite.
# The database URL is NOT hard-coded here — env.py pulls it from the same place
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
# migrations always target the same database as the running app.
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
# Use OS-native path separators on Windows dev machines.
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,63 +0,0 @@
"""Alembic environment for the Work Package Suite.
We reuse the application's own database configuration (server/db.py) so a
migration always targets the same database the app would connect to — Postgres
in production (from POSTGRES_* / DATABASE_URL) or the SQLite dev file otherwise.
No connection string is stored in alembic.ini.
"""
import os
import sys
from logging.config import fileConfig
from alembic import context
# Make the `server` package importable no matter where alembic is invoked from
# (repo root, /app in the container, etc.). env.py lives at server/alembic/env.py,
# so the repo root is two directories up.
_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO = os.path.dirname(os.path.dirname(_HERE))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
from server.db import Base, DATABASE_URL, engine # noqa: E402
from server import models # noqa: E402,F401 (imported for its side effect: registers all tables on Base.metadata)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# The app resolves its URL from the environment; feed the same value to Alembic.
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Emit SQL to stdout (`alembic upgrade --sql`) without a live connection."""
context.configure(
url=str(DATABASE_URL),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations against a live connection, reusing the app's engine."""
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,23 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,30 +0,0 @@
"""user login lockout fields
Revision ID: 18373f14809e
Revises: 47bbe76aa749
Create Date: 2026-07-15 14:50:58.423834
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '18373f14809e'
down_revision = '47bbe76aa749'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# server_default backfills existing rows to 0 (the column is NOT NULL).
op.add_column('users', sa.Column('failed_attempts', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'locked_until')
op.drop_column('users', 'failed_attempts')
# ### end Alembic commands ###

View File

@@ -1,29 +0,0 @@
"""wp archived_at
Revision ID: 47bbe76aa749
Revises: 4e094197c9aa
Create Date: 2026-07-15 12:00:14.356398
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '47bbe76aa749'
down_revision = '4e094197c9aa'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('work_packages', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_work_packages_archived_at'), 'work_packages', ['archived_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_archived_at'), table_name='work_packages')
op.drop_column('work_packages', 'archived_at')
# ### end Alembic commands ###

View File

@@ -1,48 +0,0 @@
"""audit log
Revision ID: 4e094197c9aa
Revises: c6af106a04da
Create Date: 2026-07-15 10:12:52.859694
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4e094197c9aa'
down_revision = 'c6af106a04da'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_log',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('actor', sa.String(length=200), nullable=False),
sa.Column('action', sa.String(length=60), nullable=False),
sa.Column('entity_type', sa.String(length=40), nullable=False),
sa.Column('entity_id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('summary', sa.String(length=400), nullable=False),
sa.Column('detail', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
op.create_index(op.f('ix_audit_log_entity_id'), 'audit_log', ['entity_id'], unique=False)
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
op.create_index(op.f('ix_audit_log_project_id'), 'audit_log', ['project_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_log_project_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_entity_id'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
op.drop_table('audit_log')
# ### end Alembic commands ###

View File

@@ -1,63 +0,0 @@
"""assignment + settings + notifications
Revision ID: 57dec34f11cb
Revises: ad8e6cc5de0f
Create Date: 2026-07-15 16:43:09.230419
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '57dec34f11cb'
down_revision = 'ad8e6cc5de0f'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('app_settings',
sa.Column('key', sa.String(length=80), nullable=False),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_table('notifications',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('user_id', sa.String(length=40), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('kind', sa.String(length=40), nullable=False),
sa.Column('wp_id', sa.String(length=40), nullable=True),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('subject', sa.String(length=300), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('link', sa.String(length=500), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error', sa.String(length=400), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
op.create_index(op.f('ix_notifications_kind'), 'notifications', ['kind'], unique=False)
op.create_index(op.f('ix_notifications_project_id'), 'notifications', ['project_id'], unique=False)
op.create_index(op.f('ix_notifications_status'), 'notifications', ['status'], unique=False)
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
op.add_column('work_packages', sa.Column('assignee_id', sa.String(length=40), nullable=True))
op.create_index(op.f('ix_work_packages_assignee_id'), 'work_packages', ['assignee_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_assignee_id'), table_name='work_packages')
op.drop_column('work_packages', 'assignee_id')
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
op.drop_index(op.f('ix_notifications_status'), table_name='notifications')
op.drop_index(op.f('ix_notifications_project_id'), table_name='notifications')
op.drop_index(op.f('ix_notifications_kind'), table_name='notifications')
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
op.drop_table('notifications')
op.drop_table('app_settings')
# ### end Alembic commands ###

View File

@@ -1,28 +0,0 @@
"""user token_version
Revision ID: ad8e6cc5de0f
Revises: 18373f14809e
Create Date: 2026-07-15 16:03:57.736556
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ad8e6cc5de0f'
down_revision = '18373f14809e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# server_default backfills existing rows to 0 (the column is NOT NULL).
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'token_version')
# ### end Alembic commands ###

View File

@@ -1,145 +0,0 @@
"""baseline schema
Revision ID: c6af106a04da
Revises:
Create Date: 2026-07-15 08:21:07.450350
This is the initial baseline. It creates the current schema on a fresh database,
and safely ADOPTS an existing database (one whose tables were created by the old
`Base.metadata.create_all()` before Alembic was introduced): if the schema is
already present it records this revision without recreating anything. That means
`alembic upgrade head` is safe to run on both new and existing deployments — no
manual `alembic stamp` step required.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c6af106a04da'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("projects"):
# Existing pre-Alembic database — adopt it as the baseline as-is.
return
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('comments',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('source', sa.String(length=40), nullable=False),
sa.Column('sop_id', sa.String(length=40), nullable=True),
sa.Column('wp_id', sa.String(length=40), nullable=True),
sa.Column('step', sa.Integer(), nullable=True),
sa.Column('author', sa.String(length=200), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('page', sa.String(length=200), nullable=False),
sa.Column('extra', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_comments_sop_id'), 'comments', ['sop_id'], unique=False)
op.create_index(op.f('ix_comments_source'), 'comments', ['source'], unique=False)
op.create_index(op.f('ix_comments_wp_id'), 'comments', ['wp_id'], unique=False)
op.create_table('projects',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('number', sa.String(length=100), nullable=False),
sa.Column('client', sa.String(length=300), nullable=False),
sa.Column('division', sa.String(length=200), nullable=False),
sa.Column('site', sa.String(length=300), nullable=False),
sa.Column('sample', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_projects_number'), 'projects', ['number'], unique=False)
op.create_table('users',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('username', sa.String(length=120), nullable=False),
sa.Column('email', sa.String(length=200), nullable=False),
sa.Column('full_name', sa.String(length=200), nullable=False),
sa.Column('password_hash', sa.String(length=200), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('project_members',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('user_id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'project_id', name='uq_project_member')
)
op.create_index(op.f('ix_project_members_project_id'), 'project_members', ['project_id'], unique=False)
op.create_index(op.f('ix_project_members_user_id'), 'project_members', ['user_id'], unique=False)
op.create_table('sops',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('number', sa.String(length=100), nullable=False),
sa.Column('complete', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sops_project_id'), 'sops', ['project_id'], unique=False)
op.create_table('work_packages',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('sop_id', sa.String(length=40), nullable=True),
sa.Column('parent_id', sa.String(length=40), nullable=True),
sa.Column('number', sa.String(length=120), nullable=False),
sa.Column('subject', sa.String(length=400), nullable=False),
sa.Column('type', sa.String(length=120), nullable=False),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('created_by', sa.String(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['sop_id'], ['sops.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_work_packages_parent_id'), 'work_packages', ['parent_id'], unique=False)
op.create_index(op.f('ix_work_packages_project_id'), 'work_packages', ['project_id'], unique=False)
op.create_index(op.f('ix_work_packages_sop_id'), 'work_packages', ['sop_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_work_packages_sop_id'), table_name='work_packages')
op.drop_index(op.f('ix_work_packages_project_id'), table_name='work_packages')
op.drop_index(op.f('ix_work_packages_parent_id'), table_name='work_packages')
op.drop_table('work_packages')
op.drop_index(op.f('ix_sops_project_id'), table_name='sops')
op.drop_table('sops')
op.drop_index(op.f('ix_project_members_user_id'), table_name='project_members')
op.drop_index(op.f('ix_project_members_project_id'), table_name='project_members')
op.drop_table('project_members')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_projects_number'), table_name='projects')
op.drop_table('projects')
op.drop_index(op.f('ix_comments_wp_id'), table_name='comments')
op.drop_index(op.f('ix_comments_source'), table_name='comments')
op.drop_index(op.f('ix_comments_sop_id'), table_name='comments')
op.drop_table('comments')
# ### end Alembic commands ###

View File

@@ -9,40 +9,24 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs Interactive docs: http://<host>/api/docs
""" """
import os import os
import re
import uuid import uuid
from datetime import timedelta, timezone
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, delete, func from sqlalchemy import select, delete
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, get_db from .db import Base, engine, get_db
from . import models, auth, notify from . import models, auth
# Schema management: # Create tables on startup. (For schema changes later, switch to Alembic.)
# • Local dev (SQLite) auto-creates tables for a zero-config run.
# • Production (Postgres) owns its schema through Alembic migrations, which run
# at container start (`alembic upgrade head`, see Dockerfile / DEPLOYMENT.md).
# We must NOT create_all there, or it would race/collide with the migration.
if engine.dialect.name == "sqlite":
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Interactive docs are handy in dev but hand an attacker the full API map in prod, app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url="/api/openapi.json")
# so enable them only on the SQLite dev fallback (production runs on Postgres).
_docs_enabled = engine.dialect.name == "sqlite"
app = FastAPI(
title="Work Package Suite API",
docs_url="/api/docs" if _docs_enabled else None,
redoc_url=None,
openapi_url="/api/openapi.json" if _docs_enabled else None,
)
# Same-origin in production (NGINX), so CORS is normally unnecessary. For # Same-origin in production (NGINX), so CORS is normally unnecessary. For
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..." # cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
@@ -51,7 +35,7 @@ _origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
if _origins: if _origins:
app.add_middleware( app.add_middleware(
CORSMiddleware, allow_origins=_origins, allow_credentials=True, CORSMiddleware, allow_origins=_origins, allow_credentials=True,
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"], allow_methods=["*"], allow_headers=["*"],
) )
@@ -60,33 +44,11 @@ if _origins:
# docs are exempt (see auth._needs_auth). This is the real security boundary — # docs are exempt (see auth._needs_auth). This is the real security boundary —
# the static pages are only client-side guarded for UX. OPTIONS (CORS preflight) # the static pages are only client-side guarded for UX. OPTIONS (CORS preflight)
# is always allowed so the browser can negotiate before sending credentials. # is always allowed so the browser can negotiate before sending credentials.
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def _csrf_ok(request: Request) -> bool:
"""CSRF defense-in-depth behind SameSite=Lax: when the browser sends an Origin
on a state-changing request, it must be same-origin (or an allowed CORS origin).
Non-browser clients (no Origin header) are unaffected."""
origin = request.headers.get("origin")
if not origin:
return True
if _origins and origin in _origins:
return True
try:
return urlparse(origin).netloc == request.headers.get("host", "")
except Exception:
return False
@app.middleware("http") @app.middleware("http")
async def auth_gate(request: Request, call_next): async def auth_gate(request: Request, call_next):
path = request.url.path if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
method = request.method
if method != "OPTIONS" and auth._needs_auth(path):
if not auth.is_request_authenticated(request): if not auth.is_request_authenticated(request):
return JSONResponse(status_code=401, content={"detail": "Not authenticated"}) return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
return await call_next(request) return await call_next(request)
@@ -94,16 +56,6 @@ def gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}" return f"{prefix}_{uuid.uuid4().hex[:12]}"
# Clients may supply their own resource ids (offline-first). Constrain them to a
# safe charset so an id can never carry HTML/JS that a UI might place in markup.
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
def check_id(v: Optional[str]) -> None:
if v and not _ID_RE.match(v):
raise HTTPException(status_code=400, detail="Invalid id format")
# ── Per-project access control ───────────────────────────────────────────────── # ── Per-project access control ─────────────────────────────────────────────────
# A non-admin user may only touch projects they're a member of (project_members). # A non-admin user may only touch projects they're a member of (project_members).
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not # Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
@@ -120,12 +72,8 @@ def accessible_project_ids(db: Session, user: "models.User"):
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None: def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
if user.role == "admin": if user.role == "admin" or project_id is None:
return return
if not project_id:
# Non-admins may not read/mutate resources with no project assignment
# (orphan/legacy rows); only admins can touch project-less data.
raise HTTPException(status_code=403, detail="This resource is not assigned to a project you can access")
ok = db.scalar( ok = db.scalar(
select(models.ProjectMember.id).where( select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user.id) (models.ProjectMember.user_id == user.id)
@@ -156,54 +104,6 @@ def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id)) db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
# ── Audit trail ────────────────────────────────────────────────────────────────
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
"""Append an audit-trail row in the CURRENT transaction so it commits
atomically with the change it describes. `actor` may be a User or a username."""
who = actor.username if isinstance(actor, models.User) else (actor or "")
db.add(models.AuditLog(
id=gen_id("ev"), actor=who, action=action, entity_type=entity_type,
entity_id=entity_id or "", project_id=project_id, summary=(summary or "")[:400], detail=detail or {},
))
# ── Assignment ─────────────────────────────────────────────────────────────────
def require_assignable(db: Session, user_id: str, project_id: Optional[str]) -> None:
"""A WP can only be assigned to an active user who can access its project."""
u = db.get(models.User, user_id)
if not u or not u.is_active:
raise HTTPException(status_code=400, detail="Assignee is not a valid user")
if u.role == "admin":
return
ok = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user_id) & (models.ProjectMember.project_id == project_id)
)
)
if not ok:
raise HTTPException(status_code=400, detail="Assignee is not a member of this project")
def wp_link(db: Session, wp: "models.WorkPackage") -> str:
base = (notify.get_settings(db).get("app_base_url") or "").rstrip("/")
path = f"/work-package-suite.html?tab=wp&project={wp.project_id or ''}"
return (base + path) if base else path
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps
# customer IP inside the app, behind login).
who = actor.full_name or actor.username
name = assignee.full_name or assignee.username
return (
f"Hi {name},\n\n"
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
f"Open the Work Package Suite to view and action it:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
)
# ── Request bodies ─────────────────────────────────────────────────────────── # ── Request bodies ───────────────────────────────────────────────────────────
class ProjectIn(BaseModel): class ProjectIn(BaseModel):
id: Optional[str] = None id: Optional[str] = None
@@ -236,35 +136,14 @@ class WpIn(BaseModel):
subject: str = "" subject: str = ""
type: str = "" type: str = ""
status: str = "Draft" status: str = "Draft"
assignee_id: Optional[str] = None
created_by: str = "" created_by: str = ""
data: dict[str, Any] = Field(default_factory=dict) data: dict[str, Any] = Field(default_factory=dict)
class SettingsIn(BaseModel):
model_config = ConfigDict(extra="ignore")
email_enabled: Optional[bool] = None
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_use_tls: Optional[bool] = None
smtp_username: Optional[str] = None
from_addr: Optional[str] = None
from_name: Optional[str] = None
app_base_url: Optional[str] = None
class TestEmailIn(BaseModel):
to: Optional[str] = None
class StatusIn(BaseModel): class StatusIn(BaseModel):
status: str status: str
class ArchiveIn(BaseModel):
archived: bool = True
class CommentIn(BaseModel): class CommentIn(BaseModel):
# Tolerate any extra keys the feedback payload includes (timestamp, app, …). # Tolerate any extra keys the feedback payload includes (timestamp, app, …).
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
@@ -312,49 +191,22 @@ class ActiveIn(BaseModel):
is_active: bool is_active: bool
class RoleIn(BaseModel):
role: str # 'admin' | 'user'
class ProjectAssignIn(BaseModel): class ProjectAssignIn(BaseModel):
project_ids: list[str] = Field(default_factory=list) project_ids: list[str] = Field(default_factory=list)
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
@app.post("/api/auth/login") @app.post("/api/auth/login")
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)): def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
"""Verify credentials and, on success, set the HttpOnly session cookie. """Verify credentials and, on success, set the HttpOnly session cookie."""
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
user = auth.find_user(db, body.username) user = auth.find_user(db, body.username)
now = models.utcnow() # Always run a hash comparison to avoid leaking which usernames exist via
# Always run the hash comparison first — even for missing or locked accounts — # response timing; verify_password tolerates an empty hash.
# so response timing doesn't leak which usernames exist. verify_password
# tolerates an empty hash.
valid = auth.verify_password(body.password, user.password_hash if user else "") valid = auth.verify_password(body.password, user.password_hash if user else "")
locked = user.locked_until if user else None
if locked is not None and locked.tzinfo is None:
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
if locked is not None and locked > now:
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
if not user or not valid: if not user or not valid:
if user:
user.failed_attempts = (user.failed_attempts or 0) + 1
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
user.failed_attempts = 0
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled") raise HTTPException(status_code=403, detail="Account is disabled")
user.failed_attempts = 0 user.last_login_at = models.utcnow()
user.locked_until = None
user.last_login_at = now
db.commit() db.commit()
token = auth.create_token(user) token = auth.create_token(user)
auth.set_session_cookie(response, request, token) auth.set_session_cookie(response, request, token)
@@ -374,18 +226,13 @@ def whoami(user: models.User = Depends(auth.get_current_user)):
@app.post("/api/auth/password") @app.post("/api/auth/password")
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not auth.verify_password(body.current_password, user.password_hash): if not auth.verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect") raise HTTPException(status_code=400, detail="Current password is incorrect")
problem = auth.password_problem(body.new_password, user.username, user.email) if len(body.new_password) < 8:
if problem: raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password) user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
db.commit() db.commit()
db.refresh(user)
# Keep this session logged in by re-issuing a cookie carrying the new version.
auth.set_session_cookie(response, request, auth.create_token(user))
return {"ok": True} return {"ok": True}
@@ -398,9 +245,8 @@ def list_users(_admin: models.User = Depends(auth.require_admin), db: Session =
@app.post("/api/auth/users") @app.post("/api/auth/users")
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
problem = auth.password_problem(body.password, body.username, body.email) if len(body.password) < 8:
if problem: raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
raise HTTPException(status_code=400, detail=problem)
if body.role not in ("admin", "user"): if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
if auth.find_user(db, body.username): if auth.find_user(db, body.username):
@@ -414,7 +260,6 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
role=body.role, role=body.role,
) )
db.add(u) db.add(u)
log_event(db, _admin, "user_created", "user", u.id, summary=u.username, detail={"role": u.role})
db.commit() db.commit()
db.refresh(u) db.refresh(u)
return u.to_dict() return u.to_dict()
@@ -425,11 +270,9 @@ def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.Use
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")
problem = auth.password_problem(body.new_password, u.username, u.email) if len(body.new_password) < 8:
if problem: raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password) u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -442,43 +285,10 @@ def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(a
if u.id == admin.id and not body.is_active: if u.id == admin.id and not body.is_active:
raise HTTPException(status_code=400, detail="You cannot disable your own account") raise HTTPException(status_code=400, detail="You cannot disable your own account")
u.is_active = body.is_active u.is_active = body.is_active
log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
summary=u.username, detail={"is_active": bool(body.is_active)})
db.commit() db.commit()
return u.to_dict() return u.to_dict()
@app.post("/api/auth/users/{user_id}/role")
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Change a user's role (admin ↔ user). Admins can do this at any time.
Guards: you can't change your own role (avoids self-lockout), and the last
remaining admin can't be demoted (keeps the app manageable)."""
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot change your own role")
if u.role == "admin" and body.role != "admin":
other_admins = db.scalars(
select(models.User.id).where(
(models.User.role == "admin")
& (models.User.id != u.id)
& (models.User.is_active.is_(True))
)
).all()
if not other_admins:
raise HTTPException(status_code=400, detail="Can't remove the last admin account")
old_role = u.role
u.role = body.role
log_event(db, admin, "role_changed", "user", u.id, summary=u.username,
detail={"from": old_role, "to": body.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)
@@ -486,7 +296,6 @@ def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin),
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id: if u.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account") raise HTTPException(status_code=400, detail="You cannot delete your own account")
log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
db.delete(u) db.delete(u)
db.commit() db.commit()
return {"deleted": user_id} return {"deleted": user_id}
@@ -525,7 +334,6 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
# ── Projects ───────────────────────────────────────────────────────────────── # ── Projects ─────────────────────────────────────────────────────────────────
@app.post("/api/projects") @app.post("/api/projects")
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
check_id(body.id)
proj = db.get(models.Project, body.id) if body.id else None proj = db.get(models.Project, body.id) if body.id else None
is_new = proj is None is_new = proj is None
if not is_new: if not is_new:
@@ -541,8 +349,6 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
proj.sample = body.sample proj.sample = body.sample
proj.created_by = body.created_by or proj.created_by proj.created_by = body.created_by or proj.created_by
proj.data = body.data proj.data = body.data
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))
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 is_new and user.role != "admin": if is_new and user.role != "admin":
@@ -582,12 +388,10 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
# ── 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)
require_project_access(db, user, body.project_id) require_project_access(db, user, body.project_id)
sop = db.get(models.Sop, body.id) if body.id else None sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None: if sop is not None:
require_project_access(db, user, sop.project_id) require_project_access(db, user, sop.project_id)
is_new = sop is None
if sop is None: if sop is None:
sop = models.Sop(id=body.id or gen_id("sop")) sop = models.Sop(id=body.id or gen_id("sop"))
db.add(sop) db.add(sop)
@@ -597,9 +401,6 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user),
sop.complete = body.complete sop.complete = body.complete
sop.created_by = body.created_by or sop.created_by sop.created_by = body.created_by or sop.created_by
sop.data = body.data sop.data = body.data
log_event(db, user, "completed" if body.complete else ("created" if is_new else "updated"),
"sop", sop.id, project_id=sop.project_id, summary=(sop.name or sop.number or sop.id),
detail={"complete": bool(body.complete)})
db.commit() db.commit()
db.refresh(sop) db.refresh(sop)
return sop.to_dict() return sop.to_dict()
@@ -646,8 +447,6 @@ 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_access(db, user, sop.project_id) require_project_access(db, user, 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))
db.delete(sop) db.delete(sop)
db.commit() db.commit()
return {"deleted": sop_id} return {"deleted": sop_id}
@@ -655,17 +454,11 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
# ── Work Packages ──────────────────────────────────────────────────────────── # ── Work Packages ────────────────────────────────────────────────────────────
@app.post("/api/wps") @app.post("/api/wps")
def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
check_id(body.id)
check_id(body.parent_id)
check_id(body.assignee_id)
require_project_access(db, user, body.project_id) require_project_access(db, user, body.project_id)
wp = db.get(models.WorkPackage, body.id) if body.id else None wp = db.get(models.WorkPackage, body.id) if body.id else None
if wp is not None: if wp is not None:
require_project_access(db, user, wp.project_id) require_project_access(db, user, wp.project_id)
is_new = wp is None
old_status = None if is_new else wp.status
old_assignee = None if is_new else wp.assignee_id
if wp is None: if wp is None:
wp = models.WorkPackage(id=body.id or gen_id("wp")) wp = models.WorkPackage(id=body.id or gen_id("wp"))
db.add(wp) db.add(wp)
@@ -676,52 +469,19 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
wp.subject = body.subject wp.subject = body.subject
wp.type = body.type wp.type = body.type
wp.status = body.status wp.status = body.status
new_assignee = body.assignee_id or None
if new_assignee:
require_assignable(db, new_assignee, body.project_id)
wp.assignee_id = new_assignee
wp.created_by = body.created_by or wp.created_by wp.created_by = body.created_by or wp.created_by
wp.data = body.data wp.data = body.data
if is_new:
_act, _detail = "created", {"status": wp.status}
elif old_status != wp.status:
_act, _detail = "status_changed", {"from": old_status, "to": wp.status}
else:
_act, _detail = "updated", {"status": wp.status}
log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail=_detail)
# Notify a newly-assigned owner (skip self-assignment).
notif = None
if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
assignee = db.get(models.User, new_assignee)
if assignee:
link = wp_link(db, wp)
notif = notify.enqueue(
db, user=assignee, kind="wp_assigned",
subject=f"You were assigned {wp.number or 'a work package'}",
body=assign_body(assignee, wp, user, link),
link=link, wp_id=wp.id, project_id=wp.project_id,
)
log_event(db, user, "assigned", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail={"to": assignee.username})
db.commit() db.commit()
db.refresh(wp) db.refresh(wp)
if notif is not None:
background_tasks.add_task(notify.deliver, notif.id)
return wp.to_dict() return wp.to_dict()
@app.get("/api/wps") @app.get("/api/wps")
def list_wps( def list_wps(
response: Response,
project_id: Optional[str] = Query(None), project_id: Optional[str] = Query(None),
sop_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None),
parent_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
q: Optional[str] = Query(None, description="search number / subject / type"),
archived: str = Query("exclude", description="exclude (default) | only | all"),
limit: Optional[int] = Query(None, ge=1, le=1000),
offset: int = Query(0, ge=0),
full: bool = Query(False), full: bool = Query(False),
user: models.User = Depends(auth.get_current_user), user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -735,25 +495,8 @@ def list_wps(
stmt = stmt.where(models.WorkPackage.parent_id == parent_id) stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
if status: if status:
stmt = stmt.where(models.WorkPackage.status == status) stmt = stmt.where(models.WorkPackage.status == status)
if archived == "only":
stmt = stmt.where(models.WorkPackage.archived_at.is_not(None))
elif archived != "all":
stmt = stmt.where(models.WorkPackage.archived_at.is_(None)) # default: hide archived
if q and q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
models.WorkPackage.number.ilike(like)
| models.WorkPackage.subject.ilike(like)
| models.WorkPackage.type.ilike(like)
)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user) stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
# Report the pre-pagination total so the client can build a pager. rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
total = db.scalar(select(func.count()).select_from(stmt.subquery()))
response.headers["X-Total-Count"] = str(total or 0)
stmt = stmt.order_by(models.WorkPackage.updated_at.desc()).offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
rows = db.scalars(stmt).all()
# full=true includes the data JSON (full package document) so the creator can # full=true includes the data JSON (full package document) so the creator can
# rehydrate everything in one request; default stays lean for listing. # rehydrate everything in one request; default stays lean for listing.
return [(w.to_dict() if full else w.summary()) for w in rows] return [(w.to_dict() if full else w.summary()) for w in rows]
@@ -764,7 +507,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
"""Aggregates for the dashboard. Masters (data.split == true) are excluded """Aggregates for the dashboard. Masters (data.split == true) are excluded
from counts so a split package's hours aren't double-counted with its from counts so a split package's hours aren't double-counted with its
instances.""" instances."""
stmt = select(models.WorkPackage).where(models.WorkPackage.archived_at.is_(None)) stmt = select(models.WorkPackage)
if project_id: if project_id:
stmt = stmt.where(models.WorkPackage.project_id == project_id) stmt = stmt.where(models.WorkPackage.project_id == project_id)
if sop_id: if sop_id:
@@ -817,8 +560,6 @@ def delete_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)
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id))
db.delete(wp) db.delete(wp)
db.commit() db.commit()
return {"deleted": wp_id} return {"deleted": wp_id}
@@ -838,8 +579,6 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names}) raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names})
wp.status = "Issued" wp.status = "Issued"
wp.issued_at = models.utcnow() wp.issued_at = models.utcnow()
log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail={"to": "Issued"})
db.commit() db.commit()
db.refresh(wp) db.refresh(wp)
return wp.to_dict() return wp.to_dict()
@@ -851,147 +590,16 @@ 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)
old_status = wp.status
wp.status = body.status wp.status = body.status
if body.status == "Issued" and wp.issued_at is None: if body.status == "Issued" and wp.issued_at is None:
wp.issued_at = models.utcnow() wp.issued_at = models.utcnow()
if old_status != body.status:
log_event(db, user, "status_changed", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail={"from": old_status, "to": body.status})
db.commit() db.commit()
db.refresh(wp) db.refresh(wp)
return wp.to_dict() return wp.to_dict()
@app.post("/api/wps/{wp_id}/archive")
def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Archive (or unarchive) a Work Package — hides it from the default lists and
the dashboard without deleting it. Kept for the record on long-running jobs."""
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
was_archived = wp.archived_at is not None
if body.archived and not was_archived:
wp.archived_at = models.utcnow()
log_event(db, user, "archived", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id))
elif not body.archived and was_archived:
wp.archived_at = None
log_event(db, user, "unarchived", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id))
db.commit()
db.refresh(wp)
return wp.to_dict()
# ── Audit trail (history) ──────────────────────────────────────────────────────
@app.get("/api/audit")
def list_audit(
entity_type: Optional[str] = Query(None),
entity_id: Optional[str] = Query(None),
project_id: Optional[str] = Query(None),
action: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
"""History / audit trail. Pass entity_type+entity_id for one item's history,
or project_id for a project activity feed. Scoped to the caller's project
access; admins additionally see project-less events (user management)."""
stmt = select(models.AuditLog)
if entity_type:
stmt = stmt.where(models.AuditLog.entity_type == entity_type)
if entity_id:
stmt = stmt.where(models.AuditLog.entity_id == entity_id)
if action:
stmt = stmt.where(models.AuditLog.action == action)
if project_id:
require_project_access(db, user, project_id)
stmt = stmt.where(models.AuditLog.project_id == project_id)
# Non-admins only ever see events tied to a project they can access.
ids = accessible_project_ids(db, user)
if ids is not None:
stmt = stmt.where(models.AuditLog.project_id.in_(ids))
rows = db.scalars(stmt.order_by(models.AuditLog.at.desc()).limit(limit).offset(offset)).all()
return [e.to_dict() for e in rows]
# ── Settings (admin) ────────────────────────────────────────────────────────────
@app.get("/api/settings")
def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
return notify.public_settings(db)
@app.put("/api/settings")
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
patch = {k: v for k, v in body.model_dump().items() if v is not None}
saved = notify.save_settings(db, patch)
log_event(db, admin, "settings_updated", "settings", "notifications",
summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))})
db.commit()
return notify.public_settings(db)
@app.post("/api/settings/test-email")
def send_test_email(body: TestEmailIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
s = notify.get_settings(db)
if not notify.smtp_ready(s):
raise HTTPException(status_code=400, detail="Set the SMTP host and From address first.")
to = (body.to or admin.email or "").strip()
if not to:
raise HTTPException(status_code=400, detail="No recipient — add an email to your account or pass 'to'.")
try:
notify.send_email(s, to, "Work Package Suite — test email",
"This is a test from the Work Package Suite. If you got this, SMTP is working.")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"Send failed: {e}")
return {"ok": True, "to": to}
# ── Notifications + project members ─────────────────────────────────────────────
@app.get("/api/notifications")
def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500),
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Notification)
if not (all and user.role == "admin"):
stmt = stmt.where(models.Notification.user_id == user.id)
rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all()
return [n.to_dict() for n in rows]
@app.get("/api/projects/{project_id}/members")
def project_members(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Users who can be assigned WPs on this project — its members plus admins."""
require_project_access(db, user, project_id)
member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all())
members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else []
admins = db.scalars(select(models.User).where(models.User.role == "admin")).all()
out, seen = [], set()
for u in list(members) + list(admins):
if u.id in seen or not u.is_active:
continue
seen.add(u.id)
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email})
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
return out
# ── Comments / feedback ────────────────────────────────────────────────────── # ── Comments / feedback ──────────────────────────────────────────────────────
def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict: def _save_comment(body: CommentIn, db: Session) -> dict:
# 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.
if body.wp_id:
wp = db.get(models.WorkPackage, body.wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
elif body.sop_id:
sop = db.get(models.Sop, body.sop_id)
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
extra = body.model_extra or {} extra = body.model_extra or {}
c = models.Comment( c = models.Comment(
id=gen_id("c"), id=gen_id("c"),
@@ -999,9 +607,7 @@ def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
sop_id=body.sop_id, sop_id=body.sop_id,
wp_id=body.wp_id, wp_id=body.wp_id,
step=body.step, step=body.step,
# Attribution comes from the authenticated session, NEVER the client author=(body.author or body.name or "Anonymous"),
# payload — otherwise comments could be forged as another user.
author=(user.full_name or user.username),
text=body.text or "", text=body.text or "",
page=body.page or "", page=body.page or "",
extra=extra, extra=extra,
@@ -1013,14 +619,14 @@ def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
@app.post("/api/comments") @app.post("/api/comments")
def create_comment(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def create_comment(body: CommentIn, db: Session = Depends(get_db)):
return _save_comment(body, db, user) return _save_comment(body, db)
# Alias so the existing client (which posts to /api/feedback) keeps working. # Alias so the existing client (which posts to /api/feedback) keeps working.
@app.post("/api/feedback") @app.post("/api/feedback")
def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def create_feedback(body: CommentIn, db: Session = Depends(get_db)):
return _save_comment(body, db, user) return _save_comment(body, db)
@app.get("/api/comments") @app.get("/api/comments")
@@ -1029,7 +635,6 @@ def list_comments(
sop_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None),
wp_id: Optional[str] = Query(None), wp_id: Optional[str] = Query(None),
step: Optional[int] = Query(None), step: Optional[int] = Query(None),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
stmt = select(models.Comment) stmt = select(models.Comment)
@@ -1041,22 +646,6 @@ def list_comments(
stmt = stmt.where(models.Comment.wp_id == wp_id) stmt = stmt.where(models.Comment.wp_id == wp_id)
if step is not None: if step is not None:
stmt = stmt.where(models.Comment.step == step) stmt = stmt.where(models.Comment.step == step)
# Non-admins see general app feedback plus comments on WPs/SOPs in their own
# projects only — never another project's review threads.
ids = accessible_project_ids(db, user)
if ids is not None:
acc_wp = select(models.WorkPackage.id).where(models.WorkPackage.project_id.in_(ids))
acc_sop = select(models.Sop.id).where(models.Sop.project_id.in_(ids))
# The "general feedback" branch is ONLY for comments not tied to any
# WP/SOP — otherwise a project-scoped comment tagged source=home_feedback
# by the client would leak across projects. Project-tied comments are
# visible strictly by project membership.
stmt = stmt.where(
((models.Comment.source == "home_feedback")
& models.Comment.wp_id.is_(None) & models.Comment.sop_id.is_(None))
| (models.Comment.wp_id.in_(acc_wp))
| (models.Comment.sop_id.in_(acc_sop))
)
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all() rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
return [c.to_dict() for c in rows] return [c.to_dict() for c in rows]

View File

@@ -30,7 +30,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import get_db, DATABASE_URL from .db import get_db
from . import models from . import models
log = logging.getLogger("wpsuite.auth") log = logging.getLogger("wpsuite.auth")
@@ -40,29 +40,6 @@ JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again. # How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs). # Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",) _EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = { _EXEMPT_EXACT = {
@@ -78,24 +55,13 @@ def _load_secret() -> str:
s = os.getenv("AUTH_SECRET_KEY") s = os.getenv("AUTH_SECRET_KEY")
if s: if s:
return s return s
# No key configured. In production (a real database is configured via # No secret configured: generate an ephemeral one so the app still runs in
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign # dev. Sessions won't survive a restart, and this is unsafe across multiple
# sessions with a throwaway key that silently rotates on every restart. In # workers — production must set AUTH_SECRET_KEY.
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
# runs zero-config.
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
# condition db.py uses to pick Postgres, so we don't wrongly block a
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
is_prod = not str(DATABASE_URL).startswith("sqlite")
if is_prod:
raise RuntimeError(
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
"(see server/.env.example / DEPLOYMENT.md)."
)
log.warning( log.warning(
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. " "AUTH_SECRET_KEY is not set — using a random ephemeral key. "
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev." "Logins will reset on restart and break across multiple workers. "
"Set AUTH_SECRET_KEY in the environment for production."
) )
return secrets.token_urlsafe(48) return secrets.token_urlsafe(48)
@@ -126,7 +92,6 @@ def create_token(user: "models.User") -> str:
"sub": user.id, "sub": user.id,
"username": user.username, "username": user.username,
"role": user.role, "role": user.role,
"ver": user.token_version or 0,
"iat": now, "iat": now,
"exp": now + timedelta(hours=SESSION_HOURS), "exp": now + timedelta(hours=SESSION_HOURS),
} }
@@ -198,10 +163,6 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
user = db.get(models.User, claims.get("sub")) user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
# Session revocation: a mismatch means the token was invalidated (e.g. the
# password was changed after this token was issued).
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return user return user

View File

@@ -30,16 +30,15 @@ def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}" return f"user_{uuid.uuid4().hex[:12]}"
def _prompt_password(provided: str | None, username: str = "") -> str: def _prompt_password(provided: str | None) -> str:
pw = provided pw = provided
if not pw: if not pw:
pw = getpass.getpass("New password: ") pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ") confirm = getpass.getpass("Confirm password: ")
if pw != confirm: if pw != confirm:
sys.exit("Passwords do not match.") sys.exit("Passwords do not match.")
problem = auth.password_problem(pw, username) if len(pw) < 8:
if problem: sys.exit("Password must be at least 8 characters.")
sys.exit(problem)
return pw return pw
@@ -47,7 +46,7 @@ def cmd_create(args, role: str | None = None) -> None:
role = role or args.role role = role or args.role
if role not in ("admin", "user"): if role not in ("admin", "user"):
sys.exit("role must be 'admin' or 'user'") sys.exit("role must be 'admin' or 'user'")
pw = _prompt_password(getattr(args, "password", None), args.username) pw = _prompt_password(getattr(args, "password", None))
with SessionLocal() as db: with SessionLocal() as db:
if auth.find_user(db, args.username): if auth.find_user(db, args.username):
sys.exit(f"A user named '{args.username}' already exists.") sys.exit(f"A user named '{args.username}' already exists.")
@@ -76,7 +75,7 @@ def cmd_list(args) -> None:
def cmd_reset_password(args) -> None: def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username) pw = _prompt_password(getattr(args, "password", None))
with SessionLocal() as db: with SessionLocal() as db:
u = auth.find_user(db, args.username) u = auth.find_user(db, args.username)
if not u: if not u:

View File

@@ -92,13 +92,7 @@ class WorkPackage(Base):
subject: Mapped[str] = mapped_column(String(400), default="") subject: Mapped[str] = mapped_column(String(400), default="")
type: Mapped[str] = mapped_column(String(120), default="") type: Mapped[str] = mapped_column(String(120), default="")
status: Mapped[str] = mapped_column(String(40), default="Draft") status: Mapped[str] = mapped_column(String(40), default="Draft")
# The accountable owner (a user id), for "My Work Packages" + assignment
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Archived packages are hidden from the default lists/dashboard but kept for
# the record (years-long projects accumulate hundreds of closed WPs).
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
data: Mapped[dict] = mapped_column(JSON, default=dict) data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="") created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -108,9 +102,7 @@ class WorkPackage(Base):
return { return {
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id, "id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
"parent_id": self.parent_id, "number": self.number, "subject": self.subject, "parent_id": self.parent_id, "number": self.number, "subject": self.subject,
"type": self.type, "status": self.status, "assignee_id": self.assignee_id, "type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
"issued_at": _iso(self.issued_at),
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
"created_by": self.created_by, "created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at), "created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
} }
@@ -135,12 +127,6 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Bumped to invalidate all existing sessions for this user (e.g. on a password
# change). The value is embedded in the JWT and re-checked on every request.
token_version: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Public view of a user — NEVER includes the password hash.""" """Public view of a user — NEVER includes the password hash."""
@@ -190,74 +176,5 @@ class Comment(Base):
} }
class AuditLog(Base):
"""Append-only history: who changed what, when. Rows are written inside the
same transaction as the change they describe (see server/app.py: log_event),
so the trail can't drift from the data. `detail` holds a compact JSON summary
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
Not a ForeignKey to any entity on purpose — the log must survive the deletion
of the thing it describes (you still want "who deleted WP01, and when")."""
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
"entity_type": self.entity_type, "entity_id": self.entity_id,
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
}
class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
the SMTP password are NOT stored here — they come from the environment."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
value: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class Notification(Base):
"""Outbox for user notifications (an in-app record + an optional email). A row
is written when something notable happens (e.g. a WP assignment); the email
sender processes it only when email notifications are enabled AND SMTP is set —
otherwise it's recorded as 'skipped'. See server/notify.py."""
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
email: Mapped[str] = mapped_column(String(200), default="")
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
subject: Mapped[str] = mapped_column(String(300), default="")
body: Mapped[str] = mapped_column(Text, default="")
link: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
error: Mapped[str] = mapped_column(String(400), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
"status": self.status, "error": self.error,
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
}
def _iso(dt: Optional[datetime]) -> Optional[str]: def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None return dt.isoformat() if dt else None

View File

@@ -1,134 +0,0 @@
"""Notifications: admin-configurable email + an outbox.
Email notifications are OFF by default and controlled from the admin console (a
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
and is NEVER stored in the database or shown in the UI.
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
record — and, when email is on + SMTP is set, the row is delivered by email in a
background task. Notification bodies deliberately avoid customer IP: they carry a WP
number and a deep link, not the work-package contents.
"""
import os
import smtplib
import uuid
import logging
from email.message import EmailMessage
from typing import Optional
from sqlalchemy.orm import Session
from . import models
log = logging.getLogger("wpsuite.notify")
SETTINGS_KEY = "notifications"
DEFAULTS = {
"email_enabled": False, # master toggle — OFF until SMTP is sorted
"smtp_host": "",
"smtp_port": 587,
"smtp_use_tls": True,
"smtp_username": "",
"from_addr": "",
"from_name": "Work Package Suite",
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
}
def get_settings(db: Session) -> dict:
row = db.get(models.AppSetting, SETTINGS_KEY)
s = dict(DEFAULTS)
if row and row.value:
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
return s
def save_settings(db: Session, patch: dict) -> dict:
cur = get_settings(db)
for k in DEFAULTS:
if k in patch and patch[k] is not None:
cur[k] = patch[k]
row = db.get(models.AppSetting, SETTINGS_KEY)
if row:
row.value = cur
else:
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
db.commit()
return cur
def public_settings(db: Session) -> dict:
"""Settings safe to return to the admin UI — no secrets."""
s = get_settings(db)
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
return s
def smtp_ready(s: dict) -> bool:
return bool(s.get("smtp_host") and s.get("from_addr"))
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
"""Send one email via SMTP. Raises on any failure (caller records it)."""
if not to_addr:
raise ValueError("no recipient email")
msg = EmailMessage()
from_name = s.get("from_name") or ""
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
msg["To"] = to_addr
msg["Subject"] = subject
msg.set_content(body)
host = s["smtp_host"]
port = int(s.get("smtp_port") or 587)
user = s.get("smtp_username") or ""
pw = os.getenv("SMTP_PASSWORD", "")
with smtplib.SMTP(host, port, timeout=15) as srv:
if s.get("smtp_use_tls", True):
srv.starttls()
if user:
srv.login(user, pw)
srv.send_message(msg)
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
commit — the caller commits with its own transaction. Returns the row."""
s = get_settings(db)
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
n = models.Notification(
id="ntf_" + uuid.uuid4().hex[:12],
user_id=user.id, email=user.email or "", kind=kind,
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
link=link[:500], status="pending" if deliverable else "skipped",
)
db.add(n)
return n
def deliver(notif_id: str) -> None:
"""Background task: send one pending notification, on its own DB session."""
from .db import SessionLocal
db = SessionLocal()
try:
n = db.get(models.Notification, notif_id)
if not n or n.status != "pending":
return
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
n.status = "skipped"
db.commit()
return
try:
send_email(s, n.email, n.subject, n.body)
n.status = "sent"
n.sent_at = models.utcnow()
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
n.status = "failed"
n.error = str(e)[:400]
log.warning("notification %s failed to send: %s", notif_id, e)
db.commit()
finally:
db.close()

View File

@@ -1,16 +1,9 @@
# Pinned to exact versions for reproducible builds — no silent dependency drift fastapi>=0.110
# on every `docker compose up --build`. To update: bump a version here on purpose, uvicorn[standard]>=0.29
# run `pip-audit` against the result, and test. For supply-chain integrity, the gunicorn>=21.2
# next step is a hashed lockfile (`pip-compile --generate-hashes` → install with sqlalchemy>=2.0
# `pip install --require-hashes`). psycopg[binary]>=3.1
fastapi==0.138.1 pydantic>=2.6
uvicorn[standard]==0.49.0 python-dotenv>=1.0
gunicorn==26.0.0 bcrypt>=4.1 # password hashing
sqlalchemy==2.0.51 PyJWT>=2.8 # signed session tokens
alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4
pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)