Compare commits
2 Commits
feat/wp-di
...
feat/gui-p
| Author | SHA1 | Date | |
|---|---|---|---|
| a32c275f76 | |||
| e5f77846ad |
222
DEPLOYMENT.md
222
DEPLOYMENT.md
@@ -1,81 +1,189 @@
|
||||
# Deployment
|
||||
|
||||
The Work Package Suite has two parts:
|
||||
Audience: the IT admin standing this up inside the firewall. This covers the
|
||||
**SQL-backed deployment** — NGINX serving the static front end and a Python API
|
||||
backed by **PostgreSQL**.
|
||||
|
||||
- a **static front end** (plain HTML/CSS/JS — no build step), and
|
||||
- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project
|
||||
SOPs, Work Packages, and comments so they are shared across users instead of
|
||||
living in each person's browser.
|
||||
The repo already contains everything needed to run it as a Docker stack:
|
||||
`Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in
|
||||
`html/`, and the API in `server/`. The detailed container reference (endpoints,
|
||||
password rotation, day-to-day commands) lives in
|
||||
[`server/README.md`](server/README.md) — this doc is the start-to-finish guide.
|
||||
|
||||
```
|
||||
browser → NGINX ──serves──> static site (index.html, …)
|
||||
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||
[ your TLS reverse proxy / traefik ] ← HTTPS terminates here
|
||||
│ (external "proxy" network)
|
||||
┌────▼────┐ internal network ┌──────────┐ ┌────────────┐
|
||||
browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │
|
||||
│ (html/) │ │ FastAPI │ │ (db) │
|
||||
└─────────┘ └──────────┘ └────────────┘
|
||||
```
|
||||
|
||||
Everything runs inside your firewall; the app makes **no outbound internet
|
||||
calls** (the logo and scripts are local and the old Google-Fonts dependency was
|
||||
removed).
|
||||
calls** (logo and scripts are local).
|
||||
|
||||
## 1. Front end (NGINX)
|
||||
> **Architecture note:** all static files live under **`html/`** and are *baked
|
||||
> into the nginx image* at build time (not bind-mounted). So after any front-end
|
||||
> change you rebuild the `webserver` image (see *Updating* below). The API image
|
||||
> is built from the root `Dockerfile`.
|
||||
|
||||
Copy the project files to a web root and serve them over HTTPS. The provided
|
||||
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
|
||||
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
|
||||
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
---
|
||||
|
||||
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package
|
||||
Creator (`<iframe>`) and any browser-side caching behave reliably.
|
||||
## 1. Prerequisites
|
||||
|
||||
## 2. API + database
|
||||
- A Linux host with **Docker** and **Docker Compose v2** (`docker compose …`).
|
||||
- An external Docker network named `proxy` that your TLS-terminating reverse
|
||||
proxy also sits on (the compose file marks it `external: true`):
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
If you don't run a separate reverse proxy, you can instead publish the nginx
|
||||
container's port 80 directly (see the note in step 4) and terminate TLS there.
|
||||
- The repository checked out on the host.
|
||||
|
||||
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in
|
||||
[`server/README.md`](server/README.md). In short:
|
||||
## 2. Create the database credentials (`.env`)
|
||||
|
||||
1. Create the `wpsuite` Postgres database/user.
|
||||
2. `pip install -r server/requirements.txt` into a venv.
|
||||
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
|
||||
4. Tables are created automatically on first start.
|
||||
Create a file named `.env` in the **project root** (same folder as
|
||||
`docker-compose.yml`). It is git-ignored and must never be committed.
|
||||
|
||||
Interactive API docs are at `/api/docs` once it's running.
|
||||
```bash
|
||||
# .env — project root
|
||||
POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
POSTGRES_PASSWORD=<strong-random-password>
|
||||
|
||||
## 3. Comments / feedback
|
||||
|
||||
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
|
||||
}
|
||||
# Must match the POSTGRES_* values above. Host is the compose service name "db".
|
||||
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||
```
|
||||
|
||||
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`. The
|
||||
API maps `name`/`author` → the comment author and keeps any extra fields in the
|
||||
row's `extra` JSON column.
|
||||
Generate a strong password with `openssl rand -base64 32`.
|
||||
|
||||
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;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
| Table | Holds | Key columns |
|
||||
|-------|-------|-------------|
|
||||
| `sops` | project SOP baselines | `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||
| `work_packages` | individual IWPs | `sop_id`, `number`, `subject`, `type`, `status`, `data` (full WP JSON) |
|
||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text` |
|
||||
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
|
||||
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `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;
|
||||
frequently-listed fields are promoted to real columns for filtering.
|
||||
The complete client document is stored verbatim in each row's `data` JSON
|
||||
column; frequently-listed fields are promoted to real columns for filtering.
|
||||
|
||||
### Endpoints (summary)
|
||||
|
||||
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
|
||||
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
|
||||
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
|
||||
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `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).
|
||||
|
||||
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);
|
||||
@@ -386,6 +386,7 @@
|
||||
<nav class="header-nav">
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#comments">Feedback</a>
|
||||
<a href="#" onclick="openHelp();return false;">Help</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -475,6 +476,7 @@
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script>
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,7 +192,7 @@
|
||||
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Discipline strategy *</label>
|
||||
<label>Discipline strategy *<span class="help-tip" data-tip="Decides whether a package can carry several disciplines (scope split per discipline) or one each. 'Let the planner choose' allows building a big multi-discipline package and splitting it later.">i</span></label>
|
||||
<select id="gov_discmode">
|
||||
<option value="choice">Let the planner choose per package (recommended)</option>
|
||||
<option value="single">One discipline per package (many small packages)</option>
|
||||
@@ -216,7 +217,7 @@
|
||||
<small>Sets the split threshold automatically; choose Custom to enter your own.</small>
|
||||
</div>
|
||||
<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., 80">
|
||||
<small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
|
||||
</div>
|
||||
@@ -380,6 +381,7 @@
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="work-package-suite-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -539,6 +539,7 @@ function updateReleaseBanner(){
|
||||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
||||
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
|
||||
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
||||
updateStickyStatus();
|
||||
}
|
||||
function onStatusChange(target){
|
||||
const idx=STATUS_ORDER.indexOf(target);
|
||||
@@ -761,8 +762,37 @@ function printPackage(){
|
||||
|
||||
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
||||
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
|
||||
function showOutput(){ hideDashboard(); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
|
||||
// Sticky save bar + section-nav chrome (shown only on the editable form view).
|
||||
function setFormChrome(on){
|
||||
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
|
||||
if(nav) nav.style.display = on ? '' : 'none';
|
||||
if(save) save.style.display = on ? 'flex' : 'none';
|
||||
document.body.classList.toggle('has-sticky-save', !!on);
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); }
|
||||
}
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
const STORE_KEY='wp_iwp_v1';
|
||||
@@ -780,7 +810,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 disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||||
<td>${esc(p.status||'')}</td><td>${ready}</td>
|
||||
<td>${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>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -898,13 +928,26 @@ const WPData = {
|
||||
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 isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
||||
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
||||
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
||||
|
||||
function showDashboard(){
|
||||
setFormChrome(false);
|
||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
||||
document.getElementById('pkg-output').style.display='none';
|
||||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||||
@@ -923,19 +966,25 @@ function renderDashboard(){
|
||||
if(isOverdue(p)) overdue++;
|
||||
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
||||
});
|
||||
const card=(label,val,cls)=>`<div class="dash-metric ${cls||''}"><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||
// Clickable metric cards filter the board (flag-based); a card with no flag is static.
|
||||
const card=(label,val,cls,flag)=>{
|
||||
const active = flag && dashFilter.flag===flag ? ' dm-active' : '';
|
||||
const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : '';
|
||||
return `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||
};
|
||||
let h=`<div class="dash-metrics">
|
||||
${card('Total WPs', all.length)}
|
||||
${card('Release-ready', ready, ready?'dm-green':'')}
|
||||
${card('On hold', hold, hold?'dm-red':'')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'')}
|
||||
${card('Total WPs', all.length, '', 'all')}
|
||||
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||||
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||
${card('Est. hrs', Math.round(estH))}
|
||||
${card('Actual hrs', Math.round(actH))}
|
||||
</div>`;
|
||||
|
||||
// status + discipline breakdown chips
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('')
|
||||
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:'');
|
||||
// status + discipline breakdown chips (status chips also filter the board)
|
||||
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||||
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||||
@@ -965,6 +1014,9 @@ function renderDashboard(){
|
||||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||||
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
||||
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
|
||||
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
|
||||
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
|
||||
return true;
|
||||
});
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||||
@@ -980,7 +1032,7 @@ function renderDashboard(){
|
||||
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 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>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
<!-- RELEASE READINESS BANNER -->
|
||||
<div class="release-banner" id="release-banner"></div>
|
||||
|
||||
<!-- SECTION NAV (jump links, built from the form cards) -->
|
||||
<div class="section-nav-bar" id="section-nav"></div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- GENERAL INFORMATION -->
|
||||
@@ -45,7 +48,7 @@
|
||||
<div class="section-header"><div class="section-title">General Information</div>
|
||||
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>Status</label>
|
||||
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
||||
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
||||
@@ -88,14 +91,14 @@
|
||||
|
||||
<!-- DISCIPLINES -->
|
||||
<div class="card" id="discipline-card" style="display:none">
|
||||
<div class="sub-heading">Disciplines</div>
|
||||
<div class="sub-heading">Disciplines<span class="help-tip" data-tip="Pick every discipline this package covers. Choosing two or more turns Scope into per-discipline sections and enables Split by Discipline.">i</span></div>
|
||||
<div class="notice" id="discipline-note"></div>
|
||||
<div class="disc-picker" id="discipline-picker"></div>
|
||||
</div>
|
||||
|
||||
<!-- SCOPE & WORK -->
|
||||
<div class="card">
|
||||
<div class="sub-heading">Scope & Work</div>
|
||||
<div class="sub-heading">Scope & Work<span class="help-tip" data-tip="Ordered steps the crew performs. With multiple disciplines selected, each gets its own scope section and status. Use Split by Discipline to break a large package into WP01A / WP01B / WP01C instances.">i</span></div>
|
||||
<div id="flat-scope">
|
||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||
@@ -113,7 +116,7 @@
|
||||
|
||||
<!-- MATERIAL LIST -->
|
||||
<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="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">
|
||||
@@ -157,7 +160,7 @@
|
||||
|
||||
<!-- CONSTRAINTS / RELEASE READINESS -->
|
||||
<div class="card" id="constraint-card">
|
||||
<div class="sub-heading">Constraints — Release Readiness</div>
|
||||
<div class="sub-heading">Constraints — Release Readiness<span class="help-tip" data-tip="A package can't be Issued until every constraint is Cleared or N/A. If one reopens after release, the package drops to Issue (Hold).">i</span></div>
|
||||
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
|
||||
</div>
|
||||
@@ -276,8 +279,18 @@
|
||||
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
||||
</aside>
|
||||
|
||||
<!-- STICKY SAVE BAR (always-visible save + release status) -->
|
||||
<div class="sticky-save" id="sticky-save" style="display:none">
|
||||
<span class="sticky-status" id="sticky-status"></span>
|
||||
<div class="sticky-actions">
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -564,6 +564,26 @@
|
||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.so-ovr { margin-left:8px; font-size:11px; }
|
||||
|
||||
/* 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 */
|
||||
.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);
|
||||
@@ -586,6 +606,11 @@
|
||||
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
|
||||
.dash-metric.dm-red .dm-val { color:var(--red); }
|
||||
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
||||
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
||||
.dash-chip[onclick] { cursor:pointer; }
|
||||
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
||||
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
||||
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
|
||||
|
||||
Reference in New Issue
Block a user