Compare commits

..

1 Commits

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

11
.gitignore vendored
View File

@@ -15,16 +15,5 @@ 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/

View File

@@ -51,31 +51,9 @@ Create a file named `.env` in the **project root** (same folder as
POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
# out and the API refuses to start. Generate once and keep it stable:
# openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. 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>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
```
The API builds its own DB connection string from the `POSTGRES_*`
That's it — the API now builds its own connection string from these three
values and **encodes the password automatically**, so a password with special
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
is **optional** and only needed if you want to point the API at some other
@@ -86,8 +64,7 @@ Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's
> **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.
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
These are the only credentials in the system, and they never appear in the
compose file or in git.
@@ -100,14 +77,6 @@ chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
container on that network. The container already proxies `/api/` to the `api`
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:
@@ -176,10 +145,12 @@ python3 server/seed_demo.py https://wp-suite.company.local --insecure
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
```
> **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:
> **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
> the home-page project picker right away (this is the visible proof that the
> projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
> written to SQL too, but the current front end still reads SOPs/WPs from the
> browser, so they won't render in the Creator/Dashboard until the Phase 2
> wiring. Inspect them at the SQL layer with `smoketest.py` or:
> ```bash
> docker compose exec db psql -U wpsuite -d wpsuite \
> -c "select number, subject, status from work_packages order by number;"
@@ -189,21 +160,20 @@ python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it
## 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:
Be aware of the current persistence split — the API + Postgres are fully
deployed, and:
| 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`). |
| **SOPs** | Endpoints exist (`/api/sops`); the front end still keeps the SOP in the browser (namespaced per project). Wiring it to the API is the remaining **Phase 2** step. |
| **Work Packages** | Same — `/api/wps` (+ issue/status/metrics) exist and are ready; the creator still saves to the browser per project. |
Saves go through a **durable client-side sync outbox**: edits are written to the
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.
So a fresh deployment gives you **shared, server-stored projects and comments
immediately**. Moving SOPs and Work Packages off the browser and onto the API
(so they're shared across users too) is a front-end change only — the database
and endpoints are already in place.
## Data model (PostgreSQL)
@@ -211,13 +181,8 @@ users on the same project see the same server-stored SOP and Work Packages.
|-------|-------|-------------|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `issued_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, login-lockout + `token_version` fields |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
The complete client document is stored verbatim in each row's `data` JSON
column; frequently-listed fields are promoted to real columns for filtering.
@@ -227,13 +192,8 @@ column; frequently-listed fields are promoted to real columns for filtering.
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`.
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments`.
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).
@@ -249,196 +209,27 @@ 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.
The whole dataset is in the `pgdata` volume — back it up on a schedule:
- **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.
```bash
# Backup (run from project root)
docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
## Field devices & data at rest
# Restore
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
```
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:
## Schema migrations (important)
- **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).
Tables are auto-created on API startup (`Base.metadata.create_all`). This
creates **missing tables**, but it does **not** alter existing ones. The
multi-project work added the `projects` table and new columns
(`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
## Email notifications (optional)
Work-package **owner assignment** works out of the box (in-app only). Optional
**email** on assignment is **OFF by default** and is turned on from the **Admin
console → Notifications & email** card, where an admin sets the SMTP host / port /
TLS / From address and flips the master toggle.
- The **SMTP password is never stored in the database.** It is read only from the
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
still recorded in-app (status `skipped`); nothing is sent.
- Notification emails carry only a **WP number and a deep link** — never the work
package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset
Turning email on also enables **Forgot password** on the login page. Until then the
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
## Permissions roles
`User.role` is the **permissions** role; `User.project_role` is the person's **job
function** on the project (Project Manager, Superintendent, …) and grants nothing.
Both are set in the Admin console's user table.
| Role | May do |
|---|---|
| `admin` | User administration, app settings, and every project |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
only hides controls to avoid dead-end clicks. Accounts created before this change
carried the role `user`, which the migration rewrites to `project_user`.
## Feature flags
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
## Release gates (constraints + predecessors)
A work package reaches **Issued** only when both gates are met:
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
**Closed**.
Enforced by `enforce_release_gates()` on **every** path that can set a status —
`/api/wps` (the browser and the offline outbox both save through it),
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
A blank reason is not an override. The server writes a `gate_overridden` audit
event naming the reason and what was skipped, and the reason prints on the
package. Changing the predecessor set clears the override.
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
with a 400 explaining which package already waits on this one.
- **A deleted predecessor does not block.** It would otherwise freeze everything
downstream of a package someone removed.
- The Creator's picker hides itself and any package that already waits on it, so a
cycle is hard to build in the first place; the dashboard refuses to issue a
blocked package and points at the form for the logged override.
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
descriptive — it gates nothing.
## Critical constraints reopened after release
A constraint marked **Critical** on the SOP that reopens **after** the package was
released emails the **owner, PM, CM and everyone on the package's distribution
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
Detected by comparing incoming constraints against the stored ones inside the
normal upsert — *not* a separate endpoint, because the browser saves through the
sync outbox, which only replays `POST /api/wps`; anything hung off another route
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
re-saving an already-open constraint doesn't re-announce, and never for a package
that was never released or a non-critical constraint. Bodies carry the constraint
name, WP number and a link — never the package contents.
## Localization (dates, times, numbers)
Three levels, most specific first — resolved in `html/wp-format.js`:
1. **the user's own preference** — *Language & time* in the top-right menu
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
2. **the app default** — Admin console → Features → *Localization defaults*
(`default_locale` / `default_timezone`)
3. **the browser**, as before
Timezone names are validated against the server's own `zoneinfo` database, and the
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
Calendar dates (a due date, a kitting date) are formatted from their parts and are
**never** shifted by a timezone — only real instants (MIMO windows, history,
notifications) are converted. Use the shared helpers (`wpFormatDate`,
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
`toLocaleString()`, or a page will quietly ignore the preference.
## Top-bar chrome (project switcher + search)
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
global search into whichever top bar a page has — the dark `.wp-appbar` or the
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
not get a second bar.
- Switching project reloads the current page with `?project=<id>`; every page
already resolves its project from that parameter.
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
(`scope_to_access`) and hides archived work packages. LIKE wildcards in the query
are escaped, so searching `100%` matches a literal `100%`. Two-character minimum.
- Ctrl/Cmd-K focuses the field from anywhere.
## Schema migrations (Alembic)
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
pending migrations automatically**.
- The **baseline** migration is idempotent: on a fresh database it creates every
table; on a database whose tables already exist (made by the old `create_all`)
it adopts the schema as-is — no manual `alembic stamp` needed.
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
migrations-only.
- **To change the schema:** edit `server/models.py`, then generate and review a
migration before committing:
```bash
# from the project root (against your dev SQLite or a staging DB)
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
```
The next `docker compose up -d --build api` applies it in production on startup.
- On a **fresh** database these appear automatically — nothing to do.
- On a database that **already has data** from an older schema, add the new
columns with a migration (introduce **Alembic**) or apply them manually with
`ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
## Local trial without Postgres
@@ -446,26 +237,3 @@ For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
§ *Local dev*. The front end alone can also be served statically from `html/`
(it falls back to browser storage when the API isn't reachable).
## Per-project permissions
`users.role` is the account's **default** permissions role. A membership row can
override it **per project** (`project_members.role`), so someone can be Project
Admin on one job and a plain Project User on another. Empty means "inherit the
account's role", which is how every pre-existing membership behaves.
Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
so deleting a work package, changing a completed SOP and deleting a project are all
judged **on that project**. An app `admin` is admin everywhere and bypasses
membership entirely.
Set it in **Admin console → User administration → Project access** (its own column,
showing how many projects each account can reach). The dialog ticks project access
and picks the role on each; `/api/auth/users/{id}/projects` takes
`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
project-scoped roles. Changes are audit-logged as `project_access_changed`.
**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
the project's members plus app admins, each with their effective role on that
project. A project with nobody assigned shows only the admins, which is why
assigning people is the first step on a new job.

View File

@@ -4,8 +4,7 @@ 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"]
# --preload imports the app once in the master (so create_all runs a single time)
# before forking workers, preventing a table-creation race on first startup.
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]

View File

View File

@@ -27,14 +27,9 @@ services:
POSTGRES_HOST: db
# 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)}
# Signs login session cookies. MUST be set (see server/.env.example).
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY}
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:
@@ -60,36 +55,6 @@ services:
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:

View File

@@ -6,22 +6,18 @@
<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">
<link rel="stylesheet" href="wp-chrome.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; }
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
--muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
--red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --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); }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
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{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; padding:8px 14px; cursor:pointer;
button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
@@ -29,10 +25,10 @@
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{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:12px 14px; font-family:var(--mono);
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
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; }
@@ -40,41 +36,30 @@
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{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 4px; font-size:17px; }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:12px; }
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:16px; }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
.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); }
border-radius:6px; 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.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
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">
@@ -87,7 +72,7 @@
<!-- 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 &amp; tests · talks to <code>/api</code> on this host</div></div>
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
@@ -111,33 +96,13 @@
<input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role" title="Permissions — what this account may do">
<option value="project_user">Project User</option>
<option value="project_admin">Project Admin</option>
<option value="admin">Administrator</option>
</select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
<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>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub" style="margin-bottom:10px">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div 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 &amp; comments</h2>
@@ -145,29 +110,11 @@
<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;">
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
</div>
<div 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>
@@ -208,7 +155,5 @@
</div>
<script src="admin.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

View File

