Compare commits

..

2 Commits

Author SHA1 Message Date
a32c275f76 Rewrite DEPLOYMENT.md for the SQL-backed Docker deployment
Replaces the stale non-Docker/systemd guide with an admin-facing, start-to-
finish guide for the actual stack (nginx serving html/, FastAPI api, Postgres
db). Covers prerequisites (external proxy network), the root .env credentials,
reverse-proxy wiring, bring-up, and verification. Adds the current data model
(projects + project_id/parent_id/issued_at columns), the full endpoint list, a
"what's stored in SQL today vs Phase 2" table, backups, and the schema-migration
caveat (create_all adds tables, not columns). Points to server/README.md for the
deep container reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:27:38 -07:00
e5f77846ad GUI polish: Help section, tooltips, sticky save bar + section nav, dashboard filters
- Help: shared help.js injects a Help modal (workflow + key concepts) and the
  .help-tip tooltip component. " Help" added to the suite header and home nav.
- Tooltips: ⓘ hover hints on the trickiest fields (WP number auto-build,
  disciplines, scope/split, constraints, materials-by-discipline, discipline
  strategy, split threshold).
- WP creator: sticky section-nav jump chips at the top and an always-visible
  sticky save bar (Save Draft / Save & View) showing live release readiness.
- Dashboard: metric cards (Release-ready / On hold / Overdue / Total) and the
  status chips are now clickable filters for the board.
- Consistent colored status pills in the dashboard board and the saved list.

Theme unification (home Carbon vs tools palette) intentionally left for a
separate pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:37:01 -07:00
7 changed files with 359 additions and 77 deletions

View File

@@ -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
View 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 &amp; 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 &amp; 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 &amp; 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);

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>`;

View File

@@ -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 &amp; 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>

View File

@@ -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; }