Compare commits
21 Commits
savepoint-
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
| ca8c36a889 | |||
| 39230adf07 | |||
| 2bdb65e580 | |||
| e3ef3b0023 | |||
| c64b5c8b49 | |||
| a32c275f76 | |||
| e5f77846ad | |||
| 561d4f2408 | |||
| a18ae487f6 | |||
| 68c1c803d6 | |||
| e5c450597a | |||
| 3c40b58ff8 | |||
| 4d111d608d | |||
| a37cf14e89 | |||
| 362aa633ed | |||
| fd668f0ea2 | |||
| 960b4a4b94 | |||
| 8598606165 | |||
| 65996c2c0a | |||
| b0a3d74412 | |||
| ffcaa571d1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ venv/
|
|||||||
# Local SQLite dev database
|
# Local SQLite dev database
|
||||||
*.db
|
*.db
|
||||||
wpsuite.db
|
wpsuite.db
|
||||||
|
|
||||||
|
# Runtime directories (created by containers)
|
||||||
|
logs/
|
||||||
|
|||||||
265
DEPLOYMENT.md
265
DEPLOYMENT.md
@@ -1,81 +1,232 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
The Work Package Suite has two parts:
|
Audience: the IT admin standing this up inside the firewall. This covers the
|
||||||
|
**SQL-backed deployment** — NGINX serving the static front end and a Python API
|
||||||
|
backed by **PostgreSQL**.
|
||||||
|
|
||||||
- a **static front end** (plain HTML/CSS/JS — no build step), and
|
The repo already contains everything needed to run it as a Docker stack:
|
||||||
- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project
|
`Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in
|
||||||
SOPs, Work Packages, and comments so they are shared across users instead of
|
`html/`, and the API in `server/`. The detailed container reference (endpoints,
|
||||||
living in each person's browser.
|
password rotation, day-to-day commands) lives in
|
||||||
|
[`server/README.md`](server/README.md) — this doc is the start-to-finish guide.
|
||||||
|
|
||||||
```
|
```
|
||||||
browser → NGINX ──serves──> static site (index.html, …)
|
[ your TLS reverse proxy / traefik ] ← HTTPS terminates here
|
||||||
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL
|
│ (external "proxy" network)
|
||||||
|
┌────▼────┐ internal network ┌──────────┐ ┌────────────┐
|
||||||
|
browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │
|
||||||
|
│ (html/) │ │ FastAPI │ │ (db) │
|
||||||
|
└─────────┘ └──────────┘ └────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything runs inside your firewall; the app makes **no outbound internet
|
Everything runs inside your firewall; the app makes **no outbound internet
|
||||||
calls** (the logo and scripts are local and the old Google-Fonts dependency was
|
calls** (logo and scripts are local).
|
||||||
removed).
|
|
||||||
|
|
||||||
## 1. Front end (NGINX)
|
> **Architecture note:** all static files live under **`html/`** and are *baked
|
||||||
|
> into the nginx image* at build time (not bind-mounted). So after any front-end
|
||||||
|
> change you rebuild the `webserver` image (see *Updating* below). The API image
|
||||||
|
> is built from the root `Dockerfile`.
|
||||||
|
|
||||||
Copy the project files to a web root and serve them over HTTPS. The provided
|
---
|
||||||
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
|
|
||||||
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
|
|
||||||
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
|
|
||||||
|
|
||||||
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package
|
## 1. Prerequisites
|
||||||
Creator (`<iframe>`) and any browser-side caching behave reliably.
|
|
||||||
|
|
||||||
## 2. API + database
|
- A Linux host with **Docker** and **Docker Compose v2** (`docker compose …`).
|
||||||
|
- An external Docker network named `proxy` that your TLS-terminating reverse
|
||||||
|
proxy also sits on (the compose file marks it `external: true`):
|
||||||
|
```bash
|
||||||
|
docker network create proxy
|
||||||
|
```
|
||||||
|
If you don't run a separate reverse proxy, you can instead publish the nginx
|
||||||
|
container's port 80 directly (see the note in step 4) and terminate TLS there.
|
||||||
|
- The repository checked out on the host.
|
||||||
|
|
||||||
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in
|
## 2. Create the database credentials (`.env`)
|
||||||
[`server/README.md`](server/README.md). In short:
|
|
||||||
|
|
||||||
1. Create the `wpsuite` Postgres database/user.
|
Create a file named `.env` in the **project root** (same folder as
|
||||||
2. `pip install -r server/requirements.txt` into a venv.
|
`docker-compose.yml`). It is git-ignored and must never be committed.
|
||||||
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
|
|
||||||
4. Tables are created automatically on first start.
|
|
||||||
|
|
||||||
Interactive API docs are at `/api/docs` once it's running.
|
```bash
|
||||||
|
# .env — project root
|
||||||
|
POSTGRES_DB=wpsuite
|
||||||
|
POSTGRES_USER=wpsuite
|
||||||
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
|
|
||||||
## 3. Comments / feedback
|
# Must match the POSTGRES_* values above. Host is the compose service name "db".
|
||||||
|
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||||
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
|
|
||||||
posts to `/api/feedback`, which the API stores in the `comments` table. The
|
|
||||||
**Export / Import** buttons remain as an offline fallback — a reviewer can export
|
|
||||||
a JSON file and someone can import/merge it — but with the API running, comments
|
|
||||||
are collected centrally with no manual steps.
|
|
||||||
|
|
||||||
> The earlier Power Automate route is **no longer needed** — comments go straight
|
|
||||||
> to Postgres. If you still want a Power App view, point a Power App at the
|
|
||||||
> Postgres `comments` table via the on-prem data gateway, or have a flow read the
|
|
||||||
> table; no change to this app is required.
|
|
||||||
|
|
||||||
### Comment payload shape
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"app": "Work Package Suite",
|
|
||||||
"page": "/work-package-suite.html",
|
|
||||||
"submittedAt": "2026-06-15T18:20:00.000Z",
|
|
||||||
"type": "sop_step_comment",
|
|
||||||
"name": "J. Park",
|
|
||||||
"text": "Consider adding a fiber WP type",
|
|
||||||
"step": 4
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`. The
|
Generate a strong password with `openssl rand -base64 32`.
|
||||||
API maps `name`/`author` → the comment author and keeps any extra fields in the
|
|
||||||
row's `extra` JSON column.
|
These are the only credentials in the system: the `db` container initialises
|
||||||
|
Postgres from `POSTGRES_*`, and the `api` container connects with the matching
|
||||||
|
`DATABASE_URL`. Neither value appears in the compose file or in git.
|
||||||
|
|
||||||
|
## 3. Point your reverse proxy at the nginx container
|
||||||
|
|
||||||
|
The nginx container listens on port **80** on the `proxy` network and expects
|
||||||
|
TLS to be terminated upstream (by your reverse proxy / traefik). Route your
|
||||||
|
chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
|
||||||
|
container on that network. The container already proxies `/api/` to the `api`
|
||||||
|
service internally — no extra app config needed.
|
||||||
|
|
||||||
|
## 4. Bring it up
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build # builds the api + nginx images, starts all three containers
|
||||||
|
docker compose ps # confirm nginx_webserver, wp_api, wp_db are running/healthy
|
||||||
|
docker compose logs -f api # watch the API start (Ctrl-C to stop following)
|
||||||
|
```
|
||||||
|
|
||||||
|
The database schema is **created automatically** on first API start — no manual
|
||||||
|
`CREATE TABLE`. The Postgres data lives in the named volume `pgdata` and
|
||||||
|
survives `docker compose down` (only `down -v` deletes it).
|
||||||
|
|
||||||
|
> No separate reverse proxy? Publish nginx directly by adding a `ports:` mapping
|
||||||
|
> to the `webserver` service (e.g. `"8080:80"`) and terminate TLS at whatever
|
||||||
|
> sits in front of it. The internal `api`/`db` containers should **never** be
|
||||||
|
> published.
|
||||||
|
|
||||||
|
## 5. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API liveness (from the host, through the proxy hostname)
|
||||||
|
curl https://wp-suite.company.local/api/health # → {"ok": true}
|
||||||
|
|
||||||
|
# Interactive API docs
|
||||||
|
# https://wp-suite.company.local/api/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load the site in a browser: the home page should prompt to **select or
|
||||||
|
create a project**. Create one, complete an SOP, and confirm a row appears:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated smoke test
|
||||||
|
|
||||||
|
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
|
||||||
|
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
|
||||||
|
cleanup). Stdlib only — no pip/jq.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Through the proxy (use --insecure for a self-signed internal cert):
|
||||||
|
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||||
|
|
||||||
|
# Or from inside the api container (hits FastAPI directly):
|
||||||
|
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||||
|
|
||||||
|
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
|
||||||
|
working. It cleans up after itself (the test project and its SOP/WPs are
|
||||||
|
deleted via cascade); a single tagged test comment remains (there's no comment
|
||||||
|
delete endpoint).
|
||||||
|
|
||||||
|
### Loadable demo project
|
||||||
|
|
||||||
|
`server/seed_demo.py` populates a realistic **DEMO** project (a complete SOP plus
|
||||||
|
a spread of Work Packages: issued, gated, a multi-discipline master with split
|
||||||
|
instances, an overdue one, an over-threshold draft) so there's data to look at.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||||
|
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
|
||||||
|
```
|
||||||
|
|
||||||
|
> **What shows where:** the DEMO **project** 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;"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is stored in SQL today
|
||||||
|
|
||||||
|
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** | 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. |
|
||||||
|
|
||||||
|
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)
|
## Data model (PostgreSQL)
|
||||||
|
|
||||||
| Table | Holds | Key columns |
|
| Table | Holds | Key columns |
|
||||||
|-------|-------|-------------|
|
|-------|-------|-------------|
|
||||||
| `sops` | project SOP baselines | `name`, `number`, `complete`, `data` (full SOP JSON) |
|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
|
||||||
| `work_packages` | individual IWPs | `sop_id`, `number`, `subject`, `type`, `status`, `data` (full WP JSON) |
|
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text` |
|
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `issued_at`, `data` (full WP JSON) |
|
||||||
|
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||||
|
|
||||||
The complete client document is stored verbatim in each row's `data` column;
|
The complete client document is stored verbatim in each row's `data` JSON
|
||||||
frequently-listed fields are promoted to real columns for filtering.
|
column; frequently-listed fields are promoted to real columns for filtering.
|
||||||
|
|
||||||
|
### Endpoints (summary)
|
||||||
|
|
||||||
|
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
|
||||||
|
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`, `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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating after a change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build webserver # front-end change (html/) — rebuild the baked image
|
||||||
|
docker compose up -d --build api # backend change (server/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backups & retention
|
||||||
|
|
||||||
|
The whole dataset is in the `pgdata` volume — back it up on a schedule:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup (run from project root)
|
||||||
|
docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema migrations (important)
|
||||||
|
|
||||||
|
Tables are auto-created on API startup (`Base.metadata.create_all`). This
|
||||||
|
creates **missing tables**, but it does **not** alter existing ones. The
|
||||||
|
multi-project work added the `projects` table and new columns
|
||||||
|
(`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
|
||||||
|
|
||||||
|
- On a **fresh** database these appear automatically — nothing to do.
|
||||||
|
- On a database that **already has data** from an older schema, add the new
|
||||||
|
columns with a migration (introduce **Alembic**) or apply them manually with
|
||||||
|
`ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
|
||||||
|
|
||||||
|
## Local trial without Postgres
|
||||||
|
|
||||||
|
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).
|
||||||
|
|||||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/requirements.txt ./server/
|
||||||
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
|
COPY server/ ./server/
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||||
|
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||||
@@ -130,6 +130,49 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
|
|||||||
> real data this is fine; once there is, add Alembic (see open question #2) and
|
> real data this is fine; once there is, add Alembic (see open question #2) and
|
||||||
> migrate rather than relying on `create_all`.
|
> migrate rather than relying on `create_all`.
|
||||||
|
|
||||||
|
## Multi-project support
|
||||||
|
|
||||||
|
> **Note on layout:** the IT admin moved all static files into **`html/`** and
|
||||||
|
> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`).
|
||||||
|
> Front-end paths below are under `html/`. `server/` stayed at the repo root.
|
||||||
|
|
||||||
|
The suite is now multi-project. **Projects are the top-level container**; every
|
||||||
|
SOP and Work Package belongs to one.
|
||||||
|
|
||||||
|
- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained
|
||||||
|
`project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/
|
||||||
|
metrics endpoints accept a `project_id` filter.
|
||||||
|
- **Project layer:** [html/project-data.js](html/project-data.js) — a shared,
|
||||||
|
**API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`)
|
||||||
|
that **falls back to a localStorage mirror** (`wp_projects`) when the API is
|
||||||
|
unreachable, plus active-project helpers (`getActive`/`setActive`, stored in
|
||||||
|
`wp_active_project` / `wp_active_project_obj`).
|
||||||
|
- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed;
|
||||||
|
a **Project** picker added. With no projects it offers *Create Project* / *Use
|
||||||
|
Sample Project*; otherwise a dropdown to select. The tool cards stay hidden
|
||||||
|
until a project is active and then carry `&project=<id>`; the hero shows the
|
||||||
|
active project.
|
||||||
|
- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)):
|
||||||
|
reads `?project=<id>`, resolves it via `ProjectData`, shows it in the header,
|
||||||
|
and **prefills the SOP project fields** (step 1) from the project record when
|
||||||
|
empty. Passes `&project` into the WP-creator iframe.
|
||||||
|
- **WP creator:** stamps `projectId` onto every saved package (for API sync).
|
||||||
|
|
||||||
|
**Per-project isolation (done, local):** SOP/WP localStorage keys are now
|
||||||
|
namespaced per active project via `ProjectData.key(base)` →
|
||||||
|
`base + '__' + <projectId>` (`SK()` in the suite, `wpKey()` in the creator).
|
||||||
|
So each project keeps its own `wp_suite_sop` / `wp_suite_state` /
|
||||||
|
`wp_suite_sop_complete` / `wp_iwp_v1`. On first load after this change,
|
||||||
|
`project-data.js` runs a **one-time discard** of the legacy un-namespaced keys
|
||||||
|
(guarded by `wp_ns_migrated_v1`) — chosen over migrating, since the local data
|
||||||
|
was throwaway demo content.
|
||||||
|
|
||||||
|
**Still ahead (true Phase 2):** move SOP/WP reads+writes to the API filtered by
|
||||||
|
`project_id` (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`)
|
||||||
|
so projects are shared across users, not just isolated per browser. The
|
||||||
|
endpoints already accept the `project_id` filter; the front end still reads
|
||||||
|
localStorage.
|
||||||
|
|
||||||
**Pending — Phase 2: wire the front end to the API**
|
**Pending — Phase 2: wire the front end to the API**
|
||||||
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
||||||
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
||||||
|
|||||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: nginx/Dockerfile
|
||||||
|
container_name: nginx_webserver
|
||||||
|
volumes:
|
||||||
|
- nginx_logs:/var/log/nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- proxy # external — reachable by your reverse proxy / traefik
|
||||||
|
- internal # needs a path to the api container
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: wp_api
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wp_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
nginx_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
internal: true # no outbound internet access from api/db
|
||||||
107
html/admin.html
Normal file
107
html/admin.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Console — Work Package Suite</title>
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<style>
|
||||||
|
: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:-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: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: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; }
|
||||||
|
button.primary:hover{ background:#1e54bb; color:#fff; }
|
||||||
|
button.danger{ border-color:var(--red); color:var(--red); }
|
||||||
|
button.danger:hover{ background:var(--red-bg); }
|
||||||
|
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
||||||
|
.banner{ padding:10px 14px; border-radius: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: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; }
|
||||||
|
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
|
||||||
|
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
|
||||||
|
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
|
||||||
|
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||||
|
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius: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: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:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
|
||||||
|
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- GATE -->
|
||||||
|
<div class="gate-overlay" id="admin-gate">
|
||||||
|
<div class="gate-box">
|
||||||
|
<h2>🔒 Admin Console</h2>
|
||||||
|
<p>Enter the admin passphrase to continue.</p>
|
||||||
|
<input type="password" id="gate-input" placeholder="Passphrase" autocomplete="off"
|
||||||
|
onkeydown="if(event.key==='Enter') tryUnlock()">
|
||||||
|
<div class="gate-msg" id="gate-msg"></div>
|
||||||
|
<button class="primary" style="width:100%" onclick="tryUnlock()">Unlock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONSOLE -->
|
||||||
|
<div class="wrap" id="admin-main" style="display:none">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics & tests · talks to <code>/api</code> on this host</div></div>
|
||||||
|
<div class="row"><a class="home" href="index.html">← Site</a> <button onclick="lock()">Lock</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="secwarn">⚠ This page is gated client-side only — that stops casual access, not a determined user. For real protection, restrict this host/route at the network or reverse-proxy layer.</div>
|
||||||
|
|
||||||
|
<!-- CONNECTIVITY -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>API connectivity</h2>
|
||||||
|
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
|
||||||
|
<div class="banner" id="health-banner">—</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DB SNAPSHOT -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Database snapshot</h2>
|
||||||
|
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
|
||||||
|
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMOKE TEST -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>End-to-end smoke test</h2>
|
||||||
|
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
|
||||||
|
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
|
||||||
|
<pre class="out" id="smoke-out">Ready.</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DEMO DATA -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Demo data</h2>
|
||||||
|
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" onclick="seedDemo()">Seed demo project</button>
|
||||||
|
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
|
||||||
|
</div>
|
||||||
|
<pre class="out" id="demo-out">Ready.</pre>
|
||||||
|
<div class="note">Note: the seeded <strong>project</strong> appears in the home picker; its SOP/WPs live in SQL but won't render in the Creator/Dashboard until the front end is wired to the API (Phase 2).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
170
html/admin.js
Normal file
170
html/admin.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* Admin console for the Work Package Suite.
|
||||||
|
Browser-side diagnostics + tests that call the same /api on this host.
|
||||||
|
|
||||||
|
PASSPHRASE GATE (lightweight / obfuscation only):
|
||||||
|
The gate compares a SHA-256 hash so the passphrase isn't in the source, but a
|
||||||
|
determined user can still bypass client-side JS. For real protection, restrict
|
||||||
|
this host/route at the network or reverse-proxy layer.
|
||||||
|
|
||||||
|
Default passphrase: "prime-admin"
|
||||||
|
To change it: compute a new hash and replace ADMIN_PASSPHRASE_SHA256 below —
|
||||||
|
python3 -c "import hashlib,sys;print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "your-new-passphrase"
|
||||||
|
or in a browser console:
|
||||||
|
crypto.subtle.digest('SHA-256', new TextEncoder().encode('your-new-passphrase'))
|
||||||
|
.then(b=>console.log([...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,'0')).join('')));
|
||||||
|
*/
|
||||||
|
const ADMIN_PASSPHRASE_SHA256 = 'ae1fb92c43fccbad26f05434a194f574ec98a2197e0ff4080f84e6e26a8dd00f';
|
||||||
|
|
||||||
|
// ── gate ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function sha256hex(s){
|
||||||
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
||||||
|
return [...new Uint8Array(buf)].map(b=>b.toString(16).padStart(2,'0')).join('');
|
||||||
|
}
|
||||||
|
async function tryUnlock(){
|
||||||
|
const v = document.getElementById('gate-input').value || '';
|
||||||
|
const msg = document.getElementById('gate-msg');
|
||||||
|
if(!v){ msg.textContent='Enter the passphrase.'; return; }
|
||||||
|
let h;
|
||||||
|
try { h = await sha256hex(v); }
|
||||||
|
catch(e){ msg.textContent='This page must be served over HTTPS (or localhost) to unlock.'; return; }
|
||||||
|
if(h === ADMIN_PASSPHRASE_SHA256){ sessionStorage.setItem('wp_admin_ok','1'); reveal(); }
|
||||||
|
else { msg.textContent='Incorrect passphrase.'; }
|
||||||
|
}
|
||||||
|
function reveal(){
|
||||||
|
document.getElementById('admin-gate').style.display='none';
|
||||||
|
document.getElementById('admin-main').style.display='';
|
||||||
|
checkHealth();
|
||||||
|
}
|
||||||
|
function lock(){ sessionStorage.removeItem('wp_admin_ok'); location.reload(); }
|
||||||
|
|
||||||
|
// ── api helper ──────────────────────────────────────────────────────────────
|
||||||
|
async function api(method, path, body){
|
||||||
|
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||||
|
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||||
|
try {
|
||||||
|
const r = await fetch(path, opt);
|
||||||
|
const t = await r.text();
|
||||||
|
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||||
|
return { status:r.status, json };
|
||||||
|
} catch(e){ return { status:0, json:String(e) }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||||
|
async function checkHealth(){
|
||||||
|
const b = document.getElementById('health-banner');
|
||||||
|
b.className='banner'; b.textContent='Checking…';
|
||||||
|
const { status, json } = await api('GET','/api/health');
|
||||||
|
if(status===200 && json && json.ok){
|
||||||
|
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
|
||||||
|
} else if(status===404){
|
||||||
|
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
||||||
|
} else if(status===0){
|
||||||
|
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
|
||||||
|
} else {
|
||||||
|
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── db snapshot ───────────────────────────────────────────────────────────────
|
||||||
|
async function snapshot(){
|
||||||
|
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
||||||
|
const [p,s,w,c] = await Promise.all([
|
||||||
|
api('GET','/api/projects'), api('GET','/api/sops'),
|
||||||
|
api('GET','/api/wps'), api('GET','/api/comments')]);
|
||||||
|
if(p.status!==200){
|
||||||
|
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
|
||||||
|
}
|
||||||
|
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
|
||||||
|
out.innerHTML = `<table class="kv">
|
||||||
|
<tr><th>Projects</th><td>${n(p)}</td></tr>
|
||||||
|
<tr><th>SOPs</th><td>${n(s)}</td></tr>
|
||||||
|
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
|
||||||
|
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── smoke test ────────────────────────────────────────────────────────────────
|
||||||
|
function smLog(html){ const o=document.getElementById('smoke-out'); o.innerHTML += html + '\n'; o.scrollTop=o.scrollHeight; }
|
||||||
|
async function runSmokeTest(){
|
||||||
|
const o=document.getElementById('smoke-out'); o.innerHTML=''; let pass=0, fail=0, pid=null;
|
||||||
|
const chk=(name,cond,detail)=>{ if(cond){ pass++; smLog('<span class="p">PASS</span> '+name); }
|
||||||
|
else { fail++; smLog('<span class="f">FAIL</span> '+name+(detail?' ('+detail+')':'')); } return cond; };
|
||||||
|
try {
|
||||||
|
let r = await api('GET','/api/health');
|
||||||
|
if(!chk('health endpoint ok', r.status===200 && r.json && r.json.ok, 'status '+r.status)){
|
||||||
|
smLog('\nAborting — API unreachable (fix /api/ routing).'); return finishSmoke(pass,fail);
|
||||||
|
}
|
||||||
|
r = await api('POST','/api/projects',{name:'ZZ Smoke Test Project',number:'SMOKE-001',client:'Internal QA',created_by:'admin-console'});
|
||||||
|
pid = r.json && r.json.id; chk('create project', r.status===200 && !!pid, 'status '+r.status);
|
||||||
|
r = await api('GET','/api/projects/'+pid); chk('fetch project by id', r.status===200 && r.json.number==='SMOKE-001');
|
||||||
|
r = await api('GET','/api/projects'); chk('project in list', r.status===200 && r.json.some(p=>p.id===pid));
|
||||||
|
r = await api('POST','/api/sops',{project_id:pid,name:'ZZ Smoke SOP',number:'SMOKE-001',complete:true,data:{governance:{disciplines:['Mechanical','Electrical','Tech']}}});
|
||||||
|
const sid = r.json && r.json.id; chk('create SOP linked to project', r.status===200 && !!sid && r.json.project_id===pid);
|
||||||
|
r = await api('GET','/api/sops/latest?project_id='+pid); chk('latest SOP resolves', r.status===200 && r.json.id===sid);
|
||||||
|
r = await api('POST','/api/wps',{project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'open',comment:'awaiting delivery'},{name:'Safety',status:'cleared',comment:''}]}});
|
||||||
|
const wid = r.json && r.json.id; chk('create work package', r.status===200 && !!wid);
|
||||||
|
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue blocked while a constraint is open (409)', r.status===409, 'status '+r.status);
|
||||||
|
await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'cleared',comment:''},{name:'Safety',status:'cleared',comment:''}]}});
|
||||||
|
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue succeeds once cleared', r.status===200 && r.json.status==='Issued', 'status '+r.status);
|
||||||
|
chk('issued_at timestamp set', !!(r.json && r.json.issued_at));
|
||||||
|
r = await api('POST','/api/wps/'+wid+'/status',{status:'In Progress'}); chk('status transition', r.status===200 && r.json.status==='In Progress');
|
||||||
|
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
|
||||||
|
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
|
||||||
|
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
|
||||||
|
} catch(e){ chk('unexpected error', false, String(e)); }
|
||||||
|
finally {
|
||||||
|
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
|
||||||
|
finishSmoke(pass,fail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function finishSmoke(pass,fail){
|
||||||
|
const total=pass+fail;
|
||||||
|
smLog('\n'+pass+'/'+total+' checks passed.');
|
||||||
|
smLog(fail ? '<span class="f">RESULT: FAIL ('+fail+')</span>' : '<span class="p">RESULT: ALL PASS — API, Python logic, and SQL are working.</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── demo data ─────────────────────────────────────────────────────────────────
|
||||||
|
function demoLog(s){ const o=document.getElementById('demo-out'); o.innerHTML += s + '\n'; o.scrollTop=o.scrollHeight; }
|
||||||
|
function stdConstraints(open){ return ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)']
|
||||||
|
.map(n=>({name:n, status:(open&&open.includes(n))?'open':'cleared', comment:''})); }
|
||||||
|
async function seedDemo(){
|
||||||
|
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||||
|
let r = await api('GET','/api/health');
|
||||||
|
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
|
||||||
|
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
|
||||||
|
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
|
||||||
|
const pid=r.json.id; demoLog('Project created: '+r.json.name);
|
||||||
|
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
|
||||||
|
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
|
||||||
|
const mk=async(num,subj,typ,status,data,parent)=>{ const body={project_id:pid,sop_id:sid,number:num,subject:subj,type:typ,status,created_by:'admin-console',data}; if(parent)body.parent_id=parent; const rr=await api('POST','/api/wps',body); demoLog(' WP '+num+' ['+status+']'); return rr.json; };
|
||||||
|
await mk('WP01-1P-CONDUIT','1P horn/strobe conduit','Conduit Install','Issued',{disciplines:['Electrical'],hours:'40',constraints:stdConstraints(),due:'2026-06-30'});
|
||||||
|
await mk('WP02-1P-WIRE','1P wire pull','Wire Pull','Scheduled',{disciplines:['Electrical'],hours:'60',constraints:stdConstraints(['Materials (on site, bagged & tagged)']),due:'2026-07-04'});
|
||||||
|
const masterId='wp_demo_master_chiller';
|
||||||
|
const kids=[['WP03-CHILLER_Mech','Mechanical','A','Mechanical Install','In Progress'],['WP03-CHILLER_Elec','Electrical','B','Wire Pull','Scheduled'],['WP03-CHILLER_Tech','Tech','C','Terminations','Draft']];
|
||||||
|
const kidIds=[];
|
||||||
|
for(const [num,disc,label,typ,status] of kids){ const id='wp_demo_'+label.toLowerCase(); kidIds.push(id);
|
||||||
|
await api('POST','/api/wps',{id,project_id:pid,sop_id:sid,parent_id:masterId,number:num,subject:'Chiller skid — '+disc,type:typ,status,created_by:'admin-console',data:{disciplines:[disc],instanceOf:masterId,instanceLabel:label,parentNumber:'WP03-CHILLER',hours:'50',constraints:stdConstraints(),due:'2026-07-10'}});
|
||||||
|
demoLog(' WP '+num+' ['+status+'] (instance '+label+')'); }
|
||||||
|
await api('POST','/api/wps',{id:masterId,project_id:pid,sop_id:sid,number:'WP03-CHILLER',subject:'Chiller skid (multi-discipline master)',type:'Mechanical Install',status:'Scheduled',created_by:'admin-console',data:{disciplines:['Mechanical','Electrical','Tech'],split:true,children:kidIds,hours:'150',constraints:stdConstraints(),due:'2026-07-10'}});
|
||||||
|
demoLog(' WP WP03-CHILLER [master, split into A/B/C]');
|
||||||
|
await mk('WP04-2P-TERM','2P terminations','Terminations','In Progress',{disciplines:['Tech'],hours:'30',actualHrs:'20',constraints:stdConstraints(),due:'2026-06-10'});
|
||||||
|
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
|
||||||
|
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
||||||
|
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
|
||||||
|
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
|
||||||
|
snapshot();
|
||||||
|
}
|
||||||
|
async function cleanDemo(){
|
||||||
|
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||||||
|
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||||
|
const r = await api('GET','/api/projects');
|
||||||
|
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
||||||
|
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
||||||
|
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||||||
|
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
||||||
|
demoLog('\n✅ Removed '+targets.length+' project(s).');
|
||||||
|
snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// reveal immediately if already unlocked this session
|
||||||
|
if(sessionStorage.getItem('wp_admin_ok')==='1'){ reveal(); }
|
||||||
|
else { const i=document.getElementById('gate-input'); if(i) i.focus(); }
|
||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
80
html/help.js
Normal file
80
html/help.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/* Shared Help + tooltip module for the Work Package Suite.
|
||||||
|
Included by the home page, the suite, and the embedded creator. It injects:
|
||||||
|
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
|
||||||
|
- a Help modal (workflow + key concepts) opened via window.openHelp()
|
||||||
|
Add a "❔ Help" button anywhere with onclick="openHelp()". */
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var css = `
|
||||||
|
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
||||||
|
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:#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:#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:flex-start;
|
||||||
|
justify-content:center; z-index:10000; padding:5vh 16px; overflow:auto; }
|
||||||
|
.ui-help-overlay.open{ display:flex; }
|
||||||
|
.ui-help-modal{ background:#fff; color:#1a2230; max-width:680px; width:100%; border-radius:10px;
|
||||||
|
box-shadow:0 12px 40px rgba(20,30,50,.3); font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
||||||
|
.ui-help-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px;
|
||||||
|
border-bottom:1px solid #e3e6ec; font-size:16px; }
|
||||||
|
.ui-help-head button{ background:none; border:none; font-size:18px; cursor:pointer; color:#5a6675; line-height:1; }
|
||||||
|
.ui-help-body{ padding:18px 22px; font-size:13.5px; line-height:1.6; }
|
||||||
|
.ui-help-body h4{ margin:18px 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:.03em; color:#2563d6; }
|
||||||
|
.ui-help-body h4:first-child{ margin-top:0; }
|
||||||
|
.ui-help-body ol, .ui-help-body ul{ margin:0 0 6px; padding-left:20px; }
|
||||||
|
.ui-help-body li{ margin-bottom:5px; }
|
||||||
|
.ui-help-body code{ background:#f0f2f5; padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||||
|
`;
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = css;
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
|
||||||
|
var HELP_HTML = `
|
||||||
|
<h4>How the suite works</h4>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Pick or create a Project</strong> on the home page — projects are stored centrally and each keeps its own SOP and Work Packages.</li>
|
||||||
|
<li><strong>SOP Configuration</strong> — set the project baseline (team, sign-offs, WP types, governance & sizing, quality, sequence, constraints, sources). Every Work Package inherits these defaults.</li>
|
||||||
|
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP. Use <strong>New</strong> for a blank one or <strong>Duplicate</strong> to copy an existing one.</li>
|
||||||
|
<li><strong>Dashboard</strong> — track status, hours, and what's gating each package across the project.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>Key concepts</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Constraints & release readiness:</strong> a package can't move to <em>Issued</em> until every constraint is <em>Cleared</em> or <em>N/A</em>. If a constraint reopens after release, the package drops to <em>Issue (Hold)</em>.</li>
|
||||||
|
<li><strong>Disciplines & Split:</strong> a package can carry more than one discipline (e.g. Mechanical + Electrical + Tech), each with its own scope section and status. <strong>Split by Discipline</strong> breaks it into instances — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> — each tied to the master.</li>
|
||||||
|
<li><strong>WP size:</strong> the SOP sets a typical size band, which sets a max-hours <em>split threshold</em>. The creator warns when a package's estimated hours exceed it so it can be broken down.</li>
|
||||||
|
<li><strong>Material by discipline:</strong> on a multi-discipline package each material line can be tagged to a discipline; splitting routes each instance only its own materials.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Tips</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Load Sample</strong> is context-aware — it loads the sample SOP on the SOP tab and an example Work Package on the WP tab.</li>
|
||||||
|
<li>Data is kept <strong>per project</strong>; switch projects from the home page.</li>
|
||||||
|
<li>Hover the <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icons for inline hints.</li>
|
||||||
|
</ul>`;
|
||||||
|
|
||||||
|
function buildModal() {
|
||||||
|
if (document.getElementById('ui-help-overlay')) return;
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.className = 'ui-help-overlay';
|
||||||
|
overlay.id = 'ui-help-overlay';
|
||||||
|
overlay.innerHTML = '<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help">' +
|
||||||
|
'<div class="ui-help-head"><strong>❔ Help — Work Package Suite</strong>' +
|
||||||
|
'<button type="button" onclick="closeHelp()" aria-label="Close help">✕</button></div>' +
|
||||||
|
'<div class="ui-help-body">' + HELP_HTML + '</div></div>';
|
||||||
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.openHelp = function () { buildModal(); document.getElementById('ui-help-overlay').classList.add('open'); };
|
||||||
|
global.closeHelp = function () { var o = document.getElementById('ui-help-overlay'); if (o) o.classList.remove('open'); };
|
||||||
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') global.closeHelp(); });
|
||||||
|
})(window);
|
||||||
@@ -348,6 +348,22 @@
|
|||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PROJECT PICKER */
|
||||||
|
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||||
|
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||||
|
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
|
||||||
|
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||||
|
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); 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); 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 */
|
/* RESPONSIVE */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-content { flex-direction: column; text-align: center; }
|
.header-content { flex-direction: column; text-align: center; }
|
||||||
@@ -370,6 +386,7 @@
|
|||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="#overview">Overview</a>
|
<a href="#overview">Overview</a>
|
||||||
<a href="#comments">Feedback</a>
|
<a href="#comments">Feedback</a>
|
||||||
|
<a href="#" onclick="openHelp();return false;">Help</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -379,12 +396,19 @@
|
|||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Work Package Suite</h1>
|
<h1 id="hero-title">Work Package Suite</h1>
|
||||||
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p>
|
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TOOL CARDS -->
|
<!-- PROJECT SELECTION -->
|
||||||
<div class="cards-grid" id="overview">
|
<div class="section" id="project-section">
|
||||||
|
<h2>Project</h2>
|
||||||
|
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
||||||
|
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOOL CARDS (shown once a project is active) -->
|
||||||
|
<div class="cards-grid" id="overview" style="display:none">
|
||||||
|
|
||||||
<!-- SOP CONFIG -->
|
<!-- SOP CONFIG -->
|
||||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||||
@@ -443,25 +467,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SUPPORT SECTION -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>About This Suite</h2>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
|
|
||||||
<div>
|
|
||||||
<h3>Two-Step Workflow</h3>
|
|
||||||
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Leave Feedback</h3>
|
|
||||||
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>Offline & Collaborative</h3>
|
|
||||||
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER -->
|
<!-- FOOTER -->
|
||||||
@@ -470,13 +475,137 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Reflect SOP completion on the tool cards.
|
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||||
(function reflectSOPStatus(){
|
const esc = ProjectData.esc;
|
||||||
|
let _projects = [];
|
||||||
|
|
||||||
|
function initProjects(){
|
||||||
|
ProjectData.list().then(list => {
|
||||||
|
_projects = list || [];
|
||||||
|
// Reconcile the active project against the list; clear if it's gone.
|
||||||
|
const active = ProjectData.getActive();
|
||||||
|
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
|
||||||
|
renderProjectPicker();
|
||||||
|
applyActiveProject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFormHtml(){
|
||||||
|
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||||
|
<div class="proj-form-grid">
|
||||||
|
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
||||||
|
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||||
|
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||||
|
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||||
|
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||||
|
</div>
|
||||||
|
<div class="proj-actions">
|
||||||
|
<button class="card-button" onclick="saveNewProject()">Create & Select</button>
|
||||||
|
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectPicker(){
|
||||||
|
const box = document.getElementById('project-picker');
|
||||||
|
const activeId = ProjectData.getActiveId();
|
||||||
|
if(!_projects.length){
|
||||||
|
box.innerHTML = `<div class="proj-empty">
|
||||||
|
<p>No projects yet. Create your first project, or start from a sample.</p>
|
||||||
|
<div class="proj-actions">
|
||||||
|
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
|
||||||
|
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
|
||||||
|
</div>
|
||||||
|
</div>` + createFormHtml();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = _projects.map(p =>
|
||||||
|
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
||||||
|
).join('');
|
||||||
|
box.innerHTML = `<div class="proj-row">
|
||||||
|
<select id="project-select" onchange="selectProject(this.value)">
|
||||||
|
<option value="">Select a project…</option>${opts}
|
||||||
|
</select>
|
||||||
|
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
||||||
|
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
||||||
|
</div>
|
||||||
|
<div id="active-project-info"></div>` + createFormHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
||||||
|
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
||||||
|
|
||||||
|
function saveNewProject(){
|
||||||
|
const v = id => (document.getElementById(id)?.value || '').trim();
|
||||||
|
const name = v('np_name');
|
||||||
|
if(!name){ alert('Project name is required.'); return; }
|
||||||
|
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
||||||
|
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSampleProject(){
|
||||||
|
const existing = _projects.find(p => p.sample);
|
||||||
|
if(existing){ afterProjectChosen(existing); return; }
|
||||||
|
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProject(id){
|
||||||
|
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||||
|
const p = _projects.find(x => x.id === id);
|
||||||
|
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterProjectChosen(p){
|
||||||
|
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
||||||
|
ProjectData.setActive(p);
|
||||||
|
renderProjectPicker();
|
||||||
|
applyActiveProject();
|
||||||
|
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the tool cards and stamp the active project into their links.
|
||||||
|
function applyActiveProject(){
|
||||||
|
const active = ProjectData.getActive();
|
||||||
|
const cards = document.getElementById('overview');
|
||||||
|
const heroTitle = document.getElementById('hero-title');
|
||||||
|
const heroSub = document.getElementById('hero-sub');
|
||||||
|
const info = document.getElementById('active-project-info');
|
||||||
|
|
||||||
|
if(!active){
|
||||||
|
cards.style.display = 'none';
|
||||||
|
heroTitle.textContent = 'Work Package Suite';
|
||||||
|
heroSub.textContent = 'Select a project to begin — or create one.';
|
||||||
|
if(info) info.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = '&project=' + encodeURIComponent(active.id);
|
||||||
|
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
|
||||||
|
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
||||||
|
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
||||||
|
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
||||||
|
|
||||||
|
cards.style.display = '';
|
||||||
|
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||||
|
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
||||||
|
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||||
|
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||||
|
|
||||||
|
reflectSOPStatus(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||||
|
|
||||||
|
// Reflect SOP completion on the tool cards (scoped to the active project).
|
||||||
|
function reflectSOPStatus(active){
|
||||||
let complete = false, projName = '';
|
let complete = false, projName = '';
|
||||||
try {
|
try {
|
||||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
// Storage is namespaced per project, so these already scope to `active`.
|
||||||
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
|
||||||
|
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
|
||||||
projName = sop && sop.project && sop.project.name || '';
|
projName = sop && sop.project && sop.project.name || '';
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
|
|
||||||
@@ -484,6 +613,12 @@
|
|||||||
const sopBtn = document.getElementById('card-sop-btn');
|
const sopBtn = document.getElementById('card-sop-btn');
|
||||||
const wpCard = document.getElementById('card-wp');
|
const wpCard = document.getElementById('card-wp');
|
||||||
const wpBtn = document.getElementById('card-wp-btn');
|
const wpBtn = document.getElementById('card-wp-btn');
|
||||||
|
if(!sopCard) return;
|
||||||
|
|
||||||
|
// reset (re-render can run multiple times)
|
||||||
|
sopCard.classList.remove('complete');
|
||||||
|
wpCard && wpCard.classList.remove('disabled');
|
||||||
|
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
||||||
|
|
||||||
if(complete){
|
if(complete){
|
||||||
sopCard.classList.add('complete');
|
sopCard.classList.add('complete');
|
||||||
@@ -497,7 +632,9 @@
|
|||||||
if(wpCard) wpCard.classList.add('disabled');
|
if(wpCard) wpCard.classList.add('disabled');
|
||||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
initProjects();
|
||||||
|
|
||||||
let allComments = [];
|
let allComments = [];
|
||||||
|
|
||||||
@@ -527,9 +664,7 @@
|
|||||||
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
||||||
|
|
||||||
document.getElementById('comment-text').value = '';
|
document.getElementById('comment-text').value = '';
|
||||||
document.getElementById('commenter-name').value = '';
|
|
||||||
loadComments();
|
loadComments();
|
||||||
alert('Thank you! Feedback submitted.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportFeedback() {
|
function exportFeedback() {
|
||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
96
html/project-data.js
Normal file
96
html/project-data.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/* Shared project layer for the Work Package Suite.
|
||||||
|
Projects are the top-level container — every SOP and Work Package belongs to
|
||||||
|
one. Project records live in the SQL database (via /api/projects); this
|
||||||
|
adapter is API-first and falls back to a localStorage mirror so the suite
|
||||||
|
still works in local dev / offline. Included by the home page and the suite. */
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '/api';
|
||||||
|
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
|
||||||
|
var LS_ACTIVE = 'wp_active_project'; // active project id
|
||||||
|
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
||||||
|
|
||||||
|
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||||
|
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
|
||||||
|
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
||||||
|
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
||||||
|
function cacheUpsert(p) {
|
||||||
|
var list = readLocal();
|
||||||
|
var ix = list.findIndex(function (x) { return x.id === p.id; });
|
||||||
|
if (ix >= 0) list[ix] = p; else list.unshift(p);
|
||||||
|
writeLocal(list);
|
||||||
|
}
|
||||||
|
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||||
|
|
||||||
|
var SAMPLE_PROJECT = {
|
||||||
|
name: 'Micron — INC Construction Work Packages', number: '26-67-008',
|
||||||
|
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||||
|
site: 'Boise, ID — Fab', sample: true
|
||||||
|
};
|
||||||
|
|
||||||
|
var ProjectData = {
|
||||||
|
SAMPLE: SAMPLE_PROJECT,
|
||||||
|
esc: esc,
|
||||||
|
|
||||||
|
// Returns the project list. Tries the API; falls back to the local mirror.
|
||||||
|
list: function () {
|
||||||
|
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function (rows) { writeLocal(rows); return rows; })
|
||||||
|
.catch(function () { return readLocal(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
get: function (id) {
|
||||||
|
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
|
||||||
|
save: function (p) {
|
||||||
|
if (!p.id) p.id = uid();
|
||||||
|
return fetch(API + '/projects', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function (saved) { cacheUpsert(saved); return saved; })
|
||||||
|
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: function (id) {
|
||||||
|
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
|
.then(function () { cacheRemove(id); })
|
||||||
|
.catch(function () { cacheRemove(id); });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── active project context ────────────────────────────────────────────────
|
||||||
|
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
||||||
|
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||||
|
setActive: function (p) {
|
||||||
|
try {
|
||||||
|
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
||||||
|
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
||||||
|
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
|
||||||
|
// Falls back to the bare key when no project is active.
|
||||||
|
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||||
|
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem('wp_ns_migrated_v1')) {
|
||||||
|
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
|
||||||
|
try { localStorage.removeItem(k); } catch (e) {}
|
||||||
|
});
|
||||||
|
localStorage.setItem('wp_ns_migrated_v1', '1');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
global.ProjectData = ProjectData;
|
||||||
|
})(window);
|
||||||
@@ -4,6 +4,25 @@ let currentStep = 1;
|
|||||||
let sopComplete = false;
|
let sopComplete = false;
|
||||||
let allComments = [];
|
let allComments = [];
|
||||||
|
|
||||||
|
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' when a
|
||||||
|
// project is active. Keeps each project's SOP separate in the browser.
|
||||||
|
function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } }
|
||||||
|
|
||||||
|
// WP size presets — the dropdown label maps to a default split-threshold (max
|
||||||
|
// labor hours). The label is exported as governance.woSize (human-readable
|
||||||
|
// guidance); the number drives the Creator's "consider splitting" warning.
|
||||||
|
const WP_SIZE_PRESETS = {
|
||||||
|
'Small — 1–2 days (≈8–24 hrs)': 24,
|
||||||
|
'Standard — 3–5 days (≈40–80 hrs)': 80,
|
||||||
|
'Large — 1–2 weeks (≈80–160 hrs)': 160
|
||||||
|
};
|
||||||
|
function onSizePresetChange(){
|
||||||
|
const label = document.getElementById('gov_wosize').value;
|
||||||
|
const max = WP_SIZE_PRESETS[label];
|
||||||
|
if(max != null){ document.getElementById('gov_size_hours_max').value = max; }
|
||||||
|
// 'Custom…' / '' leave the threshold for manual entry.
|
||||||
|
}
|
||||||
|
|
||||||
let state = {
|
let state = {
|
||||||
project: {name:'', number:'', client:'', division:'', site:''},
|
project: {name:'', number:'', client:'', division:'', site:''},
|
||||||
team: {pm:'', apm:'', cm:'', qm:''},
|
team: {pm:'', apm:'', cm:'', qm:''},
|
||||||
@@ -126,14 +145,17 @@ window.addEventListener('DOMContentLoaded',()=>{
|
|||||||
renderStandardConstraints();
|
renderStandardConstraints();
|
||||||
renderSequenceSteps();
|
renderSequenceSteps();
|
||||||
renderSources();
|
renderSources();
|
||||||
|
// Resolve the active project FIRST so per-project storage keys are correct
|
||||||
|
// before we restore this project's SOP.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
applyProjectContext(params.get('project'));
|
||||||
restoreSavedSOP();
|
restoreSavedSOP();
|
||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
|
|
||||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page cards.
|
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const tab = params.get('tab');
|
const tab = params.get('tab');
|
||||||
if(params.get('view') === 'dashboard') switchTool('wp');
|
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
||||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||||
|
|
||||||
track('app_open');
|
track('app_open');
|
||||||
@@ -154,7 +176,15 @@ function initializeWPTypes(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
||||||
|
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard
|
||||||
|
// tab it loads the example Work Package inside the embedded creator.
|
||||||
function loadSampleData(){
|
function loadSampleData(){
|
||||||
|
if(currentTool && currentTool !== 'sop'){
|
||||||
|
const f = document.getElementById('wp-frame');
|
||||||
|
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
|
||||||
|
else { alert('Open the Work Package Creation tab first, then load the sample.'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Populate Step 1
|
// Populate Step 1
|
||||||
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
||||||
document.getElementById('proj_number').value = '26-67-008';
|
document.getElementById('proj_number').value = '26-67-008';
|
||||||
@@ -174,10 +204,10 @@ function loadSampleData(){
|
|||||||
|
|
||||||
// Populate Step 5
|
// Populate Step 5
|
||||||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
||||||
document.getElementById('gov_wosize').value = '3–5 days / 40–80 hours';
|
document.getElementById('gov_wosize').value = 'Standard — 3–5 days (≈40–80 hrs)';
|
||||||
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
|
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
|
||||||
document.getElementById('gov_discmode').value = 'choice';
|
document.getElementById('gov_discmode').value = 'choice';
|
||||||
document.getElementById('gov_size_hours_max').value = '120';
|
document.getElementById('gov_size_hours_max').value = '80';
|
||||||
|
|
||||||
// Populate Step 6
|
// Populate Step 6
|
||||||
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
||||||
@@ -202,9 +232,9 @@ function loadSampleData(){
|
|||||||
function restoreSavedSOP(){
|
function restoreSavedSOP(){
|
||||||
let savedState = null, savedSop = null, complete = false;
|
let savedState = null, savedSop = null, complete = false;
|
||||||
try {
|
try {
|
||||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
|
||||||
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null');
|
savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
|
||||||
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
if(!complete || !savedState) return;
|
if(!complete || !savedState) return;
|
||||||
|
|
||||||
@@ -239,6 +269,12 @@ function repopulateForm(){
|
|||||||
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
||||||
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
||||||
set('gov_woformat', state.governance.woformat);
|
set('gov_woformat', state.governance.woformat);
|
||||||
|
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
||||||
|
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
||||||
|
const wsEl = document.getElementById('gov_wosize');
|
||||||
|
if(wsEl && state.governance.wosize && !Array.from(wsEl.options).some(o=>o.value===state.governance.wosize)){
|
||||||
|
wsEl.add(new Option(state.governance.wosize, state.governance.wosize));
|
||||||
|
}
|
||||||
set('gov_wosize', state.governance.wosize);
|
set('gov_wosize', state.governance.wosize);
|
||||||
set('gov_disciplines', (state.governance.disciplines||[]).join(', '));
|
set('gov_disciplines', (state.governance.disciplines||[]).join(', '));
|
||||||
set('gov_discmode', state.governance.discMode);
|
set('gov_discmode', state.governance.discMode);
|
||||||
@@ -253,30 +289,32 @@ function repopulateForm(){
|
|||||||
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
|
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
|
||||||
function switchTool(tool){
|
function switchTool(tool){
|
||||||
currentTool = tool;
|
currentTool = tool;
|
||||||
|
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
||||||
|
// creator) but opens it straight to the dashboard view.
|
||||||
|
const isDash = (tool === 'dashboard');
|
||||||
|
const contentTool = isDash ? 'wp' : tool;
|
||||||
|
|
||||||
// Update nav tabs
|
// Update nav tabs
|
||||||
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
||||||
document.querySelector(`[data-tab="${tool}"]`).classList.add('active');
|
const tabBtn = document.querySelector(`[data-tab="${tool}"]`);
|
||||||
|
if(tabBtn) tabBtn.classList.add('active');
|
||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
||||||
document.getElementById(`tool-${tool}`).classList.add('active');
|
document.getElementById(`tool-${contentTool}`).classList.add('active');
|
||||||
|
|
||||||
// Reset step counter
|
|
||||||
if(tool === 'sop'){
|
|
||||||
document.getElementById('total-steps').textContent = '10';
|
|
||||||
}else{
|
|
||||||
document.getElementById('total-steps').textContent = '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
if(tool === 'wp') renderWPTab();
|
// Reset step counter
|
||||||
|
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||||||
|
|
||||||
|
if(contentTool === 'wp') renderWPTab(isDash);
|
||||||
|
|
||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
||||||
function renderWPTab(){
|
// wantDash=true opens the creator straight to the dashboard view.
|
||||||
|
function renderWPTab(wantDash){
|
||||||
const gate = document.getElementById('wp-gate');
|
const gate = document.getElementById('wp-gate');
|
||||||
const frame = document.getElementById('wp-frame');
|
const frame = document.getElementById('wp-frame');
|
||||||
if(!gate || !frame) return;
|
if(!gate || !frame) return;
|
||||||
@@ -284,8 +322,11 @@ function renderWPTab(){
|
|||||||
gate.style.display = 'none';
|
gate.style.display = 'none';
|
||||||
frame.style.display = 'block';
|
frame.style.display = 'block';
|
||||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
// Reload each time so the creator picks up the latest SOP from localStorage.
|
||||||
const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard';
|
const sp = new URLSearchParams(window.location.search);
|
||||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now();
|
const dash = wantDash || sp.get('view') === 'dashboard';
|
||||||
|
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||||
|
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
|
||||||
|
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||||||
}else{
|
}else{
|
||||||
gate.style.display = 'block';
|
gate.style.display = 'block';
|
||||||
frame.style.display = 'none';
|
frame.style.display = 'none';
|
||||||
@@ -297,8 +338,43 @@ function onSOPReady(){
|
|||||||
if(currentTool === 'wp') renderWPTab();
|
if(currentTool === 'wp') renderWPTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active project comes from the home page (?project=<id> + ProjectData.getActive()).
|
||||||
|
// When the SOP's project fields are still empty, prefill them from the project
|
||||||
|
// record so the SOP is authored against the chosen project.
|
||||||
|
let activeProject = null;
|
||||||
|
function applyProjectContext(projectId){
|
||||||
|
try {
|
||||||
|
if(typeof ProjectData !== 'undefined'){
|
||||||
|
if(projectId && ProjectData.getActiveId() !== projectId){
|
||||||
|
// Deep-linked to a project that isn't the cached active one. Seed the id
|
||||||
|
// immediately so namespaced storage keys resolve, then fetch the full record.
|
||||||
|
const cached = ProjectData.getActive();
|
||||||
|
ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId });
|
||||||
|
ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } });
|
||||||
|
}
|
||||||
|
activeProject = ProjectData.getActive();
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
prefillProjectFields();
|
||||||
|
}
|
||||||
|
function prefillProjectFields(){
|
||||||
|
if(!activeProject) return;
|
||||||
|
const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; };
|
||||||
|
set('proj_name', activeProject.name);
|
||||||
|
set('proj_number', activeProject.number);
|
||||||
|
set('proj_client', activeProject.client);
|
||||||
|
set('proj_division', activeProject.division);
|
||||||
|
set('proj_site', activeProject.site);
|
||||||
|
if(typeof state !== 'undefined' && state.project){
|
||||||
|
state.project.name = state.project.name || activeProject.name || '';
|
||||||
|
state.project.number = state.project.number || activeProject.number || '';
|
||||||
|
state.project.client = state.project.client || activeProject.client || '';
|
||||||
|
state.project.division = state.project.division || activeProject.division || '';
|
||||||
|
state.project.site = state.project.site || activeProject.site || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
function updateProjectDisplay(){
|
function updateProjectDisplay(){
|
||||||
const projName = document.getElementById('proj_name')?.value || 'Project';
|
const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project';
|
||||||
const display = document.getElementById('project-display');
|
const display = document.getElementById('project-display');
|
||||||
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
||||||
}
|
}
|
||||||
@@ -315,14 +391,24 @@ function renderWPTypes(){
|
|||||||
state.wpTypes.forEach((t,i)=>{
|
state.wpTypes.forEach((t,i)=>{
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'wp-type-row';
|
row.className = 'wp-type-row';
|
||||||
|
const nameCell = t.custom
|
||||||
|
? `<div style="display:flex; gap:6px; align-items:center;">
|
||||||
|
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
|
||||||
|
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
|
||||||
|
</div>`
|
||||||
|
: `<div style="font-weight:600;">${t.name}</div>`;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div style="font-weight:600;">${t.name}</div>
|
${nameCell}
|
||||||
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
||||||
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||||
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||||
`;
|
`;
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
|
const addRow = document.createElement('div');
|
||||||
|
addRow.style.cssText = 'margin-top:0.85rem;';
|
||||||
|
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add Custom Type</button>`;
|
||||||
|
container.appendChild(addRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleWPType(i){
|
function toggleWPType(i){
|
||||||
@@ -330,6 +416,21 @@ function toggleWPType(i){
|
|||||||
renderWPTypes();
|
renderWPTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addCustomWPType(){
|
||||||
|
state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true});
|
||||||
|
renderWPTypes();
|
||||||
|
// Focus the new custom row's name input.
|
||||||
|
const rows = document.querySelectorAll('#wp-types-table .wp-type-row');
|
||||||
|
const last = rows[rows.length-1];
|
||||||
|
const nameInput = last && last.querySelector('input[type="text"]');
|
||||||
|
if(nameInput) nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWPType(i){
|
||||||
|
state.wpTypes.splice(i,1);
|
||||||
|
renderWPTypes();
|
||||||
|
}
|
||||||
|
|
||||||
function renderTeamMembers(){
|
function renderTeamMembers(){
|
||||||
const container = document.getElementById('team-members-list');
|
const container = document.getElementById('team-members-list');
|
||||||
if(!container) return;
|
if(!container) return;
|
||||||
@@ -376,18 +477,45 @@ function removeRole(i){
|
|||||||
renderOptionalRoles();
|
renderOptionalRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed the standard 10 once; after that, render reflects state.constraints
|
||||||
|
// (checkbox = whether each standard one is active) and never clobbers customs.
|
||||||
|
let _constraintsSeeded = false;
|
||||||
function renderStandardConstraints(){
|
function renderStandardConstraints(){
|
||||||
const container = document.getElementById('standard-constraints');
|
const container = document.getElementById('standard-constraints');
|
||||||
|
if(!_constraintsSeeded){
|
||||||
|
if(!state.constraints || !state.constraints.length){
|
||||||
|
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
||||||
|
}
|
||||||
|
_constraintsSeeded = true;
|
||||||
|
}
|
||||||
|
const active = name => state.constraints.some(c=>c.name===name);
|
||||||
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
|
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;">
|
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||||
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
<input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
||||||
<div style="flex:1;">
|
<div style="flex:1;">
|
||||||
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
|
<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 style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
renderCustomConstraints();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the custom (non-standard) constraints into their own list with remove buttons.
|
||||||
|
function renderCustomConstraints(){
|
||||||
|
const el = document.getElementById('custom-constraints-list'); if(!el) return;
|
||||||
|
const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name);
|
||||||
|
const customs = state.constraints.filter(c=>!stdNames.includes(c.name));
|
||||||
|
el.innerHTML = customs.length ? customs.map(c=>`
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||||
|
<strong>${escAttr(c.name)}</strong>
|
||||||
|
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
|
||||||
|
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomConstraint(name){
|
||||||
|
state.constraints = state.constraints.filter(c=>c.name!==name);
|
||||||
|
renderCustomConstraints();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleConstraint(name){
|
function toggleConstraint(name){
|
||||||
@@ -413,13 +541,24 @@ function closeConstraintModal(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addCustomConstraint(name){
|
function addCustomConstraint(name){
|
||||||
if(!state.constraints.find(c=>c.name===name)){
|
if(name && !state.constraints.find(c=>c.name===name)){
|
||||||
state.constraints.push({name,description:''});
|
state.constraints.push({name,description:''});
|
||||||
}
|
}
|
||||||
closeConstraintModal();
|
closeConstraintModal();
|
||||||
renderStandardConstraints();
|
renderStandardConstraints();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free-text custom constraint from the modal's input.
|
||||||
|
function addCustomConstraintText(){
|
||||||
|
const inp = document.getElementById('custom-constraint-input');
|
||||||
|
const name = (inp && inp.value || '').trim();
|
||||||
|
if(!name){ if(inp) inp.focus(); return; }
|
||||||
|
if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; }
|
||||||
|
state.constraints.push({name, description:''});
|
||||||
|
if(inp) inp.value='';
|
||||||
|
renderStandardConstraints();
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
|
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
|
||||||
|
|
||||||
let seqDragIndex = null;
|
let seqDragIndex = null;
|
||||||
@@ -499,20 +638,30 @@ const DEFAULT_SOURCES = [
|
|||||||
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
function renderSources(){
|
function renderSources(){
|
||||||
const container = document.getElementById('sources-list');
|
const container = document.getElementById('sources-list');
|
||||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph}));
|
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
|
||||||
container.innerHTML = state.sources.map((s,i)=>`
|
const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;";
|
||||||
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;";
|
||||||
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
const header = `<div style="${grid} padding:0 1rem 0.4rem; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--text-dim);">
|
||||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
<div>Data Type</div><div>Location / Platform</div><div>URL</div><div>Notes</div><div></div>
|
||||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
</div>`;
|
||||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
container.innerHTML = header + state.sources.map((s,i)=>{
|
||||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button>
|
// Preset data types are fixed labels; custom rows (Add Source) get an editable name.
|
||||||
</div>
|
const dataType = s.preset
|
||||||
`).join('');
|
? `<div style="font-weight:600; font-size:13px;">${escAttr(s.label)}</div>`
|
||||||
|
: `<input type="text" value="${escAttr(s.label)}" placeholder="Custom data type" onchange="state.sources[${i}].label=this.value" style="${inStyle} font-weight:600;">`;
|
||||||
|
return `<div style="${grid} padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||||
|
${dataType}
|
||||||
|
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'Procore / Bluebeam / SharePoint…')}" onchange="state.sources[${i}].system=this.value" style="${inStyle}">
|
||||||
|
<input type="text" value="${escAttr(s.link)}" placeholder="Paste the 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="${inStyle}">
|
||||||
|
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="${inStyle}">
|
||||||
|
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()" title="Remove">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSource(){
|
function addSource(){
|
||||||
state.sources.push({label:'',system:'',notes:'',link:''});
|
// Added rows are custom — the user types their own data type here.
|
||||||
|
state.sources.push({label:'', system:'', notes:'', link:'', preset:false});
|
||||||
renderSources();
|
renderSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,8 +800,8 @@ function completeSOP(){
|
|||||||
instanceSuffix: state.governance.instanceSuffix || 'letter',
|
instanceSuffix: state.governance.instanceSuffix || 'letter',
|
||||||
sizeHoursMax: state.governance.sizeHoursMax || ''
|
sizeHoursMax: state.governance.sizeHoursMax || ''
|
||||||
},
|
},
|
||||||
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
|
woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({
|
||||||
name: t.name,
|
name: t.name.trim(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notes: t.notes || '',
|
notes: t.notes || '',
|
||||||
approval: t.approval || ''
|
approval: t.approval || ''
|
||||||
@@ -675,21 +824,26 @@ function completeSOP(){
|
|||||||
};
|
};
|
||||||
|
|
||||||
sopComplete = true;
|
sopComplete = true;
|
||||||
|
// Stamp the active project onto the SOP so it's unambiguously tied to it.
|
||||||
|
try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){}
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
|
|
||||||
// Persist for the home page (green / "Review") and for the WP Creator tab.
|
// Persist for the home page (green / "Review") and for the WP Creator tab,
|
||||||
|
// namespaced to the active project so each project keeps its own SOP.
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('wp_suite_sop', JSON.stringify(sop));
|
localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
|
||||||
localStorage.setItem('wp_suite_state', JSON.stringify(state));
|
localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
|
||||||
localStorage.setItem('wp_suite_sop_complete', '1');
|
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
|
|
||||||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
||||||
|
|
||||||
alert('✓ SOP Configuration Complete!\n\nSwitch to "Work Package Creation" to start creating Work Packages.');
|
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
|
||||||
|
// the user stays), then return to the project home page per the requested flow.
|
||||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab.
|
|
||||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||||||
|
|
||||||
|
alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.');
|
||||||
|
window.location.href = 'index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||||||
@@ -717,9 +871,7 @@ function submitComment(){
|
|||||||
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
|
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
|
||||||
|
|
||||||
document.getElementById('comment-text').value = '';
|
document.getElementById('comment-text').value = '';
|
||||||
document.getElementById('commenter-name').value = '';
|
|
||||||
loadStepComments();
|
loadStepComments();
|
||||||
alert('✓ Comment submitted!');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportComments(){
|
function exportComments(){
|
||||||
@@ -22,9 +22,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load example SOP data">⭐ Load Sample</button>
|
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">⭐ Load Sample</button>
|
||||||
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
|
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
|
||||||
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
|
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
|
||||||
|
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">❔ Help</button>
|
||||||
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
|
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,6 +38,9 @@
|
|||||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
||||||
<span class="tab-icon">📋</span> Work Package Creation
|
<span class="tab-icon">📋</span> Work Package Creation
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
||||||
|
<span class="tab-icon">📊</span> Dashboard
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
<!-- CONTENT AREA -->
|
||||||
@@ -168,14 +172,23 @@
|
|||||||
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Issuance Strategy</label>
|
<label>Issuance Strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
|
||||||
<select id="gov_issuance" multiple size="3">
|
<select id="gov_issuance" multiple size="4">
|
||||||
<option selected>By Sector / Area</option>
|
<option selected>By Sector / Area</option>
|
||||||
<option>By Discipline</option>
|
<option>By Discipline</option>
|
||||||
<option>By Phase / Sequence</option>
|
<option>By Phase / Sequence</option>
|
||||||
<option>By Resource Availability</option>
|
<option>By Resource Availability</option>
|
||||||
</select>
|
</select>
|
||||||
<small>Hold Ctrl to select multiple</small>
|
<small>Hold Ctrl (Cmd on Mac) to select multiple.</small>
|
||||||
|
<div class="notice" style="margin-top:0.6rem; font-size:12px;">
|
||||||
|
<strong>Examples:</strong>
|
||||||
|
<ul style="margin:0.35rem 0 0; padding-left:1.1rem;">
|
||||||
|
<li><strong>By Sector / Area</strong> — one package per physical area, e.g. <em>all work in Sector 1P, Level 2 chase</em>.</li>
|
||||||
|
<li><strong>By Discipline</strong> — separate packages per trade, e.g. <em>Electrical wire-pull</em> vs <em>Mechanical install</em>.</li>
|
||||||
|
<li><strong>By Phase / Sequence</strong> — follow the build order, e.g. <em>rough-in → wire pull → terminations</em>.</li>
|
||||||
|
<li><strong>By Resource Availability</strong> — size to a crew/equipment window, e.g. <em>one boom-lift crew's week</em>.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,7 +201,7 @@
|
|||||||
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
|
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Discipline strategy *</label>
|
<label>Discipline strategy *<span class="help-tip" data-tip="Decides whether a package can carry several disciplines (scope split per discipline) or one each. 'Let the planner choose' allows building a big multi-discipline package and splitting it later.">i</span></label>
|
||||||
<select id="gov_discmode">
|
<select id="gov_discmode">
|
||||||
<option value="choice">Let the planner choose per package (recommended)</option>
|
<option value="choice">Let the planner choose per package (recommended)</option>
|
||||||
<option value="single">One discipline per package (many small packages)</option>
|
<option value="single">One discipline per package (many small packages)</option>
|
||||||
@@ -202,13 +215,20 @@
|
|||||||
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 1–2 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
|
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 1–2 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Typical WP Size (guidance)</label>
|
<label>Typical WP Size</label>
|
||||||
<input type="text" id="gov_wosize" placeholder="e.g., 3–5 days or 40–80 hours">
|
<select id="gov_wosize" onchange="onSizePresetChange()">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="Small — 1–2 days (≈8–24 hrs)">Small — 1–2 days (≈8–24 hrs)</option>
|
||||||
|
<option value="Standard — 3–5 days (≈40–80 hrs)">Standard — 3–5 days (≈40–80 hrs)</option>
|
||||||
|
<option value="Large — 1–2 weeks (≈80–160 hrs)">Large — 1–2 weeks (≈80–160 hrs)</option>
|
||||||
|
<option value="Custom…">Custom…</option>
|
||||||
|
</select>
|
||||||
|
<small>Sets the split threshold automatically; choose Custom to enter your own.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Split threshold — max labor hours</label>
|
<label>Split threshold — max labor hours<span class="help-tip" data-tip="The Work Package Creator flags any package whose estimated hours exceed this so the planner can break it down. Auto-set by the size band; override if needed.">i</span></label>
|
||||||
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 120">
|
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
|
||||||
<small>The Creator flags packages above this so they can be split (by discipline or scope).</small>
|
<small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +361,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
|
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
|
||||||
<input type="text" id="commenter-name" placeholder="e.g., Bill Clarida" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
<input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
||||||
@@ -363,12 +383,19 @@
|
|||||||
<h3>Add Custom Constraint</h3>
|
<h3>Add Custom Constraint</h3>
|
||||||
<button class="modal-close" onclick="closeConstraintModal()">✕</button>
|
<button class="modal-close" onclick="closeConstraintModal()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="constraint-library" style="max-height: 400px; overflow-y: auto; margin: 1rem 0;"></div>
|
<div style="display:flex; gap:0.5rem; margin:1rem 0 0.5rem;">
|
||||||
|
<input type="text" id="custom-constraint-input" placeholder="Type a custom constraint name…" style="flex:1; padding:0.55rem 0.65rem; border:1px solid var(--border); border-radius:4px;" onkeydown="if(event.key==='Enter'){addCustomConstraintText();event.preventDefault();}">
|
||||||
|
<button class="add-btn" onclick="addCustomConstraintText()">Add</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; color:var(--text-dim); margin-bottom:0.5rem;">…or pick from the library:</div>
|
||||||
|
<div id="constraint-library" style="max-height: 320px; overflow-y: auto; margin: 0 0 1rem;"></div>
|
||||||
<button class="nav-btn" onclick="closeConstraintModal()">Done</button>
|
<button class="nav-btn" onclick="closeConstraintModal()">Done</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
<script src="work-package-suite-app.js"></script>
|
<script src="work-package-suite-app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,7 +8,7 @@ const SAMPLE_SOP = {
|
|||||||
meta:{tool:'Work Package Configuration', sample:true},
|
meta:{tool:'Work Package Configuration', sample:true},
|
||||||
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
||||||
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
||||||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'3–5 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' },
|
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 3–5 days (≈40–80 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' },
|
||||||
woTypes:[
|
woTypes:[
|
||||||
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
||||||
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
||||||
@@ -34,15 +34,16 @@ const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
|
|||||||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
||||||
|
|
||||||
// Acumatica cost codes (comment 10) — code|description
|
// Acumatica cost codes (comment 10) — code|description
|
||||||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract','5010|Instrument Material','5020|Network & Computers Material','5030|Software Material','5040|PLC Material','5050|Panel Material','5060|Electrical Material','5070|Mechanical Material','5080|Security Material','5090|Radio Material','6000|Production','7000|Quality','7100|Panel Quality Control','7200|Factory Acceptance Testing','7300|Site Acceptance Testing','8000|Safety','9000|Administration','9100|Warranty','9200|Freight','9300|Travel','9350|Jobsite Costs/Consumable/Other Direct Costs','9400|Contingency','9450|Other','9500|Accrued Incentive Compensation','9600|Sales Tax','9650|Job Cost Labor Burden','9700|Bonding','9800|Non-Billable Compensation'];
|
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract'];
|
||||||
// Acumatica allowed units of measure (comment 15) — common first
|
// Acumatica allowed units of measure (comment 15) — common first
|
||||||
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
||||||
|
|
||||||
// Example built work package (comment 4 / "Load Example") — WP02 export
|
// Example built work package (comment 4 / "Load Example") — WP02 export
|
||||||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Bill Clarida (Prime Controls), Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
||||||
|
|
||||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||||
let SOP=null, editingId=null, numberDirty=false;
|
let SOP=null, editingId=null, numberDirty=false;
|
||||||
|
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
||||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||||
@@ -135,7 +136,12 @@ function editQuality(id){
|
|||||||
}
|
}
|
||||||
function renderCtxBar(){
|
function renderCtxBar(){
|
||||||
const bar=document.getElementById('ctx-bar');
|
const bar=document.getElementById('ctx-bar');
|
||||||
if(!SOP){ bar.innerHTML=`<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`; return; }
|
if(!SOP){
|
||||||
|
bar.innerHTML = activeProjectId
|
||||||
|
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
||||||
|
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const p=SOP.project||{}, g=SOP.governance||{};
|
const p=SOP.project||{}, g=SOP.governance||{};
|
||||||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||||||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
||||||
@@ -371,12 +377,15 @@ function rollupDisciplineStatus(){
|
|||||||
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
||||||
function onHoursChange(){
|
function onHoursChange(){
|
||||||
const el=document.getElementById('size-check'); if(!el) return;
|
const el=document.getElementById('size-check'); if(!el) return;
|
||||||
const max=parseFloat((SOP&&SOP.governance&&SOP.governance.sizeHoursMax)||'');
|
const g=(SOP&&SOP.governance)||{};
|
||||||
|
const band=g.woSize?('Target: '+g.woSize+'. '):'';
|
||||||
|
const max=parseFloat(g.sizeHoursMax||'');
|
||||||
const hrs=parseFloat(gv('wp_hours'));
|
const hrs=parseFloat(gv('wp_hours'));
|
||||||
if(max && hrs && hrs>max){
|
if(max && hrs && hrs>max){
|
||||||
el.innerHTML=`<span style="color:var(--accent-amber)">⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
el.innerHTML=`<span style="color:var(--accent-amber)">${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||||||
} else if(max){ el.textContent=`Split threshold: ${max} hrs (from SOP).`; }
|
} else if(band || max){
|
||||||
else { el.textContent=''; }
|
el.innerHTML=`<span>${esc(band)}${max?'Split threshold: '+max+' hrs.':''}</span>`;
|
||||||
|
} else { el.textContent=''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
|
// ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
|
||||||
@@ -520,6 +529,20 @@ function setConstraint(i,val){
|
|||||||
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
|
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
|
||||||
prevStatus=getRadio('status'); holdContext={index:i, before};
|
prevStatus=getRadio('status'); holdContext={index:i, before};
|
||||||
openHoldModal(pkgConstraints[i].name, true);
|
openHoldModal(pkgConstraints[i].name, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clearing the LAST open constraint makes the package release-ready — offer to
|
||||||
|
// issue it and scroll up to the status control so the change is visible.
|
||||||
|
if(before==='open' && val!=='open' && readiness().open===0){
|
||||||
|
const st=getRadio('status');
|
||||||
|
if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
|
||||||
|
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
|
||||||
|
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
|
||||||
|
track('status_change',{status:'Issued',via:'constraint_clear'});
|
||||||
|
}
|
||||||
|
const sg=document.getElementById('status-group');
|
||||||
|
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
|
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
|
||||||
@@ -530,6 +553,7 @@ function updateReleaseBanner(){
|
|||||||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
||||||
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
|
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
|
||||||
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
||||||
|
updateStickyStatus();
|
||||||
}
|
}
|
||||||
function onStatusChange(target){
|
function onStatusChange(target){
|
||||||
const idx=STATUS_ORDER.indexOf(target);
|
const idx=STATUS_ORDER.indexOf(target);
|
||||||
@@ -631,6 +655,7 @@ function collectPackage(){
|
|||||||
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
||||||
return {
|
return {
|
||||||
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
||||||
|
projectId: (prev&&prev.projectId) || activeProjectId || '',
|
||||||
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
||||||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||||
@@ -751,13 +776,61 @@ function printPackage(){
|
|||||||
|
|
||||||
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
||||||
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
|
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
|
||||||
function showOutput(){ hideDashboard(); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||||
|
|
||||||
|
// Sticky save bar + section-nav chrome (shown only on the editable form view).
|
||||||
|
function setFormChrome(on){
|
||||||
|
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
|
||||||
|
if(nav) nav.style.display = on ? '' : 'none';
|
||||||
|
if(save) save.style.display = on ? 'flex' : 'none';
|
||||||
|
document.body.classList.toggle('has-sticky-save', !!on);
|
||||||
|
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); }
|
||||||
|
}
|
||||||
|
// Make each form card collapsible by clicking its heading (idempotent).
|
||||||
|
function makeCollapsible(){
|
||||||
|
document.querySelectorAll('.main > .card').forEach(card=>{
|
||||||
|
if(card.id==='saved-card') return;
|
||||||
|
const head=card.querySelector('.section-header, .sub-heading');
|
||||||
|
if(!head || head.dataset.collapsible) return;
|
||||||
|
head.dataset.collapsible='1';
|
||||||
|
head.style.cursor='pointer';
|
||||||
|
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
|
||||||
|
head.insertBefore(chev, head.firstChild);
|
||||||
|
head.addEventListener('click', e=>{
|
||||||
|
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
|
||||||
|
const collapsed=card.classList.toggle('collapsed');
|
||||||
|
chev.textContent = collapsed ? '▸' : '▾';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function buildSectionNav(){
|
||||||
|
const nav=document.getElementById('section-nav'); if(!nav) return;
|
||||||
|
const chips=[];
|
||||||
|
document.querySelectorAll('.main > .card').forEach((card,i)=>{
|
||||||
|
if(card.id==='saved-card' || card.style.display==='none') return;
|
||||||
|
const h=card.querySelector('.section-title, .sub-heading'); if(!h) return;
|
||||||
|
const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove());
|
||||||
|
const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return;
|
||||||
|
if(!card.id) card.id='sec-'+i;
|
||||||
|
chips.push(`<span class="sec-chip" onclick="document.getElementById('${card.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(label)}</span>`);
|
||||||
|
});
|
||||||
|
nav.innerHTML=chips.join('');
|
||||||
|
}
|
||||||
|
function updateStickyStatus(){
|
||||||
|
const el=document.getElementById('sticky-status'); if(!el) return;
|
||||||
|
const r=readiness(); const st=getRadio('status');
|
||||||
|
if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; }
|
||||||
|
else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; }
|
||||||
|
else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
|
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
|
||||||
const STORE_KEY='wp_iwp_v1';
|
const STORE_KEY='wp_iwp_v1';
|
||||||
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} }
|
// Per-project namespaced key so each project keeps its own packages in the browser.
|
||||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} }
|
function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } }
|
||||||
|
function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} }
|
||||||
|
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
|
||||||
function renderSavedList(){
|
function renderSavedList(){
|
||||||
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
||||||
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
||||||
@@ -768,7 +841,7 @@ function renderSavedList(){
|
|||||||
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
|
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
|
||||||
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||||||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||||||
<td>${esc(p.status||'')}</td><td>${ready}</td>
|
<td>${statusPill(p.status)}</td><td>${ready}</td>
|
||||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -818,6 +891,36 @@ function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; bu
|
|||||||
buildConstraints(); }
|
buildConstraints(); }
|
||||||
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
||||||
|
|
||||||
|
// Duplicate the current work package N times (asks how many). Each copy is a
|
||||||
|
// fresh Draft with a unique number/subject and approvals/closeout cleared.
|
||||||
|
function duplicateWP(){
|
||||||
|
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; }
|
||||||
|
const ans=prompt('How many copies of this work package do you want to create?','1');
|
||||||
|
if(ans===null) return;
|
||||||
|
const n=parseInt(ans,10);
|
||||||
|
if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; }
|
||||||
|
const base=collectPackage();
|
||||||
|
const baseNum = base.number || ('WP'+pad2(editingSeq()));
|
||||||
|
const made=[];
|
||||||
|
for(let i=1;i<=n;i++){
|
||||||
|
const c=JSON.parse(JSON.stringify(base));
|
||||||
|
c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i;
|
||||||
|
c.number = baseNum + '-C' + i;
|
||||||
|
c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)');
|
||||||
|
c.status='Draft';
|
||||||
|
c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined;
|
||||||
|
if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''}));
|
||||||
|
c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons='';
|
||||||
|
c.projectId = base.projectId || activeProjectId || '';
|
||||||
|
c.updatedAt=new Date().toISOString();
|
||||||
|
savedPackages.push(c); made.push(c);
|
||||||
|
}
|
||||||
|
editingId=null; saveStore(); renderSavedList();
|
||||||
|
toast('Created '+n+' duplicate'+(n>1?'s':''));
|
||||||
|
track('wp_duplicated',{count:n});
|
||||||
|
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
|
||||||
|
}
|
||||||
|
|
||||||
function newPackage(){
|
function newPackage(){
|
||||||
editingId=null;
|
editingId=null;
|
||||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||||
@@ -856,13 +959,26 @@ const WPData = {
|
|||||||
p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status
|
p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status
|
||||||
};
|
};
|
||||||
|
|
||||||
let dashFilter={status:'',discipline:'',q:''};
|
let dashFilter={status:'',discipline:'',q:'',flag:''};
|
||||||
|
function dashToggleFlag(f){
|
||||||
|
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
|
||||||
|
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
|
||||||
|
// Consistent colored status pill, reused by the dashboard board and the saved list.
|
||||||
|
function statusPill(s){
|
||||||
|
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
|
||||||
|
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
|
||||||
|
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
|
||||||
|
}
|
||||||
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
||||||
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
||||||
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
||||||
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
||||||
|
|
||||||
function showDashboard(){
|
function showDashboard(){
|
||||||
|
setFormChrome(false);
|
||||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
||||||
document.getElementById('pkg-output').style.display='none';
|
document.getElementById('pkg-output').style.display='none';
|
||||||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||||||
@@ -881,19 +997,25 @@ function renderDashboard(){
|
|||||||
if(isOverdue(p)) overdue++;
|
if(isOverdue(p)) overdue++;
|
||||||
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
||||||
});
|
});
|
||||||
const card=(label,val,cls)=>`<div class="dash-metric ${cls||''}"><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
// Clickable metric cards filter the board (flag-based); a card with no flag is static.
|
||||||
|
const card=(label,val,cls,flag)=>{
|
||||||
|
const active = flag && dashFilter.flag===flag ? ' dm-active' : '';
|
||||||
|
const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : '';
|
||||||
|
return `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||||
|
};
|
||||||
let h=`<div class="dash-metrics">
|
let h=`<div class="dash-metrics">
|
||||||
${card('Total WPs', all.length)}
|
${card('Total WPs', all.length, '', 'all')}
|
||||||
${card('Release-ready', ready, ready?'dm-green':'')}
|
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||||||
${card('On hold', hold, hold?'dm-red':'')}
|
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||||||
${card('Overdue', overdue, overdue?'dm-red':'')}
|
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||||
${card('Est. hrs', Math.round(estH))}
|
${card('Est. hrs', Math.round(estH))}
|
||||||
${card('Actual hrs', Math.round(actH))}
|
${card('Actual hrs', Math.round(actH))}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// status + discipline breakdown chips
|
// status + discipline breakdown chips (status chips also filter the board)
|
||||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('')
|
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||||||
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:'');
|
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||||||
|
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||||||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||||||
@@ -923,6 +1045,9 @@ function renderDashboard(){
|
|||||||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||||||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||||||
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
||||||
|
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
|
||||||
|
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
|
||||||
|
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||||||
@@ -938,7 +1063,7 @@ function renderDashboard(){
|
|||||||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||||||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||||||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||||||
<td>${esc(p.status||'')}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||||||
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
||||||
});
|
});
|
||||||
h+=`</tbody></table></div>`;
|
h+=`</tbody></table></div>`;
|
||||||
@@ -1010,6 +1135,15 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
|
||||||
|
(function seedProject(){
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||||
|
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){
|
||||||
|
const cached = ProjectData.getActive && ProjectData.getActive();
|
||||||
|
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
|
||||||
|
}
|
||||||
|
})();
|
||||||
loadStore();
|
loadStore();
|
||||||
(function bootSOP(){
|
(function bootSOP(){
|
||||||
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
||||||
@@ -1017,13 +1151,17 @@ loadStore();
|
|||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
if(params.get('embedded')) document.body.classList.add('embedded');
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('wp_suite_sop');
|
const raw = localStorage.getItem(wpKey('wp_suite_sop'));
|
||||||
if(raw){
|
if(raw){
|
||||||
const d = JSON.parse(raw);
|
const d = JSON.parse(raw);
|
||||||
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
||||||
}
|
}
|
||||||
} catch(e){}
|
} catch(e){}
|
||||||
loadSampleSOP();
|
// No SOP found. Only show the Micron SAMPLE for a standalone preview (no project
|
||||||
|
// context). For a real project, never substitute sample data — show the empty
|
||||||
|
// state so it's clear the project's SOP must be completed first.
|
||||||
|
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||||||
|
else { loadSampleSOP(); }
|
||||||
})();
|
})();
|
||||||
setRadio('status','Draft');
|
setRadio('status','Draft');
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
@@ -13,21 +13,22 @@
|
|||||||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo-wrap">
|
<div class="logo-wrap embed-hide">
|
||||||
<div class="header-logo">Prime Controls</div>
|
<div class="header-logo">Prime Controls</div>
|
||||||
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-sep">|</div>
|
<div class="header-sep embed-hide">|</div>
|
||||||
<div class="header-title">Work Package (IWP)</div>
|
<div class="header-title">Work Package (IWP)</div>
|
||||||
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
||||||
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
|
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
|
||||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
||||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||||
<button class="btn btn-ghost" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||||
|
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
|
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
|
||||||
@@ -37,6 +38,9 @@
|
|||||||
<!-- RELEASE READINESS BANNER -->
|
<!-- RELEASE READINESS BANNER -->
|
||||||
<div class="release-banner" id="release-banner"></div>
|
<div class="release-banner" id="release-banner"></div>
|
||||||
|
|
||||||
|
<!-- SECTION NAV (jump links, built from the form cards) -->
|
||||||
|
<div class="section-nav-bar" id="section-nav"></div>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
<!-- GENERAL INFORMATION -->
|
<!-- GENERAL INFORMATION -->
|
||||||
@@ -44,7 +48,7 @@
|
|||||||
<div class="section-header"><div class="section-title">General Information</div>
|
<div class="section-header"><div class="section-title">General Information</div>
|
||||||
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
<div class="field"><label>WP Number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||||
<div class="field"><label>Status</label>
|
<div class="field"><label>Status</label>
|
||||||
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
||||||
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
||||||
@@ -87,14 +91,14 @@
|
|||||||
|
|
||||||
<!-- DISCIPLINES -->
|
<!-- DISCIPLINES -->
|
||||||
<div class="card" id="discipline-card" style="display:none">
|
<div class="card" id="discipline-card" style="display:none">
|
||||||
<div class="sub-heading">Disciplines</div>
|
<div class="sub-heading">Disciplines<span class="help-tip" data-tip="Pick every discipline this package covers. Choosing two or more turns Scope into per-discipline sections and enables Split by Discipline.">i</span></div>
|
||||||
<div class="notice" id="discipline-note"></div>
|
<div class="notice" id="discipline-note"></div>
|
||||||
<div class="disc-picker" id="discipline-picker"></div>
|
<div class="disc-picker" id="discipline-picker"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SCOPE & WORK -->
|
<!-- SCOPE & WORK -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="sub-heading">Scope & Work</div>
|
<div class="sub-heading">Scope & Work<span class="help-tip" data-tip="Ordered steps the crew performs. With multiple disciplines selected, each gets its own scope section and status. Use Split by Discipline to break a large package into WP01A / WP01B / WP01C instances.">i</span></div>
|
||||||
<div id="flat-scope">
|
<div id="flat-scope">
|
||||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||||
@@ -112,7 +116,7 @@
|
|||||||
|
|
||||||
<!-- MATERIAL LIST -->
|
<!-- MATERIAL LIST -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="sub-heading">Material List</div>
|
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
|
||||||
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
||||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
||||||
<div class="material-actions">
|
<div class="material-actions">
|
||||||
@@ -156,7 +160,7 @@
|
|||||||
|
|
||||||
<!-- CONSTRAINTS / RELEASE READINESS -->
|
<!-- CONSTRAINTS / RELEASE READINESS -->
|
||||||
<div class="card" id="constraint-card">
|
<div class="card" id="constraint-card">
|
||||||
<div class="sub-heading">Constraints — Release Readiness</div>
|
<div class="sub-heading">Constraints — Release Readiness<span class="help-tip" data-tip="A package can't be Issued until every constraint is Cleared or N/A. If one reopens after release, the package drops to Issue (Hold).">i</span></div>
|
||||||
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
|
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
|
||||||
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
|
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +279,18 @@
|
|||||||
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- STICKY SAVE BAR (always-visible save + release status) -->
|
||||||
|
<div class="sticky-save" id="sticky-save" style="display:none">
|
||||||
|
<span class="sticky-status" id="sticky-status"></span>
|
||||||
|
<div class="sticky-actions">
|
||||||
|
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
||||||
|
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
<script src="wp-creation-app.js"></script>
|
<script src="wp-creation-app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -564,6 +564,31 @@
|
|||||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||||
.so-ovr { margin-left:8px; font-size:11px; }
|
.so-ovr { margin-left:8px; font-size:11px; }
|
||||||
|
|
||||||
|
/* Collapsible form sections */
|
||||||
|
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
|
||||||
|
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
|
||||||
|
.card.collapsed .section-desc { display:none; }
|
||||||
|
|
||||||
|
/* Section nav (jump chips) */
|
||||||
|
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||||
|
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
|
||||||
|
border-bottom:1px solid var(--border); }
|
||||||
|
.section-nav-bar:empty{ display:none; }
|
||||||
|
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
|
||||||
|
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
|
||||||
|
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
|
||||||
|
|
||||||
|
/* Sticky save bar */
|
||||||
|
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
|
||||||
|
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
|
||||||
|
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
|
||||||
|
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }
|
||||||
|
.sticky-save .sticky-actions{ display:flex; gap:10px; }
|
||||||
|
.ss-ready{ color:var(--accent-green); }
|
||||||
|
.ss-notready{ color:var(--accent-amber); }
|
||||||
|
.ss-hold{ color:var(--red); }
|
||||||
|
body.has-sticky-save .main{ padding-bottom:74px; }
|
||||||
|
|
||||||
/* Disciplines + per-discipline scope */
|
/* Disciplines + per-discipline scope */
|
||||||
.disc-picker { display:flex; flex-wrap:wrap; gap:8px; }
|
.disc-picker { display:flex; flex-wrap:wrap; gap:8px; }
|
||||||
.disc-pill { display:flex; align-items:center; gap:7px; padding:7px 13px; border:1px solid var(--border-strong);
|
.disc-pill { display:flex; align-items:center; gap:7px; padding:7px 13px; border:1px solid var(--border-strong);
|
||||||
@@ -586,6 +611,11 @@
|
|||||||
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
|
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
|
||||||
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
|
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
|
||||||
.dash-metric.dm-red .dm-val { color:var(--red); }
|
.dash-metric.dm-red .dm-val { color:var(--red); }
|
||||||
|
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||||
|
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
||||||
|
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
||||||
|
.dash-chip[onclick] { cursor:pointer; }
|
||||||
|
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
||||||
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||||
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
||||||
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
|
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
|
||||||
4
nginx/Dockerfile
Normal file
4
nginx/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx/conf.d/wp-suite.conf /etc/nginx/conf.d/wp-suite.conf
|
||||||
|
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY html/ /usr/share/nginx/html/
|
||||||
25
nginx/conf.d/wp-suite.conf
Normal file
25
nginx/conf.d/wp-suite.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Work Package Suite — NGINX site config
|
||||||
|
# This container sits behind an external reverse proxy that handles SSL.
|
||||||
|
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
nginx/nginx.conf
Normal file
17
nginx/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
219
server/README.md
219
server/README.md
@@ -6,7 +6,7 @@ to this service.
|
|||||||
|
|
||||||
```
|
```
|
||||||
browser → NGINX ──serves──> static site (index.html, …)
|
browser → NGINX ──serves──> static site (index.html, …)
|
||||||
└─proxy /api/─> this API (uvicorn/gunicorn :8000) → PostgreSQL
|
└─proxy /api/─> api container (:8000) → db container (postgres)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -31,6 +31,8 @@ Interactive docs once running: **`/api/docs`**.
|
|||||||
The full client document is stored in each row's `data` (JSON) column; common
|
The full client document is stored in each row's `data` (JSON) column; common
|
||||||
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,42 +47,193 @@ Then open http://localhost:8000/api/docs.
|
|||||||
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
||||||
> `server/` directory), because the import path is `server.app:app`.
|
> `server/` directory), because the import path is `server.app:app`.
|
||||||
|
|
||||||
## PostgreSQL setup (production)
|
---
|
||||||
|
|
||||||
|
## Production — Docker Compose
|
||||||
|
|
||||||
|
This is the recommended production setup. Three containers run in an isolated
|
||||||
|
internal network; only NGINX is exposed to the outside via the external `proxy`
|
||||||
|
network.
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE wpsuite;
|
|
||||||
CREATE USER wpsuite WITH PASSWORD 'CHANGE_ME';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE wpsuite TO wpsuite;
|
|
||||||
```
|
```
|
||||||
Tables are created automatically on first startup. (For future schema changes,
|
[external proxy network]
|
||||||
introduce Alembic migrations rather than editing tables by hand.)
|
│
|
||||||
|
┌────▼────┐ internal network ┌──────────┐ ┌────────┐
|
||||||
## Run in production (gunicorn + systemd)
|
│ nginx │ ───────────────────> │ api │ → │ db │
|
||||||
|
└─────────┘ └──────────┘ └────────┘
|
||||||
`/etc/systemd/system/wp-suite-api.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Work Package Suite API
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=www-data
|
|
||||||
WorkingDirectory=/opt/wp-suite
|
|
||||||
Environment="DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite"
|
|
||||||
ExecStart=/opt/wp-suite/.venv/bin/gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 1. Create the credentials file
|
||||||
|
|
||||||
|
Create `.env` in the **project root** (same directory as `docker-compose.yml`).
|
||||||
|
This file is never committed — add it to `.gitignore`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
# .env — project root
|
||||||
sudo systemctl enable --now wp-suite-api
|
POSTGRES_DB=wpsuite
|
||||||
|
POSTGRES_USER=wpsuite
|
||||||
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
|
|
||||||
|
# Must match POSTGRES_* above; hostname is the compose service name "db"
|
||||||
|
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||||
```
|
```
|
||||||
|
|
||||||
NGINX already proxies `/api/` to `127.0.0.1:8000` (see `nginx-wp-suite.conf`).
|
Generate a strong password:
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add the Dockerfile
|
||||||
|
|
||||||
|
Create `Dockerfile` in the **project root**:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/requirements.txt ./server/
|
||||||
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
|
COPY server/ ./server/
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||||
|
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update the NGINX site config
|
||||||
|
|
||||||
|
The API is no longer at `127.0.0.1:8000` — it is the `api` container.
|
||||||
|
Update the `/api/` proxy block in your nginx conf (e.g. `nginx/conf.d/wp-suite.conf`):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000; # ← service name, not localhost
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 5m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. docker-compose.yml
|
||||||
|
|
||||||
|
Replace your existing `docker-compose.yml` with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: nginx_webserver
|
||||||
|
volumes:
|
||||||
|
- ./html:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./logs:/var/log/nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- proxy # external — reachable by your reverse proxy / traefik
|
||||||
|
- internal # needs a path to the api container
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
container_name: wp_api
|
||||||
|
env_file: .env # loads DATABASE_URL
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: wp_db
|
||||||
|
env_file: .env # loads POSTGRES_DB / USER / PASSWORD
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
name: proxy
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
internal: true # no outbound internet access from api/db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. First-time startup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the api image and start all containers
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Confirm all three containers are running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Tail logs (Ctrl-C to stop following)
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables are created automatically on first API startup — no manual `CREATE TABLE`
|
||||||
|
needed.
|
||||||
|
|
||||||
|
### Authentication notes
|
||||||
|
|
||||||
|
**Postgres → API authentication** is handled entirely through `DATABASE_URL` in
|
||||||
|
`.env`. The `db` container uses `POSTGRES_USER` / `POSTGRES_PASSWORD` to
|
||||||
|
initialise the database on first run; the `api` container uses the matching
|
||||||
|
credentials in `DATABASE_URL` to connect. Neither credential ever appears in the
|
||||||
|
compose file itself.
|
||||||
|
|
||||||
|
**Network isolation**: the `db` container is on the `internal` network only —
|
||||||
|
it has no port exposed to the host and is unreachable from outside the compose
|
||||||
|
stack. Only the `api` container can open a connection to it.
|
||||||
|
|
||||||
|
**Changing the password**: update both `POSTGRES_PASSWORD` and the password
|
||||||
|
in `DATABASE_URL` in `.env`, then:
|
||||||
|
```bash
|
||||||
|
# Stop api first (db must keep running to accept the ALTER USER command)
|
||||||
|
docker compose stop api
|
||||||
|
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
|
||||||
|
docker compose start api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Day-to-day operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild api after a code change
|
||||||
|
docker compose up -d --build api
|
||||||
|
|
||||||
|
# View postgres data directly
|
||||||
|
docker compose exec db psql -U wpsuite -d wpsuite
|
||||||
|
|
||||||
|
# Take a database backup
|
||||||
|
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql
|
||||||
|
|
||||||
|
# Stop everything (data volume is preserved)
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop everything AND delete all data
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick test
|
## Quick test
|
||||||
|
|
||||||
@@ -91,3 +244,9 @@ curl -X POST http://127.0.0.1:8000/api/comments \
|
|||||||
|
|
||||||
curl http://127.0.0.1:8000/api/comments
|
curl http://127.0.0.1:8000/api/comments
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or via the nginx proxy (replace with your hostname):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://wp-suite.company.local/api/health
|
||||||
|
```
|
||||||
|
|||||||
@@ -41,8 +41,21 @@ def gen_id(prefix: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||||
|
class ProjectIn(BaseModel):
|
||||||
|
id: Optional[str] = None
|
||||||
|
name: str = ""
|
||||||
|
number: str = ""
|
||||||
|
client: str = ""
|
||||||
|
division: str = ""
|
||||||
|
site: str = ""
|
||||||
|
sample: bool = False
|
||||||
|
created_by: str = ""
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class SopIn(BaseModel):
|
class SopIn(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
name: str = ""
|
name: str = ""
|
||||||
number: str = ""
|
number: str = ""
|
||||||
complete: bool = False
|
complete: bool = False
|
||||||
@@ -52,6 +65,7 @@ class SopIn(BaseModel):
|
|||||||
|
|
||||||
class WpIn(BaseModel):
|
class WpIn(BaseModel):
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
project_id: Optional[str] = None
|
||||||
sop_id: Optional[str] = None
|
sop_id: Optional[str] = None
|
||||||
parent_id: Optional[str] = None
|
parent_id: Optional[str] = None
|
||||||
number: str = ""
|
number: str = ""
|
||||||
@@ -86,6 +100,50 @@ def health():
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||||
|
@app.post("/api/projects")
|
||||||
|
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, body.id) if body.id else None
|
||||||
|
if proj is None:
|
||||||
|
proj = models.Project(id=body.id or gen_id("proj"))
|
||||||
|
db.add(proj)
|
||||||
|
proj.name = body.name
|
||||||
|
proj.number = body.number
|
||||||
|
proj.client = body.client
|
||||||
|
proj.division = body.division
|
||||||
|
proj.site = body.site
|
||||||
|
proj.sample = body.sample
|
||||||
|
proj.created_by = body.created_by or proj.created_by
|
||||||
|
proj.data = body.data
|
||||||
|
db.commit()
|
||||||
|
db.refresh(proj)
|
||||||
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects")
|
||||||
|
def list_projects(db: Session = Depends(get_db)):
|
||||||
|
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
|
||||||
|
return [p.summary() for p in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/projects/{project_id}")
|
||||||
|
def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if not proj:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return proj.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/projects/{project_id}")
|
||||||
|
def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
proj = db.get(models.Project, project_id)
|
||||||
|
if not proj:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
db.delete(proj)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": project_id}
|
||||||
|
|
||||||
|
|
||||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||||
@app.post("/api/sops")
|
@app.post("/api/sops")
|
||||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||||
@@ -93,6 +151,7 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
|||||||
if sop is None:
|
if sop is None:
|
||||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||||
db.add(sop)
|
db.add(sop)
|
||||||
|
sop.project_id = body.project_id
|
||||||
sop.name = body.name
|
sop.name = body.name
|
||||||
sop.number = body.number
|
sop.number = body.number
|
||||||
sop.complete = body.complete
|
sop.complete = body.complete
|
||||||
@@ -104,16 +163,21 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/sops")
|
@app.get("/api/sops")
|
||||||
def list_sops(db: Session = Depends(get_db)):
|
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all()
|
stmt = select(models.Sop)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||||
|
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
|
||||||
return [s.summary() for s in rows]
|
return [s.summary() for s in rows]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/sops/latest")
|
@app.get("/api/sops/latest")
|
||||||
def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)):
|
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
stmt = select(models.Sop)
|
stmt = select(models.Sop)
|
||||||
if complete is not None:
|
if complete is not None:
|
||||||
stmt = stmt.where(models.Sop.complete == complete)
|
stmt = stmt.where(models.Sop.complete == complete)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||||
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
||||||
if not sop:
|
if not sop:
|
||||||
raise HTTPException(status_code=404, detail="No SOP found")
|
raise HTTPException(status_code=404, detail="No SOP found")
|
||||||
@@ -145,6 +209,7 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
|||||||
if wp is None:
|
if wp is None:
|
||||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||||
db.add(wp)
|
db.add(wp)
|
||||||
|
wp.project_id = body.project_id
|
||||||
wp.sop_id = body.sop_id
|
wp.sop_id = body.sop_id
|
||||||
wp.parent_id = body.parent_id
|
wp.parent_id = body.parent_id
|
||||||
wp.number = body.number
|
wp.number = body.number
|
||||||
@@ -160,12 +225,15 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@app.get("/api/wps")
|
@app.get("/api/wps")
|
||||||
def list_wps(
|
def list_wps(
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
sop_id: Optional[str] = Query(None),
|
sop_id: Optional[str] = Query(None),
|
||||||
parent_id: Optional[str] = Query(None),
|
parent_id: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(models.WorkPackage)
|
stmt = select(models.WorkPackage)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||||
if sop_id:
|
if sop_id:
|
||||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||||
if parent_id:
|
if parent_id:
|
||||||
@@ -177,11 +245,13 @@ def list_wps(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/wps/metrics")
|
@app.get("/api/wps/metrics")
|
||||||
def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||||
from counts so a split package's hours aren't double-counted with its
|
from counts so a split package's hours aren't double-counted with its
|
||||||
instances."""
|
instances."""
|
||||||
stmt = select(models.WorkPackage)
|
stmt = select(models.WorkPackage)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||||
if sop_id:
|
if sop_id:
|
||||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||||
rows = db.scalars(stmt).all()
|
rows = db.scalars(stmt).all()
|
||||||
|
|||||||
@@ -21,10 +21,42 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
"""A construction project — the top-level container. SOPs and Work Packages
|
||||||
|
belong to a project so the suite can be used for many jobs at once."""
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
number: Mapped[str] = mapped_column(String(100), default="", index=True)
|
||||||
|
client: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
division: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
site: Mapped[str] = mapped_column(String(300), default="")
|
||||||
|
sample: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "name": self.name, "number": self.number,
|
||||||
|
"client": self.client, "division": self.division, "site": self.site,
|
||||||
|
"sample": self.sample, "created_by": self.created_by,
|
||||||
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {**self.summary(), "data": self.data or {}}
|
||||||
|
|
||||||
|
|
||||||
class Sop(Base):
|
class Sop(Base):
|
||||||
__tablename__ = "sops"
|
__tablename__ = "sops"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
name: Mapped[str] = mapped_column(String(300), default="")
|
name: Mapped[str] = mapped_column(String(300), default="")
|
||||||
number: Mapped[str] = mapped_column(String(100), default="")
|
number: Mapped[str] = mapped_column(String(100), default="")
|
||||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -35,8 +67,8 @@ class Sop(Base):
|
|||||||
|
|
||||||
def summary(self) -> dict:
|
def summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id, "name": self.name, "number": self.number,
|
"id": self.id, "project_id": self.project_id, "name": self.name,
|
||||||
"complete": self.complete, "created_by": self.created_by,
|
"number": self.number, "complete": self.complete, "created_by": self.created_by,
|
||||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +80,9 @@ class WorkPackage(Base):
|
|||||||
__tablename__ = "work_packages"
|
__tablename__ = "work_packages"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
sop_id: Mapped[Optional[str]] = mapped_column(
|
sop_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
@@ -65,9 +100,9 @@ class WorkPackage(Base):
|
|||||||
|
|
||||||
def summary(self) -> dict:
|
def summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id,
|
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||||
"number": self.number, "subject": self.subject, "type": self.type,
|
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||||
"status": self.status, "issued_at": _iso(self.issued_at),
|
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
|
||||||
"created_by": self.created_by,
|
"created_by": self.created_by,
|
||||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|||||||
175
server/seed_demo.py
Normal file
175
server/seed_demo.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed a realistic DEMO project into the Work Package Suite database via the API.
|
||||||
|
|
||||||
|
Creates one project, a complete SOP, and a spread of Work Packages that exercise
|
||||||
|
the features and dashboard: an issued package, a gated (open-constraint) package,
|
||||||
|
a multi-discipline master with its split instances (A/B/C), an overdue package,
|
||||||
|
and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end
|
||||||
|
and to have data to inspect.
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||||
|
docker compose exec api python /app/server/seed_demo.py http://localhost:8000
|
||||||
|
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects
|
||||||
|
|
||||||
|
IMPORTANT — what shows where:
|
||||||
|
* The DEMO **project** is API/SQL-backed, so it appears in the home-page
|
||||||
|
project picker immediately (proves the projects → SQL path in the UI).
|
||||||
|
* The DEMO **SOP and Work Packages** are written to SQL too, but the current
|
||||||
|
front end still reads SOPs/WPs from the browser (localStorage), so they will
|
||||||
|
NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2
|
||||||
|
wiring. Verify them at the SQL/API layer instead:
|
||||||
|
python3 server/smoketest.py <url> # automated end-to-end check
|
||||||
|
docker compose exec db psql -U wpsuite -d wpsuite \
|
||||||
|
-c "select number,subject,status from work_packages order by number;"
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = ""
|
||||||
|
CTX = None
|
||||||
|
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, body=None):
|
||||||
|
url = BASE + path
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method,
|
||||||
|
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||||
|
raw = r.read().decode(); status = r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode(); status = e.code
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw else None
|
||||||
|
except ValueError:
|
||||||
|
parsed = raw
|
||||||
|
return status, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def constraints(open_names=()):
|
||||||
|
base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs",
|
||||||
|
"Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"]
|
||||||
|
return [{"name": n, "status": ("open" if n in open_names else "cleared"),
|
||||||
|
"comment": ("awaiting delivery" if n in open_names else "")} for n in base]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global BASE, CTX
|
||||||
|
ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite")
|
||||||
|
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||||
|
help="Site root, no /api (default: http://localhost:8000)")
|
||||||
|
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||||
|
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
|
||||||
|
args = ap.parse_args()
|
||||||
|
BASE = args.base_url.rstrip("/")
|
||||||
|
if args.insecure:
|
||||||
|
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
# health gate
|
||||||
|
try:
|
||||||
|
st, _ = call("GET", "/api/health")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"ABORT: cannot reach {BASE}/api/health — {e}"); return 1
|
||||||
|
if st != 200:
|
||||||
|
print(f"ABORT: /api/health returned {st}"); return 1
|
||||||
|
|
||||||
|
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
|
||||||
|
st, projects = call("GET", "/api/projects")
|
||||||
|
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
|
||||||
|
if args.clean:
|
||||||
|
for p in demos:
|
||||||
|
call("DELETE", f"/api/projects/{p['id']}")
|
||||||
|
print(f"Removed {len(demos)} DEMO project(s).")
|
||||||
|
return 0
|
||||||
|
if demos:
|
||||||
|
print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n")
|
||||||
|
|
||||||
|
# 1) Project
|
||||||
|
st, proj = call("POST", "/api/projects", {
|
||||||
|
"name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER,
|
||||||
|
"client": "Micron Technology, Inc.", "division": "Semiconductor",
|
||||||
|
"site": "Boise, ID — Fab", "created_by": "seed_demo"})
|
||||||
|
pid = proj["id"]
|
||||||
|
print(f"Project: {proj['name']} ({pid})")
|
||||||
|
|
||||||
|
# 2) SOP (complete)
|
||||||
|
st, sop = call("POST", "/api/sops", {
|
||||||
|
"project_id": pid, "name": "DEMO SOP", "number": DEMO_NUMBER, "complete": True,
|
||||||
|
"created_by": "seed_demo",
|
||||||
|
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
|
||||||
|
"disciplines": ["Mechanical", "Electrical", "Tech"],
|
||||||
|
"discMode": "choice", "instanceSuffix": "letter",
|
||||||
|
"woSize": "Standard — 3–5 days (≈40–80 hrs)", "sizeHoursMax": "80"}}})
|
||||||
|
sid = sop["id"]
|
||||||
|
print(f"SOP: complete ({sid})")
|
||||||
|
|
||||||
|
# 3) Work packages
|
||||||
|
def wp(number, subject, typ, status, data, parent_id=None):
|
||||||
|
body = {"project_id": pid, "sop_id": sid, "number": number, "subject": subject,
|
||||||
|
"type": typ, "status": status, "created_by": "seed_demo", "data": data}
|
||||||
|
if parent_id:
|
||||||
|
body["parent_id"] = parent_id
|
||||||
|
st, w = call("POST", "/api/wps", body)
|
||||||
|
print(f" WP {number:<16} {status:<12} {subject}")
|
||||||
|
return w
|
||||||
|
|
||||||
|
# a) issued, all clear
|
||||||
|
wp("WP01-1P-CONDUIT", "1P horn/strobe conduit", "Conduit Install", "Issued",
|
||||||
|
{"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||||
|
"constraints": constraints(), "due": "2026-06-30"})
|
||||||
|
# b) gated — one open constraint, still Scheduled
|
||||||
|
wp("WP02-1P-WIRE", "1P wire pull", "Wire Pull", "Scheduled",
|
||||||
|
{"disciplines": ["Electrical"], "hours": "60", "actualHrs": "",
|
||||||
|
"constraints": constraints(open_names=["Materials (on site, bagged & tagged)"]), "due": "2026-07-04"})
|
||||||
|
# c) multi-discipline master + split instances (master excluded from metrics)
|
||||||
|
master_id = "wp_demo_master_chiller"
|
||||||
|
instances = [("WP03-CHILLER_Mech", "Mechanical", "A", "Mechanical Install", "In Progress"),
|
||||||
|
("WP03-CHILLER_Elec", "Electrical", "B", "Wire Pull", "Scheduled"),
|
||||||
|
("WP03-CHILLER_Tech", "Tech", "C", "Terminations", "Draft")]
|
||||||
|
child_ids = []
|
||||||
|
for num, disc, label, typ, status in instances:
|
||||||
|
cid = f"wp_demo_{label.lower()}"
|
||||||
|
child_ids.append(cid)
|
||||||
|
body = {"project_id": pid, "sop_id": sid, "parent_id": master_id, "id": cid,
|
||||||
|
"number": num, "subject": "Chiller skid — " + disc, "type": typ, "status": status,
|
||||||
|
"created_by": "seed_demo",
|
||||||
|
"data": {"disciplines": [disc], "instanceOf": master_id, "instanceLabel": label,
|
||||||
|
"parentNumber": "WP03-CHILLER", "hours": "50", "actualHrs": "",
|
||||||
|
"constraints": constraints(), "due": "2026-07-10"}}
|
||||||
|
call("POST", "/api/wps", body)
|
||||||
|
print(f" WP {num:<16} {status:<12} (instance {label})")
|
||||||
|
wp("WP03-CHILLER", "Chiller skid (multi-discipline master)", "Mechanical Install", "Scheduled",
|
||||||
|
{"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True, "children": child_ids,
|
||||||
|
"hours": "150", "constraints": constraints(), "due": "2026-07-10"})
|
||||||
|
call("POST", "/api/wps", {"project_id": pid, "sop_id": sid, "id": master_id,
|
||||||
|
"number": "WP03-CHILLER", "subject": "Chiller skid (multi-discipline master)",
|
||||||
|
"type": "Mechanical Install", "status": "Scheduled", "created_by": "seed_demo",
|
||||||
|
"data": {"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True,
|
||||||
|
"children": child_ids, "hours": "150", "constraints": constraints(),
|
||||||
|
"due": "2026-07-10"}})
|
||||||
|
# d) overdue, in progress
|
||||||
|
wp("WP04-2P-TERM", "2P terminations", "Terminations", "In Progress",
|
||||||
|
{"disciplines": ["Tech"], "hours": "30", "actualHrs": "20",
|
||||||
|
"constraints": constraints(), "due": "2026-06-10"}) # past today (2026-06-16) → overdue
|
||||||
|
# e) over-threshold draft (hours > 80)
|
||||||
|
wp("WP05-3P-PANEL", "3P panel install", "Panel Install", "Draft",
|
||||||
|
{"disciplines": ["Electrical"], "hours": "120", "actualHrs": "",
|
||||||
|
"constraints": constraints(open_names=["Schedule"]), "due": "2026-07-20"})
|
||||||
|
|
||||||
|
# metrics readback
|
||||||
|
st, m = call("GET", f"/api/wps/metrics?project_id={pid}")
|
||||||
|
print(f"\nMetrics (masters excluded): {m}")
|
||||||
|
print(f"\nDone. The DEMO project '{proj['name']}' now appears in the home-page picker.")
|
||||||
|
print("SOP/WPs are in SQL (see header note) — verify with smoketest.py or psql.")
|
||||||
|
print("Remove later with: python3 server/seed_demo.py <url> --clean")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
196
server/smoketest.py
Normal file
196
server/smoketest.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""End-to-end smoke test for the Work Package Suite API + PostgreSQL.
|
||||||
|
|
||||||
|
Exercises the real HTTP endpoints the way the front end does, proving that
|
||||||
|
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||||
|
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
# Against the deployed site (through the NGINX proxy):
|
||||||
|
python3 server/smoketest.py https://wp-suite.company.local
|
||||||
|
|
||||||
|
# Self-signed / internal TLS cert? skip verification:
|
||||||
|
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||||
|
|
||||||
|
# From inside the api container (hits FastAPI directly):
|
||||||
|
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||||
|
|
||||||
|
# Leave the demo project in the database so you can open it in the UI:
|
||||||
|
python3 server/smoketest.py https://wp-suite.company.local --keep
|
||||||
|
|
||||||
|
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||||
|
Exit code 0 = all checks passed, 1 = one or more failed.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
||||||
|
_PASS, _FAIL = [], []
|
||||||
|
def _c(s, code): # color if a TTY
|
||||||
|
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
||||||
|
def ok(msg): _PASS.append(msg); print(" " + _c("PASS", "32") + " " + msg)
|
||||||
|
def bad(msg): _FAIL.append(msg); print(" " + _c("FAIL", "31") + " " + msg)
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
(ok if cond else bad)(name + (f" ({detail})" if detail and not cond else ""))
|
||||||
|
return cond
|
||||||
|
|
||||||
|
BASE = ""
|
||||||
|
CTX = None
|
||||||
|
|
||||||
|
def call(method, path, body=None):
|
||||||
|
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||||
|
url = BASE + path
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data, method=method,
|
||||||
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||||
|
raw = r.read().decode(); status = r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode(); status = e.code
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw else None
|
||||||
|
except ValueError:
|
||||||
|
parsed = raw
|
||||||
|
return status, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global BASE, CTX
|
||||||
|
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
|
||||||
|
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||||
|
help="Site root, no /api (default: http://localhost:8000)")
|
||||||
|
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||||
|
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
BASE = args.base_url.rstrip("/")
|
||||||
|
if args.insecure:
|
||||||
|
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||||
|
|
||||||
|
project_id = None
|
||||||
|
try:
|
||||||
|
# 1) Health — API is up and reachable through the proxy.
|
||||||
|
try:
|
||||||
|
st, body = call("GET", "/api/health")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
|
||||||
|
" Is the stack up (docker compose ps) and the URL correct?\n")
|
||||||
|
return 1
|
||||||
|
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||||
|
f"status={st} body={body}")
|
||||||
|
|
||||||
|
# 2) Create a project (writes to the projects table).
|
||||||
|
st, proj = call("POST", "/api/projects", {
|
||||||
|
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
|
||||||
|
"client": "Internal QA", "division": "Controls", "site": "Test Host",
|
||||||
|
"created_by": "smoketest",
|
||||||
|
})
|
||||||
|
project_id = proj.get("id") if isinstance(proj, dict) else None
|
||||||
|
check("create project", st == 200 and bool(project_id), f"status={st}")
|
||||||
|
|
||||||
|
# 3) Read it back + confirm it's in the list (SQL round-trip).
|
||||||
|
st, got = call("GET", f"/api/projects/{project_id}")
|
||||||
|
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
|
||||||
|
st, lst = call("GET", "/api/projects")
|
||||||
|
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
|
||||||
|
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
|
||||||
|
|
||||||
|
# 4) Create a SOP linked to the project.
|
||||||
|
st, sop = call("POST", "/api/sops", {
|
||||||
|
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
|
||||||
|
"complete": True, "created_by": "smoketest",
|
||||||
|
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
|
||||||
|
"disciplines": ["Mechanical", "Electrical", "Tech"]}},
|
||||||
|
})
|
||||||
|
sop_id = sop.get("id") if isinstance(sop, dict) else None
|
||||||
|
check("create SOP linked to project", st == 200 and bool(sop_id) and sop.get("project_id") == project_id,
|
||||||
|
f"status={st}")
|
||||||
|
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
|
||||||
|
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
|
||||||
|
|
||||||
|
# 5) Create a Work Package with one OPEN constraint (not release-ready).
|
||||||
|
st, wp = call("POST", "/api/wps", {
|
||||||
|
"project_id": project_id, "sop_id": sop_id,
|
||||||
|
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||||
|
"status": "Scheduled", "created_by": "smoketest",
|
||||||
|
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||||
|
"constraints": [{"name": "Materials", "status": "open", "comment": "awaiting delivery"},
|
||||||
|
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
||||||
|
})
|
||||||
|
wp_id = wp.get("id") if isinstance(wp, dict) else None
|
||||||
|
check("create work package", st == 200 and bool(wp_id), f"status={st}")
|
||||||
|
|
||||||
|
# 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
||||||
|
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
|
||||||
|
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
|
||||||
|
|
||||||
|
# 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
||||||
|
call("POST", "/api/wps", {
|
||||||
|
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
||||||
|
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||||
|
"status": "Scheduled",
|
||||||
|
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||||
|
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""},
|
||||||
|
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
||||||
|
})
|
||||||
|
st, issued = call("POST", f"/api/wps/{wp_id}/issue")
|
||||||
|
check("issue succeeds once constraints clear", st == 200 and issued.get("status") == "Issued",
|
||||||
|
f"status={st}")
|
||||||
|
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
|
||||||
|
|
||||||
|
# 8) Status transition endpoint.
|
||||||
|
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
|
||||||
|
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
|
||||||
|
|
||||||
|
# 9) Metrics aggregate for the project (Python aggregation over SQL rows).
|
||||||
|
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
|
||||||
|
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
|
||||||
|
f"status={st} metrics={m}")
|
||||||
|
|
||||||
|
# 10) Comment / feedback write + read.
|
||||||
|
st, c = call("POST", "/api/feedback", {
|
||||||
|
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
|
||||||
|
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
|
||||||
|
check("post comment/feedback", st == 200 and isinstance(c, dict) and bool(c.get("id")), f"status={st}")
|
||||||
|
st, comments = call("GET", f"/api/comments?wp_id={wp_id}")
|
||||||
|
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
|
||||||
|
f"status={st}")
|
||||||
|
|
||||||
|
# 11) WPs filter by project.
|
||||||
|
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
||||||
|
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
||||||
|
if project_id and not args.keep:
|
||||||
|
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
||||||
|
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
||||||
|
st, after = call("GET", f"/api/wps?project_id={project_id}")
|
||||||
|
check("WPs removed by cascade", st == 200 and isinstance(after, list) and len(after) == 0,
|
||||||
|
f"status={st} remaining={after}")
|
||||||
|
elif project_id and args.keep:
|
||||||
|
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
|
||||||
|
|
||||||
|
# ── summary ────────────────────────────────────────────────────────────────
|
||||||
|
total = len(_PASS) + len(_FAIL)
|
||||||
|
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
|
||||||
|
if _FAIL:
|
||||||
|
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
|
||||||
|
for f in _FAIL:
|
||||||
|
print(" - " + f)
|
||||||
|
print("\nResult: " + _c("FAIL", "31") + "\n")
|
||||||
|
return 1
|
||||||
|
print("\nResult: " + _c("ALL PASS — API, Python logic, and SQL are working.", "32") + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user