@@ -9,13 +9,9 @@
function reveal(){
document.getElementById('admin-main').style.display='';
fillProjectRoleOptions();
checkHealth();
loadUsers();
loadSettings();
loadNotifications();
loadComments();
loadAudit();
loadUsage();
}
function showDenied(){
@@ -178,68 +174,12 @@ async function loadUsers(){
banner.style.display='none';
const meId = await currentUserId();
renderUsers(json, meId);
// Fill in the project-access counts, then repaint that column.
await loadProjectCounts(json);
renderUsers(json, meId);
}
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
const PERM_ROLES = ['admin','project_admin','project_user'];
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
// Job functions on a project. Descriptive only — no permissions attached.
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
'Field Technician'];
// Accounts created before permissions roles existed carry the legacy value 'user'.
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
function fillProjectRoleOptions(){
const sel=document.getElementById('nu-project-role'); if(!sel) return;
sel.innerHTML='<option value="">Project role…</option>'+
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
}
// Per-user project access gets its own column: it was buried among the action
// buttons, which is exactly where you'd fail to find "which projects can this
// person see, and what may they do there".
let _userProjectCounts = {}; // user id -> number of assigned projects
function projAccessCell(u){
const uname = uesc(u.username).replace(/'/g, "\\'");
if(normRole(u.role) === 'admin'){
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
}
const n = _userProjectCounts[u.id];
const label = (n === undefined) ? 'Projects…'
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
return '<button class="mini' + (n === 0 ? ' danger' : '') +
'" onclick="manageProjects(\'' + u.id + '\',\'' + uname + '\')"' +
' title="Choose which projects this user can access, and their role on each">' +
label + '</button>';
}
// A project's role dropdown only matters while that project is ticked.
function projRowToggled(cb){
const row = cb.closest('div');
const sel = row && row.querySelector('select');
if(sel) sel.disabled = !cb.checked;
}
// Counts for that column. One call per user, but only for non-admins and only on a
// refresh — the admin console is not a hot path.
async function loadProjectCounts(list){
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
await Promise.all(targets.map(async u => {
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
}));
}
function renderUsers(list, meId){
const wrap=document.getElementById('users-table');
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const fmt = s => s ? new Date(s).toLocaleString() : '—';
let rows = list.map(u=>{
const me = u.id===meId;
const active = u.is_active;
@@ -249,60 +189,23 @@ function renderUsers(list, meId){
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,"\\'");
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
// a tag) so an admin can't accidentally demote themselves.
const role = normRole(u.role);
const roleCell = me
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
PERM_ROLES.map(function(r){
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
}).join('')+
'</select>';
// PROJECT role — the person's job function. Descriptive only; grants nothing.
const pr = u.project_role || '';
const projRoleCell =
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
PROJECT_ROLES.map(function(r){
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
}).join('')+
// Keep a title that isn't on the list (set via the API or an older record).
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</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>'+projRoleCell+'</td>'+
'<td>'+projAccessCell(u)+'</td>'+
'<td><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td 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 title="What this account may do in the app">Permissions</th>'+
'<th title="Job function on the project — descriptive only">Project role</th>'+
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
'<th>Status</th><th>Last login</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>'+
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
'<em>Administrator</em>: manages users, settings and every project. '+
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
'change a completed SOP, and delete the project. '+
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
'but cannot delete WPs or change the SOP once it\'s complete. '+
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
'team pickers and notification routing, and grants nothing on its own.</div>';
'<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(){
@@ -311,12 +214,11 @@ async function createUser(){
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 project_role=(document.getElementById('nu-project-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<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; 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,project_role,password});
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='');
@@ -342,26 +244,6 @@ async function toggleActive(id, makeActive){
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 permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
if(status===200){ loadUsers(); }
else {
alert('Could not set the project 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);
@@ -373,45 +255,25 @@ async function deleteUser(id, username){
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, json.roles||{});
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, roles){
function openProjectModal(userId, username, projects, assigned, userObj){
closeProjectModal();
const isAdmin = userObj && normRole(userObj.role)==='admin';
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
roles = roles || {};
// Each project row: access tick + the role ON THAT project. "Same as account"
// inherits the account's Permissions, so the common case needs no thought.
const items = projects.length ? projects.map(p => {
const on = assigned.has(p.id);
const cur = roles[p.id] || '';
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
'</select>';
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
' onchange="projRowToggled(this)">'+
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
'</label>'+ sel +
'</div>';
}).join('') : '<div class="note">No projects exist yet.</div>';
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:660px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access &amp; permissions — '+uesc(username)+'</div>'+
'<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>Administrator</strong> and can access every project regardless of assignment.</div>'
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role on each. '+
'<strong>Project Admin</strong> can delete work packages, change a completed SOP and delete that project; '+
'<strong>Project User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
(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;">'+
@@ -424,13 +286,8 @@ function openProjectModal(userId, username, projects, assigned, userObj, roles){
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 roleMap = {};
ids.forEach(pid => {
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
if(sel && sel.value) roleMap[pid] = sel.value;
});
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
if(status===200){ closeProjectModal(); loadUsers(); }
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
if(status===200) closeProjectModal();
else alert('Save failed (HTTP '+status+').');
};
}
@@ -459,7 +316,7 @@ function renderComments(){
(!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 ? wpFormatDateTime(s) : '—';
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const where = c => {
const bits = [];
if(c.page) bits.push(uesc(c.page));
@@ -478,233 +335,6 @@ function renderComments(){
'</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 ? wpFormatDateTime(s) : '—';
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();
}
// Feature flags live in the same settings record but get their own card — they're
// not email, and they change what every project sees.
function renderFeatures(){
const s = _settings, box = document.getElementById('features-box');
if(!box) return;
const bim = !!s.bim_enabled;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700">'+
'<input type="checkbox" id="set-bim"'+(bim?' checked':'')+' onchange="saveFeatures()"> '+
'BIM / VDC tooling is <span style="color:'+(bim?'var(--green)':'var(--muted)')+'">'+(bim?'ON':'OFF')+'</span>'+
'</label>'+
'<div class="note" style="margin-top:8px">When OFF, the SOP creator hides the BIM/VDC section entirely and '+
'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+
'stops being shown or offered, so no project can be put on the BIM path while it\'s off.</div>'+
'<div id="features-msg" class="note" style="margin-top:6px"></div>'+
// Localization defaults. A user's own "Language & time" preference wins over
// these; these decide what everyone else sees instead of the browser's guess.
'<h2 style="margin-top:22px">Localization defaults</h2>'+
'<div class="sub" style="margin-bottom:10px">How dates, times and numbers are written for users who haven\'t '+
'set their own preference. Each user can override this from <strong>Language &amp; time</strong> in the '+
'top-right menu.</div>'+
'<div class="urow">'+
'<select id="set-locale" style="min-width:220px"></select>'+
'<select id="set-tz" style="min-width:240px"></select>'+
'<button class="primary" onclick="saveLocalization()">Save defaults</button>'+
'<span id="l10n-msg" class="note" style="margin:0"></span>'+
'</div>'+
'<div class="note" id="l10n-preview" style="margin-top:8px"></div>';
fillLocalization();
}
// Locale shortlist mirrors wp-format.js so the admin default and the per-user
// preference offer the same choices.
const L10N_LOCALES = [['','Browser default'],['en-US','en-US — 8/3/2026, 2:07 PM'],
['en-GB','en-GB — 03/08/2026, 14:07'],['en-CA','en-CA'],['es-MX','es-MX'],['es-US','es-US'],
['fr-CA','fr-CA'],['de-DE','de-DE'],['ja-JP','ja-JP'],['ko-KR','ko-KR'],['zh-TW','zh-TW']];
const L10N_ZONES = ['America/Chicago','America/New_York','America/Denver','America/Phoenix',
'America/Los_Angeles','America/Boise','Asia/Tokyo','Asia/Taipei','Asia/Seoul','Asia/Singapore',
'Europe/Dublin','Europe/London','UTC'];
function fillLocalization(){
const s = _settings;
const loc = document.getElementById('set-locale');
const tz = document.getElementById('set-tz');
if(!loc || !tz) return;
const curL = s.default_locale || '', curZ = s.default_timezone || '';
loc.innerHTML = L10N_LOCALES.map(p =>
'<option value="'+uesc(p[0])+'"'+(p[0]===curL?' selected':'')+'>'+uesc(p[1])+'</option>').join('');
if(curL && !L10N_LOCALES.some(p=>p[0]===curL)) loc.add(new Option(curL, curL, true, true));
let browserZone = '';
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch(e){}
tz.innerHTML = '<option value=""'+(curZ?'':' selected')+'>Browser default'+
(browserZone?' ('+uesc(browserZone)+')':'')+'</option>'+
L10N_ZONES.map(z => '<option value="'+uesc(z)+'"'+(z===curZ?' selected':'')+'>'+uesc(z)+'</option>').join('')+
(curZ && L10N_ZONES.indexOf(curZ)<0 ? '<option value="'+uesc(curZ)+'" selected>'+uesc(curZ)+'</option>' : '');
const preview = () => {
const el = document.getElementById('l10n-preview'); if(!el) return;
let out;
try {
out = new Intl.DateTimeFormat(loc.value||undefined, {year:'numeric',month:'short',day:'numeric',
hour:'2-digit',minute:'2-digit',timeZone:tz.value||undefined}).format(new Date());
} catch(e){ out = 'not supported by this browser'; }
el.textContent = 'Preview — right now reads: ' + out;
};
loc.onchange = preview; tz.onchange = preview; preview();
// Offer the server's full zone list once it arrives (it validates against the
// same list, so anything offered here will be accepted).
api('GET','/api/timezones').then(({status,json}) => {
if(status!==200 || !Array.isArray(json) || !json.length) return;
const rest = json.filter(z => L10N_ZONES.indexOf(z) < 0);
if(!rest.length) return;
const g = document.createElement('optgroup'); g.label = 'All time zones';
rest.forEach(z => g.appendChild(new Option(z, z, false, z === curZ)));
tz.appendChild(g);
if(curZ) tz.value = curZ;
});
}
async function saveLocalization(){
const msg = document.getElementById('l10n-msg');
const patch = {
default_locale: document.getElementById('set-locale').value,
default_timezone: document.getElementById('set-tz').value,
};
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('l10n-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else {
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
msg.style.color = 'var(--red)';
}
}
async function saveFeatures(){
const el = document.getElementById('set-bim');
const msg = document.getElementById('features-msg');
if(msg){ msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
const { status, json } = await api('PUT','/api/settings', { bim_enabled: !!(el && el.checked) });
if(status===200){
_settings = json; renderFeatures();
const m = document.getElementById('features-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else if(msg){
msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)';
}
}
function renderSettings(){
renderFeatures();
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 ? wpFormatDateTime(s) : '—';
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:[]}; } }
@@ -720,7 +350,7 @@ function loadUsage(){
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 ? wpFormatDateTime(s) : '—';
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>'+

View File

@@ -14,12 +14,6 @@
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;
@@ -40,19 +34,6 @@
}
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'); });
@@ -75,7 +56,7 @@
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<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;">' +
@@ -100,7 +81,7 @@
var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1.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' },
@@ -115,144 +96,58 @@
};
};
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
// user can't use, so nobody clicks a button just to get a 403.
// 'user' is the legacy value for what is now 'project_user'.
window.wpRole = function () {
var r = (window.WP_USER && window.WP_USER.role) || '';
return r === 'user' ? 'project_user' : r;
};
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
window.wpIsProjectAdmin = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_admin';
};
// Deleting a work package, deleting a project, and editing a completed SOP are
// all Project Admin actions (see server require_project_admin).
window.wpCanDeleteWP = window.wpIsProjectAdmin;
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
// ── app feature flags ──────────────────────────────────────────────────────
// Cached per page load. Pages that must know before rendering should await
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
window.WP_FLAGS = null;
var _flagsPromise = null;
window.wpFlags = function () {
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
if (_flagsPromise) return _flagsPromise;
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : {}; })
.catch(function () { return {}; }) // offline: fall through to defaults
.then(function (f) {
window.WP_FLAGS = f || {};
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
return window.WP_FLAGS;
});
return _flagsPromise;
};
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
// stale cache errs toward hiding the unfinished tooling rather than showing it.
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
function isDarkBg(el) {
try {
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return true; }
}
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
// bar it sits in (light links on a dark bar, blue links on a light bar).
function buildUserMenu(user, dark) {
var wrap = document.createElement('div');
wrap.id = 'wp-usermenu';
var linkColor = dark ? '#ffffff' : '#0f62fe';
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
function link(text, onClick, href) {
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
return a;
}
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
who.style.color = dark ? '#ffffff' : '#161616';
wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
if (typeof window.wpPreferences === 'function') {
wrap.appendChild(sep());
wrap.appendChild(link('Language & time', function () { window.wpPreferences(); }));
}
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap;
}
function addLogoutPill(user) {
if (inIframe) return; // the parent page already shows it
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
// Preferred: drop the menu INTO the top bar so it never floats over the
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
// the older .header bars alike.
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
if (host) {
var menu = buildUserMenu(user, isDarkBg(host));
// The older .header bars already right-align their own toolbar (via flex:1
// or a button's margin-left:auto). A second auto-margin would split the free
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
host.appendChild(menu);
return;
}
// Fallback for any page with no header bar: a floating pill (as before).
if (document.getElementById('wp-logout-pill')) return;
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);
'display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
var who = document.createElement('span');
who.textContent = user.full_name || user.username;
pill.appendChild(who);
// Admins get a link to the Admin Console (hidden when already on it).
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) {
var adm = document.createElement('a');
adm.href = 'admin.html'; adm.textContent = 'Admin';
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pill.appendChild(sep()); pill.appendChild(adm);
}
function proceed(user) {
clearTimeout(safety);
window.WP_USER = user;
reveal();
if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
}
var pw = document.createElement('a');
pw.href = '#'; pw.textContent = 'Password';
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
pill.appendChild(sep()); pill.appendChild(pw);
var out = document.createElement('a');
out.href = '#'; out.textContent = 'Sign out';
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
pill.appendChild(sep()); pill.appendChild(out);
document.body.appendChild(pill);
}
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.status === 401 || r.status === 403) { goToLogin(); return; }
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
return r.json().then(function (data) {
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);
clearTimeout(safety);
window.WP_USER = data && data.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); });
}
});
})
.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();
});
.catch(function () { goToLogin(); }); // API unreachable → send to login
})();

View File

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

View File

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

View File

@@ -15,64 +15,64 @@
// ── 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;
margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
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; }
border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; }
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
.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;
.ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
font-size:13px; outline:none; background:#f7f8fa; }
.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-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; 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;
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e3e6ec; overflow:auto; padding:10px 8px; background:#fafbfc; }
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:6px; 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.active{ background:#e7effe; color:#1d4ed8; 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-sec h3{ font-size:18px; margin:0 0 10px; color:#16213a; scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#2563d6; }
.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, .ui-help-content td{ border:1px solid #e3e6ec; padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
.pill-draft{ background:#eef1f6; color:#5a6675; } .pill-sched{ background:#e7effe; color:#1d4ed8; }
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
.pill-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;
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #2563d6; padding:10px 14px; border-radius:0 6px 6px 0;
font-size:13px; line-height:1.55; margin:10px 0; }
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; }
.ui-help-noresult{ display:none; color:#5a6675; font-size:14px; padding:10px 2px; }
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
box-shadow:0 2px 10px rgba(20,30,50,.28); }
.ui-help-fab:hover{ background:#0353e9; }
.ui-help-fab:hover{ background:#1d4ed8; }
@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{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e3e6ec; }
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
.ui-help-head{ flex-wrap:wrap; }
}`;
@@ -94,13 +94,6 @@
</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: `

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -6,59 +6,114 @@
<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">
<link rel="stylesheet" href="wp-chrome.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: 2.5rem 2rem 3rem;
padding: 3rem 2rem;
}
/* HERO */
.hero {
margin-bottom: 2.5rem;
text-align: center;
margin-bottom: 4rem;
}
.hero h1 {
font-size: 2.25rem;
font-size: 2.625rem;
font-weight: 300;
letter-spacing: -0.01em;
margin-bottom: 0.5rem;
margin-bottom: 1rem;
color: var(--cds-text-primary);
}
.hero p {
font-size: 1rem;
font-size: 1.125rem;
color: var(--cds-text-secondary);
max-width: 760px;
margin-bottom: 2rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
/* CARDS */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
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-left: 4px solid var(--cds-border-strong);
border-radius: 4px;
padding: 1.5rem;
transition: border-color 0.15s, background 0.15s;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: all 0.2s;
text-decoration: none;
color: var(--cds-text-primary);
display: flex;
@@ -66,8 +121,9 @@
}
.card:hover {
border-left-color: var(--cds-interactive-01);
background: var(--cds-layer-hover);
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
transform: translateY(-2px);
border-color: var(--cds-button-primary);
}
.card-badge {
@@ -97,10 +153,10 @@
.card-button {
display: inline-block;
align-self: flex-start;
background: var(--cds-button-primary);
color: white;
padding: 0.7rem 1.25rem;
padding: 0.75rem 1.5rem;
border-radius: 3px;
text-decoration: none;
font-weight: 600;
text-align: center;
@@ -116,15 +172,16 @@
/* COMPLETE STATE (SOP done) */
.card.complete {
border-left-color: var(--cds-support-success);
background: #ecfdf5;
border-color: #16a34a;
}
.card.complete .card-button { background: var(--cds-support-success); }
.card.complete .card-button:hover { background: #0e6027; }
.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: var(--cds-support-success);
color: #16a34a;
margin-bottom: 0.5rem;
}
.card.disabled {
@@ -135,8 +192,9 @@
/* SECTION */
.section {
background: var(--cds-layer);
padding: 1.75rem;
margin-bottom: 1.5rem;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle);
}
@@ -159,6 +217,16 @@
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);
@@ -179,16 +247,18 @@
/* COMMENTS SECTION */
.comments-section {
background: var(--cds-layer);
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--cds-border-subtle);
}
.comments-toggle {
padding: 0.7rem 1.25rem;
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;
@@ -201,7 +271,8 @@
display: none;
margin-top: 1rem;
padding: 1rem;
background: var(--cds-layer-accent);
background: var(--cds-ui-01);
border-radius: 3px;
border: 1px solid var(--cds-border-subtle);
}
@@ -210,17 +281,15 @@
.comments-panel input,
.comments-panel textarea {
width: 100%;
padding: 0.7rem;
border: 1px solid var(--cds-border-strong);
background: var(--cds-field);
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 input:focus,
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.comments-panel textarea {
resize: vertical;
min-height: 80px;
@@ -229,12 +298,12 @@
.comment-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-buttons button {
padding: 0.5rem 1rem;
border: none;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -249,12 +318,11 @@
.submit-btn:hover { background: var(--cds-hover-primary); }
.close-btn {
background: var(--cds-layer-selected);
background: var(--cds-border-subtle);
color: var(--cds-text-primary);
border: 1px solid var(--cds-border-strong);
}
.close-btn:hover { background: var(--cds-layer-selected-hover); }
.close-btn:hover { background: var(--cds-hover-ui); }
.comments-list {
margin-top: 1rem;
@@ -266,6 +334,7 @@
padding: 0.75rem;
background: var(--cds-background);
border: 1px solid var(--cds-border-subtle);
border-radius: 3px;
margin-bottom: 0.5rem;
font-size: 12px;
}
@@ -284,20 +353,22 @@
.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; }
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
padding: 1.25rem; }
border-radius: 6px; 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 { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
.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) {
.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; }
@@ -306,17 +377,19 @@
</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>
<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="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>
<div class="header-spacer"></div>
<nav class="header-nav">
<a href="#overview">Overview</a>
<a href="#comments">Feedback</a>
<a href="#" onclick="openHelp();return false;">Help</a>
</nav>
</div>
</header>
<!-- MAIN CONTENT -->
@@ -359,13 +432,17 @@
<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>
<!-- 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 -->
@@ -511,7 +588,6 @@
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';
@@ -519,8 +595,8 @@
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
&nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
// 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.
// Pull the project's shared SOP from the server into the local cache first,
// so the SOP "Complete / Review" status reflects what other users have done.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
else reflectSOPStatus(active);
}
@@ -662,7 +738,5 @@
.catch(() => {});
}
</script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

