Compare commits
38 Commits
savepoint-
...
39b48055ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 39b48055ff | |||
| dd37f1f551 | |||
| afdc815fb4 | |||
| ca4176ac00 | |||
| 0e698438a2 | |||
| 566ace9969 | |||
| eefa76e460 | |||
| 5ad3ffa58e | |||
| bdb798efdd | |||
| 151ccea0ac | |||
| 1f8c23c9bb | |||
| 20afb0e565 | |||
| deaf13c724 | |||
| a02f7ec511 | |||
| c010bc22a0 | |||
| 66da5b708a | |||
| 1e31aa535e | |||
| ca8c36a889 | |||
| 39230adf07 | |||
| 2bdb65e580 | |||
| e3ef3b0023 | |||
| c64b5c8b49 | |||
| a32c275f76 | |||
| e5f77846ad | |||
| 561d4f2408 | |||
| a18ae487f6 | |||
| 68c1c803d6 | |||
| e5c450597a | |||
| 3c40b58ff8 | |||
| 4d111d608d | |||
| a37cf14e89 | |||
| 362aa633ed | |||
| fd668f0ea2 | |||
| 960b4a4b94 | |||
| 8598606165 | |||
| 65996c2c0a | |||
| b0a3d74412 | |||
| ffcaa571d1 |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -11,3 +11,20 @@ venv/
|
||||
# Local SQLite dev database
|
||||
*.db
|
||||
wpsuite.db
|
||||
|
||||
# Runtime directories (created by containers)
|
||||
logs/
|
||||
|
||||
# Database backup dumps (large + sensitive) — keep the folder, ignore contents
|
||||
/backups/*
|
||||
!/backups/.gitkeep
|
||||
|
||||
# Local server logs
|
||||
*.log
|
||||
|
||||
# Local scratch / test artifacts (curl cookie jars hold live session tokens)
|
||||
_*.txt
|
||||
cookies.txt
|
||||
|
||||
# Claude Code local workspace (agent memory, session data)
|
||||
.claude/
|
||||
|
||||
103
DEPLOY-login-portal.md
Normal file
103
DEPLOY-login-portal.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Deploy: Work Package Suite — login portal update
|
||||
|
||||
Instructions for the **Portainer admin** to take the new secure login portal live.
|
||||
No prior context needed.
|
||||
|
||||
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
||||
|
||||
**What changed:** the app now has a username/password login. Going live needs:
|
||||
1. one new environment variable,
|
||||
2. a **rebuild** of the stack (not just a restart), and
|
||||
3. creating the first admin account.
|
||||
|
||||
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
||||
> bake the code in at build time (`COPY html/` and `COPY server/` in their
|
||||
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
|
||||
> be **rebuilt** from the latest `main`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Add an environment variable to the stack
|
||||
|
||||
In the stack's **Environment variables** section, add:
|
||||
|
||||
| Name | Value | Notes |
|
||||
|------|-------|-------|
|
||||
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
|
||||
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
|
||||
|
||||
Generate the secret on the host with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
|
||||
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
|
||||
> per-process key — logins then reset on every restart and break across the 2 gunicorn
|
||||
> workers. It must be set to a fixed value.
|
||||
|
||||
The existing database variables (`POSTGRES_*`) are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pull latest `main`, rebuild, and redeploy
|
||||
|
||||
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
|
||||
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
|
||||
`api` images.
|
||||
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
|
||||
automatically during the rebuild.
|
||||
- The `users` table is created automatically on API startup — **no DB migration needed.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the containers
|
||||
|
||||
- Confirm `wp_api` and the webserver container are both **running**.
|
||||
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
|
||||
warning — it won't crash — but please confirm it's set.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Create the first admin account
|
||||
|
||||
The login system needs one admin user in the production (Postgres) database. Open the
|
||||
**`wp_api`** container's **Console** (`/bin/sh`) and run:
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin <username> --name "<Full Name>"
|
||||
```
|
||||
|
||||
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
|
||||
|
||||
Non-interactive alternative:
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
|
||||
```
|
||||
|
||||
Other CLI commands (run the same way): `list`, `create <user> --role user`,
|
||||
`reset-password <user>`, `disable <user>`, `enable <user>`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Confirm it works
|
||||
|
||||
1. Load the site's normal URL — it should redirect to a **login page**.
|
||||
2. Sign in with the admin account from step 4.
|
||||
3. That admin can then add all other users from the in-app **Admin → User
|
||||
administration** page (top-right **Admin** link), so no further shell access is needed.
|
||||
|
||||
---
|
||||
|
||||
## Reference — what's in this release
|
||||
|
||||
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
|
||||
- `server/app.py` — `/api/auth/*` endpoints + middleware that refuses every `/api` data
|
||||
route without a valid session.
|
||||
- `server/manage_users.py` — the CLI used in step 4.
|
||||
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
|
||||
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
|
||||
administration UI.
|
||||
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
|
||||
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).
|
||||
352
DEPLOYMENT.md
352
DEPLOYMENT.md
@@ -1,81 +1,323 @@
|
||||
# Deployment
|
||||
|
||||
The Work Package Suite has two parts:
|
||||
Audience: the IT admin standing this up inside the firewall. This covers the
|
||||
**SQL-backed deployment** — NGINX serving the static front end and a Python API
|
||||
backed by **PostgreSQL**.
|
||||
|
||||
- a **static front end** (plain HTML/CSS/JS — no build step), and
|
||||
- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project
|
||||
SOPs, Work Packages, and comments so they are shared across users instead of
|
||||
living in each person's browser.
|
||||
The repo already contains everything needed to run it as a Docker stack:
|
||||
`Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in
|
||||
`html/`, and the API in `server/`. The detailed container reference (endpoints,
|
||||
password rotation, day-to-day commands) lives in
|
||||
[`server/README.md`](server/README.md) — this doc is the start-to-finish guide.
|
||||
|
||||
```
|
||||
browser → NGINX ──serves──> static site (index.html, …)
|
||||
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||
[ your TLS reverse proxy / traefik ] ← HTTPS terminates here
|
||||
│ (external "proxy" network)
|
||||
┌────▼────┐ internal network ┌──────────┐ ┌────────────┐
|
||||
browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │
|
||||
│ (html/) │ │ FastAPI │ │ (db) │
|
||||
└─────────┘ └──────────┘ └────────────┘
|
||||
```
|
||||
|
||||
Everything runs inside your firewall; the app makes **no outbound internet
|
||||
calls** (the logo and scripts are local and the old Google-Fonts dependency was
|
||||
removed).
|
||||
calls** (logo and scripts are local).
|
||||
|
||||
## 1. Front end (NGINX)
|
||||
> **Architecture note:** all static files live under **`html/`** and are *baked
|
||||
> into the nginx image* at build time (not bind-mounted). So after any front-end
|
||||
> change you rebuild the `webserver` image (see *Updating* below). The API image
|
||||
> is built from the root `Dockerfile`.
|
||||
|
||||
Copy the project files to a web root and serve them over HTTPS. The provided
|
||||
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
|
||||
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
|
||||
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
---
|
||||
|
||||
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package
|
||||
Creator (`<iframe>`) and any browser-side caching behave reliably.
|
||||
## 1. Prerequisites
|
||||
|
||||
## 2. API + database
|
||||
- A Linux host with **Docker** and **Docker Compose v2** (`docker compose …`).
|
||||
- An external Docker network named `proxy` that your TLS-terminating reverse
|
||||
proxy also sits on (the compose file marks it `external: true`):
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
If you don't run a separate reverse proxy, you can instead publish the nginx
|
||||
container's port 80 directly (see the note in step 4) and terminate TLS there.
|
||||
- The repository checked out on the host.
|
||||
|
||||
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in
|
||||
[`server/README.md`](server/README.md). In short:
|
||||
## 2. Create the database credentials (`.env`)
|
||||
|
||||
1. Create the `wpsuite` Postgres database/user.
|
||||
2. `pip install -r server/requirements.txt` into a venv.
|
||||
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
|
||||
4. Tables are created automatically on first start.
|
||||
Create a file named `.env` in the **project root** (same folder as
|
||||
`docker-compose.yml`). It is git-ignored and must never be committed.
|
||||
|
||||
Interactive API docs are at `/api/docs` once it's running.
|
||||
```bash
|
||||
# .env — project root
|
||||
POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
POSTGRES_PASSWORD=<strong-random-password>
|
||||
|
||||
## 3. Comments / feedback
|
||||
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
|
||||
# out and the API refuses to start. Generate once and keep it stable:
|
||||
# openssl rand -base64 48
|
||||
AUTH_SECRET_KEY=<strong-random-secret>
|
||||
|
||||
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
|
||||
posts to `/api/feedback`, which the API stores in the `comments` table. The
|
||||
**Export / Import** buttons remain as an offline fallback — a reviewer can export
|
||||
a JSON file and someone can import/merge it — but with the API running, comments
|
||||
are collected centrally with no manual steps.
|
||||
# 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>
|
||||
|
||||
> The earlier Power Automate route is **no longer needed** — comments go straight
|
||||
> to Postgres. If you still want a Power App view, point a Power App at the
|
||||
> Postgres `comments` table via the on-prem data gateway, or have a flow read the
|
||||
> table; no change to this app is required.
|
||||
|
||||
### Comment payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"app": "Work Package Suite",
|
||||
"page": "/work-package-suite.html",
|
||||
"submittedAt": "2026-06-15T18:20:00.000Z",
|
||||
"type": "sop_step_comment",
|
||||
"name": "J. Park",
|
||||
"text": "Consider adding a fiber WP type",
|
||||
"step": 4
|
||||
}
|
||||
# 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>
|
||||
```
|
||||
|
||||
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`. The
|
||||
API maps `name`/`author` → the comment author and keeps any extra fields in the
|
||||
row's `extra` JSON column.
|
||||
The API builds its own DB connection string from the `POSTGRES_*`
|
||||
values and **encodes the password automatically**, so a password with special
|
||||
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
|
||||
is **optional** and only needed if you want to point the API at some other
|
||||
database; if you do set it, you must URL-encode the password yourself, and it's
|
||||
ignored whenever the three `POSTGRES_*` values are present.
|
||||
|
||||
Generate a strong password with `openssl rand -base64 32`.
|
||||
|
||||
> **Portainer note:** for a Git-based stack these go in the stack's
|
||||
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
||||
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
||||
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
|
||||
|
||||
These are the only credentials in the system, and they never appear in the
|
||||
compose file or in git.
|
||||
|
||||
## 3. Point your reverse proxy at the nginx container
|
||||
|
||||
The nginx container listens on port **80** on the `proxy` network and expects
|
||||
TLS to be terminated upstream (by your reverse proxy / traefik). Route your
|
||||
chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
|
||||
container on that network. The container already proxies `/api/` to the `api`
|
||||
service internally — no extra app config needed.
|
||||
|
||||
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
|
||||
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
|
||||
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
|
||||
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
|
||||
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
|
||||
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
|
||||
> reached over HTTPS.
|
||||
|
||||
## 4. Bring it up
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build # builds the api + nginx images, starts all three containers
|
||||
docker compose ps # confirm nginx_webserver, wp_api, wp_db are running/healthy
|
||||
docker compose logs -f api # watch the API start (Ctrl-C to stop following)
|
||||
```
|
||||
|
||||
The database schema is **created automatically** on first API start — no manual
|
||||
`CREATE TABLE`. The Postgres data lives in the named volume `pgdata` and
|
||||
survives `docker compose down` (only `down -v` deletes it).
|
||||
|
||||
> No separate reverse proxy? Publish nginx directly by adding a `ports:` mapping
|
||||
> to the `webserver` service (e.g. `"8080:80"`) and terminate TLS at whatever
|
||||
> sits in front of it. The internal `api`/`db` containers should **never** be
|
||||
> published.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
```bash
|
||||
# API liveness (from the host, through the proxy hostname)
|
||||
curl https://wp-suite.company.local/api/health # → {"ok": true}
|
||||
|
||||
# Interactive API docs
|
||||
# https://wp-suite.company.local/api/docs
|
||||
```
|
||||
|
||||
Then load the site in a browser: the home page should prompt to **select or
|
||||
create a project**. Create one, complete an SOP, and confirm a row appears:
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;"
|
||||
```
|
||||
|
||||
### Automated smoke test
|
||||
|
||||
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
|
||||
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
|
||||
cleanup). Stdlib only — no pip/jq.
|
||||
|
||||
```bash
|
||||
# Through the proxy (use --insecure for a self-signed internal cert):
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# Or from inside the api container (hits FastAPI directly):
|
||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
||||
```
|
||||
|
||||
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
|
||||
working. It cleans up after itself (the test project and its SOP/WPs are
|
||||
deleted via cascade); a single tagged test comment remains (there's no comment
|
||||
delete endpoint).
|
||||
|
||||
### Loadable demo project
|
||||
|
||||
`server/seed_demo.py` populates a realistic **DEMO** project (a complete SOP plus
|
||||
a spread of Work Packages: issued, gated, a multi-discipline master with split
|
||||
instances, an overdue one, an over-threshold draft) so there's data to look at.
|
||||
|
||||
```bash
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
|
||||
```
|
||||
|
||||
> **What shows where:** the DEMO **project**, its **SOP**, and its **Work
|
||||
> Packages** are all API/SQL-backed, so they appear in the home-page project
|
||||
> picker and render in the Creator/Dashboard as soon as any user opens the
|
||||
> project. Inspect them at the SQL layer with `smoketest.py` or:
|
||||
> ```bash
|
||||
> docker compose exec db psql -U wpsuite -d wpsuite \
|
||||
> -c "select number, subject, status from work_packages order by number;"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## What is stored in SQL today
|
||||
|
||||
The API + Postgres are the system of record. Everything below is server-stored
|
||||
and shared across every user who opens the project:
|
||||
|
||||
| Data | Stored in PostgreSQL today? |
|
||||
|------|------------------------------|
|
||||
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
|
||||
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
|
||||
| **SOPs** | **Yes** — pulled from `/api/sops` on load and written through on every save. |
|
||||
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). |
|
||||
|
||||
Saves go through a **durable client-side sync outbox**: edits are written to the
|
||||
API immediately, and if the device is offline they queue and retry when it
|
||||
reconnects (4xx rejections are dropped rather than retried forever). The browser
|
||||
cache is only an offline fallback that reconciles through that outbox — so two
|
||||
users on the same project see the same server-stored SOP and Work Packages.
|
||||
|
||||
## Data model (PostgreSQL)
|
||||
|
||||
| Table | Holds | Key columns |
|
||||
|-------|-------|-------------|
|
||||
| `sops` | project SOP baselines | `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||
| `work_packages` | individual IWPs | `sop_id`, `number`, `subject`, `type`, `status`, `data` (full WP JSON) |
|
||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text` |
|
||||
| `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) |
|
||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, login-lockout + `token_version` fields |
|
||||
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
||||
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
|
||||
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
|
||||
|
||||
The complete client document is stored verbatim in each row's `data` column;
|
||||
frequently-listed fields are promoted to real columns for filtering.
|
||||
The complete client document is stored verbatim in each row's `data` JSON
|
||||
column; frequently-listed fields are promoted to real columns for filtering.
|
||||
|
||||
### Endpoints (summary)
|
||||
|
||||
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
|
||||
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
|
||||
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
|
||||
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
|
||||
`GET /api/wps/metrics` ·
|
||||
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
||||
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
||||
under `/api/auth/users` · 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
|
||||
and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Updating after a change
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build webserver # front-end change (html/) — rebuild the baked image
|
||||
docker compose up -d --build api # backend change (server/)
|
||||
```
|
||||
|
||||
## Backups & retention
|
||||
|
||||
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
|
||||
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
|
||||
stack — no cron to set up.
|
||||
|
||||
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env`
|
||||
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many
|
||||
to keep).
|
||||
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
|
||||
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
|
||||
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
|
||||
the passphrase somewhere other than this host; if you lose it the backups can't
|
||||
be restored.
|
||||
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
|
||||
- **Restore (destructive — overwrites current data):**
|
||||
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
|
||||
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
|
||||
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
|
||||
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
|
||||
`internal` network on purpose, so offsite must be pushed from the host.
|
||||
- **Test restores quarterly:** load the latest dump into a throwaway database and
|
||||
confirm it applies. An untested backup is not a backup.
|
||||
|
||||
## Field devices & data at rest
|
||||
|
||||
The field view (PWA) caches a project's Work Packages/SOP in the browser's
|
||||
localStorage so it works offline — i.e. **customer IP sits on the device**.
|
||||
localStorage is not encrypted and is not a security boundary. Signing out clears
|
||||
the cached project data, but for any tablet/phone that opens customer-IP projects:
|
||||
|
||||
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is
|
||||
encrypted by default) and a device passcode.
|
||||
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep
|
||||
the browser profile per-user on shared devices.
|
||||
- Users should **sign out** when handing off a shared device (clears the cache).
|
||||
|
||||
## Email notifications (optional)
|
||||
|
||||
Work-package **owner assignment** works out of the box (in-app only). Optional
|
||||
**email** on assignment is **OFF by default** and is turned on from the **Admin
|
||||
console → Notifications & email** card, where an admin sets the SMTP host / port /
|
||||
TLS / From address and flips the master toggle.
|
||||
|
||||
- The **SMTP password is never stored in the database.** It is read only from the
|
||||
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
|
||||
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
|
||||
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
|
||||
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
|
||||
still recorded in-app (status `skipped`); nothing is sent.
|
||||
- Notification emails carry only a **WP number and a deep link** — never the work
|
||||
package contents — so customer IP stays behind the login.
|
||||
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
||||
|
||||
## Schema migrations (Alembic)
|
||||
|
||||
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
|
||||
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
|
||||
pending migrations automatically**.
|
||||
|
||||
- The **baseline** migration is idempotent: on a fresh database it creates every
|
||||
table; on a database whose tables already exist (made by the old `create_all`)
|
||||
it adopts the schema as-is — no manual `alembic stamp` needed.
|
||||
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
|
||||
migrations-only.
|
||||
- **To change the schema:** edit `server/models.py`, then generate and review a
|
||||
migration before committing:
|
||||
```bash
|
||||
# from the project root (against your dev SQLite or a staging DB)
|
||||
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
|
||||
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
|
||||
```
|
||||
The next `docker compose up -d --build api` applies it in production on startup.
|
||||
|
||||
## Local trial without Postgres
|
||||
|
||||
For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
|
||||
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
|
||||
§ *Local dev*. The front end alone can also be served statically from `html/`
|
||||
(it falls back to browser storage when the API isn't reachable).
|
||||
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is
|
||||
# safe on both fresh and existing databases (the baseline migration adopts an
|
||||
# existing schema, so no manual stamp is needed). `exec` hands PID 1 to gunicorn
|
||||
# for correct signal handling; --preload imports the app once before forking.
|
||||
CMD ["sh", "-c", "alembic -c server/alembic.ini upgrade head && exec gunicorn -k uvicorn.workers.UvicornWorker --preload -b 0.0.0.0:8000 --workers 2 server.app:app"]
|
||||
@@ -130,6 +130,49 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
|
||||
> real data this is fine; once there is, add Alembic (see open question #2) and
|
||||
> migrate rather than relying on `create_all`.
|
||||
|
||||
## Multi-project support
|
||||
|
||||
> **Note on layout:** the IT admin moved all static files into **`html/`** and
|
||||
> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`).
|
||||
> Front-end paths below are under `html/`. `server/` stayed at the repo root.
|
||||
|
||||
The suite is now multi-project. **Projects are the top-level container**; every
|
||||
SOP and Work Package belongs to one.
|
||||
|
||||
- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained
|
||||
`project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/
|
||||
metrics endpoints accept a `project_id` filter.
|
||||
- **Project layer:** [html/project-data.js](html/project-data.js) — a shared,
|
||||
**API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`)
|
||||
that **falls back to a localStorage mirror** (`wp_projects`) when the API is
|
||||
unreachable, plus active-project helpers (`getActive`/`setActive`, stored in
|
||||
`wp_active_project` / `wp_active_project_obj`).
|
||||
- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed;
|
||||
a **Project** picker added. With no projects it offers *Create Project* / *Use
|
||||
Sample Project*; otherwise a dropdown to select. The tool cards stay hidden
|
||||
until a project is active and then carry `&project=<id>`; the hero shows the
|
||||
active project.
|
||||
- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)):
|
||||
reads `?project=<id>`, resolves it via `ProjectData`, shows it in the header,
|
||||
and **prefills the SOP project fields** (step 1) from the project record when
|
||||
empty. Passes `&project` into the WP-creator iframe.
|
||||
- **WP creator:** stamps `projectId` onto every saved package (for API sync).
|
||||
|
||||
**Per-project isolation (done, local):** SOP/WP localStorage keys are now
|
||||
namespaced per active project via `ProjectData.key(base)` →
|
||||
`base + '__' + <projectId>` (`SK()` in the suite, `wpKey()` in the creator).
|
||||
So each project keeps its own `wp_suite_sop` / `wp_suite_state` /
|
||||
`wp_suite_sop_complete` / `wp_iwp_v1`. On first load after this change,
|
||||
`project-data.js` runs a **one-time discard** of the legacy un-namespaced keys
|
||||
(guarded by `wp_ns_migrated_v1`) — chosen over migrating, since the local data
|
||||
was throwaway demo content.
|
||||
|
||||
**Still ahead (true Phase 2):** move SOP/WP reads+writes to the API filtered by
|
||||
`project_id` (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`)
|
||||
so projects are shared across users, not just isolated per browser. The
|
||||
endpoints already accept the `project_id` filter; the front end still reads
|
||||
localStorage.
|
||||
|
||||
**Pending — Phase 2: wire the front end to the API**
|
||||
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
||||
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
||||
|
||||
0
backups/.gitkeep
Normal file
0
backups/.gitkeep
Normal file
102
docker-compose.yml
Normal file
102
docker-compose.yml
Normal file
@@ -0,0 +1,102 @@
|
||||
services:
|
||||
|
||||
webserver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: nginx/Dockerfile
|
||||
container_name: nginx_webserver
|
||||
volumes:
|
||||
- nginx_logs:/var/log/nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
networks:
|
||||
- proxy # external — reachable by your reverse proxy / traefik
|
||||
- internal # needs a path to the api container
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: wp_api
|
||||
environment:
|
||||
# Preferred: the API builds its own connection string from these and
|
||||
# encodes the password automatically (no manual URL-encoding needed).
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_HOST: db
|
||||
# Optional full-URL override (must be URL-encoded if used).
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
|
||||
# and the API refuses to start in production without it (see server/auth.py).
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
||||
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||
# Optional — SMTP password for WP-assignment emails. Email is off by
|
||||
# default and enabled from the Admin console; this is the only email
|
||||
# secret and it is never stored in the DB. Leave unset until configured.
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: wp_db
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# 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:
|
||||
pgdata:
|
||||
nginx_logs:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
internal:
|
||||
internal: true # no outbound internet access from api/db
|
||||
199
html/admin.html
Normal file
199
html/admin.html
Normal file
@@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Console — Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
|
||||
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
|
||||
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd; --mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace; }
|
||||
*{ 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); }
|
||||
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
|
||||
h1{ font-size:20px; margin:0 0 2px; }
|
||||
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
|
||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:18px 20px; margin-bottom:16px; }
|
||||
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
|
||||
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; padding:8px 14px; cursor:pointer;
|
||||
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
|
||||
button:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
button.primary:hover{ background:#1e54bb; color:#fff; }
|
||||
button.danger{ border-color:var(--red); color:var(--red); }
|
||||
button.danger:hover{ background:var(--red-bg); }
|
||||
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
||||
.banner{ padding:10px 14px; border-radius:0; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
|
||||
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
|
||||
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
|
||||
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:12px 14px; font-family:var(--mono);
|
||||
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
|
||||
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
|
||||
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
|
||||
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
|
||||
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
|
||||
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
|
||||
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
|
||||
.gate-box h2{ margin:0 0 4px; font-size:17px; }
|
||||
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
|
||||
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:12px; }
|
||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
|
||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:16px; }
|
||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
|
||||
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
|
||||
border-radius:0; background:#fff; color:var(--text); }
|
||||
.urow input{ flex:1; min-width:130px; }
|
||||
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
|
||||
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
|
||||
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||
table.users tr:last-child td{ border-bottom:none; }
|
||||
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
|
||||
.tag.admin{ background:#edf5ff; color:#0f62fe; } .tag.user{ background:#e8e8e8; color:#525252; }
|
||||
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
|
||||
button.mini{ padding:4px 9px; font-size:12px; }
|
||||
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- SHARED DARK APP BAR -->
|
||||
<header class="wp-appbar">
|
||||
<a href="index.html" class="wp-appbar-brand" title="Back to site">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
|
||||
<div class="wrap" id="admin-denied" style="display:none">
|
||||
<div class="card">
|
||||
<h2>Admins only</h2>
|
||||
<p class="sub" style="margin:0 0 12px">Your account doesn’t have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
|
||||
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONSOLE -->
|
||||
<div class="wrap" id="admin-main" style="display:none">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics & tests · talks to <code>/api</code> on this host</div></div>
|
||||
<div class="row"><a class="home" href="index.html">← Site</a></div>
|
||||
</div>
|
||||
|
||||
<!-- CONNECTIVITY -->
|
||||
<div class="card">
|
||||
<h2>API connectivity</h2>
|
||||
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
|
||||
<div class="banner" id="health-banner">—</div>
|
||||
</div>
|
||||
|
||||
<!-- USER ADMINISTRATION -->
|
||||
<div class="card">
|
||||
<h2>User administration</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
|
||||
<div id="users-banner"></div>
|
||||
<div id="users-table" style="margin-top:12px"></div>
|
||||
|
||||
<h2 style="margin-top:22px">Add a user</h2>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password">
|
||||
<button class="primary" onclick="createUser()">Create user</button>
|
||||
</div>
|
||||
<div id="users-create-msg" class="note"></div>
|
||||
</div>
|
||||
|
||||
<!-- NOTIFICATIONS / EMAIL -->
|
||||
<div class="card">
|
||||
<h2>Notifications & 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 -->
|
||||
<div class="card">
|
||||
<h2>All feedback & comments</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page & step) when they commented.</div>
|
||||
<div class="row">
|
||||
<button onclick="loadComments()">Refresh comments</button>
|
||||
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
|
||||
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
|
||||
</div>
|
||||
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</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 -->
|
||||
<div class="card">
|
||||
<h2>Usage logs</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
|
||||
<div class="row">
|
||||
<button onclick="loadUsage()">Refresh</button>
|
||||
<button onclick="downloadUsage()">Download JSON</button>
|
||||
</div>
|
||||
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
||||
</div>
|
||||
|
||||
<!-- DB SNAPSHOT -->
|
||||
<div class="card">
|
||||
<h2>Database snapshot</h2>
|
||||
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
|
||||
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
|
||||
</div>
|
||||
|
||||
<!-- SMOKE TEST -->
|
||||
<div class="card">
|
||||
<h2>End-to-end smoke test</h2>
|
||||
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
|
||||
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
|
||||
<pre class="out" id="smoke-out">Ready.</pre>
|
||||
</div>
|
||||
|
||||
<!-- DEMO DATA -->
|
||||
<div class="card">
|
||||
<h2>Demo data</h2>
|
||||
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
|
||||
<div class="row">
|
||||
<button class="primary" onclick="seedDemo()">Seed demo project</button>
|
||||
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
|
||||
</div>
|
||||
<pre class="out" id="demo-out">Ready.</pre>
|
||||
<div class="note">Note: the seeded <strong>project</strong> appears in the home picker; its SOP/WPs live in SQL but won't render in the Creator/Dashboard until the front end is wired to the API (Phase 2).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
522
html/admin.js
Normal file
522
html/admin.js
Normal file
@@ -0,0 +1,522 @@
|
||||
/* Admin console for the Work Package Suite.
|
||||
Browser-side diagnostics + tests that call the same /api on this host.
|
||||
|
||||
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||
already requires a login (redirecting to login.html otherwise) and publishes
|
||||
window.WP_USER; here we show the console only when that user is an admin, and
|
||||
show an "Admins only" notice otherwise. Every user-management API is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
|
||||
|
||||
function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadSettings();
|
||||
loadNotifications();
|
||||
loadComments();
|
||||
loadAudit();
|
||||
loadUsage();
|
||||
}
|
||||
function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
}
|
||||
|
||||
// ── api helper ──────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body){
|
||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||
try {
|
||||
const r = await fetch(path, opt);
|
||||
const t = await r.text();
|
||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||
return { status:r.status, json };
|
||||
} catch(e){ return { status:0, json:String(e) }; }
|
||||
}
|
||||
|
||||
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||
async function checkHealth(){
|
||||
const b = document.getElementById('health-banner');
|
||||
b.className='banner'; b.textContent='Checking…';
|
||||
const { status, json } = await api('GET','/api/health');
|
||||
if(status===200 && json && json.ok){
|
||||
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
|
||||
} else if(status===404){
|
||||
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
||||
} else if(status===0){
|
||||
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
|
||||
} else {
|
||||
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
|
||||
}
|
||||
}
|
||||
|
||||
// ── db snapshot ───────────────────────────────────────────────────────────────
|
||||
async function snapshot(){
|
||||
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
||||
const [p,s,w,c] = await Promise.all([
|
||||
api('GET','/api/projects'), api('GET','/api/sops'),
|
||||
api('GET','/api/wps'), api('GET','/api/comments')]);
|
||||
if(p.status!==200){
|
||||
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
|
||||
}
|
||||
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
|
||||
out.innerHTML = `<table class="kv">
|
||||
<tr><th>Projects</th><td>${n(p)}</td></tr>
|
||||
<tr><th>SOPs</th><td>${n(s)}</td></tr>
|
||||
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
|
||||
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
|
||||
}
|
||||
|
||||
// ── smoke test ────────────────────────────────────────────────────────────────
|
||||
function smLog(html){ const o=document.getElementById('smoke-out'); o.innerHTML += html + '\n'; o.scrollTop=o.scrollHeight; }
|
||||
async function runSmokeTest(){
|
||||
const o=document.getElementById('smoke-out'); o.innerHTML=''; let pass=0, fail=0, pid=null;
|
||||
const chk=(name,cond,detail)=>{ if(cond){ pass++; smLog('<span class="p">PASS</span> '+name); }
|
||||
else { fail++; smLog('<span class="f">FAIL</span> '+name+(detail?' ('+detail+')':'')); } return cond; };
|
||||
try {
|
||||
let r = await api('GET','/api/health');
|
||||
if(!chk('health endpoint ok', r.status===200 && r.json && r.json.ok, 'status '+r.status)){
|
||||
smLog('\nAborting — API unreachable (fix /api/ routing).'); return finishSmoke(pass,fail);
|
||||
}
|
||||
r = await api('POST','/api/projects',{name:'ZZ Smoke Test Project',number:'SMOKE-001',client:'Internal QA',created_by:'admin-console'});
|
||||
pid = r.json && r.json.id; chk('create project', r.status===200 && !!pid, 'status '+r.status);
|
||||
r = await api('GET','/api/projects/'+pid); chk('fetch project by id', r.status===200 && r.json.number==='SMOKE-001');
|
||||
r = await api('GET','/api/projects'); chk('project in list', r.status===200 && r.json.some(p=>p.id===pid));
|
||||
r = await api('POST','/api/sops',{project_id:pid,name:'ZZ Smoke SOP',number:'SMOKE-001',complete:true,data:{governance:{disciplines:['Mechanical','Electrical','Tech']}}});
|
||||
const sid = r.json && r.json.id; chk('create SOP linked to project', r.status===200 && !!sid && r.json.project_id===pid);
|
||||
r = await api('GET','/api/sops/latest?project_id='+pid); chk('latest SOP resolves', r.status===200 && r.json.id===sid);
|
||||
r = await api('POST','/api/wps',{project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'open',comment:'awaiting delivery'},{name:'Safety',status:'cleared',comment:''}]}});
|
||||
const wid = r.json && r.json.id; chk('create work package', r.status===200 && !!wid);
|
||||
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue blocked while a constraint is open (409)', r.status===409, 'status '+r.status);
|
||||
await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'cleared',comment:''},{name:'Safety',status:'cleared',comment:''}]}});
|
||||
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue succeeds once cleared', r.status===200 && r.json.status==='Issued', 'status '+r.status);
|
||||
chk('issued_at timestamp set', !!(r.json && r.json.issued_at));
|
||||
r = await api('POST','/api/wps/'+wid+'/status',{status:'In Progress'}); chk('status transition', r.status===200 && r.json.status==='In Progress');
|
||||
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
|
||||
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
|
||||
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
|
||||
} catch(e){ chk('unexpected error', false, String(e)); }
|
||||
finally {
|
||||
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
|
||||
finishSmoke(pass,fail);
|
||||
}
|
||||
}
|
||||
function finishSmoke(pass,fail){
|
||||
const total=pass+fail;
|
||||
smLog('\n'+pass+'/'+total+' checks passed.');
|
||||
smLog(fail ? '<span class="f">RESULT: FAIL ('+fail+')</span>' : '<span class="p">RESULT: ALL PASS — API, Python logic, and SQL are working.</span>');
|
||||
}
|
||||
|
||||
// ── demo data ─────────────────────────────────────────────────────────────────
|
||||
function demoLog(s){ const o=document.getElementById('demo-out'); o.innerHTML += s + '\n'; o.scrollTop=o.scrollHeight; }
|
||||
function stdConstraints(open){ return ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)']
|
||||
.map(n=>({name:n, status:(open&&open.includes(n))?'open':'cleared', comment:''})); }
|
||||
async function seedDemo(){
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
let r = await api('GET','/api/health');
|
||||
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
|
||||
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
|
||||
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
|
||||
const pid=r.json.id; demoLog('Project created: '+r.json.name);
|
||||
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
|
||||
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
|
||||
const mk=async(num,subj,typ,status,data,parent)=>{ const body={project_id:pid,sop_id:sid,number:num,subject:subj,type:typ,status,created_by:'admin-console',data}; if(parent)body.parent_id=parent; const rr=await api('POST','/api/wps',body); demoLog(' WP '+num+' ['+status+']'); return rr.json; };
|
||||
await mk('WP01-1P-CONDUIT','1P horn/strobe conduit','Conduit Install','Issued',{disciplines:['Electrical'],hours:'40',constraints:stdConstraints(),due:'2026-06-30'});
|
||||
await mk('WP02-1P-WIRE','1P wire pull','Wire Pull','Scheduled',{disciplines:['Electrical'],hours:'60',constraints:stdConstraints(['Materials (on site, bagged & tagged)']),due:'2026-07-04'});
|
||||
const masterId='wp_demo_master_chiller';
|
||||
const kids=[['WP03-CHILLER_Mech','Mechanical','A','Mechanical Install','In Progress'],['WP03-CHILLER_Elec','Electrical','B','Wire Pull','Scheduled'],['WP03-CHILLER_Tech','Tech','C','Terminations','Draft']];
|
||||
const kidIds=[];
|
||||
for(const [num,disc,label,typ,status] of kids){ const id='wp_demo_'+label.toLowerCase(); kidIds.push(id);
|
||||
await api('POST','/api/wps',{id,project_id:pid,sop_id:sid,parent_id:masterId,number:num,subject:'Chiller skid — '+disc,type:typ,status,created_by:'admin-console',data:{disciplines:[disc],instanceOf:masterId,instanceLabel:label,parentNumber:'WP03-CHILLER',hours:'50',constraints:stdConstraints(),due:'2026-07-10'}});
|
||||
demoLog(' WP '+num+' ['+status+'] (instance '+label+')'); }
|
||||
await api('POST','/api/wps',{id:masterId,project_id:pid,sop_id:sid,number:'WP03-CHILLER',subject:'Chiller skid (multi-discipline master)',type:'Mechanical Install',status:'Scheduled',created_by:'admin-console',data:{disciplines:['Mechanical','Electrical','Tech'],split:true,children:kidIds,hours:'150',constraints:stdConstraints(),due:'2026-07-10'}});
|
||||
demoLog(' WP WP03-CHILLER [master, split into A/B/C]');
|
||||
await mk('WP04-2P-TERM','2P terminations','Terminations','In Progress',{disciplines:['Tech'],hours:'30',actualHrs:'20',constraints:stdConstraints(),due:'2026-06-10'});
|
||||
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
|
||||
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
||||
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
|
||||
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
|
||||
snapshot();
|
||||
}
|
||||
async function cleanDemo(){
|
||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
const r = await api('GET','/api/projects');
|
||||
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
||||
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
||||
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||||
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
||||
demoLog('\n✅ Removed '+targets.length+' project(s).');
|
||||
snapshot();
|
||||
}
|
||||
|
||||
// ── user administration ────────────────────────────────────────────────────────
|
||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
async function currentUserId(){
|
||||
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||||
const { status, json } = await api('GET','/api/auth/me');
|
||||
return (status===200 && json && json.user) ? json.user.id : null;
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const banner=document.getElementById('users-banner');
|
||||
const wrap=document.getElementById('users-table');
|
||||
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status===401){
|
||||
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
}
|
||||
banner.style.display='none';
|
||||
const meId = await currentUserId();
|
||||
renderUsers(json, meId);
|
||||
}
|
||||
|
||||
function renderUsers(list, meId){
|
||||
const wrap=document.getElementById('users-table');
|
||||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
let rows = list.map(u=>{
|
||||
const me = u.id===meId;
|
||||
const active = u.is_active;
|
||||
const disableBtn = me
|
||||
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||||
: '<button class="mini" onclick="toggleActive(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||||
const delBtn = me
|
||||
? ''
|
||||
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
|
||||
// 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 user’s 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>'+
|
||||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||
'<td>'+uesc(u.email||'')+'</td>'+
|
||||
'<td>'+roleCell+'</td>'+
|
||||
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
|
||||
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
|
||||
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
|
||||
disableBtn+delBtn+
|
||||
'</div></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
wrap.innerHTML='<table class="users"><thead><tr>'+
|
||||
'<th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table>';
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const msg=document.getElementById('users-create-msg');
|
||||
const username=document.getElementById('nu-username').value.trim();
|
||||
const full_name=document.getElementById('nu-fullname').value.trim();
|
||||
const email=document.getElementById('nu-email').value.trim();
|
||||
const role=document.getElementById('nu-role').value;
|
||||
const password=document.getElementById('nu-password').value;
|
||||
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||||
if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; }
|
||||
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||||
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,password});
|
||||
if(status===200){
|
||||
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
|
||||
loadUsers();
|
||||
} else {
|
||||
msg.style.color='var(--red)';
|
||||
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPw(id, username){
|
||||
const pw=prompt('New password for "'+username+'" (min 8 characters):');
|
||||
if(pw===null) return;
|
||||
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status===200) alert('Password reset for '+username+'.');
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// 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){
|
||||
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// ── project access assignment ───────────────────────────────────────────────────
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
|
||||
}
|
||||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
function openProjectModal(userId, username, projects, assigned, userObj){
|
||||
closeProjectModal();
|
||||
const isAdmin = userObj && userObj.role==='admin';
|
||||
const items = projects.length ? projects.map(p =>
|
||||
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
|
||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
|
||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
||||
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'proj-modal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
||||
modal.innerHTML =
|
||||
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
|
||||
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
|
||||
'<div id="proj-list">'+items+'</div>'+
|
||||
'</div>'+
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||||
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
|
||||
document.body.appendChild(modal);
|
||||
const saveBtn = document.getElementById('proj-save');
|
||||
if(saveBtn) saveBtn.onclick = async () => {
|
||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||||
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
|
||||
if(status===200) closeProjectModal();
|
||||
else alert('Save failed (HTTP '+status+').');
|
||||
};
|
||||
}
|
||||
|
||||
// ── all feedback / comments ─────────────────────────────────────────────────────
|
||||
let _comments = [];
|
||||
async function loadComments(){
|
||||
const box = document.getElementById('comments-admin');
|
||||
box.textContent = 'Loading…';
|
||||
const { status, json } = await api('GET','/api/comments');
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
box.innerHTML = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; return;
|
||||
}
|
||||
_comments = json;
|
||||
const sel = document.getElementById('cmt-filter'); const cur = sel.value;
|
||||
const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort();
|
||||
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').join('');
|
||||
sel.value = cur;
|
||||
renderComments();
|
||||
}
|
||||
function renderComments(){
|
||||
const box = document.getElementById('comments-admin');
|
||||
const src = document.getElementById('cmt-filter').value;
|
||||
const q = (document.getElementById('cmt-search').value||'').toLowerCase();
|
||||
let rows = _comments.filter(c => (!src || c.source===src) &&
|
||||
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
|
||||
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
|
||||
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const where = c => {
|
||||
const bits = [];
|
||||
if(c.page) bits.push(uesc(c.page));
|
||||
if(c.step!=null) bits.push('step '+c.step);
|
||||
if(c.sop_id) bits.push('SOP '+uesc(c.sop_id));
|
||||
if(c.wp_id) bits.push('WP '+uesc(c.wp_id));
|
||||
return bits.join(' · ') || '—';
|
||||
};
|
||||
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
|
||||
rows.map(c => '<tr>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
|
||||
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
|
||||
'<td>'+uesc(c.source||'—')+'</td>'+
|
||||
'<td style="color:var(--muted)">'+where(c)+'</td>'+
|
||||
'<td>'+uesc(c.text||'')+'</td>'+
|
||||
'</tr>').join('')+'</tbody></table>';
|
||||
}
|
||||
|
||||
// ── 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) ──────────────────────────
|
||||
const USAGE_KEY = 'wp_suite_analytics_v1';
|
||||
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
|
||||
function loadUsage(){
|
||||
const box = document.getElementById('usage-admin');
|
||||
const evs = (usageLoad().events) || [];
|
||||
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
|
||||
const byEvent = {}, byStep = {}, sessions = new Set();
|
||||
let first = evs[0].ts, last = evs[0].ts;
|
||||
evs.forEach(e => {
|
||||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||||
if(e.session) sessions.add(e.session);
|
||||
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
|
||||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||||
});
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
let html = '<table class="kv">'+
|
||||
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
||||
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
||||
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
|
||||
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
|
||||
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
|
||||
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
|
||||
html += '</tbody></table>';
|
||||
box.innerHTML = html;
|
||||
}
|
||||
function downloadUsage(){
|
||||
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
|
||||
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
// ── access control: admins only ─────────────────────────────────────────────────
|
||||
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||||
// Show the console for admins; otherwise show the "Admins only" notice.
|
||||
let _adminGated = false;
|
||||
function gateByRole(){
|
||||
if(_adminGated) return;
|
||||
const u = window.WP_USER;
|
||||
if(!u) return; // not resolved yet — wait for wp-auth-ready
|
||||
_adminGated = true;
|
||||
if(u.role === 'admin') reveal();
|
||||
else showDenied();
|
||||
}
|
||||
document.addEventListener('wp-auth-ready', gateByRole);
|
||||
gateByRole(); // in case WP_USER was already set before this ran
|
||||
213
html/auth-guard.js
Normal file
213
html/auth-guard.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/* Auth guard for the Work Package Suite.
|
||||
Included in the <head> of every protected page (before other scripts). It
|
||||
confirms there is a valid session by calling /api/auth/me; if not, it sends
|
||||
the user to the login page. The real protection is server-side (the API
|
||||
refuses data requests without a session) — this guard is for UX so people
|
||||
land on the login screen instead of an empty app.
|
||||
|
||||
It also exposes:
|
||||
window.WP_USER the logged-in user object (set once verified)
|
||||
window.wpLogout() clears the session and returns to the login page
|
||||
and dispatches a 'wp-auth-ready' event on document once WP_USER is set. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
|
||||
// Register the PWA service worker (caches the app shell for offline use). Only
|
||||
// from the top window; the API and writes are never cached (see sw.js).
|
||||
if (!inIframe && 'serviceWorker' in navigator) {
|
||||
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
|
||||
}
|
||||
|
||||
// Hide the page until we know the user is allowed, to avoid a flash of the app
|
||||
// before a redirect. A safety timer reveals it even if the check hangs.
|
||||
var root = document.documentElement;
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '.wp-auth-pending body{visibility:hidden!important}';
|
||||
(document.head || root).appendChild(style);
|
||||
root.className += ' wp-auth-pending';
|
||||
function reveal() { root.className = root.className.replace(/\bwp-auth-pending\b/, ''); }
|
||||
var safety = setTimeout(reveal, 4000);
|
||||
|
||||
function goToLogin() {
|
||||
clearTimeout(safety);
|
||||
var next = encodeURIComponent(location.pathname + location.search);
|
||||
var url = 'login.html?next=' + next;
|
||||
// If we're inside the WP-creator iframe, redirect the whole window.
|
||||
var w = inIframe ? window.top : window;
|
||||
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
|
||||
}
|
||||
|
||||
window.wpLogout = function () {
|
||||
try {
|
||||
// Clear the auth cache AND all cached project data (customer IP) from this
|
||||
// device on sign-out — important on shared/field tablets. The outbox
|
||||
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
|
||||
// (localStorage is not a security boundary; field devices still need
|
||||
// full-disk encryption / MDM — see DEPLOYMENT.md.)
|
||||
localStorage.removeItem('wp_auth_cache');
|
||||
Object.keys(localStorage).forEach(function (k) {
|
||||
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
|
||||
localStorage.removeItem(k);
|
||||
}
|
||||
});
|
||||
} catch (e) {}
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
.catch(function () {})
|
||||
.then(function () { window.location.replace('login.html'); });
|
||||
};
|
||||
|
||||
// Change-password dialog (uses POST /api/auth/password, which requires the
|
||||
// current password). Available from the top-right pill on any page.
|
||||
window.wpChangePassword = function () {
|
||||
if (document.getElementById('wp-pw-modal')) return;
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'wp-pw-modal';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
||||
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
|
||||
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
|
||||
ov.innerHTML =
|
||||
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
|
||||
'<div style="padding:16px 18px;">' +
|
||||
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
||||
'<label style="' + lbl + '">Current password</label>' +
|
||||
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">New password (at least 8 characters)</label>' +
|
||||
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var el = document.getElementById('wp-pw-msg');
|
||||
el.style.display = 'block'; el.textContent = text;
|
||||
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-pw-cancel').onclick = close;
|
||||
document.getElementById('wp-pw-cur').focus();
|
||||
document.getElementById('wp-pw-save').onclick = function () {
|
||||
var cur = document.getElementById('wp-pw-cur').value;
|
||||
var n1 = document.getElementById('wp-pw-new').value;
|
||||
var n2 = document.getElementById('wp-pw-new2').value;
|
||||
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
||||
if (n1.length < 8) { msg('New password must be at least 8 characters.', false); return; }
|
||||
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
||||
fetch('/api/auth/password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
||||
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
||||
})
|
||||
.catch(function () { msg('Could not reach the server.', false); });
|
||||
};
|
||||
};
|
||||
|
||||
function 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) {
|
||||
if (inIframe) return; // the parent page already shows it
|
||||
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
|
||||
|
||||
// Preferred: drop the menu INTO the top bar so it never floats over the
|
||||
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
|
||||
// the older .header bars alike.
|
||||
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
||||
if (host) {
|
||||
var menu = buildUserMenu(user, isDarkBg(host));
|
||||
// The older .header bars already right-align their own toolbar (via flex:1
|
||||
// or a button's margin-left:auto). A second auto-margin would split the free
|
||||
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
|
||||
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
|
||||
host.appendChild(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for any page with no header bar: a floating pill (as before).
|
||||
var pill = document.createElement('div');
|
||||
pill.id = 'wp-logout-pill';
|
||||
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
|
||||
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' +
|
||||
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
|
||||
pill.appendChild(buildUserMenu(user, false));
|
||||
document.body.appendChild(pill);
|
||||
}
|
||||
|
||||
function proceed(user) {
|
||||
clearTimeout(safety);
|
||||
window.WP_USER = user;
|
||||
reveal();
|
||||
if (window.WP_USER) {
|
||||
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
||||
if (document.body) addLogoutPill(window.WP_USER);
|
||||
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
|
||||
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
||||
return r.json().then(function (data) {
|
||||
var user = data && data.user;
|
||||
// Remember the last good auth so the PWA can open offline. The server is
|
||||
// still the real gate; offline writes queue in the outbox until reconnect.
|
||||
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
|
||||
proceed(user);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
// Offline / API unreachable: fall back to a recent cached auth if present,
|
||||
// so the app (and the field view) still open without a network.
|
||||
try {
|
||||
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
|
||||
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
|
||||
} catch (e) {}
|
||||
goToLogin();
|
||||
});
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
86
html/field.html
Normal file
86
html/field.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<!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>
|
||||
164
html/field.js
Normal file
164
html/field.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/* Field view — a touch-optimized screen for updating a Work Package's status,
|
||||
constraints, and a photo/note log from the work face. Reads the same shared
|
||||
data as the desktop creator (via project-data.js) and saves through the sync
|
||||
outbox, so it works offline and syncs when the network returns. */
|
||||
'use strict';
|
||||
|
||||
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||||
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||||
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();
|
||||
481
html/help.js
Normal file
481
html/help.js
Normal file
@@ -0,0 +1,481 @@
|
||||
/* Shared Help center + tooltip module for the Work Package Suite.
|
||||
Included by the home page, the suite, and the embedded creator. It injects:
|
||||
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
|
||||
- a searchable, multi-topic Help center modal opened via window.openHelp()
|
||||
- a floating "?" launcher on any page that doesn't already have a Help button
|
||||
|
||||
API (unchanged + extended):
|
||||
openHelp() open the help center
|
||||
openHelp('topicId') open and jump to a topic (e.g. openHelp('constraints'))
|
||||
closeHelp() close it
|
||||
Add a Help button anywhere with onclick="openHelp()". */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// ── styles ────────────────────────────────────────────────────────────────
|
||||
var css = `
|
||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
||||
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
|
||||
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
|
||||
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
|
||||
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
|
||||
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
|
||||
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
|
||||
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; }
|
||||
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
|
||||
|
||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
||||
justify-content:center; z-index:10000; padding:4vh 16px; }
|
||||
.ui-help-overlay.open{ display:flex; }
|
||||
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
||||
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; }
|
||||
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
|
||||
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
|
||||
font-size:13px; outline:none; background:#f7f8fa; }
|
||||
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
|
||||
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#525252; line-height:1; }
|
||||
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
||||
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px;
|
||||
cursor:pointer; margin-bottom:1px; }
|
||||
.ui-help-nav a:hover{ background:#eef1f6; }
|
||||
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; }
|
||||
.ui-help-nav a.nohit{ display:none; }
|
||||
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
||||
.ui-help-sec{ margin-bottom:30px; }
|
||||
.ui-help-sec.hide{ display:none; }
|
||||
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; }
|
||||
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; }
|
||||
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
|
||||
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
|
||||
.ui-help-content li{ margin-bottom:5px; }
|
||||
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
|
||||
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; }
|
||||
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
|
||||
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
|
||||
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
|
||||
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
|
||||
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
|
||||
.pill-hold{ background:#fde8e8; color:#c0392b; }
|
||||
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0;
|
||||
font-size:13px; line-height:1.55; margin:10px 0; }
|
||||
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; }
|
||||
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
|
||||
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
|
||||
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
|
||||
box-shadow:0 2px 10px rgba(20,30,50,.28); }
|
||||
.ui-help-fab:hover{ background:#0353e9; }
|
||||
@media (max-width:760px){
|
||||
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
|
||||
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; }
|
||||
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||
.ui-help-head{ flex-wrap:wrap; }
|
||||
}`;
|
||||
var style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// ── content ─────────────────────────────────────────────────────────────────
|
||||
// Each topic: { id, title, body(HTML) }. Order here is the nav order.
|
||||
var TOPICS = [
|
||||
{ id: 'overview', title: 'Getting started', body: `
|
||||
<h3>Getting started</h3>
|
||||
<p>The Work Package Suite turns a project's standard procedure into release-ready <strong>Installation Work Packages (IWPs)</strong>. You work in three stages, always in the same order:</p>
|
||||
<ol>
|
||||
<li><strong>Pick or create a Project</strong> on the home page. Each project keeps its own SOP and its own Work Packages, so you can run many jobs at once.</li>
|
||||
<li><strong>SOP Configuration</strong> — set the project baseline in 10 steps (team, sign-offs, WP types, governance & sizing, quality, platforms, sequence, constraints, sources). Every Work Package inherits these defaults. The Creator stays locked until the SOP is marked complete.</li>
|
||||
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP, clear their constraints, and issue them to the field.</li>
|
||||
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
||||
</ol>
|
||||
<h4>Moving around</h4>
|
||||
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
|
||||
<h4>Quick start</h4>
|
||||
<ol>
|
||||
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
|
||||
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
|
||||
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
|
||||
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
|
||||
</ol>
|
||||
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
|
||||
|
||||
{ id: 'projects', title: 'Projects', body: `
|
||||
<h3>Projects</h3>
|
||||
<p>A <strong>project</strong> is the top-level container — every SOP and Work Package belongs to one. Create or select projects on the home page.</p>
|
||||
<h4>Project fields</h4>
|
||||
<ul>
|
||||
<li><strong>Project Name</strong> (required)</li>
|
||||
<li><strong>Project Number</strong></li>
|
||||
<li><strong>Client</strong></li>
|
||||
<li><strong>Division / Sector</strong></li>
|
||||
<li><strong>Site / Location</strong></li>
|
||||
</ul>
|
||||
<h4>The active project</h4>
|
||||
<p>The <strong>active project</strong> is the one you're currently working in. All SOP and Work Package data is scoped (namespaced) to it, so switching projects loads that project's own configuration and packages — nothing leaks between jobs. Use the <em>change</em> link next to the active project name to switch.</p>
|
||||
<div class="ui-help-callout">Projects are stored centrally via the API and mirrored to your browser, so the suite still works offline; it re-syncs when the connection returns.</div>` },
|
||||
|
||||
{ id: 'sop', title: 'SOP Configuration', body: `
|
||||
<h3>SOP Configuration (10 steps)</h3>
|
||||
<p>The SOP is the project baseline. Walk the 10 steps with <strong>← Back</strong> / <strong>Next →</strong>, or jump using the step indicators. The final step is <strong>✓ SOP Complete</strong> — saving it unlocks the Work Package Creator and turns the home-page card green.</p>
|
||||
<ol>
|
||||
<li><strong>Project Basics</strong> — name, number, client, division/sector, site. Inherited by every WP.</li>
|
||||
<li><strong>Project Team Leadership</strong> — PM, APM, CM, QM, plus any additional members (<em>+ Add Team Member</em>).</li>
|
||||
<li><strong>Required Sign-Off Roles</strong> — Superintendent and Foreman are always required; add optional roles (HSE, Quality Rep, Planner, etc.) with <em>+ Add Role</em>.</li>
|
||||
<li><strong>Work Package Types</strong> — enable the install types this project uses (Conduit Install, Wire Pull, Terminations, …). Enabled types populate the WP type picker.</li>
|
||||
<li><strong>Governance & WP Numbering</strong> — the WP <strong>number format</strong> (e.g. <code>WP##-[Sector]-[TYPE]</code>), issuance strategy, the project's <strong>disciplines</strong>, the <strong>discipline strategy</strong>, and <strong>WP sizing</strong> (see <a data-help-jump="sizing">Sizing</a> and <a data-help-jump="disciplines">Disciplines</a>).</li>
|
||||
<li><strong>Quality & Inspection Strategy</strong> — QC requirement, photo/documentation standard, and hold/witness points.</li>
|
||||
<li><strong>Tracking & Commissioning Platforms</strong> — e.g. CxAlloy, Procore, ACC.</li>
|
||||
<li><strong>Construction Sequence</strong> — the install flow; reorder by dragging (⠿), edit labels, add <em>◆ QC Hold</em> gates or custom steps. These feed the WP "predecessor" picker.</li>
|
||||
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||
<li><strong>Engineering Sources & References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings & Attachments</em>.</li>
|
||||
</ol>
|
||||
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` },
|
||||
|
||||
{ id: 'wps', title: 'Work Packages', body: `
|
||||
<h3>Creating Work Packages</h3>
|
||||
<p>In the Creator, start a package with <strong>+ New</strong> (blank, auto-numbered) or <strong>⧉ Duplicate</strong> (copies a saved package and increments the number). The <strong>WP Number</strong> is built automatically from the SOP number format plus your scope fields, the WP type, and a counter — it's read-only.</p>
|
||||
<h4>Key fields</h4>
|
||||
<ul>
|
||||
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
||||
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
|
||||
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
||||
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
||||
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
||||
<li><strong>Material List</strong> — the bill of materials; import from CSV/Excel or add lines manually.</li>
|
||||
<li><strong>Drawings & Attachments</strong> — documents and SOP source-folder links.</li>
|
||||
<li><strong>Kitting & Material Movement (MIMO)</strong> — kitting status, warehouse owner, move date/location.</li>
|
||||
<li><strong>Constraints</strong> — the release gate (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||
<li><strong>Quality / Hold Points</strong>, <strong>Approvals & Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
|
||||
</ul>
|
||||
<h4>Saving</h4>
|
||||
<p><strong>Save Draft</strong> stores the package; <strong>⚡ Save & View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
|
||||
|
||||
{ id: 'statuses', title: 'Statuses', body: `
|
||||
<h3>Work Package statuses</h3>
|
||||
<table>
|
||||
<tr><th>Status</th><th>Meaning</th></tr>
|
||||
<tr><td><span class="ui-help-pill pill-draft">Draft</span></td><td>Work in progress; not yet released.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-sched">Scheduled</span></td><td>Planned and scheduled; upcoming.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-issued">Issued</span></td><td>Released to the field. Requires <em>all constraints Cleared or N/A</em>.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-prog">In Progress</span></td><td>Actively being worked.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-qc">QC</span></td><td>In quality check / inspection.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-closed">Closed</span></td><td>Completed.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-hold">Issue (Hold)</span></td><td>A constraint reopened after release — work is paused until it's resolved.</td></tr>
|
||||
</table>
|
||||
<div class="ui-help-callout">A package <strong>cannot move to Issued</strong> while any constraint is Open. If a constraint reopens after a package is Issued, its status automatically drops to <strong>Issue (Hold)</strong> and the suite makes you log what happened.</div>
|
||||
<p>On a multi-discipline package, each discipline carries its own status and the overall status <strong>rolls up to the least-advanced discipline</strong> — so a package is never "Closed" while one trade still lags.</p>` },
|
||||
|
||||
{ id: 'constraints', title: 'Constraints & release', body: `
|
||||
<h3>Constraints & release readiness</h3>
|
||||
<p>Constraints are the readiness checklist that gates a package's release to the field. They follow Advanced Work Packaging (AWP Vol II §2.3.2). The standard set:</p>
|
||||
<ol>
|
||||
<li>Safety & Permitting</li><li>Quality Control / Inspection</li><li>IFC Drawings & Specs</li>
|
||||
<li>Schedule</li><li>Materials (on site, bagged & tagged)</li><li>Prefabrication</li>
|
||||
<li>Work Access & Laydown</li><li>Craft Availability</li><li>Construction Equipment & Tools</li>
|
||||
<li>Scaffolding / Access Equipment</li>
|
||||
</ol>
|
||||
<p>Pick which apply (and add custom ones) in <strong>SOP Step 9</strong>. Each constraint on a package has one of three states:</p>
|
||||
<table>
|
||||
<tr><th>State</th><th>Effect</th></tr>
|
||||
<tr><td><strong>Open</strong></td><td>Not yet cleared — <em>blocks release</em>.</td></tr>
|
||||
<tr><td><strong>Cleared</strong></td><td>Requirement met — counts toward release-ready.</td></tr>
|
||||
<tr><td><strong>N/A</strong></td><td>Not applicable to this package — counts as cleared.</td></tr>
|
||||
</table>
|
||||
<h4>The release gate</h4>
|
||||
<ul>
|
||||
<li>A package is <strong>release-ready</strong> when every constraint is Cleared or N/A. The sticky banner shows green when ready, amber when constraints are still open, and red when on hold.</li>
|
||||
<li>When the last open constraint clears, the suite offers to mark the package <strong>Issued</strong>.</li>
|
||||
<li>If a constraint reopens after the package is Issued, you log the hold (what reopened, details, optional doc link & photo) and the status drops to <strong>Issue (Hold)</strong>.</li>
|
||||
</ul>` },
|
||||
|
||||
{ id: 'disciplines', title: 'Disciplines & Split', body: `
|
||||
<h3>Disciplines & Split by Discipline</h3>
|
||||
<p>Disciplines are trades (Mechanical, Electrical, Tech, …) set in <strong>SOP Step 5</strong>. The <strong>discipline strategy</strong> controls how packages handle them:</p>
|
||||
<ul>
|
||||
<li><strong>Let the planner choose per package</strong> (recommended) — pick one discipline (flat scope) or several (per-discipline scope + the <em>Split</em> option).</li>
|
||||
<li><strong>One discipline per package</strong> — each WP is single-discipline.</li>
|
||||
<li><strong>Multiple disciplines per package</strong> — scope is always split by discipline.</li>
|
||||
</ul>
|
||||
<h4>Split by Discipline</h4>
|
||||
<p>When a package covers 2+ disciplines, the <strong>⎘ Split by Discipline</strong> button breaks it into one numbered instance per discipline — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> (or <code>_MECH</code>/<code>_ELEC</code> suffixes, set in the SOP). The original is kept as a <strong>master / roll-up</strong>; each instance:</p>
|
||||
<ul>
|
||||
<li>becomes its own single-discipline package, issued independently;</li>
|
||||
<li>receives only the <strong>scope steps</strong> and <strong>materials tagged to that discipline</strong>;</li>
|
||||
<li>stays linked back to the master.</li>
|
||||
</ul>
|
||||
<div class="ui-help-callout">Tag material rows to a discipline <em>before</em> splitting. <strong>Untagged rows stay on the master only</strong> and won't be routed to any instance. Masters are excluded from dashboard counts so hours aren't double-counted.</div>` },
|
||||
|
||||
{ id: 'sizing', title: 'Sizing', body: `
|
||||
<h3>Work Package sizing</h3>
|
||||
<p>In <strong>SOP Step 5</strong> you set a typical WP <strong>size band</strong>, which sets a <strong>split threshold</strong> (max labor hours):</p>
|
||||
<table>
|
||||
<tr><th>Size band</th><th>Split threshold</th></tr>
|
||||
<tr><td>Small — 1–2 days (≈8–24 hrs)</td><td>24 hrs</td></tr>
|
||||
<tr><td>Standard — 3–5 days (≈40–80 hrs)</td><td>80 hrs</td></tr>
|
||||
<tr><td>Large — 1–2 weeks (≈80–160 hrs)</td><td>160 hrs</td></tr>
|
||||
<tr><td>Custom…</td><td>you set it</td></tr>
|
||||
</table>
|
||||
<p>In the Creator, the <strong>Est. Hrs.</strong> field is checked live against the threshold. Within range you see the target band; over it you get an amber warning — <em>"⚠ … exceeds the …-hr split threshold — consider breaking this package down"</em> — and a nudge to split by discipline where that applies. It's a guide, not a hard block: you can proceed if it's intentional.</p>` },
|
||||
|
||||
{ id: 'dashboard', title: 'Dashboard', body: `
|
||||
<h3>Dashboard & metrics</h3>
|
||||
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p>
|
||||
<h4>Metric cards (click to filter)</h4>
|
||||
<ul>
|
||||
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
|
||||
<li><strong>Est. hrs</strong> and <strong>Actual hrs</strong> (summed)</li>
|
||||
</ul>
|
||||
<h4>Breakdowns & gates</h4>
|
||||
<ul>
|
||||
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
|
||||
<li><strong>⛔ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
|
||||
</ul>
|
||||
<h4>The table</h4>
|
||||
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
|
||||
<div class="ui-help-callout">Split <strong>masters</strong> are labelled and excluded from the counts; you issue their instances one at a time as each becomes release-ready.</div>` },
|
||||
|
||||
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||
<h3>Samples, import / export & comments</h3>
|
||||
<h4>Load Sample</h4>
|
||||
<p><strong>⭐ Load Sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
|
||||
<h4>Import / Export</h4>
|
||||
<ul>
|
||||
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
|
||||
<li><strong>SOP</strong> — the Creator can import a SOP <code>.json</code> (via <em>⤒ Import SOP</em>) or load the sample SOP.</li>
|
||||
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
|
||||
</ul>
|
||||
<h4>Comments & feedback</h4>
|
||||
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
|
||||
<h4>Usage logs</h4>
|
||||
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
|
||||
|
||||
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
|
||||
<h3>Tips & keyboard shortcuts</h3>
|
||||
<ul>
|
||||
<li><strong>Enter</strong> in a sequence, constraint, or material input adds/saves that row.</li>
|
||||
<li><strong>Esc</strong> closes any modal — this help center, comments, the constraint library, and the hold-log dialog.</li>
|
||||
<li>Hover any <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icon for an inline hint.</li>
|
||||
<li>Data is kept <strong>per project</strong> — switch projects from the home page.</li>
|
||||
<li>Your work <strong>auto-saves</strong> to the browser as you type; <em>Save & View</em> produces the print-ready output.</li>
|
||||
<li>Click a metric card or status chip on the <strong>Dashboard</strong> to filter the table.</li>
|
||||
</ul>` },
|
||||
|
||||
{ id: 'glossary', title: 'Glossary', body: `
|
||||
<h3>Glossary</h3>
|
||||
<table>
|
||||
<tr><th>Term</th><th>Meaning</th></tr>
|
||||
<tr><td><strong>IWP</strong></td><td>Installation Work Package — the field-level package this tool produces.</td></tr>
|
||||
<tr><td><strong>AWP</strong></td><td>Advanced Work Packaging — the methodology behind the constraint set and release gate.</td></tr>
|
||||
<tr><td><strong>SOP</strong></td><td>Standard Operating Procedure — the project baseline every WP inherits.</td></tr>
|
||||
<tr><td><strong>Constraint</strong></td><td>A readiness item (Open / Cleared / N/A) that gates release.</td></tr>
|
||||
<tr><td><strong>Release-ready</strong></td><td>All constraints Cleared or N/A — the package can be Issued.</td></tr>
|
||||
<tr><td><strong>Issued</strong></td><td>Released to the field.</td></tr>
|
||||
<tr><td><strong>Issue (Hold)</strong></td><td>A released package paused because a constraint reopened.</td></tr>
|
||||
<tr><td><strong>Discipline</strong></td><td>A trade (Mechanical, Electrical, Tech, …).</td></tr>
|
||||
<tr><td><strong>Split / Master / Instance</strong></td><td>Breaking a multi-discipline package (master/roll-up) into single-discipline instances (WP01A/B/C).</td></tr>
|
||||
<tr><td><strong>Scope</strong></td><td>The sequenced steps the crew performs.</td></tr>
|
||||
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
|
||||
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
||||
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
||||
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
|
||||
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
||||
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||
</table>` },
|
||||
|
||||
{ id: 'faq', title: 'FAQ', body: `
|
||||
<h3>Frequently asked questions</h3>
|
||||
<h4>The Work Package Creator is locked — why?</h4>
|
||||
<p>The SOP for the active project isn't complete yet. Finish SOP Configuration and click <strong>✓ SOP Complete</strong> on the last step; the Creator unlocks and the home card turns green.</p>
|
||||
<h4>Why can't I set a package to Issued?</h4>
|
||||
<p>At least one constraint is still <strong>Open</strong>. Clear or mark N/A every constraint — the release banner turns green — and the suite will offer to issue it.</p>
|
||||
<h4>My package's materials didn't all carry over when I split it.</h4>
|
||||
<p>Only material rows <strong>tagged to a discipline</strong> are routed to that instance. Untagged rows stay on the master. Tag them before splitting.</p>
|
||||
<h4>Why don't split masters show in the dashboard totals?</h4>
|
||||
<p>Masters are roll-ups; counting them would double-count their hours and packages. The individual instances are counted instead.</p>
|
||||
<h4>Will I lose my work if I close the browser?</h4>
|
||||
<p>No — drafts auto-save locally per project and reload next time. Use <em>Export (JSON)</em> for a backup or to share with a teammate.</p>
|
||||
<h4>Does each project keep its own data?</h4>
|
||||
<p>Yes. SOP and Work Packages are scoped to the active project; switching projects loads that project's own set.</p>
|
||||
<h4>How do I report a problem or suggestion?</h4>
|
||||
<p>Use the feedback / comments features (home page, SOP <em>Step Comments</em>, or the Creator's <em>Comments</em> drawer).</p>` }
|
||||
];
|
||||
|
||||
// ── build ─────────────────────────────────────────────────────────────────
|
||||
function buildModal() {
|
||||
if (document.getElementById('ui-help-overlay')) return;
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'ui-help-overlay';
|
||||
overlay.id = 'ui-help-overlay';
|
||||
|
||||
var nav = TOPICS.map(function (t) {
|
||||
return '<a data-help-target="' + t.id + '">' + t.title + '</a>';
|
||||
}).join('');
|
||||
|
||||
var sections = TOPICS.map(function (t) {
|
||||
return '<section class="ui-help-sec" id="ui-help-sec-' + t.id + '">' + t.body + '</section>';
|
||||
}).join('');
|
||||
|
||||
overlay.innerHTML =
|
||||
'<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help center">' +
|
||||
'<div class="ui-help-head">' +
|
||||
'<span class="ui-help-title">Help — Work Package Suite</span>' +
|
||||
'<span class="ui-help-search"><input id="ui-help-q" type="search" placeholder="Search help…" aria-label="Search help"></span>' +
|
||||
'<button type="button" class="ui-help-x" onclick="closeHelp()" aria-label="Close help">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="ui-help-wrap">' +
|
||||
'<nav class="ui-help-nav" id="ui-help-nav">' + nav + '</nav>' +
|
||||
'<div class="ui-help-content" id="ui-help-content">' +
|
||||
'<p class="ui-help-noresult" id="ui-help-noresult">No matches. Try another word.</p>' +
|
||||
sections +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// nav clicks + in-content cross-links jump to a section
|
||||
overlay.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-help-target],[data-help-jump]');
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
jumpTo(t.getAttribute('data-help-target') || t.getAttribute('data-help-jump'));
|
||||
});
|
||||
|
||||
// search
|
||||
var q = overlay.querySelector('#ui-help-q');
|
||||
q.addEventListener('input', function () { runSearch(q.value); });
|
||||
|
||||
// highlight nav as you scroll
|
||||
var content = overlay.querySelector('#ui-help-content');
|
||||
content.addEventListener('scroll', syncActiveNav, { passive: true });
|
||||
}
|
||||
|
||||
function jumpTo(id) {
|
||||
var sec = document.getElementById('ui-help-sec-' + id);
|
||||
if (!sec) return;
|
||||
// Clear any active search filter so the target is visible.
|
||||
var q = document.getElementById('ui-help-q');
|
||||
if (q && q.value) { q.value = ''; runSearch(''); }
|
||||
sec.scrollIntoView({ block: 'start' });
|
||||
setActiveNav(id);
|
||||
}
|
||||
|
||||
function setActiveNav(id) {
|
||||
var nav = document.getElementById('ui-help-nav');
|
||||
if (!nav) return;
|
||||
nav.querySelectorAll('a').forEach(function (a) {
|
||||
a.classList.toggle('active', a.getAttribute('data-help-target') === id);
|
||||
});
|
||||
}
|
||||
|
||||
function syncActiveNav() {
|
||||
var content = document.getElementById('ui-help-content');
|
||||
if (!content) return;
|
||||
var top = content.scrollTop, best = null, bestDist = Infinity;
|
||||
TOPICS.forEach(function (t) {
|
||||
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||
if (!sec || sec.classList.contains('hide')) return;
|
||||
var d = Math.abs(sec.offsetTop - top);
|
||||
if (sec.offsetTop - top <= 40 && d < bestDist) { bestDist = d; best = t.id; }
|
||||
});
|
||||
if (best) setActiveNav(best);
|
||||
}
|
||||
|
||||
// ── search: filter sections + highlight matches ───────────────────────────
|
||||
function clearMarks(root) {
|
||||
root.querySelectorAll('mark').forEach(function (m) {
|
||||
var txt = document.createTextNode(m.textContent);
|
||||
m.parentNode.replaceChild(txt, m);
|
||||
});
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
function markMatches(el, query) {
|
||||
var lower = query.toLowerCase();
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (node) {
|
||||
if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||||
var p = node.parentNode.nodeName;
|
||||
if (p === 'MARK' || p === 'STYLE' || p === 'SCRIPT') return NodeFilter.FILTER_REJECT;
|
||||
return node.nodeValue.toLowerCase().indexOf(lower) >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
});
|
||||
var nodes = [], n;
|
||||
while ((n = walker.nextNode())) nodes.push(n);
|
||||
nodes.forEach(function (node) {
|
||||
var val = node.nodeValue, low = val.toLowerCase(), frag = document.createDocumentFragment(), i = 0, idx;
|
||||
while ((idx = low.indexOf(lower, i)) >= 0) {
|
||||
if (idx > i) frag.appendChild(document.createTextNode(val.slice(i, idx)));
|
||||
var mk = document.createElement('mark');
|
||||
mk.textContent = val.slice(idx, idx + query.length);
|
||||
frag.appendChild(mk);
|
||||
i = idx + query.length;
|
||||
}
|
||||
if (i < val.length) frag.appendChild(document.createTextNode(val.slice(i)));
|
||||
node.parentNode.replaceChild(frag, node);
|
||||
});
|
||||
}
|
||||
|
||||
function runSearch(query) {
|
||||
var content = document.getElementById('ui-help-content');
|
||||
var nav = document.getElementById('ui-help-nav');
|
||||
var noresult = document.getElementById('ui-help-noresult');
|
||||
if (!content) return;
|
||||
query = (query || '').trim();
|
||||
var hits = 0;
|
||||
|
||||
TOPICS.forEach(function (t) {
|
||||
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||
var navItem = nav.querySelector('[data-help-target="' + t.id + '"]');
|
||||
clearMarks(sec);
|
||||
var match = !query || sec.textContent.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
sec.classList.toggle('hide', !match);
|
||||
if (navItem) navItem.classList.toggle('nohit', !!query && !match);
|
||||
if (match) {
|
||||
hits++;
|
||||
if (query) markMatches(sec, query);
|
||||
}
|
||||
});
|
||||
|
||||
noresult.style.display = (query && hits === 0) ? 'block' : 'none';
|
||||
if (query) { content.scrollTop = 0; }
|
||||
else { syncActiveNav(); }
|
||||
}
|
||||
|
||||
// ── public API ──────────────────────────────────────────────────────────────
|
||||
global.openHelp = function (topicId) {
|
||||
buildModal();
|
||||
document.getElementById('ui-help-overlay').classList.add('open');
|
||||
var q = document.getElementById('ui-help-q');
|
||||
if (topicId && typeof topicId === 'string') jumpTo(topicId);
|
||||
else { setActiveNav(TOPICS[0].id); if (q) setTimeout(function () { q.focus(); }, 30); }
|
||||
};
|
||||
global.closeHelp = function () {
|
||||
var o = document.getElementById('ui-help-overlay');
|
||||
if (o) o.classList.remove('open');
|
||||
};
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') global.closeHelp();
|
||||
});
|
||||
|
||||
// ── floating launcher on pages without their own Help button ────────────────
|
||||
function maybeAddFab() {
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
if (inIframe || global.WP_HELP_NO_FAB) return; // suite shows the parent's button
|
||||
if (document.querySelector('[onclick*="openHelp"]')) return; // page already has a Help trigger
|
||||
if (document.getElementById('ui-help-fab')) return;
|
||||
var b = document.createElement('button');
|
||||
b.id = 'ui-help-fab'; b.className = 'ui-help-fab'; b.type = 'button';
|
||||
b.title = 'Help'; b.setAttribute('aria-label', 'Open help'); b.textContent = '?';
|
||||
b.addEventListener('click', function () { global.openHelp(); });
|
||||
document.body.appendChild(b);
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', maybeAddFab);
|
||||
else maybeAddFab();
|
||||
})(window);
|
||||
BIN
html/icon-192.png
Normal file
BIN
html/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
html/icon-512.png
Normal file
BIN
html/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
665
html/index.html
Normal file
665
html/index.html
Normal file
@@ -0,0 +1,665 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Package Suite — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<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>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--cds-background);
|
||||
color: var(--cds-text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* CONTAINER */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 2rem 3rem;
|
||||
}
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1rem;
|
||||
color: var(--cds-text-secondary);
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
/* CARDS */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--cds-layer);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-left: 4px solid var(--cds-border-strong);
|
||||
padding: 1.5rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
text-decoration: none;
|
||||
color: var(--cds-text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-left-color: var(--cds-interactive-01);
|
||||
background: var(--cds-layer-hover);
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
display: inline-block;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
display: inline-block;
|
||||
align-self: flex-start;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
padding: 0.7rem 1.25rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: background 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-button:hover {
|
||||
background: var(--cds-hover-primary);
|
||||
}
|
||||
|
||||
/* COMPLETE STATE (SOP done) */
|
||||
.card.complete {
|
||||
border-left-color: var(--cds-support-success);
|
||||
}
|
||||
.card.complete .card-button { background: var(--cds-support-success); }
|
||||
.card.complete .card-button:hover { background: #0e6027; }
|
||||
.card-status {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--cds-support-success);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.card.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* SECTION */
|
||||
.section {
|
||||
background: var(--cds-layer);
|
||||
padding: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section p {
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
.footer {
|
||||
background: var(--cds-ui-01);
|
||||
color: var(--cds-text-secondary);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--cds-link-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* COMMENTS SECTION */
|
||||
.comments-section {
|
||||
background: var(--cds-layer);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.comments-toggle {
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.comments-toggle:hover { background: var(--cds-hover-primary); }
|
||||
|
||||
.comments-panel {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--cds-layer-accent);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.comments-panel.open { display: block; }
|
||||
|
||||
.comments-panel input,
|
||||
.comments-panel textarea {
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--cds-border-strong);
|
||||
background: var(--cds-field);
|
||||
color: var(--cds-text-primary);
|
||||
font-family: inherit;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.comments-panel input:focus,
|
||||
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||
|
||||
.comments-panel textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.comment-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.comment-buttons button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:hover { background: var(--cds-hover-primary); }
|
||||
|
||||
.close-btn {
|
||||
background: var(--cds-layer-selected);
|
||||
color: var(--cds-text-primary);
|
||||
border: 1px solid var(--cds-border-strong);
|
||||
}
|
||||
|
||||
.close-btn:hover { background: var(--cds-layer-selected-hover); }
|
||||
|
||||
.comments-list {
|
||||
margin-top: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 0.75rem;
|
||||
background: var(--cds-background);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
font-size: 11px;
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
/* PROJECT PICKER */
|
||||
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; }
|
||||
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||
padding: 1.25rem; }
|
||||
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); background: var(--cds-ui-01, #fff); }
|
||||
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
||||
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); }
|
||||
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
||||
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.hero h1 { font-size: 1.75rem; }
|
||||
.cards-grid { grid-template-columns: 1fr; }
|
||||
.container { padding: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- HEADER -->
|
||||
<header class="wp-appbar">
|
||||
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
<span class="wp-appbar-title">Work Package Suite</span>
|
||||
</a>
|
||||
<div class="wp-appbar-spacer"></div>
|
||||
<nav class="wp-appbar-actions">
|
||||
<a class="wp-appbar-link" href="#overview">Overview</a>
|
||||
<a class="wp-appbar-link" href="#comments">Feedback</a>
|
||||
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<div class="container">
|
||||
|
||||
<!-- HERO -->
|
||||
<div class="hero">
|
||||
<h1 id="hero-title">Work Package Suite</h1>
|
||||
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||
</div>
|
||||
|
||||
<!-- PROJECT SELECTION -->
|
||||
<div class="section" id="project-section">
|
||||
<h2>Project</h2>
|
||||
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
||||
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- TOOL CARDS (shown once a project is active) -->
|
||||
<div class="cards-grid" id="overview" style="display:none">
|
||||
|
||||
<!-- SOP CONFIG -->
|
||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||
<h3>SOP Configuration</h3>
|
||||
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
|
||||
<button class="card-button" id="card-sop-btn">Open Tool</button>
|
||||
</a>
|
||||
|
||||
<!-- WP CREATOR -->
|
||||
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
|
||||
<h3>Work Package Creator</h3>
|
||||
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
|
||||
<button class="card-button" id="card-wp-btn">Open Tool</button>
|
||||
</a>
|
||||
|
||||
<!-- WP DASHBOARD -->
|
||||
<a href="work-package-suite.html?view=dashboard" class="card" id="card-dash">
|
||||
<h3>Work Package Dashboard</h3>
|
||||
<p>Track status and gating across every Work Package — release-readiness, on-hold packages, overdue work, hours, and breakdowns by status and discipline. Issue release-ready packages in one click.</p>
|
||||
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
||||
</a>
|
||||
|
||||
<!-- FIELD VIEW -->
|
||||
<a href="field.html" class="card" id="card-field">
|
||||
<h3>Field View</h3>
|
||||
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
|
||||
<button class="card-button" id="card-field-btn">Open Field View</button>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COMMENTS SECTION -->
|
||||
<div class="comments-section" id="comments">
|
||||
<button class="comments-toggle" onclick="toggleComments()">Leave Feedback</button>
|
||||
<div class="comments-panel" id="comments-panel">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
|
||||
<input type="text" id="commenter-name" placeholder="Your name">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Feedback</label>
|
||||
<textarea id="comment-text" placeholder="Your feedback here..."></textarea>
|
||||
</div>
|
||||
<div class="comment-buttons">
|
||||
<button class="submit-btn" onclick="submitComment()">Submit</button>
|
||||
<button class="close-btn" onclick="exportFeedback()">⤓ Export</button>
|
||||
<button class="close-btn" onclick="document.getElementById('feedback-import').click()">⤒ Import</button>
|
||||
<button class="close-btn" onclick="toggleComments()">Close</button>
|
||||
<input type="file" id="feedback-import" accept="application/json" style="display:none" onchange="importFeedback(event)">
|
||||
</div>
|
||||
<div class="comments-list" id="comments-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
|
||||
</footer>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script>
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
let _projects = [];
|
||||
|
||||
function initProjects(){
|
||||
ProjectData.list().then(list => {
|
||||
_projects = list || [];
|
||||
// Reconcile the active project against the list; clear if it's gone.
|
||||
const active = ProjectData.getActive();
|
||||
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
|
||||
renderProjectPicker();
|
||||
applyActiveProject();
|
||||
});
|
||||
}
|
||||
|
||||
function createFormHtml(){
|
||||
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||
<div class="proj-form-grid">
|
||||
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
||||
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||
</div>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="saveNewProject()">Create & Select</button>
|
||||
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderProjectPicker(){
|
||||
const box = document.getElementById('project-picker');
|
||||
const activeId = ProjectData.getActiveId();
|
||||
if(!_projects.length){
|
||||
box.innerHTML = `<div class="proj-empty">
|
||||
<p>No projects yet. Create your first project, or start from a sample.</p>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
|
||||
</div>
|
||||
</div>` + createFormHtml();
|
||||
return;
|
||||
}
|
||||
const opts = _projects.map(p =>
|
||||
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
||||
).join('');
|
||||
box.innerHTML = `<div class="proj-row">
|
||||
<select id="project-select" onchange="selectProject(this.value)">
|
||||
<option value="">Select a project…</option>${opts}
|
||||
</select>
|
||||
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
||||
</div>
|
||||
<div id="active-project-info"></div>` + createFormHtml();
|
||||
}
|
||||
|
||||
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
||||
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
||||
|
||||
function saveNewProject(){
|
||||
const v = id => (document.getElementById(id)?.value || '').trim();
|
||||
const name = v('np_name');
|
||||
if(!name){ alert('Project name is required.'); return; }
|
||||
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
||||
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
function useSampleProject(){
|
||||
const existing = _projects.find(p => p.sample);
|
||||
if(existing){ afterProjectChosen(existing); return; }
|
||||
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
function selectProject(id){
|
||||
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||
const p = _projects.find(x => x.id === id);
|
||||
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||
}
|
||||
|
||||
function afterProjectChosen(p){
|
||||
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
||||
ProjectData.setActive(p);
|
||||
renderProjectPicker();
|
||||
applyActiveProject();
|
||||
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
||||
}
|
||||
|
||||
// Show/hide the tool cards and stamp the active project into their links.
|
||||
function applyActiveProject(){
|
||||
const active = ProjectData.getActive();
|
||||
const cards = document.getElementById('overview');
|
||||
const heroTitle = document.getElementById('hero-title');
|
||||
const heroSub = document.getElementById('hero-sub');
|
||||
const info = document.getElementById('active-project-info');
|
||||
|
||||
if(!active){
|
||||
cards.style.display = 'none';
|
||||
heroTitle.textContent = 'Work Package Suite';
|
||||
heroSub.textContent = 'Select a project to begin — or create one.';
|
||||
if(info) info.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const q = '&project=' + encodeURIComponent(active.id);
|
||||
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
|
||||
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
||||
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
||||
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
||||
setHref('card-field', 'field.html?src=home');
|
||||
|
||||
cards.style.display = '';
|
||||
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
||||
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||
|
||||
// Pull the project's shared SOP/WPs from the server into the local cache
|
||||
// first, so the SOP "Complete / Review" status reflects what other users did.
|
||||
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
|
||||
else reflectSOPStatus(active);
|
||||
}
|
||||
|
||||
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||
|
||||
// Reflect SOP completion on the tool cards (scoped to the active project).
|
||||
function reflectSOPStatus(active){
|
||||
let complete = false, projName = '';
|
||||
try {
|
||||
// Storage is namespaced per project, so these already scope to `active`.
|
||||
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
|
||||
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
|
||||
projName = sop && sop.project && sop.project.name || '';
|
||||
} catch(e){}
|
||||
|
||||
const sopCard = document.getElementById('card-sop');
|
||||
const sopBtn = document.getElementById('card-sop-btn');
|
||||
const wpCard = document.getElementById('card-wp');
|
||||
const wpBtn = document.getElementById('card-wp-btn');
|
||||
if(!sopCard) return;
|
||||
|
||||
// reset (re-render can run multiple times)
|
||||
sopCard.classList.remove('complete');
|
||||
wpCard && wpCard.classList.remove('disabled');
|
||||
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
||||
|
||||
if(complete){
|
||||
sopCard.classList.add('complete');
|
||||
sopBtn.textContent = 'Review';
|
||||
const status = document.createElement('div');
|
||||
status.className = 'card-status';
|
||||
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
|
||||
sopCard.insertBefore(status, sopCard.firstChild);
|
||||
if(wpBtn) wpBtn.textContent = 'Open Creator';
|
||||
} else {
|
||||
if(wpCard) wpCard.classList.add('disabled');
|
||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||
}
|
||||
}
|
||||
|
||||
initProjects();
|
||||
|
||||
let allComments = [];
|
||||
|
||||
function toggleComments() {
|
||||
const panel = document.getElementById('comments-panel');
|
||||
panel.classList.toggle('open');
|
||||
if (panel.classList.contains('open')) loadComments();
|
||||
}
|
||||
|
||||
function submitComment() {
|
||||
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
|
||||
if (!text) {
|
||||
alert('Please enter feedback.');
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = {
|
||||
name,
|
||||
text,
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
allComments.push(comment);
|
||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
||||
|
||||
document.getElementById('comment-text').value = '';
|
||||
loadComments();
|
||||
}
|
||||
|
||||
function exportFeedback() {
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
const data = saved ? JSON.parse(saved) : [];
|
||||
if (!data.length) { alert('No feedback to export yet.'); return; }
|
||||
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'wp-suite-feedback-home-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
function importFeedback(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
try {
|
||||
const inc = JSON.parse(r.result);
|
||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
||||
if (!incoming.length) { alert('No feedback found in that file.'); return; }
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
allComments = saved ? JSON.parse(saved) : [];
|
||||
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
||||
let added = 0;
|
||||
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||
loadComments();
|
||||
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { alert('Could not read that file.'); }
|
||||
ev.target.value = '';
|
||||
};
|
||||
r.readAsText(f);
|
||||
}
|
||||
|
||||
function renderComments() {
|
||||
const list = document.getElementById('comments-list');
|
||||
if (allComments.length === 0) {
|
||||
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
|
||||
} else {
|
||||
list.innerHTML = allComments.map(c => `
|
||||
<div class="comment-item">
|
||||
<div class="comment-meta"><strong>${(c.name||'Anonymous').replace(/</g,'<')}</strong> • ${c.timestamp||''}</div>
|
||||
<div class="comment-text">${(c.text||'').replace(/</g,'<').replace(/>/g,'>')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function loadComments() {
|
||||
// Server is authoritative (so feedback is shared across users); fall back to
|
||||
// the local cache if the API is unreachable.
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
if (saved) { try { allComments = JSON.parse(saved) || []; } catch(e) { allComments = []; } }
|
||||
renderComments();
|
||||
fetch('/api/comments?source=home_feedback', { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(rows => {
|
||||
if (Array.isArray(rows)) {
|
||||
allComments = rows.map(c => ({ name: c.author, text: c.text, timestamp: c.created_at ? new Date(c.created_at).toLocaleString() : '' }));
|
||||
renderComments();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
109
html/login.html
Normal file
109
html/login.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in — Work Package Suite</title>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--cds-background);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--cds-layer);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-top: 3px solid var(--cds-interactive-01);
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.brand img { height: 36px; width: auto; }
|
||||
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
||||
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
|
||||
.field { margin-bottom: 1.25rem; }
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
background: var(--cds-field);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--cds-border-strong);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--cds-text-on-color);
|
||||
background: var(--cds-button-primary);
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
||||
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||
.error {
|
||||
display: none;
|
||||
background: #fff1f1;
|
||||
border-left: 3px solid var(--cds-support-error);
|
||||
color: var(--cds-text-error);
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.error.show { display: block; }
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="brand">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite</p>
|
||||
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
|
||||
<form id="login-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button id="submit" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
|
||||
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
|
||||
</p>
|
||||
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem;">
|
||||
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
|
||||
</div>
|
||||
|
||||
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||
</main>
|
||||
|
||||
<script src="login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
68
html/login.js
Normal file
68
html/login.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/* Login page logic for the Work Package Suite.
|
||||
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
|
||||
session cookie (not readable here — that's the point) and we redirect to the
|
||||
page the user was trying to reach, or the home page. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var form = document.getElementById('login-form');
|
||||
var errorBox = document.getElementById('error');
|
||||
var submitBtn = document.getElementById('submit');
|
||||
|
||||
// Where to go after signing in: the ?next= param if it's a safe same-site
|
||||
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
||||
// open-redirect.)
|
||||
function nextTarget() {
|
||||
try {
|
||||
var next = new URLSearchParams(location.search).get('next') || '';
|
||||
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||
} catch (e) {}
|
||||
return 'index.html';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorBox.textContent = msg;
|
||||
errorBox.classList.add('show');
|
||||
}
|
||||
|
||||
var forgot = document.getElementById('forgot-link');
|
||||
if (forgot) {
|
||||
forgot.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var m = document.getElementById('forgot-msg');
|
||||
if (m) m.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
errorBox.classList.remove('show');
|
||||
var username = document.getElementById('username').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in…';
|
||||
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.ok) { location.replace(nextTarget()); return null; }
|
||||
return r.json().catch(function () { return null; }).then(function (j) {
|
||||
if (r.status === 401) showError('Invalid username or password.');
|
||||
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
|
||||
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
});
|
||||
})();
|
||||
18
html/manifest.webmanifest
Normal file
18
html/manifest.webmanifest
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Prime Work Package Suite",
|
||||
"short_name": "WP Suite",
|
||||
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
|
||||
"start_url": "/index.html",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#f4f4f4",
|
||||
"theme_color": "#161616",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
366
html/project-data.js
Normal file
366
html/project-data.js
Normal file
@@ -0,0 +1,366 @@
|
||||
/* Shared project layer for the Work Package Suite.
|
||||
Projects are the top-level container — every SOP and Work Package belongs to
|
||||
one. Project records live in the SQL database (via /api/projects); this
|
||||
adapter is API-first and falls back to a localStorage mirror so the suite
|
||||
still works in local dev / offline. Included by the home page and the suite. */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var API = '/api';
|
||||
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
|
||||
var LS_ACTIVE = 'wp_active_project'; // active project id
|
||||
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
||||
|
||||
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
|
||||
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
||||
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
||||
function cacheUpsert(p) {
|
||||
var list = readLocal();
|
||||
var ix = list.findIndex(function (x) { return x.id === p.id; });
|
||||
if (ix >= 0) list[ix] = p; else list.unshift(p);
|
||||
writeLocal(list);
|
||||
}
|
||||
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||
|
||||
var SAMPLE_PROJECT = {
|
||||
name: 'Micron FMCS Install (sample)', number: '26-67-008',
|
||||
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||
site: 'Boise, ID — Fab', sample: true
|
||||
};
|
||||
|
||||
var ProjectData = {
|
||||
SAMPLE: SAMPLE_PROJECT,
|
||||
esc: esc,
|
||||
|
||||
// Returns the project list. Tries the API; falls back to the local mirror.
|
||||
list: function () {
|
||||
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (rows) { writeLocal(rows); return rows; })
|
||||
.catch(function () { return readLocal(); });
|
||||
},
|
||||
|
||||
get: function (id) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
|
||||
},
|
||||
|
||||
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
|
||||
save: function (p) {
|
||||
if (!p.id) p.id = uid();
|
||||
return fetch(API + '/projects', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (saved) { cacheUpsert(saved); return saved; })
|
||||
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
||||
},
|
||||
|
||||
remove: function (id) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.then(function () { cacheRemove(id); })
|
||||
.catch(function () { cacheRemove(id); });
|
||||
},
|
||||
|
||||
// ── active project context ────────────────────────────────────────────────
|
||||
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
||||
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||
setActive: function (p) {
|
||||
try {
|
||||
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
||||
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
||||
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
|
||||
// Falls back to the bare key when no project is active.
|
||||
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
||||
};
|
||||
|
||||
// ── Server sync for SOPs and Work Packages ─────────────────────────────────
|
||||
// SOPs and WPs are authoritative on the server (so every user of a project sees
|
||||
// the same data). To avoid rewriting the two apps, we keep their existing
|
||||
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
|
||||
// keys from the API on page load, and the push* helpers write through to the
|
||||
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
|
||||
// (Original author: C-West8, "storing data in DB instead of client only";
|
||||
// reintegrated on top of the BIM/per-package work.)
|
||||
function nsKey(base, id) { return id ? base + '__' + id : base; }
|
||||
function currentUser() {
|
||||
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// A saved Work Package is a flat object in the browser; the API splits it into
|
||||
// promoted columns + a `data` blob. We store the whole flat object in `data`
|
||||
// for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all
|
||||
// survive), and mirror the few fields the API promotes to columns.
|
||||
function pkgToServer(p, projectId) {
|
||||
return {
|
||||
id: p.id,
|
||||
project_id: p.projectId || projectId || null,
|
||||
parent_id: p.instanceOf || null,
|
||||
number: p.number || '',
|
||||
subject: p.subject || '',
|
||||
type: p.type || '',
|
||||
status: p.status || 'Draft',
|
||||
assignee_id: p.assigneeId || null,
|
||||
created_by: p.createdBy || currentUser(),
|
||||
data: p
|
||||
};
|
||||
}
|
||||
function serverToPkg(row) {
|
||||
var p = Object.assign({}, row.data || {}); // full flat object lives in data
|
||||
p.id = row.id;
|
||||
p.projectId = row.project_id || p.projectId || '';
|
||||
if (row.number) p.number = row.number;
|
||||
if (row.subject != null) p.subject = row.subject;
|
||||
if (row.type != null) p.type = row.type;
|
||||
if (row.status) p.status = row.status; // honor server-side status changes
|
||||
if (row.parent_id) p.instanceOf = row.parent_id;
|
||||
p.archived = !!row.archived_at;
|
||||
p.assigneeId = row.assignee_id || '';
|
||||
return p;
|
||||
}
|
||||
|
||||
// Pull this project's SOP + WPs from the API into the localStorage keys the
|
||||
// apps read. Resolves even on failure (offline / no API) so boot continues.
|
||||
ProjectData.pullProject = function (projectId) {
|
||||
if (!projectId) return Promise.resolve();
|
||||
var jobs = [];
|
||||
jobs.push(
|
||||
fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (sopRow) {
|
||||
if (sopRow && sopRow.data) {
|
||||
var d = sopRow.data; // { sop, state } as written by pushSOP
|
||||
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
|
||||
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
|
||||
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||
}
|
||||
}).catch(function () {})
|
||||
);
|
||||
jobs.push(
|
||||
fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (rows) {
|
||||
if (Array.isArray(rows)) {
|
||||
localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg)));
|
||||
}
|
||||
}).catch(function () {})
|
||||
);
|
||||
return Promise.all(jobs).then(function () {});
|
||||
};
|
||||
|
||||
// ── Durable write-through outbox ───────────────────────────────────────────
|
||||
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
|
||||
// silently-failed POST leaves the browser and server divergent. Instead of a
|
||||
// fire-and-forget request, each mutation is appended to a localStorage-backed
|
||||
// queue and flushed to the API with retry + backoff. The API upserts by id and
|
||||
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
|
||||
// local cache still updates immediately, so rendering never waits on the network.
|
||||
var OUTBOX_KEY = 'wp_sync_outbox_v1';
|
||||
var _flushTimer = null, _backoff = 0, _flushing = false;
|
||||
|
||||
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
|
||||
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
|
||||
|
||||
// Append an op, coalescing by (kind,key) so only the latest write per entity is
|
||||
// queued. A delete supersedes any pending upsert for the same id.
|
||||
function enqueue(op) {
|
||||
var q = qRead();
|
||||
if (op.kind === 'wp-del') {
|
||||
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
|
||||
} else {
|
||||
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
|
||||
}
|
||||
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
op.tries = 0;
|
||||
q.push(op);
|
||||
qWrite(q);
|
||||
notifySync();
|
||||
scheduleFlush(0);
|
||||
}
|
||||
|
||||
function opRequest(op) {
|
||||
if (op.kind === 'wp-del') {
|
||||
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
|
||||
}
|
||||
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
|
||||
});
|
||||
}
|
||||
|
||||
function bumpTries(opId, err) {
|
||||
var q = qRead();
|
||||
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
|
||||
qWrite(q);
|
||||
}
|
||||
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
|
||||
// retrying, so a rejected write can't loop forever.
|
||||
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) {
|
||||
if (!projectId) return Promise.resolve(null);
|
||||
enqueue({
|
||||
kind: 'sop', key: 'sop__' + projectId,
|
||||
body: {
|
||||
id: 'sop__' + projectId, project_id: projectId,
|
||||
name: (sop && sop.project && sop.project.name) || 'SOP',
|
||||
number: (sop && sop.project && sop.project.number) || '',
|
||||
complete: true, created_by: currentUser(), data: { sop: sop, state: state }
|
||||
}
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
// Upsert a single Work Package. The local cache stays the source of truth for
|
||||
// immediate rendering; the outbox guarantees the write reaches the server.
|
||||
ProjectData.pushWP = function (p, projectId) {
|
||||
if (!p || !p.id) return Promise.resolve(null);
|
||||
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
ProjectData.removeWP = function (id) {
|
||||
if (!id) return Promise.resolve();
|
||||
enqueue({ kind: 'wp-del', key: id });
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
// Force a flush now and resolve when the queue drains (or a round-trip is done).
|
||||
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
|
||||
|
||||
// Archive / unarchive a Work Package (hide from active lists without deleting).
|
||||
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
|
||||
// the caller updates the view on the returned result.
|
||||
ProjectData.archiveWP = function (id, archived) {
|
||||
if (!id) return Promise.resolve(null);
|
||||
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
|
||||
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
};
|
||||
|
||||
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
|
||||
// "show archived" view. Returns app-shaped package objects (p.archived === true).
|
||||
ProjectData.listArchived = function (projectId) {
|
||||
if (!projectId) return Promise.resolve([]);
|
||||
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
|
||||
.catch(function () { return []; });
|
||||
};
|
||||
|
||||
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
||||
try {
|
||||
if (!localStorage.getItem('wp_ns_migrated_v1')) {
|
||||
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
|
||||
try { localStorage.removeItem(k); } catch (e) {}
|
||||
});
|
||||
localStorage.setItem('wp_ns_migrated_v1', '1');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
global.ProjectData = ProjectData;
|
||||
})(window);
|
||||
63
html/sw.js
Normal file
63
html/sw.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/* 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
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -157,3 +157,93 @@ input, textarea, select {
|
||||
font-family: inherit;
|
||||
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; }
|
||||
}
|
||||
@@ -4,7 +4,27 @@ let currentStep = 1;
|
||||
let sopComplete = false;
|
||||
let allComments = [];
|
||||
|
||||
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' when a
|
||||
// project is active. Keeps each project's SOP separate in the browser.
|
||||
function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } }
|
||||
|
||||
// WP size presets — the dropdown label maps to a default split-threshold (max
|
||||
// labor hours). The label is exported as governance.woSize (human-readable
|
||||
// guidance); the number drives the Creator's "consider splitting" warning.
|
||||
const WP_SIZE_PRESETS = {
|
||||
'Small — 1–2 days (≈8–24 hrs)': 24,
|
||||
'Standard — 3–5 days (≈40–80 hrs)': 80,
|
||||
'Large — 1–2 weeks (≈80–160 hrs)': 160
|
||||
};
|
||||
function onSizePresetChange(){
|
||||
const label = document.getElementById('gov_wosize').value;
|
||||
const max = WP_SIZE_PRESETS[label];
|
||||
if(max != null){ document.getElementById('gov_size_hours_max').value = max; }
|
||||
// 'Custom…' / '' leave the threshold for manual entry.
|
||||
}
|
||||
|
||||
let state = {
|
||||
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:''},
|
||||
team: {pm:'', apm:'', cm:'', qm:''},
|
||||
teamMembers: [],
|
||||
@@ -12,7 +32,7 @@ let state = {
|
||||
wpTypes: [],
|
||||
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
|
||||
quality: {qcreq:'', photo:'', hold:''},
|
||||
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
|
||||
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''},
|
||||
sequence: [],
|
||||
constraints: [],
|
||||
sources: []
|
||||
@@ -73,7 +93,14 @@ const OPTIONAL_ROLES = [
|
||||
'Quality Representative',
|
||||
'Planner',
|
||||
'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 = [
|
||||
@@ -118,6 +145,100 @@ const LABOR_COST_CODES = [
|
||||
'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 ────────────────────────────────────────────────────────────
|
||||
window.addEventListener('DOMContentLoaded',()=>{
|
||||
initializeWPTypes();
|
||||
@@ -126,17 +247,36 @@ window.addEventListener('DOMContentLoaded',()=>{
|
||||
renderStandardConstraints();
|
||||
renderSequenceSteps();
|
||||
renderSources();
|
||||
// Resolve the active project FIRST so per-project storage keys are correct
|
||||
// before we restore this project's SOP.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const projId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
applyProjectContext(params.get('project'));
|
||||
|
||||
// Pull the project's shared SOP from the server into the local cache, THEN
|
||||
// restore it. Falls back to the local cache if offline.
|
||||
function afterPull(){
|
||||
restoreSavedSOP();
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page cards.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
||||
const tab = params.get('tab');
|
||||
if(params.get('view') === 'dashboard') switchTool('wp');
|
||||
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||
}
|
||||
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||||
} else {
|
||||
afterPull();
|
||||
}
|
||||
|
||||
track('app_open');
|
||||
|
||||
// Feedback author auto-populates from the signed-in user (auth-guard sets
|
||||
// window.WP_USER and fires 'wp-auth-ready'); the field is read-only.
|
||||
setCommenterName();
|
||||
document.addEventListener('wp-auth-ready', setCommenterName);
|
||||
|
||||
let _fieldTimer;
|
||||
document.addEventListener('input', e=>{
|
||||
const t = e.target;
|
||||
@@ -154,7 +294,15 @@ function initializeWPTypes(){
|
||||
}
|
||||
|
||||
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
||||
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard
|
||||
// tab it loads the example Work Package inside the embedded creator.
|
||||
function loadSampleData(){
|
||||
if(currentTool && currentTool !== 'sop'){
|
||||
const f = document.getElementById('wp-frame');
|
||||
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
|
||||
else { alert('Open the Work Package Creation tab first, then load the sample.'); }
|
||||
return;
|
||||
}
|
||||
// Populate Step 1
|
||||
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
||||
document.getElementById('proj_number').value = '26-67-008';
|
||||
@@ -168,16 +316,20 @@ function loadSampleData(){
|
||||
document.getElementById('proj_cm').value = 'K. Boyd';
|
||||
document.getElementById('proj_qm').value = 'D. Nguyen';
|
||||
|
||||
// Step 3 already has defaults
|
||||
// Step 3 — standard required roles
|
||||
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_foreman_name').value = 'Mike Jones';
|
||||
|
||||
// Populate Step 5
|
||||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
||||
document.getElementById('gov_wosize').value = '3–5 days / 40–80 hours';
|
||||
document.getElementById('gov_wosize').value = 'Standard — 3–5 days (≈40–80 hrs)';
|
||||
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
|
||||
document.getElementById('gov_discmode').value = 'choice';
|
||||
document.getElementById('gov_size_hours_max').value = '120';
|
||||
document.getElementById('gov_size_hours_max').value = '80';
|
||||
|
||||
// Populate Step 6
|
||||
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
||||
@@ -186,6 +338,12 @@ function loadSampleData(){
|
||||
|
||||
// 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
|
||||
collectStepData();
|
||||
track('sample_loaded');
|
||||
@@ -202,9 +360,9 @@ function loadSampleData(){
|
||||
function restoreSavedSOP(){
|
||||
let savedState = null, savedSop = null, complete = false;
|
||||
try {
|
||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
||||
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null');
|
||||
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
||||
complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
|
||||
savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
|
||||
savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
|
||||
} catch(e){}
|
||||
if(!complete || !savedState) return;
|
||||
|
||||
@@ -236,9 +394,15 @@ function repopulateForm(){
|
||||
set('proj_apm', state.team.apm);
|
||||
set('proj_cm', state.team.cm);
|
||||
set('proj_qm', state.team.qm);
|
||||
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
||||
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
||||
if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); 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); }
|
||||
set('gov_woformat', state.governance.woformat);
|
||||
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
||||
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
||||
const wsEl = document.getElementById('gov_wosize');
|
||||
if(wsEl && state.governance.wosize && !Array.from(wsEl.options).some(o=>o.value===state.governance.wosize)){
|
||||
wsEl.add(new Option(state.governance.wosize, state.governance.wosize));
|
||||
}
|
||||
set('gov_wosize', state.governance.wosize);
|
||||
set('gov_disciplines', (state.governance.disciplines||[]).join(', '));
|
||||
set('gov_discmode', state.governance.discMode);
|
||||
@@ -248,35 +412,40 @@ function repopulateForm(){
|
||||
set('qual_hold', state.quality.hold);
|
||||
set('plat_tracking', state.platforms.tracking);
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
function switchTool(tool){
|
||||
currentTool = tool;
|
||||
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
||||
// creator) but opens it straight to the dashboard view.
|
||||
const isDash = (tool === 'dashboard');
|
||||
const contentTool = isDash ? 'wp' : tool;
|
||||
|
||||
// Update nav tabs
|
||||
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.querySelector(`[data-tab="${tool}"]`).classList.add('active');
|
||||
const tabBtn = document.querySelector(`[data-tab="${tool}"]`);
|
||||
if(tabBtn) tabBtn.classList.add('active');
|
||||
|
||||
// Update content
|
||||
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById(`tool-${tool}`).classList.add('active');
|
||||
document.getElementById(`tool-${contentTool}`).classList.add('active');
|
||||
|
||||
// Reset step counter
|
||||
if(tool === 'sop'){
|
||||
document.getElementById('total-steps').textContent = '10';
|
||||
}else{
|
||||
document.getElementById('total-steps').textContent = '—';
|
||||
}
|
||||
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||||
|
||||
if(tool === 'wp') renderWPTab();
|
||||
if(contentTool === 'wp') renderWPTab(isDash);
|
||||
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
}
|
||||
|
||||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
||||
function renderWPTab(){
|
||||
// wantDash=true opens the creator straight to the dashboard view.
|
||||
function renderWPTab(wantDash){
|
||||
const gate = document.getElementById('wp-gate');
|
||||
const frame = document.getElementById('wp-frame');
|
||||
if(!gate || !frame) return;
|
||||
@@ -284,8 +453,11 @@ function renderWPTab(){
|
||||
gate.style.display = 'none';
|
||||
frame.style.display = 'block';
|
||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
||||
const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard';
|
||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now();
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const dash = wantDash || sp.get('view') === 'dashboard';
|
||||
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
|
||||
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||||
}else{
|
||||
gate.style.display = 'block';
|
||||
frame.style.display = 'none';
|
||||
@@ -297,8 +469,43 @@ function onSOPReady(){
|
||||
if(currentTool === 'wp') renderWPTab();
|
||||
}
|
||||
|
||||
// Active project comes from the home page (?project=<id> + ProjectData.getActive()).
|
||||
// When the SOP's project fields are still empty, prefill them from the project
|
||||
// record so the SOP is authored against the chosen project.
|
||||
let activeProject = null;
|
||||
function applyProjectContext(projectId){
|
||||
try {
|
||||
if(typeof ProjectData !== 'undefined'){
|
||||
if(projectId && ProjectData.getActiveId() !== projectId){
|
||||
// Deep-linked to a project that isn't the cached active one. Seed the id
|
||||
// immediately so namespaced storage keys resolve, then fetch the full record.
|
||||
const cached = ProjectData.getActive();
|
||||
ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId });
|
||||
ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } });
|
||||
}
|
||||
activeProject = ProjectData.getActive();
|
||||
}
|
||||
} catch(e){}
|
||||
prefillProjectFields();
|
||||
}
|
||||
function prefillProjectFields(){
|
||||
if(!activeProject) return;
|
||||
const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; };
|
||||
set('proj_name', activeProject.name);
|
||||
set('proj_number', activeProject.number);
|
||||
set('proj_client', activeProject.client);
|
||||
set('proj_division', activeProject.division);
|
||||
set('proj_site', activeProject.site);
|
||||
if(typeof state !== 'undefined' && state.project){
|
||||
state.project.name = state.project.name || activeProject.name || '';
|
||||
state.project.number = state.project.number || activeProject.number || '';
|
||||
state.project.client = state.project.client || activeProject.client || '';
|
||||
state.project.division = state.project.division || activeProject.division || '';
|
||||
state.project.site = state.project.site || activeProject.site || '';
|
||||
}
|
||||
}
|
||||
function updateProjectDisplay(){
|
||||
const projName = document.getElementById('proj_name')?.value || 'Project';
|
||||
const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project';
|
||||
const display = document.getElementById('project-display');
|
||||
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
||||
}
|
||||
@@ -315,14 +522,24 @@ function renderWPTypes(){
|
||||
state.wpTypes.forEach((t,i)=>{
|
||||
const row = document.createElement('div');
|
||||
row.className = 'wp-type-row';
|
||||
const nameCell = t.custom
|
||||
? `<div style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
|
||||
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
|
||||
</div>`
|
||||
: `<div style="font-weight:600;">${t.name}</div>`;
|
||||
row.innerHTML = `
|
||||
<div style="font-weight:600;">${t.name}</div>
|
||||
${nameCell}
|
||||
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
||||
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
const addRow = document.createElement('div');
|
||||
addRow.style.cssText = 'margin-top:0.85rem;';
|
||||
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add Custom Type</button>`;
|
||||
container.appendChild(addRow);
|
||||
}
|
||||
|
||||
function toggleWPType(i){
|
||||
@@ -330,6 +547,21 @@ function toggleWPType(i){
|
||||
renderWPTypes();
|
||||
}
|
||||
|
||||
function addCustomWPType(){
|
||||
state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true});
|
||||
renderWPTypes();
|
||||
// Focus the new custom row's name input.
|
||||
const rows = document.querySelectorAll('#wp-types-table .wp-type-row');
|
||||
const last = rows[rows.length-1];
|
||||
const nameInput = last && last.querySelector('input[type="text"]');
|
||||
if(nameInput) nameInput.focus();
|
||||
}
|
||||
|
||||
function removeWPType(i){
|
||||
state.wpTypes.splice(i,1);
|
||||
renderWPTypes();
|
||||
}
|
||||
|
||||
function renderTeamMembers(){
|
||||
const container = document.getElementById('team-members-list');
|
||||
if(!container) return;
|
||||
@@ -354,7 +586,8 @@ function removeTeamMember(i){
|
||||
|
||||
function renderOptionalRoles(){
|
||||
const container = document.getElementById('optional-roles-list');
|
||||
const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
|
||||
// The first two entries are the required (editable-title) roles; the rest are optional.
|
||||
const current = state.signoffRoles.slice(2);
|
||||
container.innerHTML = current.map((r,i)=>`
|
||||
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
|
||||
@@ -376,18 +609,45 @@ function removeRole(i){
|
||||
renderOptionalRoles();
|
||||
}
|
||||
|
||||
// Seed the standard 10 once; after that, render reflects state.constraints
|
||||
// (checkbox = whether each standard one is active) and never clobbers customs.
|
||||
let _constraintsSeeded = false;
|
||||
function renderStandardConstraints(){
|
||||
const container = document.getElementById('standard-constraints');
|
||||
if(!_constraintsSeeded){
|
||||
if(!state.constraints || !state.constraints.length){
|
||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
||||
}
|
||||
_constraintsSeeded = true;
|
||||
}
|
||||
const active = name => state.constraints.some(c=>c.name===name);
|
||||
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
|
||||
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
||||
<input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
||||
<div style="flex:1;">
|
||||
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
|
||||
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
||||
renderCustomConstraints();
|
||||
}
|
||||
|
||||
// Render the custom (non-standard) constraints into their own list with remove buttons.
|
||||
function renderCustomConstraints(){
|
||||
const el = document.getElementById('custom-constraints-list'); if(!el) return;
|
||||
const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name);
|
||||
const customs = state.constraints.filter(c=>!stdNames.includes(c.name));
|
||||
el.innerHTML = customs.length ? customs.map(c=>`
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||
<strong>${escAttr(c.name)}</strong>
|
||||
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
|
||||
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
||||
}
|
||||
|
||||
function removeCustomConstraint(name){
|
||||
state.constraints = state.constraints.filter(c=>c.name!==name);
|
||||
renderCustomConstraints();
|
||||
}
|
||||
|
||||
function toggleConstraint(name){
|
||||
@@ -413,20 +673,44 @@ function closeConstraintModal(){
|
||||
}
|
||||
|
||||
function addCustomConstraint(name){
|
||||
if(!state.constraints.find(c=>c.name===name)){
|
||||
if(name && !state.constraints.find(c=>c.name===name)){
|
||||
state.constraints.push({name,description:''});
|
||||
}
|
||||
closeConstraintModal();
|
||||
renderStandardConstraints();
|
||||
}
|
||||
|
||||
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
|
||||
// Free-text custom constraint from the modal's input.
|
||||
function addCustomConstraintText(){
|
||||
const inp = document.getElementById('custom-constraint-input');
|
||||
const name = (inp && inp.value || '').trim();
|
||||
if(!name){ if(inp) inp.focus(); return; }
|
||||
if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; }
|
||||
state.constraints.push({name, description:''});
|
||||
if(inp) inp.value='';
|
||||
renderStandardConstraints();
|
||||
}
|
||||
|
||||
// Default construction flow (used when BIM is off; the BIM steps are prepended when
|
||||
// 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;
|
||||
function renderSequenceSteps(){
|
||||
const container = document.getElementById('sequence-list');
|
||||
if(!container) return;
|
||||
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label:s,kind:'step'}));
|
||||
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=> typeof s==='string' ? {label:s,kind:'step'} : {label:s.label, kind:s.kind||'step'});
|
||||
container.innerHTML = '';
|
||||
let stepNo = 0;
|
||||
state.sequence.forEach((item,i)=>{
|
||||
@@ -499,20 +783,30 @@ const DEFAULT_SOURCES = [
|
||||
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function renderSources(){
|
||||
const container = document.getElementById('sources-list');
|
||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph}));
|
||||
container.innerHTML = state.sources.map((s,i)=>`
|
||||
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
|
||||
const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;";
|
||||
const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;";
|
||||
const header = `<div style="${grid} padding:0 1rem 0.4rem; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--text-dim);">
|
||||
<div>Data Type</div><div>Location / Platform</div><div>URL</div><div>Notes</div><div></div>
|
||||
</div>`;
|
||||
container.innerHTML = header + state.sources.map((s,i)=>{
|
||||
// Preset data types are fixed labels; custom rows (Add Source) get an editable name.
|
||||
const dataType = s.preset
|
||||
? `<div style="font-weight:600; font-size:13px;">${escAttr(s.label)}</div>`
|
||||
: `<input type="text" value="${escAttr(s.label)}" placeholder="Custom data type" onchange="state.sources[${i}].label=this.value" style="${inStyle} font-weight:600;">`;
|
||||
return `<div style="${grid} padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
${dataType}
|
||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'Procore / Bluebeam / SharePoint…')}" onchange="state.sources[${i}].system=this.value" style="${inStyle}">
|
||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste the 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="${inStyle}">
|
||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="${inStyle}">
|
||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addSource(){
|
||||
state.sources.push({label:'',system:'',notes:'',link:''});
|
||||
// Added rows are custom — the user types their own data type here.
|
||||
state.sources.push({label:'', system:'', notes:'', link:'', preset:false});
|
||||
renderSources();
|
||||
}
|
||||
|
||||
@@ -577,7 +871,10 @@ function collectStepData(){
|
||||
state.team.qm = document.getElementById('proj_qm').value;
|
||||
break;
|
||||
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[1].role = (document.getElementById('role_foreman_title').value || 'Role 2').trim();
|
||||
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
|
||||
break;
|
||||
case 5:
|
||||
@@ -598,6 +895,8 @@ function collectStepData(){
|
||||
case 7:
|
||||
state.platforms.tracking = document.getElementById('plat_tracking').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;
|
||||
}
|
||||
}
|
||||
@@ -628,6 +927,7 @@ function completeSOP(){
|
||||
|
||||
sop = {
|
||||
meta: {tool:'Work Package Configuration', sample:false},
|
||||
bimEnabled: !!state.bimEnabled, // project also produces BIM (EWP) packages → Creator offers per-package IWP/EWP kind
|
||||
project: {
|
||||
name: state.project.name,
|
||||
number: state.project.number,
|
||||
@@ -651,15 +951,22 @@ function completeSOP(){
|
||||
instanceSuffix: state.governance.instanceSuffix || 'letter',
|
||||
sizeHoursMax: state.governance.sizeHoursMax || ''
|
||||
},
|
||||
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
|
||||
name: t.name,
|
||||
woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({
|
||||
name: t.name.trim(),
|
||||
enabled: true,
|
||||
notes: t.notes || '',
|
||||
approval: t.approval || ''
|
||||
approval: t.approval || '',
|
||||
bim: !!t.bim
|
||||
})),
|
||||
sources: state.sources.filter(s=>s.label),
|
||||
field: {trackPlatform: state.platforms.tracking},
|
||||
commissioning: {tool: state.platforms.commissioning},
|
||||
field: {trackPlatform: state.platforms.tracking, trackPlatformUrl: state.platforms.trackingUrl || ''},
|
||||
commissioning: {tool: state.platforms.commissioning, toolUrl: state.platforms.commissioningUrl || ''},
|
||||
// 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: {
|
||||
qcReq: state.quality.qcreq,
|
||||
photo: state.quality.photo,
|
||||
@@ -671,25 +978,36 @@ function completeSOP(){
|
||||
kind: s.kind || 'step'
|
||||
})),
|
||||
costCodes: LABOR_COST_CODES,
|
||||
constraints: state.constraints.map(c=>({name: c.name, description: c.description || ''}))
|
||||
constraints: state.constraints.map(c=>({name: c.name, description: c.description || '', bim: !!c.bim}))
|
||||
};
|
||||
|
||||
sopComplete = true;
|
||||
// Stamp the active project onto the SOP so it's unambiguously tied to it.
|
||||
try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){}
|
||||
updateProjectDisplay();
|
||||
|
||||
// Persist for the home page (green / "Review") and for the WP Creator tab.
|
||||
// Persist for the home page (green / "Review") and for the WP Creator tab,
|
||||
// namespaced to the active project so each project keeps its own SOP.
|
||||
try {
|
||||
localStorage.setItem('wp_suite_sop', JSON.stringify(sop));
|
||||
localStorage.setItem('wp_suite_state', JSON.stringify(state));
|
||||
localStorage.setItem('wp_suite_sop_complete', '1');
|
||||
localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
|
||||
localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
|
||||
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
|
||||
} catch(e){}
|
||||
|
||||
// Share the SOP to the server so every user of this project gets it.
|
||||
try {
|
||||
const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || '';
|
||||
if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state);
|
||||
} catch(e){}
|
||||
|
||||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
||||
|
||||
alert('✓ SOP Configuration Complete!\n\nSwitch to "Work Package Creation" to start creating Work Packages.');
|
||||
|
||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab.
|
||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
|
||||
// the user stays), then return to the project home page per the requested flow.
|
||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||||
|
||||
alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||||
@@ -699,8 +1017,17 @@ function toggleComments(){
|
||||
if(panel.style.display === 'block') loadStepComments();
|
||||
}
|
||||
|
||||
function currentUserName(){
|
||||
const u = window.WP_USER;
|
||||
return (u && (u.full_name || u.username)) || '';
|
||||
}
|
||||
function setCommenterName(){
|
||||
const el = document.getElementById('commenter-name');
|
||||
if(el) el.value = currentUserName();
|
||||
}
|
||||
|
||||
function submitComment(){
|
||||
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
||||
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
|
||||
if(!text){ alert('Please enter a comment.'); return; }
|
||||
@@ -717,9 +1044,7 @@ function submitComment(){
|
||||
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
|
||||
|
||||
document.getElementById('comment-text').value = '';
|
||||
document.getElementById('commenter-name').value = '';
|
||||
loadStepComments();
|
||||
alert('✓ Comment submitted!');
|
||||
}
|
||||
|
||||
function exportComments(){
|
||||
@@ -770,8 +1095,8 @@ function loadStepComments(){
|
||||
}else{
|
||||
list.innerHTML = stepComments.map(c=>`
|
||||
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
|
||||
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
|
||||
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'<').replace(/>/g,'>')}</div>
|
||||
<div 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:12px; color:var(--text);">${escAttr(c.text)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-light: #dbeafe;
|
||||
--success: #16a34a;
|
||||
--warning: #ea580c;
|
||||
--danger: #dc2626;
|
||||
--text: #1f2937;
|
||||
--text-light: #6b7280;
|
||||
--text-dim: #9ca3af;
|
||||
--border: #e5e7eb;
|
||||
--bg: #f9fafb;
|
||||
--primary: #0f62fe;
|
||||
--primary-light: #edf5ff;
|
||||
--success: #198038;
|
||||
--warning: #8e6a00;
|
||||
--warning-bg: #fdf6dd;
|
||||
--danger: #da1e28;
|
||||
--text: #161616;
|
||||
--text-light: #525252;
|
||||
--text-dim: #8d8d8d;
|
||||
--border: #e0e0e0;
|
||||
--border-strong: #8d8d8d;
|
||||
--bg: #f4f4f4;
|
||||
--bg-card: #ffffff;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
|
||||
--appbar: #161616;
|
||||
--shadow: none;
|
||||
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
@@ -28,43 +31,46 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
/* HEADER — dark UI Shell bar */
|
||||
.header {
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
padding: 1.5rem 2rem;
|
||||
background: var(--appbar);
|
||||
color: #fff;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
|
||||
.logo {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo:hover { opacity: 0.7; }
|
||||
.logo:hover { opacity: 0.92; }
|
||||
.logo img { height: 24px; width: auto; display: block; }
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--primary-light);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -73,84 +79,90 @@ body {
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
color: var(--text);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
min-height: 20px;
|
||||
font-size: 12px;
|
||||
color: #c6c6c6;
|
||||
min-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 1px solid #6f6f6f;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
background: #353535;
|
||||
border-color: #6f6f6f;
|
||||
}
|
||||
|
||||
.step-counter {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-light);
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: transparent;
|
||||
border: 1px solid #6f6f6f;
|
||||
color: #c6c6c6;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* MAIN NAVIGATION */
|
||||
/* MAIN NAVIGATION — underline tabs */
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 2rem;
|
||||
gap: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 13px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-icon { font-size: 16px; }
|
||||
@@ -186,35 +198,37 @@ body {
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.step-item:hover { background: var(--primary-light); border-color: var(--primary); }
|
||||
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); }
|
||||
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; }
|
||||
|
||||
/* STEP CONTENT */
|
||||
.step-content {
|
||||
background: var(--bg-card);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.step { display: none; }
|
||||
|
||||
.step h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -223,7 +237,7 @@ body {
|
||||
color: var(--text-light);
|
||||
background: var(--primary-light);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
@@ -253,7 +267,7 @@ body {
|
||||
.field textarea {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
@@ -287,7 +301,7 @@ body {
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -319,7 +333,7 @@ body {
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
@@ -339,31 +353,32 @@ body {
|
||||
|
||||
/* BUTTONS */
|
||||
.add-btn {
|
||||
padding: 0.75rem 1.25rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.add-btn:hover { background: #1d4ed8; }
|
||||
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); }
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 1.4rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); }
|
||||
|
||||
.nav-btn.primary {
|
||||
background: var(--success);
|
||||
@@ -371,7 +386,7 @@ body {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
|
||||
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; }
|
||||
|
||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@@ -382,7 +397,7 @@ body {
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@@ -398,7 +413,7 @@ body {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -411,7 +426,7 @@ body {
|
||||
#sequence-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.seq-step {
|
||||
display: flex; align-items: center; gap: 12px; padding: 11px 14px;
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0;
|
||||
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
|
||||
}
|
||||
.seq-step:hover { border-color: var(--primary); }
|
||||
@@ -432,7 +447,7 @@ body {
|
||||
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-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
|
||||
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; }
|
||||
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
|
||||
.seq-gate-badge {
|
||||
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
|
||||
@@ -448,7 +463,7 @@ body {
|
||||
max-width: calc(100vw - 2rem);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1.25rem;
|
||||
z-index: 1200;
|
||||
@@ -488,7 +503,7 @@ body {
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
@@ -528,7 +543,7 @@ body {
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; text-align: center; gap: 1rem; }
|
||||
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
|
||||
.main-nav { flex-wrap: wrap; }
|
||||
.content-area { padding: 1rem; }
|
||||
.step-content { padding: 1rem; }
|
||||
@@ -4,7 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>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">
|
||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||
</head>
|
||||
@@ -14,17 +17,17 @@
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<a href="index.html" class="logo" title="Back to Home">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;">
|
||||
</a>
|
||||
<div>
|
||||
<div style="min-width:0;overflow:hidden">
|
||||
<div class="header-title">Work Package Suite</div>
|
||||
<div class="header-subtitle" id="project-display"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load example SOP data">⭐ Load Sample</button>
|
||||
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
|
||||
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
|
||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
|
||||
<button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button>
|
||||
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
|
||||
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,10 +35,13 @@
|
||||
<!-- MAIN NAVIGATION -->
|
||||
<div class="main-nav">
|
||||
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
|
||||
<span class="tab-icon">⚙️</span> SOP Configuration
|
||||
SOP Configuration
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
||||
<span class="tab-icon">📋</span> Work Package Creation
|
||||
Work Package Creation
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -126,19 +132,19 @@
|
||||
<!-- STEP 3: SIGN-OFF ROLES -->
|
||||
<div class="step" id="sop-step-3" style="display: none;">
|
||||
<h2>3. Required Sign-Off Roles</h2>
|
||||
<div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
|
||||
<div class="notice">Two roles are required on every package. They default to <strong>Superintendent</strong> and <strong>Foreman</strong> — rename either to fit your project (e.g. a BIM SOP uses <em>BIM Coordinator</em> and <em>Construction Lead</em>). Add more below.</div>
|
||||
<div class="required-roles">
|
||||
<div class="role-required">
|
||||
<div class="role-checkbox">
|
||||
<input type="checkbox" id="role_super" checked disabled>
|
||||
<label>Superintendent *</label>
|
||||
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||
</div>
|
||||
<input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
||||
</div>
|
||||
<div class="role-required">
|
||||
<div class="role-checkbox">
|
||||
<input type="checkbox" id="role_foreman" checked disabled>
|
||||
<label>Foreman *</label>
|
||||
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||
</div>
|
||||
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
||||
</div>
|
||||
@@ -154,6 +160,11 @@
|
||||
<div class="step" id="sop-step-4" style="display: none;">
|
||||
<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>
|
||||
<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 & 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>
|
||||
|
||||
@@ -168,14 +179,23 @@
|
||||
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Issuance Strategy</label>
|
||||
<select id="gov_issuance" multiple size="3">
|
||||
<label>Issuance Strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
|
||||
<select id="gov_issuance" multiple size="4">
|
||||
<option selected>By Sector / Area</option>
|
||||
<option>By Discipline</option>
|
||||
<option>By Phase / Sequence</option>
|
||||
<option>By Resource Availability</option>
|
||||
</select>
|
||||
<small>Hold Ctrl to select multiple</small>
|
||||
<small>Hold Ctrl (Cmd on Mac) to select multiple.</small>
|
||||
<div class="notice" style="margin-top:0.6rem; font-size:12px;">
|
||||
<strong>Examples:</strong>
|
||||
<ul style="margin:0.35rem 0 0; padding-left:1.1rem;">
|
||||
<li><strong>By Sector / Area</strong> — one package per physical area, e.g. <em>all work in Sector 1P, Level 2 chase</em>.</li>
|
||||
<li><strong>By Discipline</strong> — separate packages per trade, e.g. <em>Electrical wire-pull</em> vs <em>Mechanical install</em>.</li>
|
||||
<li><strong>By Phase / Sequence</strong> — follow the build order, e.g. <em>rough-in → wire pull → terminations</em>.</li>
|
||||
<li><strong>By Resource Availability</strong> — size to a crew/equipment window, e.g. <em>one boom-lift crew's week</em>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +208,7 @@
|
||||
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Discipline strategy *</label>
|
||||
<label>Discipline strategy *<span class="help-tip" data-tip="Decides whether a package can carry several disciplines (scope split per discipline) or one each. 'Let the planner choose' allows building a big multi-discipline package and splitting it later.">i</span></label>
|
||||
<select id="gov_discmode">
|
||||
<option value="choice">Let the planner choose per package (recommended)</option>
|
||||
<option value="single">One discipline per package (many small packages)</option>
|
||||
@@ -202,13 +222,20 @@
|
||||
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 1–2 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Typical WP Size (guidance)</label>
|
||||
<input type="text" id="gov_wosize" placeholder="e.g., 3–5 days or 40–80 hours">
|
||||
<label>Typical WP Size</label>
|
||||
<select id="gov_wosize" onchange="onSizePresetChange()">
|
||||
<option value="">Select…</option>
|
||||
<option value="Small — 1–2 days (≈8–24 hrs)">Small — 1–2 days (≈8–24 hrs)</option>
|
||||
<option value="Standard — 3–5 days (≈40–80 hrs)">Standard — 3–5 days (≈40–80 hrs)</option>
|
||||
<option value="Large — 1–2 weeks (≈80–160 hrs)">Large — 1–2 weeks (≈80–160 hrs)</option>
|
||||
<option value="Custom…">Custom…</option>
|
||||
</select>
|
||||
<small>Sets the split threshold automatically; choose Custom to enter your own.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Split threshold — max labor hours</label>
|
||||
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 120">
|
||||
<small>The Creator flags packages above this so they can be split (by discipline or scope).</small>
|
||||
<label>Split threshold — max labor hours<span class="help-tip" data-tip="The Work Package Creator flags any package whose estimated hours exceed this so the planner can break it down. Auto-set by the size band; override if needed.">i</span></label>
|
||||
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
|
||||
<small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +294,16 @@
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<!-- STEP 8: SEQUENCE -->
|
||||
@@ -277,7 +314,7 @@
|
||||
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
|
||||
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
|
||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">◆ Add QC Hold</button>
|
||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -308,9 +345,9 @@
|
||||
|
||||
<!-- SOP NAVIGATION -->
|
||||
<div class="step-navigation">
|
||||
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">← Back</button>
|
||||
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next →</button>
|
||||
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">✓ SOP Complete</button>
|
||||
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
|
||||
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
|
||||
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -321,9 +358,9 @@
|
||||
<div id="tool-wp" class="tool">
|
||||
<!-- Shown until the SOP is complete -->
|
||||
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
|
||||
<h2>📋 Work Package Creation</h2>
|
||||
<h2>Work Package Creation</h2>
|
||||
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
|
||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">← Go to SOP Configuration</button>
|
||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
|
||||
</div>
|
||||
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
||||
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
|
||||
@@ -336,12 +373,12 @@
|
||||
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
|
||||
<div id="comments-panel" class="comments-dropdown" style="display: none;">
|
||||
<div class="comments-dropdown-header">
|
||||
<strong>💬 Step Comments</strong>
|
||||
<strong>Feedback</strong>
|
||||
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
|
||||
<input type="text" id="commenter-name" placeholder="e.g., Bill Clarida" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name</label>
|
||||
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
||||
@@ -349,8 +386,8 @@
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
||||
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤓ Export</button>
|
||||
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤒ Import</button>
|
||||
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
|
||||
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
|
||||
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
|
||||
</div>
|
||||
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
|
||||
@@ -363,12 +400,19 @@
|
||||
<h3>Add Custom Constraint</h3>
|
||||
<button class="modal-close" onclick="closeConstraintModal()">✕</button>
|
||||
</div>
|
||||
<div id="constraint-library" style="max-height: 400px; overflow-y: auto; margin: 1rem 0;"></div>
|
||||
<div style="display:flex; gap:0.5rem; margin:1rem 0 0.5rem;">
|
||||
<input type="text" id="custom-constraint-input" placeholder="Type a custom constraint name…" style="flex:1; padding:0.55rem 0.65rem; border:1px solid var(--border); border-radius:4px;" onkeydown="if(event.key==='Enter'){addCustomConstraintText();event.preventDefault();}">
|
||||
<button class="add-btn" onclick="addCustomConstraintText()">Add</button>
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-dim); margin-bottom:0.5rem;">…or pick from the library:</div>
|
||||
<div id="constraint-library" style="max-height: 320px; overflow-y: auto; margin: 0 0 1rem;"></div>
|
||||
<button class="nav-btn" onclick="closeConstraintModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="work-package-suite-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,7 +8,7 @@ const SAMPLE_SOP = {
|
||||
meta:{tool:'Work Package Configuration', sample:true},
|
||||
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
||||
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
||||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' },
|
||||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 3–5 days (≈40–80 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' },
|
||||
woTypes:[
|
||||
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
||||
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
||||
@@ -34,15 +34,17 @@ const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
|
||||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
||||
|
||||
// Acumatica cost codes (comment 10) — code|description
|
||||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract','5010|Instrument Material','5020|Network & Computers Material','5030|Software Material','5040|PLC Material','5050|Panel Material','5060|Electrical Material','5070|Mechanical Material','5080|Security Material','5090|Radio Material','6000|Production','7000|Quality','7100|Panel Quality Control','7200|Factory Acceptance Testing','7300|Site Acceptance Testing','8000|Safety','9000|Administration','9100|Warranty','9200|Freight','9300|Travel','9350|Jobsite Costs/Consumable/Other Direct Costs','9400|Contingency','9450|Other','9500|Accrued Incentive Compensation','9600|Sales Tax','9650|Job Cost Labor Burden','9700|Bonding','9800|Non-Billable Compensation'];
|
||||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract'];
|
||||
// Acumatica allowed units of measure (comment 15) — common first
|
||||
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
||||
|
||||
// Example built work package (comment 4 / "Load Example") — WP02 export
|
||||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Bill Clarida (Prime Controls), Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
||||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
||||
|
||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||
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 pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||
@@ -56,7 +58,11 @@ let currentView='Work Package Form';
|
||||
|
||||
// ── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
|
||||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
// Attribute-safe HTML escaper (also escapes " and ' so values are safe inside
|
||||
// href="…" / src="…" attributes, not just element text).
|
||||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
// 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 cell(v){ return v ? esc(v) : ns(); }
|
||||
function pad2(n){ return n<10?'0'+n:''+n; }
|
||||
@@ -66,7 +72,12 @@ 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 setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); }
|
||||
function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
|
||||
function constraintNames(){ return (SOP&&Array.isArray(SOP.constraints)&&SOP.constraints.length)?SOP.constraints:DEFAULT_CONSTRAINTS; }
|
||||
function constraintNames(){
|
||||
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; }
|
||||
|
||||
// ── SOP LOADING ──────────────────────────────────────────────────────────────
|
||||
@@ -91,12 +102,39 @@ function applySOP(){
|
||||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||
const hint=document.getElementById('wp_number_hint'); hint.textContent = SOP.governance&&SOP.governance.woFormat ? 'auto-built · format: '+SOP.governance.woFormat : '';
|
||||
buildConstraints(); buildSignoffs();
|
||||
applyKind();
|
||||
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
||||
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
||||
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||||
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
||||
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(){
|
||||
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('');
|
||||
@@ -135,7 +173,12 @@ function editQuality(id){
|
||||
}
|
||||
function renderCtxBar(){
|
||||
const bar=document.getElementById('ctx-bar');
|
||||
if(!SOP){ bar.innerHTML=`<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; return; }
|
||||
if(!SOP){
|
||||
bar.innerHTML = activeProjectId
|
||||
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
||||
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`;
|
||||
return;
|
||||
}
|
||||
const p=SOP.project||{}, g=SOP.governance||{};
|
||||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
||||
@@ -158,14 +201,20 @@ function renderSopRefLinks(){
|
||||
const srcs=sopLinkedSources();
|
||||
if(!srcs.length){ box.innerHTML=''; return; }
|
||||
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find & copy the specific file link:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||||
`<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>`;
|
||||
}
|
||||
function renderSpecFolderLink(){
|
||||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
||||
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
|
||||
el.innerHTML = spec ? `<a href="${esc(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
||||
el.innerHTML = spec ? `<a href="${hrefAttr(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 onTypeChange(){
|
||||
updateNumber(); track('type_selected');
|
||||
@@ -371,12 +420,15 @@ function rollupDisciplineStatus(){
|
||||
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
||||
function onHoursChange(){
|
||||
const el=document.getElementById('size-check'); if(!el) return;
|
||||
const max=parseFloat((SOP&&SOP.governance&&SOP.governance.sizeHoursMax)||'');
|
||||
const g=(SOP&&SOP.governance)||{};
|
||||
const band=g.woSize?('Target: '+g.woSize+'. '):'';
|
||||
const max=parseFloat(g.sizeHoursMax||'');
|
||||
const hrs=parseFloat(gv('wp_hours'));
|
||||
if(max && hrs && hrs>max){
|
||||
el.innerHTML=`<span style="color:var(--accent-amber)">⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||||
} else if(max){ el.textContent=`Split threshold: ${max} hrs (from SOP).`; }
|
||||
else { el.textContent=''; }
|
||||
el.innerHTML=`<span style="color:var(--accent-amber)">${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||||
} else if(band || max){
|
||||
el.innerHTML=`<span>${esc(band)}${max?'Split threshold: '+max+' hrs.':''}</span>`;
|
||||
} else { el.textContent=''; }
|
||||
}
|
||||
|
||||
// ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
|
||||
@@ -476,7 +528,7 @@ function renderSopFileFolders(){
|
||||
const srcs=sopLinkedSources();
|
||||
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="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="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="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
|
||||
}
|
||||
function toggleSopFilePanel(){
|
||||
@@ -520,6 +572,20 @@ function setConstraint(i,val){
|
||||
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
|
||||
prevStatus=getRadio('status'); holdContext={index:i, before};
|
||||
openHoldModal(pkgConstraints[i].name, true);
|
||||
return;
|
||||
}
|
||||
// Clearing the LAST open constraint makes the package release-ready — offer to
|
||||
// issue it and scroll up to the status control so the change is visible.
|
||||
if(before==='open' && val!=='open' && readiness().open===0){
|
||||
const st=getRadio('status');
|
||||
if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
|
||||
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
|
||||
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
|
||||
track('status_change',{status:'Issued',via:'constraint_clear'});
|
||||
}
|
||||
const sg=document.getElementById('status-group');
|
||||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
}
|
||||
}
|
||||
}
|
||||
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
|
||||
@@ -530,6 +596,7 @@ function updateReleaseBanner(){
|
||||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
||||
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
|
||||
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
||||
updateStickyStatus();
|
||||
}
|
||||
function onStatusChange(target){
|
||||
const idx=STATUS_ORDER.indexOf(target);
|
||||
@@ -556,7 +623,7 @@ function openHoldModal(preselect, fromConstraint){
|
||||
}
|
||||
function holdPhotoChange(ev){
|
||||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
||||
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; document.getElementById('hold-photo-preview').innerHTML=`<img src="${holdPhotoData}" alt="supporting photo">`; }; r.readAsDataURL(f);
|
||||
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);
|
||||
}
|
||||
function submitHold(){
|
||||
const constraint=document.getElementById('hold-constraint').value;
|
||||
@@ -631,11 +698,12 @@ function collectPackage(){
|
||||
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
||||
return {
|
||||
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
||||
projectId: (prev&&prev.projectId) || activeProjectId || '',
|
||||
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
||||
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'),
|
||||
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
|
||||
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
|
||||
disciplines:[...pkgDisciplines],
|
||||
@@ -652,7 +720,15 @@ function collectPackage(){
|
||||
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
|
||||
holds:pkgHolds.map(h=>({...h})),
|
||||
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
|
||||
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 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()
|
||||
};
|
||||
}
|
||||
@@ -662,15 +738,19 @@ function savePackage(view){
|
||||
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
||||
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
||||
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
|
||||
}
|
||||
function renderPackage(pkg){
|
||||
const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0};
|
||||
const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS'));
|
||||
const kindLbl = pkg.kind==='ewp' ? 'BIM (EWP)' : 'IWP';
|
||||
let h=`<h1>${esc(pkg.number||'(no number)')} — Work Package</h1>
|
||||
<div class="doc-subtitle">${esc(pkg.project)} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}</div>`;
|
||||
<div class="doc-subtitle">${esc(pkg.project)} · ${kindLbl} · 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];
|
||||
// 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>
|
||||
<tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr>
|
||||
<tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr>
|
||||
@@ -685,6 +765,9 @@ function renderPackage(pkg){
|
||||
<tr><th>Due Date</th><td>${cell(pkg.due)}</td></tr>
|
||||
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
|
||||
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
|
||||
${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>`;
|
||||
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>`; }
|
||||
@@ -751,13 +834,82 @@ function printPackage(){
|
||||
|
||||
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
||||
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
|
||||
function showOutput(){ hideDashboard(); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
|
||||
// Sticky save bar + section-nav chrome (shown only on the editable form view).
|
||||
function setFormChrome(on){
|
||||
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
|
||||
if(nav) nav.style.display = on ? '' : 'none';
|
||||
if(save) save.style.display = on ? 'flex' : 'none';
|
||||
document.body.classList.toggle('has-sticky-save', !!on);
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
|
||||
}
|
||||
// Make each form card collapsible by clicking its heading (idempotent).
|
||||
function makeCollapsible(){
|
||||
document.querySelectorAll('.main > .card').forEach(card=>{
|
||||
if(card.id==='saved-card') return;
|
||||
const head=card.querySelector('.section-header, .sub-heading');
|
||||
if(!head || head.dataset.collapsible) return;
|
||||
head.dataset.collapsible='1';
|
||||
head.style.cursor='pointer';
|
||||
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
|
||||
head.insertBefore(chev, head.firstChild);
|
||||
head.addEventListener('click', e=>{
|
||||
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
|
||||
const collapsed=card.classList.toggle('collapsed');
|
||||
chev.textContent = collapsed ? '▸' : '▾';
|
||||
});
|
||||
});
|
||||
}
|
||||
function buildSectionNav(){
|
||||
const nav=document.getElementById('section-nav'); if(!nav) return;
|
||||
const chips=[];
|
||||
document.querySelectorAll('.main > .card').forEach((card,i)=>{
|
||||
if(card.id==='saved-card' || card.style.display==='none') return;
|
||||
const h=card.querySelector('.section-title, .sub-heading'); if(!h) return;
|
||||
const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove());
|
||||
const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return;
|
||||
if(!card.id) card.id='sec-'+i;
|
||||
chips.push(`<span class="sec-chip" onclick="document.getElementById('${card.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(label)}</span>`);
|
||||
});
|
||||
nav.innerHTML=chips.join('');
|
||||
}
|
||||
// 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(){
|
||||
const el=document.getElementById('sticky-status'); if(!el) return;
|
||||
const r=readiness(); const st=getRadio('status');
|
||||
if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; }
|
||||
else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; }
|
||||
else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; }
|
||||
}
|
||||
|
||||
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
|
||||
const STORE_KEY='wp_iwp_v1';
|
||||
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} }
|
||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} }
|
||||
// Per-project namespaced key so each project keeps its own packages in the browser.
|
||||
function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } }
|
||||
function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} }
|
||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
|
||||
function renderSavedList(){
|
||||
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
||||
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
||||
@@ -768,22 +920,59 @@ function renderSavedList(){
|
||||
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
|
||||
const 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>
|
||||
<td>${esc(p.status||'')}</td><td>${ready}</td>
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||
<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>`;
|
||||
}).join('');
|
||||
}
|
||||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); }
|
||||
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages on this device?')) return; savedPackages=[]; saveStore(); renderSavedList(); }
|
||||
|
||||
// ── 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 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 loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
||||
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||'';};
|
||||
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_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_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_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||'';
|
||||
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
|
||||
set('wp_wbs',p.wbs);
|
||||
@@ -818,10 +1007,43 @@ function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; bu
|
||||
buildConstraints(); }
|
||||
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
||||
|
||||
// Duplicate the current work package N times (asks how many). Each copy is a
|
||||
// fresh Draft with a unique number/subject and approvals/closeout cleared.
|
||||
function duplicateWP(){
|
||||
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; }
|
||||
const ans=prompt('How many copies of this work package do you want to create?','1');
|
||||
if(ans===null) return;
|
||||
const n=parseInt(ans,10);
|
||||
if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; }
|
||||
const base=collectPackage();
|
||||
const baseNum = base.number || ('WP'+pad2(editingSeq()));
|
||||
const made=[];
|
||||
for(let i=1;i<=n;i++){
|
||||
const c=JSON.parse(JSON.stringify(base));
|
||||
c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i;
|
||||
c.number = baseNum + '-C' + i;
|
||||
c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)');
|
||||
c.status='Draft';
|
||||
c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined;
|
||||
if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''}));
|
||||
c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons='';
|
||||
c.projectId = base.projectId || activeProjectId || '';
|
||||
c.updatedAt=new Date().toISOString();
|
||||
savedPackages.push(c); made.push(c);
|
||||
}
|
||||
editingId=null; saveStore(); renderSavedList();
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
|
||||
toast('Created '+n+' duplicate'+(n>1?'s':''));
|
||||
track('wp_duplicated',{count:n});
|
||||
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
|
||||
}
|
||||
|
||||
function newPackage(){
|
||||
editingId=null;
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_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='';});
|
||||
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');
|
||||
numberDims={}; buildNumberDims();
|
||||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||||
@@ -851,18 +1073,61 @@ const WPData = {
|
||||
list(){ return savedPackages.slice(); }, // → GET /api/wps
|
||||
get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id}
|
||||
issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||||
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); return true; }, // → POST /api/wps/{id}/issue
|
||||
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore();
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||||
setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||||
p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status
|
||||
p.status=status; p.updatedAt=new Date().toISOString(); saveStore();
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||||
};
|
||||
|
||||
let dashFilter={status:'',discipline:'',q:''};
|
||||
let dashFilter={status:'',discipline:'',q:'',flag:''};
|
||||
function dashToggleFlag(f){
|
||||
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
|
||||
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||||
dashPage=0; 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||''));
|
||||
}
|
||||
// Consistent colored status pill, reused by the dashboard board and the saved list.
|
||||
function statusPill(s){
|
||||
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
|
||||
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
|
||||
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
|
||||
}
|
||||
function 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 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.
|
||||
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
||||
|
||||
function showDashboard(){
|
||||
setFormChrome(false);
|
||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
||||
document.getElementById('pkg-output').style.display='none';
|
||||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||||
@@ -872,32 +1137,51 @@ function showDashboard(){
|
||||
function renderDashboard(){
|
||||
const all=countableWPs();
|
||||
const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0);
|
||||
let estH=0, actH=0, ready=0, hold=0, overdue=0; const byDisc={};
|
||||
let estH=0, actH=0, ready=0, hold=0, overdue=0, mine=0; const byDisc={}; const meId=myUserId();
|
||||
all.forEach(p=>{
|
||||
byStatus[p.status]=(byStatus[p.status]||0)+1;
|
||||
estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0;
|
||||
if(p.status==='Issue') hold++;
|
||||
if(meId && p.assigneeId===meId) mine++;
|
||||
if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++;
|
||||
if(isOverdue(p)) overdue++;
|
||||
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
||||
});
|
||||
const card=(label,val,cls)=>`<div class="dash-metric ${cls||''}"><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||
// Clickable metric cards filter the board (flag-based); a card with no flag is static.
|
||||
const card=(label,val,cls,flag)=>{
|
||||
const active = flag && dashFilter.flag===flag ? ' dm-active' : '';
|
||||
const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : '';
|
||||
return `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||
};
|
||||
let h=`<div class="dash-metrics">
|
||||
${card('Total WPs', all.length)}
|
||||
${card('Release-ready', ready, ready?'dm-green':'')}
|
||||
${card('On hold', hold, hold?'dm-red':'')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'')}
|
||||
${card('Total WPs', all.length, '', 'all')}
|
||||
${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''}
|
||||
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||||
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||
${card('Est. hrs', Math.round(estH))}
|
||||
${card('Actual hrs', Math.round(actH))}
|
||||
</div>`;
|
||||
|
||||
// status + discipline breakdown chips
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('')
|
||||
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:'');
|
||||
// status + discipline breakdown chips (status chips also filter the board)
|
||||
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||||
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||
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>`;
|
||||
|
||||
// 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 Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
|
||||
h+=prog;
|
||||
|
||||
// gating panel — what's blocking release
|
||||
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>`;
|
||||
@@ -912,36 +1196,60 @@ function renderDashboard(){
|
||||
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('');
|
||||
h+=`<div class="dash-filters">
|
||||
<input type="search" placeholder="Search WP # / subject…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;renderDashboard()">
|
||||
<select onchange="dashFilter.status=this.value;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;renderDashboard()">${discOpts}</select>
|
||||
<input type="search" placeholder="Search WP # / subject / type…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;dashPage=0;renderDashboard()">
|
||||
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
|
||||
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
|
||||
</div>`;
|
||||
|
||||
// main board (includes masters, marked)
|
||||
// main board (includes masters, marked; archived only when toggled on)
|
||||
const q=(dashFilter.q||'').toLowerCase();
|
||||
const rows=WPData.list().filter(p=>{
|
||||
const boardSource = dashShowArchived ? WPData.list().concat(dashArchived) : WPData.list();
|
||||
const rows=boardSource.filter(p=>{
|
||||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||||
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
||||
if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).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==='onhold' && p.status!=='Issue') return false;
|
||||
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
|
||||
return true;
|
||||
});
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||||
const totalRows=rows.length;
|
||||
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>`;
|
||||
if(!rows.length) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||||
rows.forEach(p=>{
|
||||
if(!totalRows) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||||
pageRows.forEach(p=>{
|
||||
const ix=savedPackages.findIndex(x=>x.id===p.id);
|
||||
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 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 issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${p.id}')">issue</button>`:'';
|
||||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">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${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 style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||||
<td>${esc(p.status||'')}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||||
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
||||
<td>${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>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
h+=`</tbody></table>`;
|
||||
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;
|
||||
}
|
||||
function dashIssue(id){
|
||||
@@ -1010,25 +1318,66 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
|
||||
}));
|
||||
|
||||
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
||||
loadStore();
|
||||
(function bootSOP(){
|
||||
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
|
||||
(function seedProject(){
|
||||
const params = new URLSearchParams(location.search);
|
||||
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){
|
||||
const cached = ProjectData.getActive && ProjectData.getActive();
|
||||
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
|
||||
}
|
||||
})();
|
||||
function bootSOP(){
|
||||
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
||||
// and prefer the SOP the Suite just completed (persisted to localStorage).
|
||||
// and prefer the SOP the Suite just completed (persisted to localStorage, which
|
||||
// we've already hydrated from the server for this project).
|
||||
const params = new URLSearchParams(location.search);
|
||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
||||
try {
|
||||
const raw = localStorage.getItem('wp_suite_sop');
|
||||
const raw = localStorage.getItem(wpKey('wp_suite_sop'));
|
||||
if(raw){
|
||||
const d = JSON.parse(raw);
|
||||
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
||||
}
|
||||
} catch(e){}
|
||||
loadSampleSOP();
|
||||
})();
|
||||
// No SOP found. Only show the Micron SAMPLE for a standalone preview (no project
|
||||
// context). For a real project, never substitute sample data — show the empty
|
||||
// state so it's clear the project's SOP must be completed first.
|
||||
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||||
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(){
|
||||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||||
bootSOP();
|
||||
setRadio('status','Draft');
|
||||
loadMembers();
|
||||
renderSavedList();
|
||||
cmtInit();
|
||||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
||||
(function(){ const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } })();
|
||||
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
||||
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
||||
track('app_open');
|
||||
}
|
||||
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
||||
// Pull this project's shared SOP + Work Packages from the server first, then boot
|
||||
// off the refreshed cache. Falls back to whatever is cached locally if offline.
|
||||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||
ProjectData.pullProject(activeProjectId).then(bootData).catch(bootData);
|
||||
} else {
|
||||
bootData();
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Work Package (IWP) — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-creation-styles.css">
|
||||
</head>
|
||||
@@ -13,21 +16,23 @@
|
||||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="logo-wrap">
|
||||
<div class="logo-wrap embed-hide">
|
||||
<div class="header-logo">Prime Controls</div>
|
||||
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
||||
</div>
|
||||
<div class="header-sep">|</div>
|
||||
<div class="header-sep embed-hide">|</div>
|
||||
<div class="header-title">Work Package (IWP)</div>
|
||||
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
||||
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||
<button class="btn btn-ghost" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button>
|
||||
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||
</div>
|
||||
|
||||
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
|
||||
@@ -37,14 +42,27 @@
|
||||
<!-- RELEASE READINESS BANNER -->
|
||||
<div class="release-banner" id="release-banner"></div>
|
||||
|
||||
<!-- SECTION NAV (jump links, built from the form cards) -->
|
||||
<div class="section-nav-bar" id="section-nav"></div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
|
||||
<div class="card" id="kind-row" style="display:none">
|
||||
<div class="sub-heading">Package Type</div>
|
||||
<div class="notice">This project includes BIM/VDC packages. Choose what this one is — it tailors the fields below and the WP types / release gates offered.</div>
|
||||
<div class="radio-group" id="kind-group" style="margin-bottom:0">
|
||||
<label class="radio-pill" data-val="iwp"><input type="radio" name="pkgkind" onclick="setKind('iwp')"><span class="dot"></span>Install package (IWP)</label>
|
||||
<label class="radio-pill" data-val="ewp"><input type="radio" name="pkgkind" onclick="setKind('ewp')"><span class="dot"></span>BIM package (EWP)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GENERAL INFORMATION -->
|
||||
<div class="card">
|
||||
<div class="section-header"><div class="section-title">General Information</div>
|
||||
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>Status</label>
|
||||
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
||||
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
||||
@@ -69,16 +87,32 @@
|
||||
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
|
||||
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
|
||||
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
|
||||
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
|
||||
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||
<div 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>
|
||||
|
||||
<!-- ASSETS (controls.dev) -->
|
||||
<div class="card">
|
||||
<div class="card" id="asset-card">
|
||||
<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="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
||||
@@ -87,14 +121,14 @@
|
||||
|
||||
<!-- DISCIPLINES -->
|
||||
<div class="card" id="discipline-card" style="display:none">
|
||||
<div class="sub-heading">Disciplines</div>
|
||||
<div class="sub-heading">Disciplines<span class="help-tip" data-tip="Pick every discipline this package covers. Choosing two or more turns Scope into per-discipline sections and enables Split by Discipline.">i</span></div>
|
||||
<div class="notice" id="discipline-note"></div>
|
||||
<div class="disc-picker" id="discipline-picker"></div>
|
||||
</div>
|
||||
|
||||
<!-- SCOPE & WORK -->
|
||||
<div class="card">
|
||||
<div class="sub-heading">Scope & Work</div>
|
||||
<div class="sub-heading">Scope & Work<span class="help-tip" data-tip="Ordered steps the crew performs. With multiple disciplines selected, each gets its own scope section and status. Use Split by Discipline to break a large package into WP01A / WP01B / WP01C instances.">i</span></div>
|
||||
<div id="flat-scope">
|
||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||
@@ -111,8 +145,8 @@
|
||||
</div>
|
||||
|
||||
<!-- MATERIAL LIST -->
|
||||
<div class="card">
|
||||
<div class="sub-heading">Material List</div>
|
||||
<div class="card" id="material-card">
|
||||
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
|
||||
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
||||
<div class="material-actions">
|
||||
@@ -142,7 +176,7 @@
|
||||
</div>
|
||||
|
||||
<!-- KITTING & MIMO -->
|
||||
<div class="card">
|
||||
<div class="card" id="mimo-card">
|
||||
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Kitting Status</label>
|
||||
@@ -156,7 +190,7 @@
|
||||
|
||||
<!-- CONSTRAINTS / RELEASE READINESS -->
|
||||
<div class="card" id="constraint-card">
|
||||
<div class="sub-heading">Constraints — Release Readiness</div>
|
||||
<div class="sub-heading">Constraints — Release Readiness<span class="help-tip" data-tip="A package can't be Issued until every constraint is Cleared or N/A. If one reopens after release, the package drops to Issue (Hold).">i</span></div>
|
||||
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
|
||||
</div>
|
||||
@@ -275,7 +309,18 @@
|
||||
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
||||
</aside>
|
||||
|
||||
<!-- STICKY SAVE BAR (always-visible save + release status) -->
|
||||
<div class="sticky-save" id="sticky-save" style="display:none">
|
||||
<span class="sticky-status" id="sticky-status"></span>
|
||||
<div class="sticky-actions">
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,25 +7,25 @@
|
||||
body.embedded .embed-first { margin-left: auto; }
|
||||
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--bg: #f4f4f4;
|
||||
--surface: #ffffff;
|
||||
--surface2: #f7f8fa;
|
||||
--border: #e3e6ec;
|
||||
--border-strong: #d0d5de;
|
||||
--text: #1a2230;
|
||||
--text-muted: #5a6675;
|
||||
--text-dim: #9aa3b2;
|
||||
--accent: #2563d6;
|
||||
--accent-dim: #e8f0fe;
|
||||
--accent-green: #15924f;
|
||||
--accent-green-dim: #e4f6ec;
|
||||
--accent-amber: #b87100;
|
||||
--accent-amber-dim: #fdf2e0;
|
||||
--red: #cf3b3b;
|
||||
--red-dim: #fbeaea;
|
||||
--radius: 5px;
|
||||
--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,.08);
|
||||
--surface2: #f4f4f4;
|
||||
--border: #e0e0e0;
|
||||
--border-strong: #8d8d8d;
|
||||
--text: #161616;
|
||||
--text-muted: #525252;
|
||||
--text-dim: #8d8d8d;
|
||||
--accent: #0f62fe;
|
||||
--accent-dim: #edf5ff;
|
||||
--accent-green: #198038;
|
||||
--accent-green-dim: #defbe6;
|
||||
--accent-amber: #8e6a00;
|
||||
--accent-amber-dim: #fdf6dd;
|
||||
--red: #da1e28;
|
||||
--red-dim: #fff1f1;
|
||||
--radius: 0;
|
||||
--shadow: none;
|
||||
--shadow-lg: 0 4px 16px rgba(20,30,50,.12);
|
||||
--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;
|
||||
}
|
||||
@@ -213,7 +213,7 @@
|
||||
|
||||
.notice {
|
||||
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
|
||||
padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
|
||||
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* ── DELIVERABLES ── */
|
||||
@@ -245,9 +245,9 @@
|
||||
.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-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
|
||||
.btn-primary:hover { background: #1d52b8; }
|
||||
.btn-primary:hover { background: #0353e9; }
|
||||
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
|
||||
.btn-generate:hover { background: #117a42; }
|
||||
.btn-generate:hover { background: #0e6027; }
|
||||
|
||||
/* ── OUTPUT ── */
|
||||
#output-section { display: none; }
|
||||
@@ -419,7 +419,7 @@
|
||||
.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;
|
||||
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
|
||||
.use-btn:hover { background:#0f7a40; }
|
||||
.use-btn:hover { background:#0e6027; }
|
||||
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
||||
.sum-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; }
|
||||
@@ -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.open { display:flex; }
|
||||
.modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
||||
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
||||
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
|
||||
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
|
||||
.modal-body { padding:18px 20px; overflow-y:auto; }
|
||||
@@ -564,6 +564,33 @@
|
||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.so-ovr { margin-left:8px; font-size:11px; }
|
||||
|
||||
/* Collapsible form sections */
|
||||
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
|
||||
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
|
||||
.card.collapsed .section-desc { display:none; }
|
||||
|
||||
/* Section nav (jump chips) */
|
||||
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||
padding:8px 12px; background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
|
||||
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
|
||||
transition:transform .22s ease; }
|
||||
.section-nav-bar:empty{ display:none; }
|
||||
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
|
||||
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
|
||||
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
|
||||
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
|
||||
/* Sticky save bar */
|
||||
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
|
||||
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
|
||||
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
|
||||
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }
|
||||
.sticky-save .sticky-actions{ display:flex; gap:10px; }
|
||||
.ss-ready{ color:var(--accent-green); }
|
||||
.ss-notready{ color:var(--accent-amber); }
|
||||
.ss-hold{ color:var(--red); }
|
||||
body.has-sticky-save .main{ padding-bottom:74px; }
|
||||
|
||||
/* Disciplines + per-discipline scope */
|
||||
.disc-picker { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.disc-pill { display:flex; align-items:center; gap:7px; padding:7px 13px; border:1px solid var(--border-strong);
|
||||
@@ -581,16 +608,22 @@
|
||||
|
||||
/* Dashboard */
|
||||
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
|
||||
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
|
||||
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; }
|
||||
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
|
||||
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
|
||||
.dash-metric.dm-red .dm-val { color:var(--red); }
|
||||
.dash-metric.dm-blue .dm-val { color:var(--accent, #0f62fe); }
|
||||
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
||||
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
||||
.dash-chip[onclick] { cursor:pointer; }
|
||||
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
||||
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
||||
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
|
||||
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
|
||||
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
|
||||
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; }
|
||||
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
|
||||
.dash-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); }
|
||||
@@ -599,3 +632,27 @@
|
||||
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
|
||||
.dash-filters input[type=search] { flex:1; min-width:200px; }
|
||||
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
|
||||
|
||||
/* ── WP history (audit trail) modal ──────────────────────────────────────── */
|
||||
.hist-list { display:flex; flex-direction:column; }
|
||||
.hist-item { display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:baseline;
|
||||
padding:9px 2px; border-bottom:1px solid var(--border); }
|
||||
.hist-item:last-child { border-bottom:none; }
|
||||
.hist-when { font-family:var(--mono); font-size:11px; color:var(--text-muted); white-space:nowrap; }
|
||||
.hist-action { font-weight:600; color:var(--text); }
|
||||
.hist-detail { color:var(--accent); font-size:13px; }
|
||||
.hist-actor { font-size:12px; color:var(--text-muted); white-space:nowrap; }
|
||||
@media (max-width:560px){ .hist-item { grid-template-columns:1fr; gap:2px; } }
|
||||
|
||||
/* ── Dashboard progress bars + pager + archived toggle (Phase 2) ──────────── */
|
||||
.prog-row { display:grid; grid-template-columns:150px 1fr 96px; gap:10px; align-items:center; margin-bottom:7px; }
|
||||
.prog-name { font-size:12.5px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.prog-bar { height:10px; background:var(--surface2); border:1px solid var(--border); overflow:hidden; }
|
||||
.prog-fill { height:100%; background:var(--accent); transition:width .3s ease; }
|
||||
.prog-pct { font-size:12px; font-weight:600; color:var(--text); text-align:right; white-space:nowrap; }
|
||||
.prog-sub { font-weight:400; color:var(--text-muted); font-size:11px; }
|
||||
.dash-arch-toggle { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--text-muted); white-space:nowrap; cursor:pointer; }
|
||||
.dash-pager { display:flex; align-items:center; gap:12px; margin-top:12px; font-size:12.5px; color:var(--text-muted); }
|
||||
.dash-pager-btns { margin-left:auto; display:flex; gap:8px; }
|
||||
.dash-pager .btn { padding:5px 12px; }
|
||||
@media (max-width:560px){ .prog-row { grid-template-columns:110px 1fr 74px; } }
|
||||
589
index.html
589
index.html
@@ -1,589 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Package Suite — Prime Controls</title>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--cds-background);
|
||||
color: var(--cds-text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.header {
|
||||
background: var(--cds-layer);
|
||||
padding: 1.5rem 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
border-bottom: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
color: var(--cds-text-primary);
|
||||
background: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logo:hover { opacity: 0.9; }
|
||||
|
||||
.header-spacer { flex: 1; }
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-nav a {
|
||||
color: var(--cds-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.header-nav a:hover { color: var(--cds-text-primary); }
|
||||
|
||||
/* CONTAINER */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.625rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.125rem;
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* CARDS */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--cds-layer);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: var(--cds-text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--cds-button-primary);
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
display: inline-block;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
display: inline-block;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: background 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-button:hover {
|
||||
background: var(--cds-hover-primary);
|
||||
}
|
||||
|
||||
/* COMPLETE STATE (SOP done) */
|
||||
.card.complete {
|
||||
background: #ecfdf5;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
.card.complete .card-button { background: #16a34a; }
|
||||
.card.complete .card-button:hover { background: #15803d; }
|
||||
.card-status {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #16a34a;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.card.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* SECTION */
|
||||
.section {
|
||||
background: var(--cds-layer);
|
||||
border-radius: 4px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section p {
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.quick-start {
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.quick-start h2 { color: white; }
|
||||
.quick-start ol { margin-left: 1.5rem; line-height: 2; }
|
||||
.quick-start li { margin-bottom: 0.5rem; }
|
||||
|
||||
/* FOOTER */
|
||||
.footer {
|
||||
background: var(--cds-ui-01);
|
||||
color: var(--cds-text-secondary);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--cds-link-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* COMMENTS SECTION */
|
||||
.comments-section {
|
||||
background: var(--cds-layer);
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.comments-toggle {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.comments-toggle:hover { background: var(--cds-hover-primary); }
|
||||
|
||||
.comments-panel {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--cds-ui-01);
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.comments-panel.open { display: block; }
|
||||
|
||||
.comments-panel input,
|
||||
.comments-panel textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--cds-ui-02);
|
||||
color: var(--cds-text-primary);
|
||||
font-family: inherit;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.comments-panel textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.comment-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.comment-buttons button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:hover { background: var(--cds-hover-primary); }
|
||||
|
||||
.close-btn {
|
||||
background: var(--cds-border-subtle);
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
.close-btn:hover { background: var(--cds-hover-ui); }
|
||||
|
||||
.comments-list {
|
||||
margin-top: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 0.75rem;
|
||||
background: var(--cds-background);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
font-size: 11px;
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.header-content { flex-direction: column; text-align: center; }
|
||||
.header-spacer { display: none; }
|
||||
.hero h1 { font-size: 1.75rem; }
|
||||
.cards-grid { grid-template-columns: 1fr; }
|
||||
.container { padding: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<a href="index.html" class="logo">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls">
|
||||
<div>Work Package Suite</div>
|
||||
</a>
|
||||
<div class="header-spacer"></div>
|
||||
<nav class="header-nav">
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#comments">Feedback</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<div class="container">
|
||||
|
||||
<!-- HERO -->
|
||||
<div class="hero">
|
||||
<h1>Work Package Suite</h1>
|
||||
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p>
|
||||
</div>
|
||||
|
||||
<!-- TOOL CARDS -->
|
||||
<div class="cards-grid" id="overview">
|
||||
|
||||
<!-- SOP CONFIG -->
|
||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||
<h3>SOP Configuration</h3>
|
||||
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
|
||||
<button class="card-button" id="card-sop-btn">Open Tool</button>
|
||||
</a>
|
||||
|
||||
<!-- WP CREATOR -->
|
||||
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
|
||||
<h3>Work Package Creator</h3>
|
||||
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
|
||||
<button class="card-button" id="card-wp-btn">Open Tool</button>
|
||||
</a>
|
||||
|
||||
<!-- WP DASHBOARD -->
|
||||
<a href="work-package-suite.html?view=dashboard" class="card" id="card-dash">
|
||||
<h3>Work Package Dashboard</h3>
|
||||
<p>Track status and gating across every Work Package — release-readiness, on-hold packages, overdue work, hours, and breakdowns by status and discipline. Issue release-ready packages in one click.</p>
|
||||
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- QUICK START -->
|
||||
<div class="section quick-start">
|
||||
<h2>Getting Started</h2>
|
||||
<ol>
|
||||
<li><strong>Open "SOP Configuration"</strong> and complete the 10 steps for your project (~15 minutes)</li>
|
||||
<li><strong>Finish the SOP</strong> — this card turns green and unlocks the Work Package Creator</li>
|
||||
<li><strong>Open "Work Package Creator"</strong> to author Work Packages with your SOP defaults pre-populated</li>
|
||||
<li><strong>Leave feedback</strong> on any page using the feedback button below</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- COMMENTS SECTION -->
|
||||
<div class="comments-section" id="comments">
|
||||
<button class="comments-toggle" onclick="toggleComments()">Leave Feedback</button>
|
||||
<div class="comments-panel" id="comments-panel">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
|
||||
<input type="text" id="commenter-name" placeholder="Your name">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Feedback</label>
|
||||
<textarea id="comment-text" placeholder="Your feedback here..."></textarea>
|
||||
</div>
|
||||
<div class="comment-buttons">
|
||||
<button class="submit-btn" onclick="submitComment()">Submit</button>
|
||||
<button class="close-btn" onclick="exportFeedback()">⤓ Export</button>
|
||||
<button class="close-btn" onclick="document.getElementById('feedback-import').click()">⤒ Import</button>
|
||||
<button class="close-btn" onclick="toggleComments()">Close</button>
|
||||
<input type="file" id="feedback-import" accept="application/json" style="display:none" onchange="importFeedback(event)">
|
||||
</div>
|
||||
<div class="comments-list" id="comments-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUPPORT SECTION -->
|
||||
<div class="section">
|
||||
<h2>About This Suite</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
|
||||
<div>
|
||||
<h3>Two-Step Workflow</h3>
|
||||
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Leave Feedback</h3>
|
||||
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Offline & Collaborative</h3>
|
||||
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<p>Work Package Suite v1.0 | Prime Controls | All files work offline with local browser storage</p>
|
||||
</footer>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script>
|
||||
// Reflect SOP completion on the tool cards.
|
||||
(function reflectSOPStatus(){
|
||||
let complete = false, projName = '';
|
||||
try {
|
||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
||||
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
||||
projName = sop && sop.project && sop.project.name || '';
|
||||
} catch(e){}
|
||||
|
||||
const sopCard = document.getElementById('card-sop');
|
||||
const sopBtn = document.getElementById('card-sop-btn');
|
||||
const wpCard = document.getElementById('card-wp');
|
||||
const wpBtn = document.getElementById('card-wp-btn');
|
||||
|
||||
if(complete){
|
||||
sopCard.classList.add('complete');
|
||||
sopBtn.textContent = 'Review';
|
||||
const status = document.createElement('div');
|
||||
status.className = 'card-status';
|
||||
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
|
||||
sopCard.insertBefore(status, sopCard.firstChild);
|
||||
if(wpBtn) wpBtn.textContent = 'Open Creator';
|
||||
} else {
|
||||
if(wpCard) wpCard.classList.add('disabled');
|
||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||
}
|
||||
})();
|
||||
|
||||
let allComments = [];
|
||||
|
||||
function toggleComments() {
|
||||
const panel = document.getElementById('comments-panel');
|
||||
panel.classList.toggle('open');
|
||||
if (panel.classList.contains('open')) loadComments();
|
||||
}
|
||||
|
||||
function submitComment() {
|
||||
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
|
||||
if (!text) {
|
||||
alert('Please enter feedback.');
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = {
|
||||
name,
|
||||
text,
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
allComments.push(comment);
|
||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
||||
|
||||
document.getElementById('comment-text').value = '';
|
||||
document.getElementById('commenter-name').value = '';
|
||||
loadComments();
|
||||
alert('Thank you! Feedback submitted.');
|
||||
}
|
||||
|
||||
function exportFeedback() {
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
const data = saved ? JSON.parse(saved) : [];
|
||||
if (!data.length) { alert('No feedback to export yet.'); return; }
|
||||
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'wp-suite-feedback-home-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
function importFeedback(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
try {
|
||||
const inc = JSON.parse(r.result);
|
||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
||||
if (!incoming.length) { alert('No feedback found in that file.'); return; }
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
allComments = saved ? JSON.parse(saved) : [];
|
||||
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
||||
let added = 0;
|
||||
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||
loadComments();
|
||||
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { alert('Could not read that file.'); }
|
||||
ev.target.value = '';
|
||||
};
|
||||
r.readAsText(f);
|
||||
}
|
||||
|
||||
function loadComments() {
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
if (saved) allComments = JSON.parse(saved);
|
||||
|
||||
const list = document.getElementById('comments-list');
|
||||
if (allComments.length === 0) {
|
||||
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
|
||||
} else {
|
||||
list.innerHTML = allComments.map(c => `
|
||||
<div class="comment-item">
|
||||
<div class="comment-meta"><strong>${c.name}</strong> • ${c.timestamp}</div>
|
||||
<div class="comment-text">${c.text.replace(/</g,'<').replace(/>/g,'>')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -34,6 +34,13 @@ server {
|
||||
root /var/www/wp-suite; # <-- web root
|
||||
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 / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
4
nginx/Dockerfile
Normal file
4
nginx/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM nginx:alpine
|
||||
COPY nginx/conf.d/wp-suite.conf /etc/nginx/conf.d/wp-suite.conf
|
||||
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY html/ /usr/share/nginx/html/
|
||||
39
nginx/conf.d/wp-suite.conf
Normal file
39
nginx/conf.d/wp-suite.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
# Work Package Suite — NGINX site config
|
||||
# This container sits behind an external reverse proxy that handles SSL.
|
||||
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name wp.controls.dev;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ── 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 / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
# This container is only ever reached via the TLS-terminating external
|
||||
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
|
||||
# here is always "http") so the API marks the session cookie Secure.
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
client_max_body_size 5m;
|
||||
}
|
||||
}
|
||||
17
nginx/nginx.conf
Normal file
17
nginx/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
16
scripts/backup-cron.sh
Normal file
16
scripts/backup-cron.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed
|
||||
# interval (default: daily). Kept deliberately simple — a sleep loop instead of a
|
||||
# cron daemon — so it works in a bare postgres:16-alpine image.
|
||||
set -eu
|
||||
|
||||
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
|
||||
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
|
||||
|
||||
# Take one backup shortly after start so a freshly-deployed stack has an
|
||||
# immediate restore point instead of waiting a whole interval.
|
||||
sleep 20
|
||||
while true; do
|
||||
sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
5
scripts/backup.Dockerfile
Normal file
5
scripts/backup.Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
|
||||
# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime
|
||||
# (see the `backup` service in docker-compose.yml), so they're not COPYed here.
|
||||
FROM postgres:16-alpine
|
||||
RUN apk add --no-cache openssl
|
||||
58
scripts/db-backup.sh
Normal file
58
scripts/db-backup.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
|
||||
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
|
||||
#
|
||||
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
|
||||
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
|
||||
# the database holds customer IP — otherwise the dump (and every offsite copy) is
|
||||
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
|
||||
# possible); losing it means the backups are unrecoverable.
|
||||
#
|
||||
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
|
||||
set -eu
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
KEEP="${BACKUP_KEEP:-14}"
|
||||
PGHOST="${PGHOST:-db}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
ENC="${BACKUP_ENC_PASSPHRASE:-}"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
ts="$(date -u +%Y%m%d-%H%M%SZ)"
|
||||
if [ -n "$ENC" ]; then
|
||||
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
|
||||
else
|
||||
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
|
||||
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
|
||||
fi
|
||||
tmp="$out.partial"
|
||||
|
||||
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
|
||||
if [ -n "$ENC" ]; then
|
||||
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
|
||||
| gzip -c \
|
||||
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
|
||||
mv "$tmp" "$out"
|
||||
else
|
||||
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
|
||||
fi
|
||||
else
|
||||
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
|
||||
mv "$tmp" "$out"
|
||||
else
|
||||
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
|
||||
fi
|
||||
fi
|
||||
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
|
||||
|
||||
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
|
||||
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
|
||||
if [ "$count" -gt "$KEEP" ]; then
|
||||
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
|
||||
echo "[db-backup] pruning $f"
|
||||
rm -f "$f"
|
||||
done
|
||||
fi
|
||||
33
scripts/db-restore.sh
Normal file
33
scripts/db-restore.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
|
||||
#
|
||||
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
|
||||
# recreates objects before loading. Take a fresh backup first if in doubt.
|
||||
#
|
||||
# Usage (from the project root):
|
||||
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
|
||||
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
|
||||
# backup container's environment).
|
||||
set -eu
|
||||
|
||||
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
|
||||
PGHOST="${PGHOST:-db}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
|
||||
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
|
||||
|
||||
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
|
||||
case "$FILE" in
|
||||
*.enc)
|
||||
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
|
||||
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||
;;
|
||||
*)
|
||||
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||
;;
|
||||
esac
|
||||
echo "[db-restore] done."
|
||||
@@ -10,3 +10,23 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
||||
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
||||
# production the site is same-origin via NGINX, so leave this unset.
|
||||
# CORS_ORIGINS=http://localhost:5500
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
|
||||
# the API falls back to a random per-process key, so logins reset on every
|
||||
# restart and break across multiple gunicorn workers. Generate a strong one:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
|
||||
# How long a login lasts before re-authentication (hours). Default 12.
|
||||
# AUTH_SESSION_HOURS=12
|
||||
|
||||
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||
# console (Notifications & email card), where the SMTP host/port/from-address
|
||||
# live. The one secret that must NOT be stored in the database — the SMTP
|
||||
# password — is read from this environment variable instead. Leave it unset
|
||||
# until you have the SMTP details; the toggle stays effectively off (queued
|
||||
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||
# on and SMTP is configured.
|
||||
# SMTP_PASSWORD=your-smtp-app-password
|
||||
|
||||
294
server/README.md
294
server/README.md
@@ -6,14 +6,21 @@ to this service.
|
||||
|
||||
```
|
||||
browser → NGINX ──serves──> static site (index.html, …)
|
||||
└─proxy /api/─> this API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||
└─proxy /api/─> api container (:8000) → db container (postgres)
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | liveness check |
|
||||
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||
| POST | `/api/auth/logout` | clear the session cookie |
|
||||
| GET | `/api/auth/me` | the logged-in user |
|
||||
| POST | `/api/auth/password` | change your own password |
|
||||
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||
| GET | `/api/sops` | list SOP summaries |
|
||||
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
||||
@@ -31,6 +38,55 @@ Interactive docs once running: **`/api/docs`**.
|
||||
The full client document is stored in each row's `data` (JSON) column; common
|
||||
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
||||
|
||||
---
|
||||
|
||||
## Login portal (user accounts)
|
||||
|
||||
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||
request is validated by checking the cookie's signature and expiry.
|
||||
|
||||
**The real security boundary is the API:** every `/api/` data route is refused
|
||||
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||
to `login.html` when there's no session — that's for UX, not protection.
|
||||
|
||||
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||
`admin` (may manage users) and `user`.
|
||||
|
||||
### Set the signing secret
|
||||
|
||||
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
|
||||
without it the API uses a random per-process key, so logins reset on restart.
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
|
||||
### Create the first admin
|
||||
|
||||
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||
shell (run from the **project root**, like uvicorn):
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
# prompts for a password (min 8 chars)
|
||||
```
|
||||
|
||||
In Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
```
|
||||
|
||||
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||
API (or you can keep using the CLI).
|
||||
|
||||
---
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
@@ -45,49 +101,215 @@ Then open http://localhost:8000/api/docs.
|
||||
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
||||
> `server/` directory), because the import path is `server.app:app`.
|
||||
|
||||
## PostgreSQL setup (production)
|
||||
---
|
||||
|
||||
## Production — Docker Compose
|
||||
|
||||
This is the recommended production setup. Three containers run in an isolated
|
||||
internal network; only NGINX is exposed to the outside via the external `proxy`
|
||||
network.
|
||||
|
||||
```sql
|
||||
CREATE DATABASE wpsuite;
|
||||
CREATE USER wpsuite WITH PASSWORD 'CHANGE_ME';
|
||||
GRANT ALL PRIVILEGES ON DATABASE wpsuite TO wpsuite;
|
||||
```
|
||||
Tables are created automatically on first startup. (For future schema changes,
|
||||
introduce Alembic migrations rather than editing tables by hand.)
|
||||
|
||||
## Run in production (gunicorn + systemd)
|
||||
|
||||
`/etc/systemd/system/wp-suite-api.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Work Package Suite API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/wp-suite
|
||||
Environment="DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite"
|
||||
ExecStart=/opt/wp-suite/.venv/bin/gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
[external proxy network]
|
||||
│
|
||||
┌────▼────┐ internal network ┌──────────┐ ┌────────┐
|
||||
│ nginx │ ───────────────────> │ api │ → │ db │
|
||||
└─────────┘ └──────────┘ └────────┘
|
||||
```
|
||||
|
||||
### 1. Create the credentials file
|
||||
|
||||
Create `.env` in the **project root** (same directory as `docker-compose.yml`).
|
||||
This file is never committed — add it to `.gitignore`.
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now wp-suite-api
|
||||
# .env — project root
|
||||
POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
POSTGRES_PASSWORD=<strong-random-password>
|
||||
|
||||
# Must match POSTGRES_* above; hostname is the compose service name "db"
|
||||
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||
```
|
||||
|
||||
NGINX already proxies `/api/` to `127.0.0.1:8000` (see `nginx-wp-suite.conf`).
|
||||
Generate a strong password:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### 2. Add the Dockerfile
|
||||
|
||||
Create `Dockerfile` in the **project root**:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||
```
|
||||
|
||||
### 3. Update the NGINX site config
|
||||
|
||||
The API is no longer at `127.0.0.1:8000` — it is the `api` container.
|
||||
Update the `/api/` proxy block in your nginx conf (e.g. `nginx/conf.d/wp-suite.conf`):
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000; # ← service name, not localhost
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 5m;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. docker-compose.yml
|
||||
|
||||
Replace your existing `docker-compose.yml` with:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
|
||||
webserver:
|
||||
image: nginx:alpine
|
||||
container_name: nginx_webserver
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./logs:/var/log/nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
networks:
|
||||
- proxy # external — reachable by your reverse proxy / traefik
|
||||
- internal # needs a path to the api container
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: wp_api
|
||||
env_file: .env # loads DATABASE_URL
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: wp_db
|
||||
env_file: .env # loads POSTGRES_DB / USER / PASSWORD
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
internal:
|
||||
internal: true # no outbound internet access from api/db
|
||||
```
|
||||
|
||||
### 5. First-time startup
|
||||
|
||||
```bash
|
||||
# Build the api image and start all containers
|
||||
docker compose up -d --build
|
||||
|
||||
# Confirm all three containers are running
|
||||
docker compose ps
|
||||
|
||||
# Tail logs (Ctrl-C to stop following)
|
||||
docker compose logs -f api
|
||||
```
|
||||
|
||||
Tables are created automatically on first API startup — no manual `CREATE TABLE`
|
||||
needed.
|
||||
|
||||
### Authentication notes
|
||||
|
||||
**Postgres → API authentication** is handled entirely through `DATABASE_URL` in
|
||||
`.env`. The `db` container uses `POSTGRES_USER` / `POSTGRES_PASSWORD` to
|
||||
initialise the database on first run; the `api` container uses the matching
|
||||
credentials in `DATABASE_URL` to connect. Neither credential ever appears in the
|
||||
compose file itself.
|
||||
|
||||
**Network isolation**: the `db` container is on the `internal` network only —
|
||||
it has no port exposed to the host and is unreachable from outside the compose
|
||||
stack. Only the `api` container can open a connection to it.
|
||||
|
||||
**Changing the password**: update both `POSTGRES_PASSWORD` and the password
|
||||
in `DATABASE_URL` in `.env`, then:
|
||||
```bash
|
||||
# Stop api first (db must keep running to accept the ALTER USER command)
|
||||
docker compose stop api
|
||||
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
|
||||
docker compose start api
|
||||
```
|
||||
|
||||
### Day-to-day operations
|
||||
|
||||
```bash
|
||||
# Rebuild api after a code change
|
||||
docker compose up -d --build api
|
||||
|
||||
# View postgres data directly
|
||||
docker compose exec db psql -U wpsuite -d wpsuite
|
||||
|
||||
# Take a database backup
|
||||
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||
|
||||
# Restore from backup
|
||||
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql
|
||||
|
||||
# Stop everything (data volume is preserved)
|
||||
docker compose down
|
||||
|
||||
# Stop everything AND delete all data
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick test
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/comments \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
|
||||
`/api/health` is open; data routes now require a session, so log in first and
|
||||
reuse the cookie jar:
|
||||
|
||||
curl http://127.0.0.1:8000/api/comments
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||
|
||||
# Sign in, saving the session cookie to a jar
|
||||
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"<password>"}'
|
||||
|
||||
# Reuse the cookie on protected routes
|
||||
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
||||
```
|
||||
|
||||
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||
|
||||
Or via the nginx proxy (replace with your hostname):
|
||||
|
||||
```bash
|
||||
curl https://wp-suite.company.local/api/health
|
||||
```
|
||||
|
||||
43
server/alembic.ini
Normal file
43
server/alembic.ini
Normal file
@@ -0,0 +1,43 @@
|
||||
# Alembic configuration for the Work Package Suite.
|
||||
# The database URL is NOT hard-coded here — env.py pulls it from the same place
|
||||
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
|
||||
# migrations always target the same database as the running app.
|
||||
[alembic]
|
||||
script_location = %(here)s/alembic
|
||||
prepend_sys_path = .
|
||||
# Use OS-native path separators on Windows dev machines.
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
63
server/alembic/env.py
Normal file
63
server/alembic/env.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Alembic environment for the Work Package Suite.
|
||||
|
||||
We reuse the application's own database configuration (server/db.py) so a
|
||||
migration always targets the same database the app would connect to — Postgres
|
||||
in production (from POSTGRES_* / DATABASE_URL) or the SQLite dev file otherwise.
|
||||
No connection string is stored in alembic.ini.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Make the `server` package importable no matter where alembic is invoked from
|
||||
# (repo root, /app in the container, etc.). env.py lives at server/alembic/env.py,
|
||||
# so the repo root is two directories up.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_REPO = os.path.dirname(os.path.dirname(_HERE))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from server.db import Base, DATABASE_URL, engine # noqa: E402
|
||||
from server import models # noqa: E402,F401 (imported for its side effect: registers all tables on Base.metadata)
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# The app resolves its URL from the environment; feed the same value to Alembic.
|
||||
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Emit SQL to stdout (`alembic upgrade --sql`) without a live connection."""
|
||||
context.configure(
|
||||
url=str(DATABASE_URL),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations against a live connection, reusing the app's engine."""
|
||||
with engine.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
23
server/alembic/script.py.mako
Normal file
23
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
server/alembic/versions/.gitkeep
Normal file
0
server/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,30 @@
|
||||
"""user login lockout fields
|
||||
|
||||
Revision ID: 18373f14809e
|
||||
Revises: 47bbe76aa749
|
||||
Create Date: 2026-07-15 14:50:58.423834
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '18373f14809e'
|
||||
down_revision = '47bbe76aa749'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||
op.add_column('users', sa.Column('failed_attempts', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'locked_until')
|
||||
op.drop_column('users', 'failed_attempts')
|
||||
# ### end Alembic commands ###
|
||||
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""wp archived_at
|
||||
|
||||
Revision ID: 47bbe76aa749
|
||||
Revises: 4e094197c9aa
|
||||
Create Date: 2026-07-15 12:00:14.356398
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '47bbe76aa749'
|
||||
down_revision = '4e094197c9aa'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('work_packages', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index(op.f('ix_work_packages_archived_at'), 'work_packages', ['archived_at'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_archived_at'), table_name='work_packages')
|
||||
op.drop_column('work_packages', 'archived_at')
|
||||
# ### end Alembic commands ###
|
||||
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""audit log
|
||||
|
||||
Revision ID: 4e094197c9aa
|
||||
Revises: c6af106a04da
|
||||
Create Date: 2026-07-15 10:12:52.859694
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4e094197c9aa'
|
||||
down_revision = 'c6af106a04da'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('actor', sa.String(length=200), nullable=False),
|
||||
sa.Column('action', sa.String(length=60), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=40), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('summary', sa.String(length=400), nullable=False),
|
||||
sa.Column('detail', sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_entity_id'), 'audit_log', ['entity_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_project_id'), 'audit_log', ['project_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_audit_log_project_id'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_entity_id'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
|
||||
op.drop_table('audit_log')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,63 @@
|
||||
"""assignment + settings + notifications
|
||||
|
||||
Revision ID: 57dec34f11cb
|
||||
Revises: ad8e6cc5de0f
|
||||
Create Date: 2026-07-15 16:43:09.230419
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '57dec34f11cb'
|
||||
down_revision = 'ad8e6cc5de0f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('app_settings',
|
||||
sa.Column('key', sa.String(length=80), nullable=False),
|
||||
sa.Column('value', sa.JSON(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('key')
|
||||
)
|
||||
op.create_table('notifications',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('kind', sa.String(length=40), nullable=False),
|
||||
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('subject', sa.String(length=300), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('link', sa.String(length=500), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('error', sa.String(length=400), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_kind'), 'notifications', ['kind'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_project_id'), 'notifications', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_status'), 'notifications', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
|
||||
op.add_column('work_packages', sa.Column('assignee_id', sa.String(length=40), nullable=True))
|
||||
op.create_index(op.f('ix_work_packages_assignee_id'), 'work_packages', ['assignee_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_assignee_id'), table_name='work_packages')
|
||||
op.drop_column('work_packages', 'assignee_id')
|
||||
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_status'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_project_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_kind'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
|
||||
op.drop_table('notifications')
|
||||
op.drop_table('app_settings')
|
||||
# ### end Alembic commands ###
|
||||
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""user token_version
|
||||
|
||||
Revision ID: ad8e6cc5de0f
|
||||
Revises: 18373f14809e
|
||||
Create Date: 2026-07-15 16:03:57.736556
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ad8e6cc5de0f'
|
||||
down_revision = '18373f14809e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'token_version')
|
||||
# ### end Alembic commands ###
|
||||
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""baseline schema
|
||||
|
||||
Revision ID: c6af106a04da
|
||||
Revises:
|
||||
Create Date: 2026-07-15 08:21:07.450350
|
||||
|
||||
This is the initial baseline. It creates the current schema on a fresh database,
|
||||
and safely ADOPTS an existing database (one whose tables were created by the old
|
||||
`Base.metadata.create_all()` before Alembic was introduced): if the schema is
|
||||
already present it records this revision without recreating anything. That means
|
||||
`alembic upgrade head` is safe to run on both new and existing deployments — no
|
||||
manual `alembic stamp` step required.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c6af106a04da'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if sa.inspect(bind).has_table("projects"):
|
||||
# Existing pre-Alembic database — adopt it as the baseline as-is.
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('comments',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('source', sa.String(length=40), nullable=False),
|
||||
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('step', sa.Integer(), nullable=True),
|
||||
sa.Column('author', sa.String(length=200), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('page', sa.String(length=200), nullable=False),
|
||||
sa.Column('extra', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_comments_sop_id'), 'comments', ['sop_id'], unique=False)
|
||||
op.create_index(op.f('ix_comments_source'), 'comments', ['source'], unique=False)
|
||||
op.create_index(op.f('ix_comments_wp_id'), 'comments', ['wp_id'], unique=False)
|
||||
op.create_table('projects',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('name', sa.String(length=300), nullable=False),
|
||||
sa.Column('number', sa.String(length=100), nullable=False),
|
||||
sa.Column('client', sa.String(length=300), nullable=False),
|
||||
sa.Column('division', sa.String(length=200), nullable=False),
|
||||
sa.Column('site', sa.String(length=300), nullable=False),
|
||||
sa.Column('sample', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_projects_number'), 'projects', ['number'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('username', sa.String(length=120), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=200), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('project_members',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'project_id', name='uq_project_member')
|
||||
)
|
||||
op.create_index(op.f('ix_project_members_project_id'), 'project_members', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_project_members_user_id'), 'project_members', ['user_id'], unique=False)
|
||||
op.create_table('sops',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('name', sa.String(length=300), nullable=False),
|
||||
sa.Column('number', sa.String(length=100), nullable=False),
|
||||
sa.Column('complete', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sops_project_id'), 'sops', ['project_id'], unique=False)
|
||||
op.create_table('work_packages',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('parent_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('number', sa.String(length=120), nullable=False),
|
||||
sa.Column('subject', sa.String(length=400), nullable=False),
|
||||
sa.Column('type', sa.String(length=120), nullable=False),
|
||||
sa.Column('status', sa.String(length=40), nullable=False),
|
||||
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['sop_id'], ['sops.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_work_packages_parent_id'), 'work_packages', ['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_work_packages_project_id'), 'work_packages', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_work_packages_sop_id'), 'work_packages', ['sop_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_sop_id'), table_name='work_packages')
|
||||
op.drop_index(op.f('ix_work_packages_project_id'), table_name='work_packages')
|
||||
op.drop_index(op.f('ix_work_packages_parent_id'), table_name='work_packages')
|
||||
op.drop_table('work_packages')
|
||||
op.drop_index(op.f('ix_sops_project_id'), table_name='sops')
|
||||
op.drop_table('sops')
|
||||
op.drop_index(op.f('ix_project_members_user_id'), table_name='project_members')
|
||||
op.drop_index(op.f('ix_project_members_project_id'), table_name='project_members')
|
||||
op.drop_table('project_members')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_projects_number'), table_name='projects')
|
||||
op.drop_table('projects')
|
||||
op.drop_index(op.f('ix_comments_wp_id'), table_name='comments')
|
||||
op.drop_index(op.f('ix_comments_source'), table_name='comments')
|
||||
op.drop_index(op.f('ix_comments_sop_id'), table_name='comments')
|
||||
op.drop_table('comments')
|
||||
# ### end Alembic commands ###
|
||||
815
server/app.py
815
server/app.py
File diff suppressed because it is too large
Load Diff
225
server/auth.py
Normal file
225
server/auth.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Authentication for the Work Package Suite.
|
||||
|
||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||
server-side session store — every request is checked by verifying the cookie's
|
||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
|
||||
Security model:
|
||||
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
||||
route is refused with 401 unless a valid session cookie is present.
|
||||
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
||||
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
||||
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
||||
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
||||
set; if it is missing we fall back to a random per-process key (which logs a
|
||||
warning and invalidates every session on restart) so dev still works.
|
||||
|
||||
Roles: 'admin' (may manage users) and 'user'.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db, DATABASE_URL
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.auth")
|
||||
|
||||
COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
_COMMON_PASSWORDS = {
|
||||
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
||||
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
||||
"iloveyou1", "abc12345", "qwertyuiop",
|
||||
}
|
||||
|
||||
|
||||
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
||||
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
||||
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
||||
if len(pw) < MIN_PASSWORD_LEN:
|
||||
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
||||
low = pw.lower()
|
||||
if username and low == username.strip().lower():
|
||||
return "Password must not be the same as the username."
|
||||
if email and low == email.strip().lower():
|
||||
return "Password must not be the same as the email."
|
||||
if low in _COMMON_PASSWORDS:
|
||||
return "That password is too common — choose something less guessable."
|
||||
return None
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
"/api/health",
|
||||
"/api/docs",
|
||||
"/api/openapi.json",
|
||||
"/api/docs/oauth2-redirect",
|
||||
"/api/redoc",
|
||||
}
|
||||
|
||||
|
||||
def _load_secret() -> str:
|
||||
s = os.getenv("AUTH_SECRET_KEY")
|
||||
if s:
|
||||
return s
|
||||
# No key configured. In production (a real database is configured via
|
||||
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
|
||||
# sessions with a throwaway key that silently rotates on every restart. In
|
||||
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
|
||||
# runs zero-config.
|
||||
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
|
||||
# condition db.py uses to pick Postgres, so we don't wrongly block a
|
||||
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
|
||||
is_prod = not str(DATABASE_URL).startswith("sqlite")
|
||||
if is_prod:
|
||||
raise RuntimeError(
|
||||
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
|
||||
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
|
||||
"(see server/.env.example / DEPLOYMENT.md)."
|
||||
)
|
||||
log.warning(
|
||||
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
|
||||
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
|
||||
)
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── password hashing ──────────────────────────────────────────────────────────
|
||||
def hash_password(plain: str) -> str:
|
||||
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||
def create_token(user: "models.User") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Return the token claims if the signature and expiry are valid, else None."""
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||
def _is_https(request: Request) -> bool:
|
||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||
# so trust X-Forwarded-Proto (set in nginx-wp-suite.conf) when present.
|
||||
xfp = request.headers.get("x-forwarded-proto", "")
|
||||
if xfp:
|
||||
return xfp.split(",")[0].strip().lower() == "https"
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, request: Request, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=SESSION_HOURS * 3600,
|
||||
httponly=True,
|
||||
secure=_is_https(request),
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
# ── request gate (used as middleware in app.py) ─────────────────────────────────
|
||||
def _needs_auth(path: str) -> bool:
|
||||
if not path.startswith("/api/"):
|
||||
return False # static assets are served by NGINX, not this app
|
||||
if path in _EXEMPT_EXACT:
|
||||
return False
|
||||
return not any(path.startswith(p) for p in _EXEMPT_PREFIXES)
|
||||
|
||||
|
||||
def is_request_authenticated(request: Request) -> Optional[dict]:
|
||||
"""Validate the session cookie on a raw request. Returns claims or None.
|
||||
Used by the middleware gate, which has no dependency-injection context."""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
return decode_token(token)
|
||||
|
||||
|
||||
# ── dependencies (used inside route handlers) ───────────────────────────────────
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models.User":
|
||||
"""Resolve the logged-in user from the session cookie, or raise 401.
|
||||
|
||||
Unlike the middleware gate (which only checks the token signature), this also
|
||||
confirms the account still exists and is active — so disabling a user takes
|
||||
effect on their next request."""
|
||||
claims = is_request_authenticated(request)
|
||||
if not claims:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
|
||||
# Session revocation: a mismatch means the token was invalidated (e.g. the
|
||||
# password was changed after this token was issued).
|
||||
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
||||
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
||||
"""Look up by username, case-insensitively (also matches on email)."""
|
||||
uname = (username or "").strip().lower()
|
||||
if not uname:
|
||||
return None
|
||||
return db.scalars(
|
||||
select(models.User).where(
|
||||
(func.lower(models.User.username) == uname)
|
||||
| (func.lower(models.User.email) == uname)
|
||||
)
|
||||
).first()
|
||||
43
server/db.py
43
server/db.py
@@ -1,28 +1,49 @@
|
||||
"""Database engine and session setup.
|
||||
|
||||
The connection string comes from the DATABASE_URL environment variable, e.g.
|
||||
postgresql+psycopg://wpsuite:secret@db-host:5432/wpsuite
|
||||
Connection precedence:
|
||||
1. POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB (preferred) — the URL is
|
||||
built with SQLAlchemy's URL.create(), which encodes the password for you,
|
||||
so passwords with special characters (@ ! # : / …) need NO manual escaping.
|
||||
Host/port default to POSTGRES_HOST=db / POSTGRES_PORT=5432.
|
||||
2. DATABASE_URL — a full SQLAlchemy URL, if you'd rather supply one directly
|
||||
(you must URL-encode any special characters in the password yourself).
|
||||
3. Neither set → a local SQLite file, so the API runs anywhere without Postgres.
|
||||
|
||||
If unset, it falls back to a local SQLite file so the API can be run and tested
|
||||
on any machine without Postgres. The schema is identical either way (SQLAlchemy
|
||||
handles the dialect differences).
|
||||
The schema is identical either way (SQLAlchemy handles dialect differences).
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, URL
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
# Load a local .env if present (dev convenience). In production the DATABASE_URL
|
||||
# normally comes from the systemd unit's Environment / EnvironmentFile instead.
|
||||
# Load a local .env if present (dev convenience).
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./wpsuite.db")
|
||||
|
||||
# SQLite needs this flag to be used from FastAPI's threadpool; Postgres ignores it.
|
||||
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||
def _resolve_url():
|
||||
user = os.getenv("POSTGRES_USER")
|
||||
pw = os.getenv("POSTGRES_PASSWORD")
|
||||
dbname = os.getenv("POSTGRES_DB")
|
||||
if user and pw and dbname:
|
||||
# Build from components — password is encoded automatically.
|
||||
return URL.create(
|
||||
"postgresql+psycopg",
|
||||
username=user, password=pw,
|
||||
host=os.getenv("POSTGRES_HOST", "db"),
|
||||
port=int(os.getenv("POSTGRES_PORT", "5432")),
|
||||
database=dbname,
|
||||
)
|
||||
return os.getenv("DATABASE_URL") or "sqlite:///./wpsuite.db"
|
||||
|
||||
|
||||
DATABASE_URL = _resolve_url()
|
||||
|
||||
# SQLite needs this flag from FastAPI's threadpool; Postgres ignores it.
|
||||
_is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
|
||||
connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
145
server/manage_users.py
Normal file
145
server/manage_users.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Command-line user management for the Work Package Suite.
|
||||
|
||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||
existing admin, so you have to bootstrap one here), and for occasional account
|
||||
maintenance from a shell on the server.
|
||||
|
||||
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
||||
and .env resolve the same way the API does:
|
||||
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
python -m server.manage_users create bob --role user --name "Bob Jones"
|
||||
python -m server.manage_users list
|
||||
python -m server.manage_users reset-password alice
|
||||
python -m server.manage_users disable bob
|
||||
python -m server.manage_users enable bob
|
||||
|
||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||
at least 8 characters.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
|
||||
|
||||
def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None, username: str = "") -> str:
|
||||
pw = provided
|
||||
if not pw:
|
||||
pw = getpass.getpass("New password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if pw != confirm:
|
||||
sys.exit("Passwords do not match.")
|
||||
problem = auth.password_problem(pw, username)
|
||||
if problem:
|
||||
sys.exit(problem)
|
||||
return pw
|
||||
|
||||
|
||||
def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
if role not in ("admin", "user"):
|
||||
sys.exit("role must be 'admin' or 'user'")
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
u = models.User(
|
||||
id=_gen_id(),
|
||||
username=args.username.strip(),
|
||||
full_name=(args.name or "").strip(),
|
||||
email=(args.email or "").strip(),
|
||||
password_hash=auth.hash_password(pw),
|
||||
role=role,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
print(f"Created {role}: {u.username} (id={u.id})")
|
||||
|
||||
|
||||
def cmd_list(args) -> None:
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(models.User).order_by(models.User.username).all()
|
||||
if not rows:
|
||||
print("No users yet. Create one with: create-admin <username>")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
||||
for u in rows:
|
||||
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{args.username}'.")
|
||||
u.password_hash = auth.hash_password(pw)
|
||||
db.commit()
|
||||
print(f"Password reset for {u.username}.")
|
||||
|
||||
|
||||
def _set_active(username: str, active: bool) -> None:
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{username}'.")
|
||||
u.is_active = active
|
||||
db.commit()
|
||||
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Ensure the users table exists even on a fresh database.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def add_create(name, help_):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.add_argument("username")
|
||||
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
sp.add_argument("--name", default="", help="full name")
|
||||
sp.add_argument("--email", default="")
|
||||
return sp
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=["admin", "user"], default="user")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||
rp.add_argument("username")
|
||||
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
||||
dp.add_argument("username")
|
||||
ep = sub.add_parser("enable", help="re-enable an account")
|
||||
ep.add_argument("username")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "create-admin":
|
||||
cmd_create(args, role="admin")
|
||||
elif args.cmd == "create":
|
||||
cmd_create(args)
|
||||
elif args.cmd == "list":
|
||||
cmd_list(args)
|
||||
elif args.cmd == "reset-password":
|
||||
cmd_reset_password(args)
|
||||
elif args.cmd == "disable":
|
||||
_set_active(args.username, False)
|
||||
elif args.cmd == "enable":
|
||||
_set_active(args.username, True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
server/models.py
173
server/models.py
@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .db import Base
|
||||
|
||||
@@ -21,10 +21,42 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""A construction project — the top-level container. SOPs and Work Packages
|
||||
belong to a project so the suite can be used for many jobs at once."""
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(300), default="")
|
||||
number: Mapped[str] = mapped_column(String(100), default="", index=True)
|
||||
client: Mapped[str] = mapped_column(String(300), default="")
|
||||
division: Mapped[str] = mapped_column(String(200), default="")
|
||||
site: Mapped[str] = mapped_column(String(300), default="")
|
||||
sample: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "name": self.name, "number": self.number,
|
||||
"client": self.client, "division": self.division, "site": self.site,
|
||||
"sample": self.sample, "created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {**self.summary(), "data": self.data or {}}
|
||||
|
||||
|
||||
class Sop(Base):
|
||||
__tablename__ = "sops"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(300), default="")
|
||||
number: Mapped[str] = mapped_column(String(100), default="")
|
||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -35,8 +67,8 @@ class Sop(Base):
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "name": self.name, "number": self.number,
|
||||
"complete": self.complete, "created_by": self.created_by,
|
||||
"id": self.id, "project_id": self.project_id, "name": self.name,
|
||||
"number": self.number, "complete": self.complete, "created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
@@ -48,6 +80,9 @@ class WorkPackage(Base):
|
||||
__tablename__ = "work_packages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
sop_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
@@ -57,7 +92,13 @@ class WorkPackage(Base):
|
||||
subject: Mapped[str] = mapped_column(String(400), default="")
|
||||
type: Mapped[str] = mapped_column(String(120), default="")
|
||||
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
||||
# The accountable owner (a user id), for "My Work Packages" + assignment
|
||||
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
|
||||
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Archived packages are hidden from the default lists/dashboard but kept for
|
||||
# the record (years-long projects accumulate hundreds of closed WPs).
|
||||
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
@@ -65,9 +106,11 @@ class WorkPackage(Base):
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id,
|
||||
"number": self.number, "subject": self.subject, "type": self.type,
|
||||
"status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||
"type": self.type, "status": self.status, "assignee_id": self.assignee_id,
|
||||
"issued_at": _iso(self.issued_at),
|
||||
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
||||
"created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
@@ -76,6 +119,55 @@ class WorkPackage(Base):
|
||||
return {**self.summary(), "data": self.data or {}}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||
hash (see server/auth.py). `username` is what people sign in with; `role` is
|
||||
either 'admin' (can manage users) or 'user'."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user'
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
||||
# change). The value is embedded in the JWT and re-checked on every request.
|
||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
return {
|
||||
"id": self.id, "username": self.username, "email": self.email,
|
||||
"full_name": self.full_name, "role": self.role, "is_active": self.is_active,
|
||||
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||
}
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
"""Which users may access which projects. A user sees/operates on a project
|
||||
only if a row links them to it (admins bypass this entirely). One row per
|
||||
(user, project) pair."""
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
@@ -98,5 +190,74 @@ class Comment(Base):
|
||||
}
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Append-only history: who changed what, when. Rows are written inside the
|
||||
same transaction as the change they describe (see server/app.py: log_event),
|
||||
so the trail can't drift from the data. `detail` holds a compact JSON summary
|
||||
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
|
||||
|
||||
Not a ForeignKey to any entity on purpose — the log must survive the deletion
|
||||
of the thing it describes (you still want "who deleted WP01, and when")."""
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
|
||||
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
|
||||
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
|
||||
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
|
||||
"entity_type": self.entity_type, "entity_id": self.entity_id,
|
||||
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
|
||||
}
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
||||
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
||||
the SMTP password are NOT stored here — they come from the environment."""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""Outbox for user notifications (an in-app record + an optional email). A row
|
||||
is written when something notable happens (e.g. a WP assignment); the email
|
||||
sender processes it only when email notifications are enabled AND SMTP is set —
|
||||
otherwise it's recorded as 'skipped'. See server/notify.py."""
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
|
||||
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(300), default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
link: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
|
||||
error: Mapped[str] = mapped_column(String(400), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
|
||||
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
|
||||
"status": self.status, "error": self.error,
|
||||
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
|
||||
}
|
||||
|
||||
|
||||
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
134
server/notify.py
Normal file
134
server/notify.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""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()
|
||||
@@ -1,7 +1,16 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
gunicorn>=21.2
|
||||
sqlalchemy>=2.0
|
||||
psycopg[binary]>=3.1
|
||||
pydantic>=2.6
|
||||
python-dotenv>=1.0
|
||||
# Pinned to exact versions for reproducible builds — no silent dependency drift
|
||||
# on every `docker compose up --build`. To update: bump a version here on purpose,
|
||||
# run `pip-audit` against the result, and test. For supply-chain integrity, the
|
||||
# next step is a hashed lockfile (`pip-compile --generate-hashes` → install with
|
||||
# `pip install --require-hashes`).
|
||||
fastapi==0.138.1
|
||||
uvicorn[standard]==0.49.0
|
||||
gunicorn==26.0.0
|
||||
sqlalchemy==2.0.51
|
||||
alembic==1.18.5 # database migrations
|
||||
psycopg[binary]==3.3.4
|
||||
pydantic==2.13.4
|
||||
python-dotenv==1.2.2
|
||||
bcrypt==5.0.0 # password hashing
|
||||
PyJWT==2.13.0 # signed session tokens
|
||||
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||
|
||||
175
server/seed_demo.py
Normal file
175
server/seed_demo.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a realistic DEMO project into the Work Package Suite database via the API.
|
||||
|
||||
Creates one project, a complete SOP, and a spread of Work Packages that exercise
|
||||
the features and dashboard: an issued package, a gated (open-constraint) package,
|
||||
a multi-discipline master with its split instances (A/B/C), an overdue package,
|
||||
and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end
|
||||
and to have data to inspect.
|
||||
|
||||
USAGE
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||
docker compose exec api python /app/server/seed_demo.py http://localhost:8000
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects
|
||||
|
||||
IMPORTANT — what shows where:
|
||||
* The DEMO **project** is API/SQL-backed, so it appears in the home-page
|
||||
project picker immediately (proves the projects → SQL path in the UI).
|
||||
* The DEMO **SOP and Work Packages** are written to SQL too, but the current
|
||||
front end still reads SOPs/WPs from the browser (localStorage), so they will
|
||||
NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2
|
||||
wiring. Verify them at the SQL/API layer instead:
|
||||
python3 server/smoketest.py <url> # automated end-to-end check
|
||||
docker compose exec db psql -U wpsuite -d wpsuite \
|
||||
-c "select number,subject,status from work_packages order by number;"
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
url = BASE + path
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||
raw = r.read().decode(); status = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(); status = e.code
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except ValueError:
|
||||
parsed = raw
|
||||
return status, parsed
|
||||
|
||||
|
||||
def constraints(open_names=()):
|
||||
base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs",
|
||||
"Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"]
|
||||
return [{"name": n, "status": ("open" if n in open_names else "cleared"),
|
||||
"comment": ("awaiting delivery" if n in open_names else "")} for n in base]
|
||||
|
||||
|
||||
def main():
|
||||
global BASE, CTX
|
||||
ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite")
|
||||
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||
help="Site root, no /api (default: http://localhost:8000)")
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# health gate
|
||||
try:
|
||||
st, _ = call("GET", "/api/health")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"ABORT: cannot reach {BASE}/api/health — {e}"); return 1
|
||||
if st != 200:
|
||||
print(f"ABORT: /api/health returned {st}"); return 1
|
||||
|
||||
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
|
||||
st, projects = call("GET", "/api/projects")
|
||||
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
|
||||
if args.clean:
|
||||
for p in demos:
|
||||
call("DELETE", f"/api/projects/{p['id']}")
|
||||
print(f"Removed {len(demos)} DEMO project(s).")
|
||||
return 0
|
||||
if demos:
|
||||
print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n")
|
||||
|
||||
# 1) Project
|
||||
st, proj = call("POST", "/api/projects", {
|
||||
"name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER,
|
||||
"client": "Micron Technology, Inc.", "division": "Semiconductor",
|
||||
"site": "Boise, ID — Fab", "created_by": "seed_demo"})
|
||||
pid = proj["id"]
|
||||
print(f"Project: {proj['name']} ({pid})")
|
||||
|
||||
# 2) SOP (complete)
|
||||
st, sop = call("POST", "/api/sops", {
|
||||
"project_id": pid, "name": "DEMO SOP", "number": DEMO_NUMBER, "complete": True,
|
||||
"created_by": "seed_demo",
|
||||
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
|
||||
"disciplines": ["Mechanical", "Electrical", "Tech"],
|
||||
"discMode": "choice", "instanceSuffix": "letter",
|
||||
"woSize": "Standard — 3–5 days (≈40–80 hrs)", "sizeHoursMax": "80"}}})
|
||||
sid = sop["id"]
|
||||
print(f"SOP: complete ({sid})")
|
||||
|
||||
# 3) Work packages
|
||||
def wp(number, subject, typ, status, data, parent_id=None):
|
||||
body = {"project_id": pid, "sop_id": sid, "number": number, "subject": subject,
|
||||
"type": typ, "status": status, "created_by": "seed_demo", "data": data}
|
||||
if parent_id:
|
||||
body["parent_id"] = parent_id
|
||||
st, w = call("POST", "/api/wps", body)
|
||||
print(f" WP {number:<16} {status:<12} {subject}")
|
||||
return w
|
||||
|
||||
# a) issued, all clear
|
||||
wp("WP01-1P-CONDUIT", "1P horn/strobe conduit", "Conduit Install", "Issued",
|
||||
{"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||
"constraints": constraints(), "due": "2026-06-30"})
|
||||
# b) gated — one open constraint, still Scheduled
|
||||
wp("WP02-1P-WIRE", "1P wire pull", "Wire Pull", "Scheduled",
|
||||
{"disciplines": ["Electrical"], "hours": "60", "actualHrs": "",
|
||||
"constraints": constraints(open_names=["Materials (on site, bagged & tagged)"]), "due": "2026-07-04"})
|
||||
# c) multi-discipline master + split instances (master excluded from metrics)
|
||||
master_id = "wp_demo_master_chiller"
|
||||
instances = [("WP03-CHILLER_Mech", "Mechanical", "A", "Mechanical Install", "In Progress"),
|
||||
("WP03-CHILLER_Elec", "Electrical", "B", "Wire Pull", "Scheduled"),
|
||||
("WP03-CHILLER_Tech", "Tech", "C", "Terminations", "Draft")]
|
||||
child_ids = []
|
||||
for num, disc, label, typ, status in instances:
|
||||
cid = f"wp_demo_{label.lower()}"
|
||||
child_ids.append(cid)
|
||||
body = {"project_id": pid, "sop_id": sid, "parent_id": master_id, "id": cid,
|
||||
"number": num, "subject": "Chiller skid — " + disc, "type": typ, "status": status,
|
||||
"created_by": "seed_demo",
|
||||
"data": {"disciplines": [disc], "instanceOf": master_id, "instanceLabel": label,
|
||||
"parentNumber": "WP03-CHILLER", "hours": "50", "actualHrs": "",
|
||||
"constraints": constraints(), "due": "2026-07-10"}}
|
||||
call("POST", "/api/wps", body)
|
||||
print(f" WP {num:<16} {status:<12} (instance {label})")
|
||||
wp("WP03-CHILLER", "Chiller skid (multi-discipline master)", "Mechanical Install", "Scheduled",
|
||||
{"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True, "children": child_ids,
|
||||
"hours": "150", "constraints": constraints(), "due": "2026-07-10"})
|
||||
call("POST", "/api/wps", {"project_id": pid, "sop_id": sid, "id": master_id,
|
||||
"number": "WP03-CHILLER", "subject": "Chiller skid (multi-discipline master)",
|
||||
"type": "Mechanical Install", "status": "Scheduled", "created_by": "seed_demo",
|
||||
"data": {"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True,
|
||||
"children": child_ids, "hours": "150", "constraints": constraints(),
|
||||
"due": "2026-07-10"}})
|
||||
# d) overdue, in progress
|
||||
wp("WP04-2P-TERM", "2P terminations", "Terminations", "In Progress",
|
||||
{"disciplines": ["Tech"], "hours": "30", "actualHrs": "20",
|
||||
"constraints": constraints(), "due": "2026-06-10"}) # past today (2026-06-16) → overdue
|
||||
# e) over-threshold draft (hours > 80)
|
||||
wp("WP05-3P-PANEL", "3P panel install", "Panel Install", "Draft",
|
||||
{"disciplines": ["Electrical"], "hours": "120", "actualHrs": "",
|
||||
"constraints": constraints(open_names=["Schedule"]), "due": "2026-07-20"})
|
||||
|
||||
# metrics readback
|
||||
st, m = call("GET", f"/api/wps/metrics?project_id={pid}")
|
||||
print(f"\nMetrics (masters excluded): {m}")
|
||||
print(f"\nDone. The DEMO project '{proj['name']}' now appears in the home-page picker.")
|
||||
print("SOP/WPs are in SQL (see header note) — verify with smoketest.py or psql.")
|
||||
print("Remove later with: python3 server/seed_demo.py <url> --clean")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
196
server/smoketest.py
Normal file
196
server/smoketest.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end smoke test for the Work Package Suite API + PostgreSQL.
|
||||
|
||||
Exercises the real HTTP endpoints the way the front end does, proving that
|
||||
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||
|
||||
USAGE
|
||||
# Against the deployed site (through the NGINX proxy):
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
# Self-signed / internal TLS cert? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# From inside the api container (hits FastAPI directly):
|
||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Leave the demo project in the database so you can open it in the UI:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --keep
|
||||
|
||||
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||
Exit code 0 = all checks passed, 1 = one or more failed.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
||||
_PASS, _FAIL = [], []
|
||||
def _c(s, code): # color if a TTY
|
||||
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
||||
def ok(msg): _PASS.append(msg); print(" " + _c("PASS", "32") + " " + msg)
|
||||
def bad(msg): _FAIL.append(msg); print(" " + _c("FAIL", "31") + " " + msg)
|
||||
def check(name, cond, detail=""):
|
||||
(ok if cond else bad)(name + (f" ({detail})" if detail and not cond else ""))
|
||||
return cond
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
|
||||
def call(method, path, body=None):
|
||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||
url = BASE + path
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||
raw = r.read().decode(); status = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(); status = e.code
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except ValueError:
|
||||
parsed = raw
|
||||
return status, parsed
|
||||
|
||||
|
||||
def main():
|
||||
global BASE, CTX
|
||||
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
|
||||
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||
help="Site root, no /api (default: http://localhost:8000)")
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||
|
||||
project_id = None
|
||||
try:
|
||||
# 1) Health — API is up and reachable through the proxy.
|
||||
try:
|
||||
st, body = call("GET", "/api/health")
|
||||
except urllib.error.URLError as e:
|
||||
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
|
||||
" Is the stack up (docker compose ps) and the URL correct?\n")
|
||||
return 1
|
||||
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||
f"status={st} body={body}")
|
||||
|
||||
# 2) Create a project (writes to the projects table).
|
||||
st, proj = call("POST", "/api/projects", {
|
||||
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
|
||||
"client": "Internal QA", "division": "Controls", "site": "Test Host",
|
||||
"created_by": "smoketest",
|
||||
})
|
||||
project_id = proj.get("id") if isinstance(proj, dict) else None
|
||||
check("create project", st == 200 and bool(project_id), f"status={st}")
|
||||
|
||||
# 3) Read it back + confirm it's in the list (SQL round-trip).
|
||||
st, got = call("GET", f"/api/projects/{project_id}")
|
||||
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
|
||||
st, lst = call("GET", "/api/projects")
|
||||
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
|
||||
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
|
||||
|
||||
# 4) Create a SOP linked to the project.
|
||||
st, sop = call("POST", "/api/sops", {
|
||||
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
|
||||
"complete": True, "created_by": "smoketest",
|
||||
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
|
||||
"disciplines": ["Mechanical", "Electrical", "Tech"]}},
|
||||
})
|
||||
sop_id = sop.get("id") if isinstance(sop, dict) else None
|
||||
check("create SOP linked to project", st == 200 and bool(sop_id) and sop.get("project_id") == project_id,
|
||||
f"status={st}")
|
||||
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
|
||||
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
|
||||
|
||||
# 5) Create a Work Package with one OPEN constraint (not release-ready).
|
||||
st, wp = call("POST", "/api/wps", {
|
||||
"project_id": project_id, "sop_id": sop_id,
|
||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||
"status": "Scheduled", "created_by": "smoketest",
|
||||
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||
"constraints": [{"name": "Materials", "status": "open", "comment": "awaiting delivery"},
|
||||
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
||||
})
|
||||
wp_id = wp.get("id") if isinstance(wp, dict) else None
|
||||
check("create work package", st == 200 and bool(wp_id), f"status={st}")
|
||||
|
||||
# 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
||||
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
|
||||
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
|
||||
|
||||
# 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
||||
call("POST", "/api/wps", {
|
||||
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||
"status": "Scheduled",
|
||||
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""},
|
||||
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
||||
})
|
||||
st, issued = call("POST", f"/api/wps/{wp_id}/issue")
|
||||
check("issue succeeds once constraints clear", st == 200 and issued.get("status") == "Issued",
|
||||
f"status={st}")
|
||||
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
|
||||
|
||||
# 8) Status transition endpoint.
|
||||
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
|
||||
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
|
||||
|
||||
# 9) Metrics aggregate for the project (Python aggregation over SQL rows).
|
||||
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
|
||||
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
|
||||
f"status={st} metrics={m}")
|
||||
|
||||
# 10) Comment / feedback write + read.
|
||||
st, c = call("POST", "/api/feedback", {
|
||||
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
|
||||
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
|
||||
check("post comment/feedback", st == 200 and isinstance(c, dict) and bool(c.get("id")), f"status={st}")
|
||||
st, comments = call("GET", f"/api/comments?wp_id={wp_id}")
|
||||
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
|
||||
f"status={st}")
|
||||
|
||||
# 11) WPs filter by project.
|
||||
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
||||
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
|
||||
|
||||
finally:
|
||||
# 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
||||
if project_id and not args.keep:
|
||||
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
||||
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
||||
st, after = call("GET", f"/api/wps?project_id={project_id}")
|
||||
check("WPs removed by cascade", st == 200 and isinstance(after, list) and len(after) == 0,
|
||||
f"status={st} remaining={after}")
|
||||
elif project_id and args.keep:
|
||||
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
|
||||
|
||||
# ── summary ────────────────────────────────────────────────────────────────
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
|
||||
if _FAIL:
|
||||
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
print("\nResult: " + _c("FAIL", "31") + "\n")
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS — API, Python logic, and SQL are working.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user