View File

@@ -21,7 +21,7 @@
max-width: 400px;
background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle);
border-top: 3px solid var(--cds-interactive-01);
box-shadow: 0 2px 6px var(--cds-shadow);
padding: 2.5rem 2rem;
}
.brand {
@@ -70,25 +70,6 @@
}
.error.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
.ok {
display: none;
background: #defbe6;
border-left: 3px solid var(--cds-support-success);
color: #0e6027;
padding: 0.75rem;
font-size: 0.8125rem;
margin-bottom: 1.25rem;
}
.ok.show { display: block; }
.note {
font-size: 0.8125rem; color: var(--cds-text-secondary);
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
padding: 0.75rem; margin-bottom: 1.25rem;
}
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
a.link:hover { text-decoration: underline; }
.center { text-align: center; margin-top: 1.25rem; }
</style>
</head>
<body>
@@ -96,13 +77,11 @@
<div class="brand">
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
</div>
<div id="error" class="error" role="alert"></div>
<div id="ok" class="ok" role="status"></div>
<!-- SIGN IN -->
<section id="view-login">
<h1>Sign in</h1>
<p class="sub">Work Package Suite</p>
<div id="error" class="error" role="alert"></div>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="username">Username</label>
@@ -114,46 +93,13 @@
</div>
<button id="submit" type="submit">Sign in</button>
</form>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<!-- FORGOT PASSWORD (email reset) -->
<section id="view-forgot" style="display:none">
<h1>Reset password</h1>
<p class="sub">We'll email you a link to set a new one.</p>
<div id="forgot-unavailable" class="note" style="display:none">
Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
<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; border-radius:0 6px 6px 0;">
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
</div>
<form id="forgot-form" autocomplete="on">
<div class="field">
<label for="forgot-username">Username or email</label>
<input id="forgot-username" type="text" autocomplete="username" required>
</div>
<button id="forgot-submit" type="submit">Email me a reset link</button>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
<section id="view-reset" style="display:none">
<h1>Set a new password</h1>
<p class="sub">Choose a password you don't use anywhere else.</p>
<form id="reset-form" autocomplete="on">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
</section>
<p class="foot">Authorized use only · BTG / Pilot</p>
</main>

View File

@@ -1,42 +1,13 @@
/* Login page logic for the Work Package Suite.
Three views on one page:
• sign in posts to /api/auth/login. On success the server sets an
HttpOnly session cookie (not readable here — that's the
point) and we redirect to ?next= or the home page.
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
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 okBox = document.getElementById('ok');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); }
function showError(msg) {
okBox.classList.remove('show');
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
}
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
@@ -49,55 +20,44 @@
return 'index.html';
}
function resetToken() {
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function postJson(url, payload) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().catch(function () { return null; }).then(function (j) {
return { status: r.status, ok: r.ok, json: j };
});
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';
});
}
function detail(res, fallback) {
var d = res && res.json && res.json.detail;
return (typeof d === 'string' && d) ? d : fallback;
}
function view(which) {
clearBanners();
['login', 'forgot', 'reset'].forEach(function (v) {
(which === v ? show : hide)(byId('view-' + v));
});
}
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
form.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var username = byId('username').value.trim();
var password = byId('password').value;
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…';
postJson('/api/auth/login', { username: username, password: password })
.then(function (res) {
if (res.ok) { location.replace(nextTarget()); return; }
if (res.status === 401) showError('Invalid username or password.');
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
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.');
@@ -105,108 +65,4 @@
submitBtn.textContent = 'Sign in';
});
});
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
byId('forgot-link').addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
byId('back-to-login').addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form');
var forgotBtn = byId('forgot-submit');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
byId('reset-to-login').addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form');
var resetBtn = byId('reset-submit');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
})();

View File

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

View File

@@ -12,7 +12,7 @@
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
@@ -59,18 +59,10 @@
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
},
// Deleting a project cascades its SOPs and work packages, and the server
// allows it only for a Project Admin. Drop it from the local cache ONLY if
// the server actually deleted it (or it was already gone) — removing it on a
// 403 would hide a project that still exists for everyone else.
remove: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function (r) {
if (r.ok || r.status === 404) { cacheRemove(id); return true; }
return r.json().catch(function () { return null; }).then(function (j) {
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
});
});
.then(function () { cacheRemove(id); })
.catch(function () { cacheRemove(id); });
},
// ── active project context ────────────────────────────────────────────────
@@ -95,8 +87,6 @@
// 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 ''; }
@@ -104,8 +94,7 @@
// 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.
// for perfect round-tripping, and mirror the few fields the API promotes.
function pkgToServer(p, projectId) {
return {
id: p.id,
@@ -115,7 +104,6 @@
subject: p.subject || '',
type: p.type || '',
status: p.status || 'Draft',
assignee_id: p.assigneeId || null,
created_by: p.createdBy || currentUser(),
data: p
};
@@ -129,8 +117,6 @@
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;
}
@@ -163,200 +149,37 @@
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.
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
// 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,
var 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);
complete: true,
created_by: currentUser(),
data: { sop: sop, state: state }
};
return fetch(API + '/sops', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
// Upsert a single Work Package. The local cache stays the source of truth for
// immediate rendering; the outbox guarantees the write reaches the server.
// Upsert a single Work Package to the API (fire-and-forget from the caller's
// perspective; the local cache is the source of truth for immediate rendering).
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);
return fetch(API + '/wps', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
};
ProjectData.removeWP = function (id) {
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 []; });
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () {}).catch(function () {});
};
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale

View File

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

View File

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

View File

@@ -24,20 +24,14 @@ function onSizePresetChange(){
}
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:''},
// Leadership names are kept as display strings (so existing SOPs and exports
// still read the same) alongside the user-account id each one resolves to.
// The ids are what let the Creator offer these people as a WP owner and what
// notification routing uses — a typed name can't be emailed.
team: {pm:'', apm:'', cm:'', qm:''},
teamIds: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [],
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
quality: {qcreq:'', photo:'', hold:''},
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''},
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
sequence: [],
constraints: [],
sources: []
@@ -98,14 +92,7 @@ const OPTIONAL_ROLES = [
'Quality Representative',
'Planner',
'Safety 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'
'Project Controls Manager'
];
const LABOR_COST_CODES = [
@@ -150,125 +137,6 @@ 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.
// ── BIM/VDC app switch (admin console → Features) ─────────────────────────────
// BIM is off until it's ready for the field. With the flag off we hide the
// per-project toggle so no new project can be put on the BIM path — but we never
// strip a SOP that already has it on, because that would silently delete its BIM
// types, gates and sequence steps. Such a SOP just stops offering BIM until the
// flag comes back.
function applyBimFlag(){
const wrap = document.getElementById('bim-toggle-wrap');
const note = document.getElementById('bim-disabled-note');
const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false;
if(wrap) wrap.style.display = enabled ? '' : 'none';
if(note){
const stale = !enabled && !!state.bimEnabled;
note.style.display = enabled ? 'none' : '';
note.innerHTML = stale
? '<strong>BIM / VDC is switched off for the whole suite.</strong> This SOP already has BIM enabled, so its ' +
'BIM package types, gates and sequence steps are kept as they are — but they aren\'t offered while the ' +
'feature is off. An administrator can turn it back on under Features in the Admin Console.'
: '<strong>BIM / VDC packages aren\'t available yet.</strong> Every project is install-only (IWP) for now. ' +
'An administrator can enable the BIM tooling under Features in the Admin Console once it\'s ready.';
}
}
// Re-check once the flags land (they arrive asynchronously after auth).
document.addEventListener('wp-flags-ready', applyBimFlag);
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();
@@ -289,8 +157,6 @@ window.addEventListener('DOMContentLoaded',()=>{
restoreSavedSOP();
updateStepUI();
updateProjectDisplay();
loadProjectUsers(); // team pickers: who's on this project
applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard');
@@ -342,25 +208,15 @@ function loadSampleData(){
document.getElementById('proj_division').value = 'Semiconductor';
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
// Populate Step 2. The leadership slots are account pickers now, so the sample's
// fictional names can't be "selected" — setting .value on a <select> with no
// matching option silently does nothing. Store them as names without an account,
// which is exactly how the picker shows a person who isn't a suite user yet.
state.team.pm = 'Mariano Sanchez';
state.team.apm = 'Assistant PM';
state.team.cm = 'K. Boyd';
state.team.qm = 'D. Nguyen';
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
renderTeamPickers();
// Populate Step 2
document.getElementById('proj_pm').value = 'Mariano Sanchez';
document.getElementById('proj_apm').value = 'Assistant PM';
document.getElementById('proj_cm').value = 'K. Boyd';
document.getElementById('proj_qm').value = 'D. Nguyen';
// Step 3 — 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';
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
renderSignoffRolePickers();
// Step 3 already has defaults
document.getElementById('role_super_name').value = 'John Smith';
document.getElementById('role_foreman_name').value = 'Mike Jones';
// Populate Step 5
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
@@ -376,12 +232,6 @@ 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');
@@ -407,9 +257,6 @@ function restoreSavedSOP(){
state = savedState;
sop = savedSop;
sopComplete = true;
// A SOP saved before the team was account-backed has no teamIds; default them
// so the pickers render (the stored names show as "(no account)" until linked).
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
// Re-render dynamic lists from restored state.
renderWPTypes();
@@ -431,12 +278,12 @@ function repopulateForm(){
set('proj_client', state.project.client);
set('proj_division', state.project.division);
set('proj_site', state.project.site);
// The leadership slots and sign-off names are account pickers, not text inputs —
// these build their options and mark the current selection.
renderTeamPickers();
renderSignoffRolePickers();
if(state.signoffRoles[0]) set('role_super_title', state.signoffRoles[0].role);
if(state.signoffRoles[1]) set('role_foreman_title', state.signoffRoles[1].role);
set('proj_pm', state.team.pm);
set('proj_apm', state.team.apm);
set('proj_cm', state.team.cm);
set('proj_qm', state.team.qm);
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
set('gov_woformat', state.governance.woformat);
// 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.
@@ -453,9 +300,6 @@ 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 ────────────────────────────────────────────────────────────
@@ -479,33 +323,11 @@ function switchTool(tool){
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
if(contentTool === 'wp') renderWPTab(isDash);
applyEmbedLayout(contentTool === 'wp');
updateStepUI();
updateProjectDisplay();
}
// The embedded creator/dashboard fills the window below the app chrome, so there
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
// page — and the creator's sticky bars have a real viewport to stick to.
function applyEmbedLayout(on){
const area = document.querySelector('.content-area');
const frame = document.getElementById('wp-frame');
if(area) area.classList.toggle('embed-full', !!on);
if(frame) frame.classList.toggle('fill', !!on);
document.body.classList.toggle('embed-full', !!on);
if(on) measureChrome();
}
// Height of the app bar + tab strip, so the iframe can be exactly the rest.
function measureChrome(){
const hdr = document.querySelector('.header');
const nav = document.querySelector('.main-nav');
const h = (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
document.documentElement.style.setProperty('--wp-chrome-h', h + 'px');
}
window.addEventListener('resize', () => { if(document.body.classList.contains('embed-full')) measureChrome(); }, {passive:true});
// Show the gate or the embedded Work Package Creator depending on SOP status.
// wantDash=true opens the creator straight to the dashboard view.
function renderWPTab(wantDash){
@@ -579,7 +401,6 @@ function renderWPTypes(){
container.innerHTML = `<div class="wp-types-header">
<div>Work Order Type</div>
<div style="text-align:center;">Enabled</div>
<div>Spec Section</div>
<div>Special Rules / Notes</div>
<div>WO Complete Approval</div>
</div>`;
@@ -595,7 +416,6 @@ function renderWPTypes(){
row.innerHTML = `
${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="e.g. 26_05_33_00" title="Specification section for this WP type. The Creator fills it in automatically on every package of this type, so nobody types it per package." value="${(t.specSection||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].specSection=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-family:var(--mono,monospace); font-size:12.5px;">
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" 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,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`;
@@ -627,163 +447,20 @@ function removeWPType(i){
renderWPTypes();
}
// ── PROJECT TEAM (drawn from user accounts on the project) ────────────────
// The people nameable on a SOP are the project's members (plus admins), so every
// name on the team resolves to an account the suite can assign work to and email.
// Their `project_role` (job function, set in the Admin Console) is offered as the
// default title for an additional team member.
let projectUsers = []; // [{id, full_name, username, email, project_role}]
let projectUsersLoaded = false;
function userLabel(u){
const name = u.full_name || u.username || '';
return u.project_role ? `${name}${u.project_role}` : name;
}
function userById(id){ return projectUsers.find(u => u.id === id) || null; }
async function loadProjectUsers(){
const pid = (typeof ProjectData !== 'undefined' && ProjectData.getActiveId) ? ProjectData.getActiveId() : '';
if(pid){
try {
const r = await fetch('/api/projects/' + encodeURIComponent(pid) + '/members', {credentials:'same-origin'});
if(r.ok) projectUsers = await r.json();
} catch(e){ /* offline — fall back to whatever the SOP already stored */ }
}
projectUsersLoaded = true;
renderTeamPickers();
renderTeamMembers();
renderSignoffRolePickers();
}
// One <select> per leadership slot. A name already on the SOP that no longer
// matches an account is kept as a selected option (tagged) rather than silently
// dropped — an old SOP shouldn't lose its PM because they left the project.
function renderTeamPickers(){
const warn = document.getElementById('team-accounts-warn');
const orphans = [];
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
const curId = state.teamIds[key] || '';
const curName = state.team[key] || '';
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
// A stored name with no matching account (typed on an older SOP, or the
// person has since been removed from the project).
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
orphans.push(curName);
}
sel.innerHTML = html;
sel.onchange = function(){ setTeamLead(key, this.value); };
});
if(!warn) return;
if(projectUsersLoaded && !projectUsers.length){
warn.style.display = '';
warn.innerHTML = 'No user accounts are assigned to this project yet, so there is nobody to pick. ' +
'Assign people to the project in the <a href="admin.html" target="_blank" rel="noopener">Admin Console</a> ' +
'(User administration → Projects), then reopen this step.';
} else if(orphans.length){
warn.style.display = '';
warn.textContent = 'Named on this SOP but not a user account on the project: ' + orphans.join(', ') +
'. They cannot be assigned work packages or emailed until they are added as a user and assigned to this project.';
} else {
warn.style.display = 'none';
}
}
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
// name that has no matching account as a selected "(no account)" option so older
// SOPs — and the sample's fictional names — are never silently dropped.
function userSelectOptions(curId, curName){
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
}
return html;
}
// Sign-off roles (step 3) name the people who must sign a package, so they use the
// same picker as the leadership slots — a signature belongs to an account.
function renderSignoffRolePickers(){
[['role_super_name', 0], ['role_foreman_name', 1]].forEach(([id, ix]) => {
const sel = document.getElementById(id);
const r = state.signoffRoles[ix];
if(!sel || !r) return;
sel.innerHTML = userSelectOptions(r.userId || '', r.name || '');
sel.onchange = function(){
if(this.value === '__orphan__') return;
const u = userById(this.value);
r.userId = u ? u.id : '';
r.name = u ? (u.full_name || u.username) : '';
renderSignoffRolePickers();
};
});
renderOptionalRoles();
}
// Read the four leadership pickers back into state. `state.team[key]` always holds
// a display NAME and `state.teamIds[key]` the account id; a name kept from an older
// SOP whose person has no account (the "(no account)" option) is left alone.
function syncTeamFromPickers(){
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
if(sel.value === '__orphan__') return; // legacy typed name — keep it
const u = userById(sel.value);
state.teamIds[key] = u ? u.id : '';
if(u) state.team[key] = u.full_name || u.username;
else if(sel.value === '') state.team[key] = ''; // explicitly unassigned
});
}
function setOptionalRolePerson(ix, value){
const r = state.signoffRoles[ix];
if(!r || value === '__orphan__') return;
const u = userById(value);
r.userId = u ? u.id : '';
r.name = u ? (u.full_name || u.username) : '';
renderOptionalRoles();
}
function setTeamLead(key, userId){
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
const u = userById(userId);
state.teamIds[key] = u ? u.id : '';
state.team[key] = u ? (u.full_name || u.username) : '';
renderTeamPickers();
}
function renderTeamMembers(){
const container = document.getElementById('team-members-list');
if(!container) return;
const opts = (cur) => '<option value="">— pick a person —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===cur?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
container.innerHTML = state.teamMembers.map((m,i)=>`
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="setExtraTeamMember(${i}, this.value)" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">${opts(m.userId||'')}</select>
<input type="text" placeholder="Role / title on this project" value="${escAttr(m.role)}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].name=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button>
</div>
${(m.name && !m.userId) ? `<div style="font-size:12px; margin:-0.25rem 0 0.75rem 0.25rem; color:var(--warning);">“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.</div>` : ''}
`).join('');
}
// Picking the person fills the title from their project role but leaves it
// editable — the same person can wear a different hat on a given project.
function setExtraTeamMember(i, userId){
const m = state.teamMembers[i]; if(!m) return;
const u = userById(userId);
m.userId = u ? u.id : '';
m.name = u ? (u.full_name || u.username) : '';
if(u && !m.role) m.role = u.project_role || '';
renderTeamMembers();
}
function addTeamMember(){
state.teamMembers.push({role:'',name:'',userId:''});
state.teamMembers.push({role:'',name:''});
renderTeamMembers();
}
@@ -794,14 +471,13 @@ function removeTeamMember(i){
function renderOptionalRoles(){
const container = document.getElementById('optional-roles-list');
// The first two entries are the required (editable-title) roles; the rest are optional.
const current = state.signoffRoles.slice(2);
const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
container.innerHTML = current.map((r,i)=>`
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
</select>
<select onchange="setOptionalRolePerson(${state.signoffRoles.indexOf(r)}, this.value)">${userSelectOptions(r.userId||'', r.name||'')}</select>
<input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
</div>
`).join('');
@@ -829,7 +505,6 @@ function renderStandardConstraints(){
_constraintsSeeded = true;
}
const active = name => state.constraints.some(c=>c.name===name);
const critical = name => { const c = state.constraints.find(x=>x.name===name); return !!(c && c.critical); };
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}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
@@ -837,33 +512,11 @@ function renderStandardConstraints(){
<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>
${criticalToggle(c.name, active(c.name), critical(c.name))}
</div>
`).join('');
renderCustomConstraints();
}
// A CRITICAL constraint is one whose reopening after release is announced by
// email (PM + CM + the package owner) rather than only dropping the package to
// Issue (Hold). Only meaningful for a constraint that's switched on.
function criticalToggle(name, enabled, isCritical){
const id = 'crit_' + name.replace(/[^A-Za-z0-9]+/g,'_');
const tip = 'Critical: if this constraint reopens after the work package has been released, ' +
'notify the PM, CM and the package owner by email.';
return `<label for="${id}" title="${escAttr(tip)}" style="display:flex; align-items:center; gap:0.4rem; white-space:nowrap; font-size:12px; font-weight:600; cursor:${enabled?'pointer':'not-allowed'}; opacity:${enabled?1:0.45}; color:${isCritical?'var(--danger)':'var(--text-light)'};">
<input type="checkbox" id="${id}" ${isCritical?'checked':''} ${enabled?'':'disabled'} onchange="toggleCriticalConstraint(this.dataset.name, this.checked)" data-name="${escAttr(name)}" style="width:16px; height:16px; cursor:inherit;">
${isCritical ? '⚠ Critical' : 'Critical?'}
</label>`;
}
function toggleCriticalConstraint(name, on){
const c = state.constraints.find(x=>x.name===name);
if(!c) return;
c.critical = !!on;
renderStandardConstraints();
track(on ? 'constraint_marked_critical' : 'constraint_unmarked_critical', {name});
}
// 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;
@@ -872,10 +525,7 @@ function renderCustomConstraints(){
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>
<span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
@@ -887,13 +537,7 @@ function removeCustomConstraint(name){
function toggleConstraint(name){
const idx = state.constraints.findIndex(c=>c.name===name);
if(idx>=0) state.constraints.splice(idx,1);
else {
// Copy the library entry — pushing the shared object would let one project's
// `critical` flag leak into every other project's default constraint set.
const def = STANDARD_10_CONSTRAINTS.find(c=>c.name===name);
if(def) state.constraints.push({...def});
}
renderStandardConstraints(); // the Critical toggle enables/disables with the row
else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name));
}
function showConstraintLibrary(){
@@ -931,26 +575,13 @@ function addCustomConstraintText(){
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'}
];
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
let seqDragIndex = null;
function renderSequenceSteps(){
const container = document.getElementById('sequence-list');
if(!container) return;
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=> typeof s==='string' ? {label:s,kind:'step'} : {label:s.label, kind:s.kind||'step'});
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label:s,kind:'step'}));
container.innerHTML = '';
let stepNo = 0;
state.sequence.forEach((item,i)=>{
@@ -1105,19 +736,14 @@ function collectStepData(){
state.project.site = document.getElementById('proj_site').value;
break;
case 2:
// These are user-account pickers now, so their .value is an ID, not a name.
// Reading them straight into state.team would put an id where the display
// name belongs (and it would then print on the SOP as `user_ab12…`).
syncTeamFromPickers();
state.team.pm = document.getElementById('proj_pm').value;
state.team.apm = document.getElementById('proj_apm').value;
state.team.cm = document.getElementById('proj_cm').value;
state.team.qm = document.getElementById('proj_qm').value;
break;
case 3:
// The two required roles now have editable titles (default Superintendent/Foreman).
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
// Titles are still free text; the NAMES are account pickers whose .value is
// an id, so they're maintained by their own onchange (see
// renderSignoffRolePickers) rather than read as text here.
state.signoffRoles[0].role = document.getElementById('role_super_title').value || 'Superintendent';
state.signoffRoles[1].role = document.getElementById('role_foreman_title').value || 'Foreman';
state.signoffRoles[0].name = document.getElementById('role_super_name').value;
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
break;
case 5:
state.governance.woformat = document.getElementById('gov_woformat').value;
@@ -1137,8 +763,6 @@ 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;
}
}
@@ -1163,25 +787,12 @@ function validateStep(n){
}
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
// Re-saving a SOP that is already complete changes the project's baseline, which
// the server restricts to a Project Admin. Check before doing the work so the
// answer is a clear message rather than a 403 from the sync outbox.
function canEditCompletedSOP(){
return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true;
}
function completeSOP(){
if(sopComplete && !canEditCompletedSOP()){
alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
return;
}
if(!validateStep(10)) return;
collectStepData();
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,
@@ -1191,20 +802,10 @@ function completeSOP(){
apm: state.team.apm,
cm: state.team.cm,
qm: state.team.qm,
// User-account ids for the same four people. These are what the Creator
// uses to offer an owner and what notification routing needs — a display
// name alone can't be assigned work or emailed.
pmId: state.teamIds.pm || '',
apmId: state.teamIds.apm || '',
cmId: state.teamIds.cm || '',
qmId: state.teamIds.qm || '',
site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
teamMembers: state.teamMembers.filter(m=>(m.role||m.name))
},
roles: state.signoffRoles.filter(r=>r.role).map(r=>({
role: r.role, name: r.name || '',
userId: r.userId || '' // who signs — an account, so it can be notified
})),
roles: state.signoffRoles.filter(r=>r.role),
governance: {
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
woSize: state.governance.wosize,
@@ -1219,21 +820,11 @@ function completeSOP(){
name: t.name.trim(),
enabled: true,
notes: t.notes || '',
approval: t.approval || '',
// Spec section for this type — the Creator fills the WP's Specification
// Section from it, so it's authored once here instead of per package.
specSection: t.specSection || '',
bim: !!t.bim
approval: t.approval || ''
})),
sources: state.sources.filter(s=>s.label),
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),
field: {trackPlatform: state.platforms.tracking},
commissioning: {tool: state.platforms.commissioning},
quality: {
qcReq: state.quality.qcreq,
photo: state.quality.photo,
@@ -1245,11 +836,7 @@ function completeSOP(){
kind: s.kind || 'step'
})),
costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({
name: c.name, description: c.description || '', bim: !!c.bim,
// Critical → reopening after release is emailed, not just flagged on the package.
critical: !!c.critical
}))
constraints: state.constraints.map(c=>({name: c.name, description: c.description || ''}))
};
sopComplete = true;
@@ -1366,8 +953,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>${escAttr(c.name)}</strong> • ${escAttr(c.timestamp)}</div>
<div style="font-size:12px; color:var(--text);">${escAttr(c.text)}</div>
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
</div>
`).join('');
}

View File

@@ -1,25 +1,22 @@
:root {
--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;
--primary: #2563eb;
--primary-light: #dbeafe;
--success: #16a34a;
--warning: #ea580c;
--danger: #dc2626;
--text: #1f2937;
--text-light: #6b7280;
--text-dim: #9ca3af;
--border: #e5e7eb;
--bg: #f9fafb;
--bg-card: #ffffff;
--appbar: #161616;
--shadow: none;
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.5;
@@ -31,46 +28,43 @@ body {
min-height: 100vh;
}
/* HEADER — dark UI Shell bar */
/* HEADER */
.header {
background: var(--appbar);
color: #fff;
padding: 0 16px;
height: 48px;
background: #ffffff;
color: var(--text);
padding: 1.5rem 2rem;
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: 14px;
min-width: 0;
gap: 1.5rem;
}
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
.logo {
display: inline-flex;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
gap: 0.5rem;
text-decoration: none;
color: var(--text);
font-weight: 700;
font-size: 14px;
transition: opacity 0.2s;
flex-shrink: 0;
}
.logo:hover { opacity: 0.92; }
.logo img { height: 24px; width: auto; display: block; }
.logo:hover { opacity: 0.7; }
.logo-icon {
width: 36px;
height: 36px;
background: var(--primary-light);
border-radius: 0;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
@@ -79,142 +73,97 @@ body {
}
.header-title {
font-size: 15px;
font-weight: 600;
font-size: 24px;
font-weight: 700;
margin-bottom: 0;
color: #fff;
white-space: nowrap;
color: var(--text);
}
.header-subtitle {
font-size: 12px;
color: #c6c6c6;
min-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--text-light);
min-height: 20px;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
gap: 1.5rem;
}
.header-button {
padding: 7px 14px;
background: transparent;
color: #fff;
border: 1px solid #6f6f6f;
border-radius: 0;
padding: 0.5rem 1rem;
background: var(--bg);
color: var(--primary);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 400;
transition: background 0.15s, border-color 0.15s;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.header-button:hover {
background: #353535;
border-color: #6f6f6f;
background: var(--primary-light);
border-color: var(--primary);
}
.step-counter {
background: transparent;
border: 1px solid #6f6f6f;
color: #c6c6c6;
padding: 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-light);
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
/* MAIN NAVIGATION — underline tabs */
/* MAIN NAVIGATION */
.main-nav {
display: flex;
gap: 0;
padding: 0 16px;
gap: 0.5rem;
padding: 1rem 2rem;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.nav-tab {
padding: 13px 18px;
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
padding: 0.75rem 1.5rem;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 15px;
font-weight: 400;
color: var(--text-light);
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.15s, color 0.15s;
transition: all 0.2s;
}
.nav-tab:hover {
background: var(--bg);
color: var(--text);
border-color: var(--primary);
color: var(--primary);
}
.nav-tab.active {
background: none;
color: var(--text);
border-bottom-color: var(--primary);
font-weight: 600;
background: var(--primary);
color: white;
border-color: var(--primary);
}
.tab-icon { font-size: 16px; }
/* CONTENT AREA
The SOP wizard reads better with a bound on line length, but 1000px on a 1920
screen wasted half the display — and it also squeezed the embedded Work Package
Creator (an iframe living in here) into a ~930px column with its own scrollbar
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
(see .content-area.embed-full below). */
/* CONTENT AREA */
.content-area {
flex: 1;
padding: 2rem;
max-width: 1700px;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
/* Work Package Creation / Dashboard: the iframe fills the window below the app
chrome and owns the only scrollbar, so the creator's sticky save bar and
navigator drawer position against a real viewport instead of scrolling away. */
.content-area.embed-full {
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
child's `height:100%` resolves to auto and the iframe collapses to its 150px
default. Opting out of flex sizing makes the height definite. */
flex: none;
max-width: none;
padding: 0;
height: calc(100vh - var(--wp-chrome-h, 96px));
overflow: hidden;
display: flex;
flex-direction: column;
}
.content-area.embed-full > .tool.active {
flex: 1 1 auto;
min-height: 0; /* let it shrink instead of overflowing the shell */
height: 100%;
}
#wp-frame {
width: 100%;
border: 0;
min-height: calc(100vh - 200px);
}
#wp-frame.fill {
display: block;
height: 100%;
min-height: 0;
}
/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
body.embed-full { overflow: hidden; }
.tool {
display: none;
}
@@ -237,37 +186,35 @@ body.embed-full { overflow: hidden; }
}
.step-item {
padding: 0.6rem 0.9rem;
border-radius: 0;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-light);
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--bg);
border: 2px solid var(--border);
cursor: pointer;
font-size: 12px;
font-weight: 500;
font-weight: 600;
white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s;
transition: all 0.2s;
}
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; }
.step-item:hover { background: var(--primary-light); border-color: var(--primary); }
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
/* STEP CONTENT */
.step-content {
background: var(--bg-card);
padding: 2rem;
border: 1px solid var(--border);
border-radius: 0;
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.step { display: none; }
.step h2 {
font-size: 22px;
font-weight: 400;
letter-spacing: -0.01em;
margin-bottom: 0.75rem;
font-size: 20px;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text);
}
@@ -276,20 +223,15 @@ body.embed-full { overflow: hidden; }
color: var(--text-light);
background: var(--primary-light);
padding: 0.75rem 1rem;
border-radius: 0;
border-radius: 6px;
margin-bottom: 1.5rem;
border-left: 4px solid var(--primary);
}
/* FIELDS
The wizard's fields were one per row, which looked right in a 1000px column but
stretches a text input across the screen now that the content area is wide. Flow
them into as many ~340px columns as fit; `.col1` still forces a single column for
the fields that genuinely want the width (long text, textareas). */
/* FIELDS */
.field-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
}
.field-grid.col1 { grid-template-columns: 1fr; }
@@ -311,7 +253,7 @@ body.embed-full { overflow: hidden; }
.field textarea {
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
color: var(--text);
@@ -345,7 +287,7 @@ body.embed-full { overflow: hidden; }
align-items: center;
padding: 1rem;
background: var(--bg);
border-radius: 0;
border-radius: 6px;
border: 1px solid var(--border);
}
@@ -372,20 +314,20 @@ body.embed-full { overflow: hidden; }
.wp-type-row {
display: grid;
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
gap: 0.85rem;
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 1rem;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg);
border-radius: 0;
border-radius: 6px;
margin-bottom: 0.5rem;
border: 1px solid var(--border);
}
.wp-types-header {
display: grid;
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
gap: 0.85rem;
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
gap: 1rem;
padding: 0.5rem 1rem;
font-size: 11px;
font-weight: 600;
@@ -397,32 +339,31 @@ body.embed-full { overflow: hidden; }
/* BUTTONS */
.add-btn {
padding: 0.7rem 1.25rem;
padding: 0.75rem 1.25rem;
background: var(--primary);
color: white;
border: none;
border-radius: 0;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); }
.add-btn:hover { background: #1d4ed8; }
.nav-btn {
padding: 0.7rem 1.4rem;
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: 0;
padding: 0.75rem 1.5rem;
background: var(--bg);
border: 2px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-weight: 600;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
transition: all 0.2s;
}
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); }
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
.nav-btn.primary {
background: var(--success);
@@ -430,7 +371,7 @@ body.embed-full { overflow: hidden; }
border-color: var(--success);
}
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; }
.nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -441,7 +382,7 @@ body.embed-full { overflow: hidden; }
justify-content: space-between;
padding: 1.5rem;
background: var(--bg-card);
border-radius: 0;
border-radius: 8px;
box-shadow: var(--shadow);
}
@@ -457,7 +398,7 @@ body.embed-full { overflow: hidden; }
background: var(--primary-light);
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 0;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -470,7 +411,7 @@ body.embed-full { overflow: hidden; }
#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: 0;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
}
.seq-step:hover { border-color: var(--primary); }
@@ -491,7 +432,7 @@ body.embed-full { overflow: hidden; }
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: var(--warning-bg); border-style: dashed; }
.seq-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
.seq-gate-badge {
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
@@ -507,7 +448,7 @@ body.embed-full { overflow: hidden; }
max-width: calc(100vw - 2rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0;
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 1.25rem;
z-index: 1200;
@@ -547,7 +488,7 @@ body.embed-full { overflow: hidden; }
.modal-content {
background: var(--bg-card);
border-radius: 0;
border-radius: 8px;
padding: 2rem;
max-width: 600px;
max-height: 80vh;
@@ -587,7 +528,7 @@ body.embed-full { overflow: hidden; }
/* RESPONSIVE */
@media (max-width: 768px) {
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
.header { flex-direction: column; text-align: center; gap: 1rem; }
.main-nav { flex-wrap: wrap; }
.content-area { padding: 1rem; }
.step-content { padding: 1rem; }

View File

@@ -6,10 +6,7 @@
<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="wp-chrome.css">
<link rel="stylesheet" href="work-package-suite-styles.css">
</head>
<body>
@@ -18,9 +15,9 @@
<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: 24px; width: auto;">
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
</a>
<div style="min-width:0;overflow:hidden">
<div>
<div class="header-title">Work Package Suite</div>
<div class="header-subtitle" id="project-display"></div>
</div>
@@ -104,28 +101,23 @@
<!-- STEP 2: PROJECT TEAM -->
<div class="step" id="sop-step-2" style="display: none;">
<h2>2. Project Team Leadership</h2>
<div class="notice">Pick the key project leaders from the people assigned to this project. Choosing a
<strong>user account</strong> (rather than typing a name) is what lets the Work Package Creator offer them
as an owner and lets the suite email them — so add anyone missing to the project first, in the
<a href="admin.html" target="_blank" rel="noopener">Admin Console</a>.</div>
<div id="team-accounts-warn" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div>
<div class="field-grid">
<div class="field">
<label>Project Manager (PM)</label>
<select id="proj_pm" class="team-pick" data-team="pm"></select>
<input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez">
</div>
<div class="field">
<label>Assistant Project Manager (APM)</label>
<select id="proj_apm" class="team-pick" data-team="apm"></select>
<input type="text" id="proj_apm" placeholder="e.g., Assistant PM name">
</div>
<div class="field">
<label>Construction Manager (CM)</label>
<select id="proj_cm" class="team-pick" data-team="cm"></select>
<div class="field-hint">Kept on the distribution list of every work package by default.</div>
<input type="text" id="proj_cm" placeholder="e.g., K. Boyd">
</div>
<div class="field">
<label>Quality Manager (QM)</label>
<select id="proj_qm" class="team-pick" data-team="qm"></select>
<input type="text" id="proj_qm" placeholder="e.g., D. Nguyen">
</div>
</div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -138,21 +130,21 @@
<!-- 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">Two roles are required on every package. They default to <strong>Superintendent</strong> and <strong>Foreman</strong> — rename either to fit your project (e.g. a BIM SOP uses <em>BIM Coordinator</em> and <em>Construction Lead</em>). Add more below.</div>
<div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
<div class="required-roles">
<div class="role-required">
<div class="role-checkbox">
<input type="checkbox" id="role_super" checked disabled>
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
<label>Superintendent *</label>
</div>
<select id="role_super_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
<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>
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
<label>Foreman *</label>
</div>
<select id="role_foreman_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
</div>
</div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -165,15 +157,7 @@
<!-- STEP 4: WORK PACKAGE TYPES -->
<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.
<strong>Spec Section</strong> is filled onto every work package of that type automatically, so nobody types it per package.</div>
<label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
<span><strong>Include BIM / VDC work packages on this project</strong><br>
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
</label>
<!-- Shown instead of the toggle when an admin has the BIM tooling switched off app-wide. -->
<div id="bim-disabled-note" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
<div id="wp-types-table" style="margin-top: 1.5rem;"></div>
</div>
@@ -303,16 +287,6 @@
</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 -->
@@ -372,7 +346,7 @@
<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"></iframe>
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
</div>
</div>
@@ -423,7 +397,5 @@
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="work-package-suite-app.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>

View File

@@ -1,211 +0,0 @@
/* ============================================================================
SHARED APP CHROME — project switcher + global search
----------------------------------------------------------------------------
Injected by wp-chrome.js into whichever top bar a page has: the dark UI-shell
bar (.wp-appbar on index / admin / field) or the older light bars (.header on
the SOP suite and the WP creator). The two live side by side, so every colour
here comes from a variable that wp-chrome.js sets per host bar — the markup and
behaviour are identical on both.
============================================================================ */
.wp-chrome {
display: flex;
align-items: center;
gap: 10px;
min-width: 0; /* lets the search shrink instead of overflowing */
flex: 1 1 auto;
}
/* Light host bar (the two tool pages) */
.wp-chrome {
--wpc-fg: #161616;
--wpc-fg-dim: #525252;
--wpc-bg: #ffffff;
--wpc-bg-soft: #f4f4f4;
--wpc-border: #c6c6c6;
--wpc-hover: #e8e8e8;
--wpc-accent: #0f62fe;
}
/* Dark host bar (the UI-shell appbar) */
.wp-chrome[data-bar="dark"] {
--wpc-fg: #ffffff;
--wpc-fg-dim: #c6c6c6;
--wpc-bg: #262626;
--wpc-bg-soft: #393939;
--wpc-border: #6f6f6f;
--wpc-hover: #353535;
--wpc-accent: #78a9ff;
}
/* ── project switcher ─────────────────────────────────────────────────────── */
.wpc-proj { position: relative; flex: 0 0 auto; }
.wpc-proj-btn {
display: flex;
align-items: center;
gap: 10px;
max-width: 280px;
padding: 5px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: var(--wpc-fg);
font: inherit;
font-size: 13px;
line-height: 1.25;
text-align: left;
cursor: pointer;
}
.wpc-proj-btn:hover { background: var(--wpc-hover); border-color: var(--wpc-border); }
.wpc-proj-btn[aria-expanded="true"] { background: var(--wpc-hover); border-color: var(--wpc-border); }
.wpc-proj-labels { min-width: 0; }
.wpc-proj-kicker {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--wpc-fg-dim);
}
.wpc-proj-name {
display: block;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.wpc-caret { flex: 0 0 auto; align-self: flex-end; margin-bottom: 3px; font-size: 10px;
line-height: 1; color: var(--wpc-fg-dim); }
/* ── dropdown / results panel (shared shell) ──────────────────────────────── */
.wpc-pop {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 2000;
min-width: 320px;
max-width: min(460px, 92vw);
max-height: min(70vh, 560px);
overflow-y: auto;
background: #fff;
color: #161616;
border: 1px solid #e0e0e0;
box-shadow: 0 8px 28px rgba(20, 30, 50, .22);
border-radius: 4px;
}
.wpc-pop[hidden] { display: none; }
.wpc-pop-head {
padding: 9px 12px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: .07em;
text-transform: uppercase;
color: #6f6f6f;
border-bottom: 1px solid #f0f0f0;
}
.wpc-item {
display: block;
width: 100%;
padding: 8px 12px;
background: transparent;
border: 0;
border-left: 3px solid transparent;
text-align: left;
font: inherit;
font-size: 13px;
color: #161616;
cursor: pointer;
text-decoration: none;
}
.wpc-item:hover, .wpc-item.is-active { background: #f4f4f4; }
.wpc-item.is-current { border-left-color: #0f62fe; background: #edf5ff; }
.wpc-item-title { display: block; font-weight: 600; }
.wpc-item-sub { display: block; font-size: 11.5px; color: #6f6f6f; }
.wpc-item-mono { font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; font-size: 12px; color: #0f62fe; }
.wpc-empty { padding: 14px 12px; font-size: 13px; color: #6f6f6f; }
.wpc-pop-foot {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.wpc-foot-btn {
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
border: 1px solid #c6c6c6;
background: #fff;
color: #161616;
border-radius: 3px;
cursor: pointer;
text-decoration: none;
}
.wpc-foot-btn:hover { border-color: #0f62fe; color: #0f62fe; }
/* ── global search ────────────────────────────────────────────────────────── */
/* Centered in the bar: the wrapper takes the free space and centres a capped box,
which keeps the field mid-screen without absolute positioning (so it can never
sit on top of the bar's own buttons). */
.wpc-search {
position: relative;
flex: 1 1 auto;
display: flex;
justify-content: center;
min-width: 0;
}
.wpc-search-box {
position: relative;
width: 100%;
max-width: 560px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
height: 34px;
background: var(--wpc-bg);
border: 1px solid var(--wpc-border);
border-radius: 3px;
}
.wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; }
.wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; }
.wpc-search-input {
flex: 1 1 auto;
min-width: 0;
background: transparent;
border: 0;
outline: none;
color: var(--wpc-fg);
font: inherit;
font-size: 13.5px;
}
.wpc-search-input::placeholder { color: var(--wpc-fg-dim); }
.wpc-kbd {
flex: 0 0 auto;
font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace;
font-size: 10.5px;
color: var(--wpc-fg-dim);
border: 1px solid var(--wpc-border);
border-radius: 3px;
padding: 1px 5px;
white-space: nowrap;
}
.wpc-search .wpc-pop { left: 50%; transform: translateX(-50%); min-width: min(560px, 92vw); }
.wpc-clear {
flex: 0 0 auto; background: transparent; border: 0; cursor: pointer;
color: var(--wpc-fg-dim); font: inherit; font-size: 14px; line-height: 1; padding: 2px 4px;
}
.wpc-clear:hover { color: var(--wpc-fg); }
/* ── narrow screens ───────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.wpc-search-box { max-width: none; }
.wpc-kbd { display: none; }
.wpc-proj-btn { max-width: 190px; }
.wpc-proj-name { max-width: 150px; }
}
@media (max-width: 620px) {
/* Keep the switcher (you must be able to change project) and let the search
collapse to an icon-width field rather than pushing the bar out of shape. */
.wpc-proj-kicker { display: none; }
.wpc-search { flex: 1 1 120px; }
}

View File

@@ -1,335 +0,0 @@
/* Shared app chrome for the Work Package Suite: a project switcher beside the
Prime logo and a global search centered in the top bar.
One script for every page because there are two generations of top bar — the
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
flip a colour set based on how dark the host bar is.
Search hits GET /api/search, which scopes results to the projects the signed-in
user may access — so this is a convenience, never a way to see another job.
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
second bar inside the frame would be nonsense. */
(function () {
'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
var SEARCH_MIN = 2; // characters before we ask the server
var DEBOUNCE_MS = 180;
function el(tag, cls, html) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function isDark(node) {
try {
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return false;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return false; }
}
// ── where to put the chrome ────────────────────────────────────────────────
// Returns {host, insertBefore} or null. The insertion point matters: on the
// dark bar we sit before the spacer (so search takes the middle); on the light
// bars we sit between the left block and the right-hand buttons.
function findMount() {
var appbar = document.querySelector('.wp-appbar');
if (appbar) {
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
}
var header = document.querySelector('.header');
if (header) {
// The suite page wraps its own left/right groups; the creator's bar is a
// flat row of buttons whose first button carries margin-left:auto.
var right = header.querySelector('.header-right');
if (right) return { host: header, before: right };
var firstBtn = header.querySelector('.btn, button');
return { host: header, before: firstBtn };
}
return null;
}
// ── project switcher ───────────────────────────────────────────────────────
var projects = [];
function activeProject() {
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
}
function projectLabel(p) {
if (!p) return 'Select a project';
var n = p.name || '(unnamed)';
return p.number ? (p.number + ' — ' + n) : n;
}
// Switching project reloads the current page with ?project=<id>. Every page
// already resolves its project from that param (falling back to the stored
// active id), so a reload is both the simplest and the safest route — no page
// has to re-hydrate half its state in place.
function switchProject(p) {
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
var url = new URL(location.href);
url.searchParams.set('project', p.id);
url.hash = '';
location.assign(url.toString());
}
function buildProjectSwitcher() {
var wrap = el('div', 'wpc-proj');
var btn = el('button', 'wpc-proj-btn');
btn.type = 'button';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
btn.title = 'Switch project';
var cur = activeProject();
btn.innerHTML =
'<span class="wpc-proj-labels">' +
'<span class="wpc-proj-kicker">Project</span>' +
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
'</span><span class="wpc-caret">▾</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(btn);
wrap.appendChild(pop);
function render() {
var curId = (activeProject() || {}).id || '';
var rows = projects.map(function (p) {
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
'" data-pid="' + esc(p.id) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
'</button>';
}).join('');
pop.innerHTML =
'<div class="wpc-pop-head">Switch project</div>' +
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
item.addEventListener('click', function () {
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
if (p) switchProject(p);
});
});
}
function open() {
render();
pop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function close() {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (pop.hidden) open(); else close();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
// Refresh the label once the project list (and any active project) is known.
wrap.wpcRefresh = function () {
var c = activeProject();
var nameEl = btn.querySelector('.wpc-proj-name');
if (nameEl) nameEl.textContent = projectLabel(c);
if (!pop.hidden) render();
};
return wrap;
}
function loadProjects(switcher) {
// ProjectData.list() already hits the API and falls back to its local cache
// when offline, so there's no second request to make here.
var p;
try {
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
} catch (e) { p = null; }
if (!p) {
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; });
}
Promise.resolve(p)
.then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); })
.catch(function () {});
}
// ── global search ──────────────────────────────────────────────────────────
function buildSearch() {
var wrap = el('div', 'wpc-search');
var box = el('div', 'wpc-search-box');
box.innerHTML =
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
'<span class="wpc-kbd">Ctrl K</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(box);
wrap.appendChild(pop);
var input = box.querySelector('.wpc-search-input');
var clear = box.querySelector('.wpc-clear');
var timer = null, seq = 0, items = [], activeIx = -1;
function close() { pop.hidden = true; activeIx = -1; }
function highlight() {
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
n.classList.toggle('is-active', i === activeIx);
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
});
}
// A work package lives inside the suite's Creator tab, so open the suite on
// that project with the package requested; a SOP opens the SOP tab.
function hrefFor(hit) {
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
if (hit.kind === 'wp') {
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
'&wp=' + encodeURIComponent(hit.id);
}
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
}
function go(hit) {
if (!hit) return;
if (hit.kind === 'project') {
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
if (p) { switchProject(p); return; }
}
// Set the active project only from a full record — writing a stub would
// clobber the cached project (name, number, client) other pages read. The
// ?project= param in the URL is what actually switches context.
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
location.assign(hrefFor(hit));
}
function renderResults(data) {
items = [];
var html = '';
function group(title, rows) {
if (!rows.length) return;
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
}
group('Work packages', (data.wps || []).map(function (w) {
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
esc(w.subject || '') + '</span>' +
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
group('Projects', (data.projects || []).map(function (p) {
items.push({ kind: 'project', id: p.id });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
'</button>';
}));
group('SOPs', (data.sops || []).map(function (s) {
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
if (!items.length) {
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
}
pop.innerHTML = html;
pop.hidden = false;
activeIx = items.length ? 0 : -1;
highlight();
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
});
}
function run(q) {
var mine = ++seq;
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (mine !== seq) return; // a newer keystroke already won
if (!data) { close(); return; }
renderResults(data);
})
.catch(function () {
if (mine !== seq) return;
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
pop.hidden = false;
});
}
input.addEventListener('input', function () {
var q = input.value.trim();
clear.hidden = !q;
clearTimeout(timer);
if (q.length < SEARCH_MIN) { close(); return; }
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); input.blur(); return; }
if (pop.hidden || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
});
input.addEventListener('focus', function () {
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
});
clear.addEventListener('click', function () {
input.value = ''; clear.hidden = true; close(); input.focus();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
// use). Ignored while typing in another field so it can't steal a shortcut.
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
input.focus();
input.select();
}
});
return wrap;
}
// ── mount ──────────────────────────────────────────────────────────────────
function mount() {
if (document.querySelector('.wp-chrome')) return;
var m = findMount();
if (!m) return;
var chrome = el('div', 'wp-chrome');
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
var switcher = buildProjectSwitcher();
chrome.appendChild(switcher);
chrome.appendChild(buildSearch());
if (m.before) m.host.insertBefore(chrome, m.before);
else m.host.appendChild(chrome);
loadProjects(switcher);
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
}
// Wait for the auth guard: an unauthenticated page is about to redirect, and
// /api/search would 401 anyway.
if (window.WP_USER) mount();
else document.addEventListener('wp-auth-ready', mount);
})();

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,6 @@
<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>
@@ -30,7 +28,6 @@
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
<button class="btn btn-ghost embed-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>
@@ -45,40 +42,8 @@
<!-- SECTION NAV (jump links, built from the form cards) -->
<div class="section-nav-bar" id="section-nav"></div>
<div class="wp-layout">
<!-- WP NAVIGATOR — auto-hiding drawer. The handle is always visible; hover or tap
it to slide the list in over the form, or pin it to keep it open. -->
<button class="wp-nav-handle" id="wp-nav-handle" onclick="toggleWpNav()"
title="Work packages on this project" aria-label="Show work packages" aria-expanded="false">
Work Packages <span class="wp-nav-handle-count" id="wp-nav-handle-count">0</span>
</button>
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
<div class="wp-nav-head">
<div class="wp-nav-title">Work Packages <span class="wp-nav-count" id="wp-nav-count"></span></div>
<button class="wp-nav-btn" id="wp-nav-pin" onclick="toggleWpNavPin()" title="Keep this list open" aria-label="Pin list open">📌</button>
<button class="wp-nav-btn" onclick="closeWpNav(true)" title="Close list" aria-label="Close list"></button>
</div>
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter by number, subject, type…" oninput="renderWpNav()">
<div class="wp-nav-list" id="wp-nav-list"></div>
<div class="wp-nav-foot">
<button class="add-btn" onclick="newPackage()">+ New</button>
<button class="add-btn" onclick="showDashboard()">📊 Dashboard</button>
</div>
</aside>
<div class="main">
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
<div class="card" id="kind-row" style="display:none">
<div class="sub-heading">Package Type</div>
<div class="notice">This project includes BIM/VDC packages. Choose what this one is — it tailors the fields below and the WP types / release gates offered.</div>
<div class="radio-group" id="kind-group" style="margin-bottom:0">
<label class="radio-pill" data-val="iwp"><input type="radio" name="pkgkind" onclick="setKind('iwp')"><span class="dot"></span>Install package (IWP)</label>
<label class="radio-pill" data-val="ewp"><input type="radio" name="pkgkind" onclick="setKind('ewp')"><span class="dot"></span>BIM package (EWP)</label>
</div>
</div>
<!-- GENERAL INFORMATION -->
<div class="card">
<div class="section-header"><div class="section-title">General Information</div>
@@ -109,39 +74,16 @@
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
</div>
<div class="field-grid">
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
<div class="people-pick" id="pick_assignees"></div>
<input type="hidden" id="wp_assignees"></div>
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
<div class="people-pick" id="pick_distribution"></div>
<input type="hidden" id="wp_distribution"></div>
<div class="field"><label>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" readonly class="locked-field" placeholder="set on the WP type in the SOP">
<div class="field-hint" id="spec-folder-link"></div></div>
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
</div>
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
<div class="card" id="bim-card" style="display:none">
<div class="sub-heading">BIM / Model Details</div>
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
<div class="field-grid">
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
<div class="field"><label>Clash / Coordination Status</label>
<select id="wp_clash" onchange="onClashChange()"><option value=""></option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
<div class="field"><label>IFF #<span class="help-tip" data-tip="Issued-For-Fabrication/Field number — the GC sign-off reference for this model package. Required once the coordination status is Signed off (IFF).">i</span></label>
<input type="text" id="wp_iff" placeholder="e.g. IFF-2026-0142" oninput="onClashChange()">
<div class="field-hint" id="iff-hint"></div></div>
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
</div>
</div>
<!-- ASSETS (controls.dev) -->
<div class="card" id="asset-card">
<div class="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>
@@ -169,16 +111,12 @@
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
<div class="field-grid" style="margin-top:14px">
<div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
<div class="field"><label>Predecessor work packages<span class="help-tip" data-tip="The packages that must be Closed before this one can be released. A package with an open predecessor is not release-ready — you can still release it, but the override is logged.">i</span></label>
<div class="people-pick" id="pick_predecessors"></div>
<div class="field-hint" id="pred-hint"></div></div>
<div class="field"><label>Sequence phase <span class="help-tip" data-tip="Which phase of the SOP's construction sequence this package belongs to. Descriptive — it does not gate release; predecessor packages do.">i</span></label>
<select id="wp_seq"></select><div class="field-hint sop-hint">from the SOP construction sequence</div></div>
<div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div>
</div>
</div>
<!-- MATERIAL LIST -->
<div class="card" id="material-card">
<div class="card">
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
<div class="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>
@@ -209,7 +147,7 @@
</div>
<!-- KITTING & MIMO -->
<div class="card" id="mimo-card">
<div class="card">
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
<div class="field-grid">
<div class="field"><label>Kitting Status</label>
@@ -301,7 +239,6 @@
</div>
</div>
</div>
<!-- HOLD LOG MODAL (comment 7) -->
<div class="modal-overlay" id="hold-modal">
@@ -356,6 +293,5 @@
<script src="project-data.js"></script>
<script src="help.js"></script>
<script src="wp-creation-app.js"></script>
<script src="wp-format.js"></script>
</body>
</html>

View File

@@ -7,25 +7,25 @@
body.embedded .embed-first { margin-left: auto; }
:root {
--bg: #f4f4f4;
--bg: #f4f5f7;
--surface: #ffffff;
--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);
--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);
--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;
}
@@ -99,14 +99,8 @@
.step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); }
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
/* ── MAIN ──
The form uses the full width it's given. The work-package navigator is an
auto-hiding overlay drawer (see below) rather than a column, so it never takes
width away from the form — which matters most when this page is embedded in the
suite's tab and every pixel is shared with the app chrome. */
.wp-layout { display: block; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0; padding: 22px 28px 72px var(--gutter,34px);
transition: padding-left .18s ease; }
/* ── MAIN ── */
.main { max-width: 1000px; margin: 0 auto; padding: 28px 32px 64px; }
.section { display: none; }
.section.active { display: block; animation: fade .25s ease; }
@@ -132,11 +126,6 @@
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
.field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; }
.field-grid.col1 { grid-template-columns: 1fr; }
/* On a wide screen let the two-up grids flow into 34 columns instead of
stretching two fields across the whole card. */
@media (min-width: 1200px) {
.field-grid:not(.col1):not(.col3) { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field.span2 { grid-column: span 2; }
@@ -224,7 +213,7 @@
.notice {
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono);
padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
}
/* ── DELIVERABLES ── */
@@ -256,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: #0353e9; }
.btn-primary:hover { background: #1d52b8; }
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
.btn-generate:hover { background: #0e6027; }
.btn-generate:hover { background: #117a42; }
/* ── OUTPUT ── */
#output-section { display: none; }
@@ -430,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:#0e6027; }
.use-btn:hover { background:#0f7a40; }
.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; }
@@ -438,7 +427,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px var(--gutter,34px); display:flex; align-items:center; gap:20px;
.ctx-bar { max-width:1080px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -450,7 +439,7 @@
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
.mode-wrap { max-width:none; margin:0; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-wrap { max-width:1080px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
color:var(--text-muted); cursor:pointer; }
@@ -474,7 +463,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; }
.release-banner { max-width:none; margin:0; padding:0 28px 0 var(--gutter,34px); }
.release-banner { max-width:1080px; margin:0 auto; padding:0 28px; }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
@@ -506,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:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
.modal-body { padding:18px 20px; overflow-y:auto; }
@@ -582,173 +571,13 @@
/* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px 8px var(--gutter,34px); 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; }
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); }
.section-nav-bar:empty{ display:none; }
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
/* ── SOP-inherited marker ───────────────────────────────────────────────────
The "from SOP types" subtext used to sit under the field. It's now a small
chip on the label with the detail in a hover tooltip (site comment 8/3).
The chip stays VISIBLE rather than hover-only: on a field tablet there is no
hover, and "this value came from the SOP" is the part people need to see. */
.field-hint.sop-hint { display: none; }
.sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px;
background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb;
font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
vertical-align:middle; cursor:help; position:relative; }
.sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%;
transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px;
font-weight:400; letter-spacing:0; text-transform:none; line-height:1.4; white-space:normal;
width:max-content; max-width:260px; text-align:left; z-index:9999; opacity:0;
pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.sop-chip::before { content:''; position:absolute; bottom:135%; left:50%;
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616;
opacity:0; transition:opacity .12s; z-index:9999; }
.sop-chip:hover::after, .sop-chip:hover::before,
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
/* ── people picker (Assignees / Distribution / Predecessors) ─────────────────
Multi-select over the SOP project team instead of a free-text list. */
.people-pick { border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
.people-pick:focus-within { outline:2px solid var(--accent); outline-offset:-2px; }
.pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px;
background:var(--surface2); border:1px solid var(--border); border-radius:12px;
font-size:12px; max-width:100%; }
.pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); }
.pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted);
font-size:12px; line-height:1; padding:0 1px; }
.pp-chip .pp-x:hover { color:var(--red); }
.pp-add { position:relative; }
.pp-add-btn { background:none; border:1px dashed var(--border-strong); border-radius:12px;
color:var(--text-muted); font:inherit; font-size:12px; padding:2px 9px; cursor:pointer; }
.pp-add-btn:hover { border-color:var(--accent); color:var(--accent); }
.pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px;
max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong);
box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; }
.pp-menu[hidden] { display:none; }
.pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
color:var(--text-dim); padding:7px 10px 3px; }
.pp-opt { display:flex; align-items:center; gap:8px; padding:5px 10px; font-size:13px; cursor:pointer; }
.pp-opt:hover { background:var(--surface2); }
.pp-opt input { width:15px; height:15px; cursor:pointer; }
.pp-opt .pp-role { color:var(--text-dim); font-size:11.5px; }
.pp-free { border-top:1px solid var(--border); margin-top:5px; padding:7px 10px 3px; }
.pp-free input { width:100%; padding:5px 7px; font:inherit; font-size:12.5px;
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
/* ── WP NAVIGATOR — auto-hiding drawer ─────────────────────────────────────
Was a fixed 262px column, which (a) stole width from the form and (b) vanished
entirely under 1100px — so embedded in the suite tab it was never visible at
all. Now it slides in over the content from a slim edge handle: hover (or tap)
the handle to open, move away to close, or pin it open if you'd rather it stay.
Nothing is taken from the form unless you pin it. */
.wp-nav {
position: fixed;
top: var(--rail-top, 0px);
left: 0;
bottom: 0;
width: 300px;
max-width: 86vw;
z-index: 120;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border-strong);
box-shadow: 6px 0 22px rgba(20,30,50,.16);
transform: translateX(-100%);
transition: transform .18s ease;
}
body.wp-nav-open .wp-nav,
body.wp-nav-pinned .wp-nav { transform: none; }
/* Pinned: no shadow (it's part of the layout now) and the form shifts over. */
body.wp-nav-pinned .wp-nav { box-shadow: none; }
/* The always-visible edge handle. Vertical so it costs ~26px of width. */
.wp-nav-handle {
position: fixed;
top: calc(var(--rail-top, 0px) + 14px);
left: 0;
z-index: 119;
display: flex;
align-items: center;
gap: 6px;
padding: 12px 5px;
writing-mode: vertical-rl;
background: var(--surface);
color: var(--text-muted);
border: 1px solid var(--border-strong);
border-left: 0;
border-radius: 0 5px 5px 0;
box-shadow: 2px 0 8px rgba(20,30,50,.10);
font: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
cursor: pointer;
}
.wp-nav-handle:hover { color: var(--accent); border-color: var(--accent); }
.wp-nav-handle .wp-nav-handle-count {
writing-mode: horizontal-tb; font-size: 10px; font-weight: 700; letter-spacing: 0;
background: var(--accent-dim); color: var(--accent); border-radius: 8px; padding: 0 5px;
}
body.wp-nav-open .wp-nav-handle,
body.wp-nav-pinned .wp-nav-handle { display: none; }
.wp-nav-head { display:flex; align-items:center; gap:8px; padding:12px 12px 8px; }
.wp-nav-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); }
.wp-nav-count { color:var(--text-dim); font-weight:600; letter-spacing:0; }
.wp-nav-btn { background:transparent; border:1px solid var(--border); border-radius:4px;
color:var(--text-muted); cursor:pointer; font-size:13px; line-height:1; padding:3px 7px; }
.wp-nav-btn:hover { border-color:var(--accent); color:var(--accent); }
.wp-nav-btn.is-on { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
.wp-nav-head .wp-nav-btn:first-of-type { margin-left:auto; }
.wp-nav-search { margin:0 12px 10px; padding:6px 9px; font-size:12px; font-family:inherit;
border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text); }
.wp-nav-search:focus { outline:none; border-color:var(--accent); }
.wp-nav-list { flex:1 1 auto; overflow-y:auto; padding:0 8px 8px; }
.wp-nav-group { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
color:var(--text-dim); padding:10px 6px 5px; }
.wp-nav-item { display:block; width:100%; text-align:left; background:transparent; border:0;
border-left:3px solid transparent; border-radius:4px; padding:6px 8px; cursor:pointer;
font-family:inherit; color:var(--text); }
.wp-nav-item:hover { background:var(--surface2); }
.wp-nav-item.active { background:var(--accent-dim); border-left-color:var(--accent); }
.wp-nav-num { display:block; font-family:var(--mono); font-size:11px; font-weight:600; color:var(--accent); }
.wp-nav-subj { display:block; font-size:12px; color:var(--text-muted); line-height:1.35;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.wp-nav-meta { display:flex; align-items:center; gap:6px; margin-top:3px; font-size:10px; color:var(--text-dim); }
.wp-nav-dot { width:7px; height:7px; border-radius:50%; background:var(--text-dim); flex:0 0 auto; }
.wp-nav-dot.ok { background:var(--accent-green); }
.wp-nav-dot.open { background:var(--accent-amber); }
.wp-nav-dot.hold { background:var(--red); }
.wp-nav-empty { padding:12px 8px; font-size:12px; color:var(--text-dim); }
.wp-nav-foot { border-top:1px solid var(--border); padding:9px 12px; display:flex; gap:8px; flex-wrap:wrap; }
/* Keep the drawer's footer clear of the fixed save bar. */
body.has-sticky-save .wp-nav { bottom: 54px; }
/* Pinned: the page chrome shifts with the form so nothing hides behind the panel. */
body.wp-nav-pinned { --gutter: 328px; }
body.wp-nav-pinned .sticky-save { left: 300px; }
/* Narrow screens: never pin (there isn't the width) — overlay only. */
@media (max-width: 900px) {
body.wp-nav-pinned { --gutter: 34px; } /* no room to pin — overlay only */
.wp-nav { width: 290px; }
}
/* 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;
@@ -777,12 +606,11 @@
/* Dashboard */
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; }
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
.dash-metric .dm-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); }
@@ -792,7 +620,7 @@
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; }
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
.dash-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); }
@@ -801,27 +629,3 @@
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
.dash-filters input[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; } }

View File

@@ -1,232 +0,0 @@
/* Localization + time formatting for the Work Package Suite.
Every date the app shows should agree, wherever it's rendered. Three sources,
most specific first:
1. the signed-in user's own preference (users.locale / users.timezone)
2. the app default set by an admin (Admin console → Localization)
3. the browser's own locale / timezone (the previous behaviour)
Why store it server-side: on a shared field tablet the browser's locale isn't
the person's, and a package due date that reads a day early because the device
sits in another zone is a real scheduling problem — not a cosmetic one.
Exposes:
wpFormatDate(v) → 3 Aug 2026 (date only)
wpFormatDateTime(v) → 3 Aug 2026, 14:07 (date + time)
wpFormatTime(v) → 14:07
wpFormatNumber(v) → locale-grouped number
wpTimeZoneLabel() → the zone in effect, for a UI hint
wpPreferences() → opens the preferences dialog
All formatters take an ISO string, Date, or epoch ms, and return '' for empty
input (never 'Invalid Date'), so they're safe to drop into a template. */
(function () {
'use strict';
function prefs() {
var u = window.WP_USER || {};
var f = window.WP_FLAGS || {};
return {
locale: (u.locale || f.default_locale || '') || undefined,
timezone: (u.timezone || f.default_timezone || '') || undefined
};
}
// A date-only value ('2026-08-03') is a calendar date, not an instant. Parsed as
// UTC midnight by the platform, it can render as the previous day in a western
// zone — so format these from their parts and never apply a timezone.
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
function toDate(v) {
if (v == null || v === '') return null;
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
if (typeof v === 'number') { var n = new Date(v); return isNaN(n.getTime()) ? null : n; }
var s = String(v).trim();
if (!s) return null;
var d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
function fmt(v, opts, forceNoTz) {
var s = (typeof v === 'string') ? v.trim() : v;
var dateOnly = (typeof s === 'string') && DATE_ONLY.test(s);
var d = dateOnly ? new Date(s + 'T12:00:00') : toDate(s); // noon: immune to ±12h shifts
if (!d) return '';
var p = prefs();
var o = {};
for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k];
if (p.timezone && !dateOnly && !forceNoTz) o.timeZone = p.timezone;
try {
return new Intl.DateTimeFormat(p.locale, o).format(d);
} catch (e) {
// Bad locale/zone (e.g. a preference set before tzdata was available):
// fall back to the platform default rather than showing nothing.
try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e2) { return String(v); }
}
}
window.wpFormatDate = function (v) {
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric' });
};
window.wpFormatDateTime = function (v) {
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
window.wpFormatTime = function (v) {
return fmt(v, { hour: '2-digit', minute: '2-digit' });
};
window.wpFormatNumber = function (v, opts) {
if (v == null || v === '' || isNaN(+v)) return '';
try { return new Intl.NumberFormat(prefs().locale, opts || {}).format(+v); }
catch (e) { return String(v); }
};
window.wpTimeZoneLabel = function () {
var p = prefs();
if (p.timezone) return p.timezone;
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'browser default'; }
catch (e) { return 'browser default'; }
};
window.wpLocaleLabel = function () {
var p = prefs();
if (p.locale) return p.locale;
try { return Intl.DateTimeFormat().resolvedOptions().locale || 'browser default'; }
catch (e) { return 'browser default'; }
};
// ── preferences dialog ─────────────────────────────────────────────────────
var COMMON_LOCALES = [
['', 'Browser default'],
['en-US', 'English (United States) — 8/3/2026, 2:07 PM'],
['en-GB', 'English (United Kingdom) — 03/08/2026, 14:07'],
['en-CA', 'English (Canada)'],
['es-MX', 'Español (México)'],
['es-US', 'Español (Estados Unidos)'],
['fr-CA', 'Français (Canada)'],
['de-DE', 'Deutsch (Deutschland)'],
['ja-JP', '日本語 (日本)'],
['ko-KR', '한국어 (대한민국)'],
['zh-TW', '中文 (台灣)']
];
// Zones the fabs and offices actually sit in, offered before the full list.
var COMMON_ZONES = [
'America/Chicago', 'America/New_York', 'America/Denver', 'America/Phoenix',
'America/Los_Angeles', 'America/Boise', 'Asia/Tokyo', 'Asia/Taipei',
'Asia/Seoul', 'Asia/Singapore', 'Europe/Dublin', 'Europe/London', 'UTC'
];
window.wpPreferences = function () {
if (document.getElementById('wp-prefs-modal')) return;
var u = window.WP_USER || {};
var ov = document.createElement('div');
ov.id = 'wp-prefs-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' +
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;background:#fff;';
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Language &amp; time</div>' +
'<div style="padding:4px 18px 16px;">' +
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
'<label style="' + lbl + '">Language &amp; number format</label>' +
'<select id="wp-prefs-locale" style="' + fld + '"></select>' +
'<div style="' + hint + '">Sets how dates and numbers are written. It does not translate the app.</div>' +
'<label style="' + lbl + '">Time zone</label>' +
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
'Calendar dates like a due date are never shifted.</div>' +
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
function msg(text, ok) {
var e = document.getElementById('wp-prefs-msg');
e.style.display = 'block'; e.textContent = text;
e.style.background = ok ? '#defbe6' : '#fff1f1';
e.style.color = ok ? '#0e6027' : '#da1e28';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
var locSel = document.getElementById('wp-prefs-locale');
var tzSel = document.getElementById('wp-prefs-tz');
var preview = document.getElementById('wp-prefs-preview');
locSel.innerHTML = COMMON_LOCALES.map(function (p) {
return '<option value="' + p[0] + '"' + (p[0] === (u.locale || '') ? ' selected' : '') + '>' + p[1] + '</option>';
}).join('');
// A stored locale that isn't in the shortlist stays selectable.
if (u.locale && !COMMON_LOCALES.some(function (p) { return p[0] === u.locale; })) {
locSel.add(new Option(u.locale, u.locale, true, true));
}
function fillZones(all) {
var cur = u.timezone || '';
var browser = '';
try { browser = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
var html = '<option value=""' + (cur ? '' : ' selected') + '>Browser default' +
(browser ? ' (' + browser + ')' : '') + '</option>';
html += '<optgroup label="Common">' + COMMON_ZONES.map(function (z) {
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
}).join('') + '</optgroup>';
var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; });
if (rest.length) {
html += '<optgroup label="All time zones">' + rest.map(function (z) {
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
}).join('') + '</optgroup>';
} else if (cur && COMMON_ZONES.indexOf(cur) < 0) {
html += '<option value="' + cur + '" selected>' + cur + '</option>';
}
tzSel.innerHTML = html;
updatePreview();
}
// Preview uses the picked values, not the saved ones, so the effect is visible
// before committing.
function updatePreview() {
var l = locSel.value || undefined, z = tzSel.value || undefined;
var now = new Date();
var out;
try {
out = new Intl.DateTimeFormat(l, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', timeZone: z
}).format(now);
} catch (e) { out = 'Not supported by this browser'; }
preview.innerHTML = '<strong>Preview</strong><br>Right now: ' +
String(out).replace(/[<>]/g, '') +
'<br>A due date (2026-08-03) always reads: ' + window.wpFormatDate('2026-08-03');
}
locSel.addEventListener('change', updatePreview);
tzSel.addEventListener('change', updatePreview);
// The picker offers exactly what the server will accept.
fetch('/api/timezones', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(fillZones)
.catch(function () { fillZones([]); });
document.getElementById('wp-prefs-cancel').onclick = close;
document.getElementById('wp-prefs-save').onclick = function () {
var body = { locale: locSel.value || '', timezone: tzSel.value || '' };
fetch('/api/auth/preferences', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
})
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
.then(function (res) {
if (!res.ok) { msg((res.j && res.j.detail) || ('Could not save (HTTP ' + res.status + ').'), false); return; }
if (res.j && res.j.user) window.WP_USER = res.j.user;
try { localStorage.setItem('wp_auth_cache', JSON.stringify({ user: window.WP_USER, at: Date.now() })); } catch (e) {}
msg('Saved. Reloading so every date on the page agrees…', true);
// Dates are formatted at render time all over the app; a reload is the
// honest way to apply the change everywhere at once.
setTimeout(function () { location.reload(); }, 700);
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
})();

View File

@@ -34,13 +34,6 @@ 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;
}

View File

@@ -9,17 +9,6 @@ server {
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;
}
@@ -30,10 +19,7 @@ server {
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;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 5m;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
"""permissions roles + project (job function) role
Adds `users.project_role` (job function on the project — carries no permissions)
and migrates the permissions vocabulary: the legacy role 'user' becomes
'project_user'. 'admin' is untouched; 'project_admin' is new and is only ever
granted explicitly from the admin console.
Revision ID: b41c7ae90d52
Revises: 57dec34f11cb
Create Date: 2026-08-03 15:12:04.118322
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b41c7ae90d52'
down_revision = '57dec34f11cb'
branch_labels = None
depends_on = None
def upgrade() -> None:
# server_default backfills existing rows (the column is NOT NULL).
op.add_column('users', sa.Column('project_role', sa.String(length=120),
nullable=False, server_default=''))
# Legacy 'user' means exactly what 'project_user' means now.
op.execute("UPDATE users SET role = 'project_user' WHERE role = 'user'")
def downgrade() -> None:
# Fold the new role back onto the legacy value so an older build still reads
# the table. A project_admin loses its elevated rights on downgrade.
op.execute("UPDATE users SET role = 'user' WHERE role IN ('project_user', 'project_admin')")
op.drop_column('users', 'project_role')

View File

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

View File

@@ -1,29 +0,0 @@
"""user locale + timezone preferences
Per-user display preferences. Empty means "use the app default (admin console),
then the browser". Stored server-side so they follow the person between devices —
shared field tablets are the case that matters.
Revision ID: c93f2b1d7e04
Revises: b41c7ae90d52
Create Date: 2026-08-03 16:44:10.882931
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c93f2b1d7e04'
down_revision = 'b41c7ae90d52'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('users', sa.Column('locale', sa.String(length=20), nullable=False, server_default=''))
op.add_column('users', sa.Column('timezone', sa.String(length=60), nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('users', 'timezone')
op.drop_column('users', 'locale')

View File

@@ -1,28 +0,0 @@
"""per-project member role
Lets someone be Project Admin on one job and a normal Project User on another.
Empty string means "inherit the account's own role" (users.role), which is exactly
how every existing membership behaved, so this is a no-op for current data.
Revision ID: d15b8c4ef207
Revises: c93f2b1d7e04
Create Date: 2026-08-03 17:58:22.401118
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd15b8c4ef207'
down_revision = 'c93f2b1d7e04'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('project_members', sa.Column('role', sa.String(length=20),
nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('project_members', 'role')

File diff suppressed because it is too large Load Diff

View File

@@ -16,19 +16,7 @@ Security model:
set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works.
Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
Roles: 'admin' (may manage users) and 'user'.
"""
import os
import secrets
@@ -42,7 +30,7 @@ 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 .db import get_db
from . import models
log = logging.getLogger("wpsuite.auth")
@@ -51,71 +39,6 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for app admins and project admins — the two roles allowed to delete
work packages and change a completed SOP."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
@@ -132,24 +55,13 @@ 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)."
)
# No secret configured: generate an ephemeral one so the app still runs in
# dev. Sessions won't survive a restart, and this is unsafe across multiple
# workers — production must set AUTH_SECRET_KEY.
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."
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
"Logins will reset on restart and break across multiple workers. "
"Set AUTH_SECRET_KEY in the environment for production."
)
return secrets.token_urlsafe(48)
@@ -180,7 +92,6 @@ def create_token(user: "models.User") -> str:
"sub": user.id,
"username": user.username,
"role": user.role,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(hours=SESSION_HOURS),
}
@@ -188,43 +99,11 @@ def create_token(user: "models.User") -> str:
def decode_token(token: str) -> Optional[dict]:
"""Return the token claims if the signature and expiry are valid, else None.
Session cookies only — a token of any other type is rejected."""
"""Return the token claims if the signature and expiry are valid, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
# A password-reset token must never be usable as a session cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ────────────────────────────────────────────────────────────
@@ -284,15 +163,11 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
# Session revocation: a mismatch means the token was invalidated (e.g. the
# password was changed after this token was issued).
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return user
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if not is_admin(user):
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

View File

@@ -30,16 +30,15 @@ def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}"
def _prompt_password(provided: str | None, username: str = "") -> str:
def _prompt_password(provided: str | None) -> str:
pw = provided
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)
if len(pw) < 8:
sys.exit("Password must be at least 8 characters.")
return pw
@@ -47,7 +46,7 @@ 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)
pw = _prompt_password(getattr(args, "password", None))
with SessionLocal() as db:
if auth.find_user(db, args.username):
sys.exit(f"A user named '{args.username}' already exists.")
@@ -76,7 +75,7 @@ def cmd_list(args) -> None:
def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username)
pw = _prompt_password(getattr(args, "password", None))
with SessionLocal() as db:
u = auth.find_user(db, args.username)
if not u:

View File

@@ -92,13 +92,7 @@ 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)
@@ -108,9 +102,7 @@ class WorkPackage(Base):
return {
"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,
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
"created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
@@ -121,15 +113,8 @@ class WorkPackage(Base):
class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with.
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
• project_role the person's JOB FUNCTION on the project (Project Manager,
Superintendent, QA/QC, …). Carries no permissions; it's what
the SOP team pickers and notification routing read.
"""
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)
@@ -137,44 +122,25 @@ class User(Base):
email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
# Display preferences. Empty means "fall back to the app default, then to the
# browser". A stored value follows the person between devices, which matters on
# shared field tablets where the browser locale isn't theirs.
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
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,
"project_role": self.project_role or "", "is_active": self.is_active,
"locale": self.locale or "", "timezone": self.timezone or "",
"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, and what they may do there. A user
sees/operates on a project only if a row links them to it (admins bypass this
entirely). One row per (user, project) pair.
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
one job and a normal Project User on another. Empty means "inherit the account's
own role" (User.role), which is how every existing row behaves."""
"""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"),)
@@ -185,7 +151,6 @@ class ProjectMember(Base):
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -211,74 +176,5 @@ class Comment(Base):
}
class AuditLog(Base):
"""Append-only history: who changed what, when. Rows are written inside the
same transaction as the change they describe (see server/app.py: log_event),
so the trail can't drift from the data. `detail` holds a compact JSON summary
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
Not a ForeignKey to any entity on purpose — the log must survive the deletion
of the thing it describes (you still want "who deleted WP01, and when")."""
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
"entity_type": self.entity_type, "entity_id": self.entity_id,
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
}
class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
the SMTP password are NOT stored here — they come from the environment."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
value: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class Notification(Base):
"""Outbox for user notifications (an in-app record + an optional email). A row
is written when something notable happens (e.g. a WP assignment); the email
sender processes it only when email notifications are enabled AND SMTP is set —
otherwise it's recorded as 'skipped'. See server/notify.py."""
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
email: Mapped[str] = mapped_column(String(200), default="")
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
subject: Mapped[str] = mapped_column(String(300), default="")
body: Mapped[str] = mapped_column(Text, default="")
link: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
error: Mapped[str] = mapped_column(String(400), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
"status": self.status, "error": self.error,
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
}
def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None

View File

@@ -1,174 +0,0 @@
"""Notifications: admin-configurable email + an outbox.
Email notifications are OFF by default and controlled from the admin console (a
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
and is NEVER stored in the database or shown in the UI.
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
record — and, when email is on + SMTP is set, the row is delivered by email in a
background task. Notification bodies deliberately avoid customer IP: they carry a WP
number and a deep link, not the work-package contents.
"""
import os
import smtplib
import uuid
import logging
from email.message import EmailMessage
from typing import Optional
from sqlalchemy.orm import Session
from . import models
log = logging.getLogger("wpsuite.notify")
SETTINGS_KEY = "notifications"
DEFAULTS = {
"email_enabled": False, # master toggle — OFF until SMTP is sorted
"smtp_host": "",
"smtp_port": 587,
"smtp_use_tls": True,
"smtp_username": "",
"from_addr": "",
"from_name": "Work Package Suite",
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
# with it off, the SOP creator hides the BIM section entirely and every SOP is
# install-only, so no project can be put on the BIM path by accident.
"bim_enabled": False,
# Localization defaults for dates, times and numbers. Empty = use each
# browser's own locale / timezone. A user's own preference wins over these.
"default_locale": "", # BCP47, e.g. en-US
"default_timezone": "", # IANA, e.g. America/Chicago
}
# Settings the app needs before anyone is signed in, or that carry no secrets and
# are safe for any authenticated user to read (feature flags + localization
# defaults + whether self-service password reset can work at all).
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
def get_settings(db: Session) -> dict:
row = db.get(models.AppSetting, SETTINGS_KEY)
s = dict(DEFAULTS)
if row and row.value:
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
return s
def save_settings(db: Session, patch: dict) -> dict:
cur = get_settings(db)
for k in DEFAULTS:
if k in patch and patch[k] is not None:
cur[k] = patch[k]
row = db.get(models.AppSetting, SETTINGS_KEY)
if row:
row.value = cur
else:
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
db.commit()
return cur
def public_settings(db: Session) -> dict:
"""Settings safe to return to the admin UI — no secrets."""
s = get_settings(db)
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
return s
def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
`password_reset_enabled` tells the login page whether a self-service reset can
actually deliver mail — there's no point offering the link otherwise."""
s = get_settings(db)
out = {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
def smtp_ready(s: dict) -> bool:
return bool(s.get("smtp_host") and s.get("from_addr"))
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
"""Send one email via SMTP. Raises on any failure (caller records it)."""
if not to_addr:
raise ValueError("no recipient email")
msg = EmailMessage()
from_name = s.get("from_name") or ""
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
msg["To"] = to_addr
msg["Subject"] = subject
msg.set_content(body)
host = s["smtp_host"]
port = int(s.get("smtp_port") or 587)
user = s.get("smtp_username") or ""
pw = os.getenv("SMTP_PASSWORD", "")
with smtplib.SMTP(host, port, timeout=15) as srv:
if s.get("smtp_use_tls", True):
srv.starttls()
if user:
srv.login(user, pw)
srv.send_message(msg)
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
"""Send one email immediately, outside the outbox. Used for password resets —
a reset link must never sit in a queue, and it must not be persisted in the
notifications table where an admin could read it and take over the account.
Returns True if it went out."""
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
return False
try:
send_email(s, to_addr, subject, body)
return True
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
log.warning("password-reset email to %s failed: %s", to_addr, e)
return False
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
commit — the caller commits with its own transaction. Returns the row."""
s = get_settings(db)
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
n = models.Notification(
id="ntf_" + uuid.uuid4().hex[:12],
user_id=user.id, email=user.email or "", kind=kind,
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
link=link[:500], status="pending" if deliverable else "skipped",
)
db.add(n)
return n
def deliver(notif_id: str) -> None:
"""Background task: send one pending notification, on its own DB session."""
from .db import SessionLocal
db = SessionLocal()
try:
n = db.get(models.Notification, notif_id)
if not n or n.status != "pending":
return
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
n.status = "skipped"
db.commit()
return
try:
send_email(s, n.email, n.subject, n.body)
n.status = "sent"
n.sent_at = models.utcnow()
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
n.status = "failed"
n.error = str(e)[:400]
log.warning("notification %s failed to send: %s", notif_id, e)
db.commit()
finally:
db.close()

View File

@@ -1,16 +1,9 @@
# 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)
fastapi>=0.110
uvicorn[standard]>=0.29
gunicorn>=21.2
sqlalchemy>=2.0
psycopg[binary]>=3.1
pydantic>=2.6
python-dotenv>=1.0
bcrypt>=4.1 # password hashing
PyJWT>=2.8 # signed session tokens