Compare commits

..

4 Commits

Author SHA1 Message Date
b38348e6ae Wave 3: predecessor references with a release gate, and critical-constraint reopen alerts
Predecessors are real references now
- data.predecessors holds work-package ids, replacing a free-text SOP phase label
  that couldn't express "WP04 waits on WP02" and gated nothing. The SOP phase
  survives beside it as the descriptive "Sequence phase" field.
- readiness() has two gates: constraints clear AND every predecessor Closed. The
  banner, sticky bar, left rail, dashboard Gates column and the ready counters all
  reflect the second one.
- Enforced server-side by enforce_release_gates() on every path that sets a status
  — the plain upsert included, since that's how the browser and the offline outbox
  save. /issue and /status would otherwise have been ways around it.
- Cycles are refused directly and through a chain, with a message naming the
  package that already waits on this one. The Creator's picker also hides itself
  and its own descendants, so a cycle is hard to build in the first place.
- A deleted predecessor does not block: it would freeze everything downstream of a
  package someone removed.
- The gate is refusable, on purpose. Planners release ahead of upstream close-out,
  so an explicit reason (data.gateOverride) allows it, gets a gate_overridden audit
  event naming what was skipped, and prints on the package. A blank reason is not
  an override, and changing the predecessor set clears it. The dashboard won't
  release a blocked package at all — it points at the form where the reason is
  captured.

Critical constraints reopened after release
- Reopening a SOP-critical constraint on a released package emails the owner, PM,
  CM and the package's distribution list (minus whoever did it) and writes a
  constraint_reopened audit event.
- Detected by diffing the incoming constraints against the stored ones inside the
  normal upsert rather than via a new endpoint: the sync outbox only replays
  POST /api/wps, so a dedicated route would be lost offline. It fires only on a
  real cleared→open transition, so re-saving an already-open constraint doesn't
  re-announce, and never before release or for a non-critical constraint.
- Bodies carry the constraint name, WP number and a link — never package contents.

Verified: 139 API checks on one fresh database (44 permissions + 22 password reset
+ 34 search/localization + 39 gates/notifications), including every bypass path,
cycle shapes, the deleted-predecessor case, blank-reason overrides, and the four
recipients confirmed both in the outbox and on the wire against a local SMTP sink.
27 driven UI checks against the real Creator page in headless Chrome covering the
picker, the override prompt (accept and cancel), override invalidation, the cycle
exclusions and the dashboard refusal. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:15:36 -07:00
61d1cf4bff Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
Site comments (8/3)
- BIM card: LOD removed, IFF # added next to the coordination status, and required
  once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable.
  A LOD already stored on a package is preserved and shown as legacy, not blanked.
- The blue "from SOP types" subtext under a field is now a SOP chip on the label
  with the detail in a tooltip. The chip stays visible rather than hover-only:
  field tablets have no hover, and "this came from the SOP" is the part that
  matters. The hint elements stay in the DOM (hidden) so the code writing to them
  keeps working; an observer mirrors their text into the tooltip.
- Specification Section is no longer typed per package. Each WP type carries a
  spec section on the SOP; the field is read-only in the Creator and follows the
  type, with the SOP's spec folder linked underneath. This reads both spec
  comments as one intent — stop typing it, derive it.
- Assignees and Distribution are multi-selects over the SOP project team, showing
  each person's job function, with the CM pre-added to Distribution (removable per
  package) and a free-text option for people with no account. The stored display
  strings are unchanged so print/export/dashboard keep working; account ids ride
  alongside for the notification work in wave 3.

Localization + time
- Per-user locale/timezone (Language & time in the user menu), an app-wide default
  in the admin console, then the browser. Timezones are validated against the
  server's zoneinfo and the picker is fed from it. Calendar dates are formatted
  from their parts so a due date never reads a day early in another zone.
- Every displayed timestamp now goes through the shared helpers.

Top-bar chrome
- Project switcher beside the logo and a centered global search, injected into
  either generation of top bar; skipped in an iframe so the embedded Creator
  doesn't get a second one. Ctrl/Cmd-K focuses search.
- GET /api/search covers work packages, projects and SOPs, scoped to the caller's
  projects, hiding archived packages, with LIKE wildcards escaped.

Fixed along the way: showForm() cleared every card's inline display, which undid
applyKind() — so the Package Type and BIM cards reappeared on an install-only
project. Split out applyKindVisibility() and re-apply it there.

Verified: 100 API checks on a fresh database (44 permissions + 22 password reset +
34 search/localization), 24 driven UI checks against the real Creator page in
headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags,
BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:17:51 -07:00
79b0e955b4 Wave 1: permissions roles, account-backed SOP team, critical constraints, password reset, BIM flag
Acts on the site comments from 8/3 plus the follow-ups. Foundation work first —
four of the comments all needed the project team to resolve to real user accounts.

Permissions vs project role (new)
- User.role is now the PERMISSIONS role: admin | project_admin | project_user.
  project_admin may delete work packages, change a SOP after it is complete, and
  delete a project; project_user may not (archiving a WP is still open to them).
  Enforced by require_project_admin() server-side; the UI only hides dead ends.
- New User.project_role holds the person's JOB FUNCTION on the project. It grants
  nothing — it feeds the SOP team pickers and notification routing.
- Admin console shows both columns and explains the difference. Migration rewrites
  the legacy role 'user' to 'project_user'.
- Deleting a project was previously open to any member and unaudited; it now needs
  project_admin and writes an audit event. ProjectData.remove no longer drops the
  project from the local cache when the server refuses.

SOP project team from user accounts
- PM/APM/CM/QM and additional team members are pickers over the project's members,
  storing the account id next to the display name. A name from an older SOP with no
  matching account is kept and flagged rather than dropped.
- The WP Creator lists the SOP team first in the Owner picker, and a new package
  defaults to whoever is creating it.

Critical constraints
- SOP constraints carry a Critical flag; buildConstraints() now copies the whole
  definition through to the package (it previously reduced them to names, losing
  description too), and critical rows are marked in the WP form. The email on
  reopen-after-release is wave 3.

Password reset by email
- login.html gains Forgot password and a set-a-new-password view, offered only when
  the server reports email is actually configured.
- Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version,
  sent immediately rather than through the notifications outbox so a reset link is
  never persisted. Identical response for unknown accounts; per-account send
  cooldown; a completed reset clears any login lockout.
- Session and reset tokens are no longer interchangeable.

BIM kill-switch
- New admin Features card with bim_enabled, OFF by default. The SOP creator hides
  the BIM section and the Creator treats every package as install-only while it is
  off; a SOP that already has BIM keeps its data untouched.

Verified with two throwaway-database test scripts: 44 checks on the permissions
matrix and token handling, 22 on the reset flow end-to-end against a local SMTP
sink (real message captured, link extracted and used). Front-end files parse-checked
in headless Chrome. Not yet exercised in a browser against a real login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:48:59 -07:00
1d004cab75 Widen the IWP screen and add a left work-package navigator
The Work Package form was capped at a 1000px column, which wasted most of a
desktop screen, and the only way to reach another package was to scroll to the
Saved table at the bottom.

- Put the form in a wide two-column shell (max 1760px); ctx-bar, mode-wrap and
  the release banner widened to match.
- Above 1200px the two-up field grids flow to 3-4 columns instead of stretching
  two fields across the whole card. Narrow layouts are unchanged.
- New sticky left rail listing every saved package, grouped by status in field
  order, with WP number, subject, readiness dot and type. Click to open it in
  the form; the package being edited is highlighted. Filter box, + New and
  Dashboard shortcuts, collapsible (state persisted), hidden under 1100px where
  the Saved table still covers navigation.
- The rail re-renders from renderSavedList(), so saves, deletes, splits,
  archive/restore and the project pull all keep it current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:59:34 -07:00
26 changed files with 3155 additions and 185 deletions

View File

@@ -62,11 +62,17 @@ AUTH_SECRET_KEY=<strong-random-secret>
# unrecoverable: openssl rand -base64 32 # unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase> BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email notifications. Email is OFF # OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
# by default and enabled from the Admin console; the host/port/from-address are # is OFF by default and enabled from the Admin console; the host/port/from-address
# configured there, but the password is only ever read from this variable (never # are configured there, but the password is only ever read from this variable
# stored in the DB or shown in the UI). Leave unset until you have SMTP details. # (never stored in the DB or shown in the UI). Leave unset until you have SMTP
# details.
# SMTP_PASSWORD=<smtp-app-password> # SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
``` ```
The API builds its own DB connection string from the `POSTGRES_*` The API builds its own DB connection string from the `POSTGRES_*`
@@ -295,6 +301,125 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login. package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling. - Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset
Turning email on also enables **Forgot password** on the login page. Until then the
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
## Permissions roles
`User.role` is the **permissions** role; `User.project_role` is the person's **job
function** on the project (Project Manager, Superintendent, …) and grants nothing.
Both are set in the Admin console's user table.
| Role | May do |
|---|---|
| `admin` | User administration, app settings, and every project |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
only hides controls to avoid dead-end clicks. Accounts created before this change
carried the role `user`, which the migration rewrites to `project_user`.
## Feature flags
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
## Release gates (constraints + predecessors)
A work package reaches **Issued** only when both gates are met:
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
**Closed**.
Enforced by `enforce_release_gates()` on **every** path that can set a status —
`/api/wps` (the browser and the offline outbox both save through it),
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
A blank reason is not an override. The server writes a `gate_overridden` audit
event naming the reason and what was skipped, and the reason prints on the
package. Changing the predecessor set clears the override.
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
with a 400 explaining which package already waits on this one.
- **A deleted predecessor does not block.** It would otherwise freeze everything
downstream of a package someone removed.
- The Creator's picker hides itself and any package that already waits on it, so a
cycle is hard to build in the first place; the dashboard refuses to issue a
blocked package and points at the form for the logged override.
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
descriptive — it gates nothing.
## Critical constraints reopened after release
A constraint marked **Critical** on the SOP that reopens **after** the package was
released emails the **owner, PM, CM and everyone on the package's distribution
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
Detected by comparing incoming constraints against the stored ones inside the
normal upsert — *not* a separate endpoint, because the browser saves through the
sync outbox, which only replays `POST /api/wps`; anything hung off another route
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
re-saving an already-open constraint doesn't re-announce, and never for a package
that was never released or a non-critical constraint. Bodies carry the constraint
name, WP number and a link — never the package contents.
## Localization (dates, times, numbers)
Three levels, most specific first — resolved in `html/wp-format.js`:
1. **the user's own preference** — *Language & time* in the top-right menu
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
2. **the app default** — Admin console → Features → *Localization defaults*
(`default_locale` / `default_timezone`)
3. **the browser**, as before
Timezone names are validated against the server's own `zoneinfo` database, and the
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
Calendar dates (a due date, a kitting date) are formatted from their parts and are
**never** shifted by a timezone — only real instants (MIMO windows, history,
notifications) are converted. Use the shared helpers (`wpFormatDate`,
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
`toLocaleString()`, or a page will quietly ignore the preference.
## Top-bar chrome (project switcher + search)
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
global search into whichever top bar a page has — the dark `.wp-appbar` or the
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
not get a second bar.
- Switching project reloads the current page with `?project=<id>`; every page
already resolves its project from that parameter.
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
(`scope_to_access`) and hides archived work packages. LIKE wildcards in the query
are escaped, so searching `100%` matches a literal `100%`. Two-character minimum.
- Ctrl/Cmd-K focuses the field from anywhere.
## Schema migrations (Alembic) ## Schema migrations (Alembic)
Schema is managed by **Alembic** (`server/alembic/`). The API container runs Schema is managed by **Alembic** (`server/alembic/`). The API container runs

View File

@@ -9,6 +9,7 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style> <style>
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616; :root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6; --muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
@@ -110,17 +111,29 @@
<input id="nu-username" placeholder="Username *" autocomplete="off"> <input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off"> <input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off"> <input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select> <select id="nu-role" title="Permissions — what this account may do">
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password"> <option value="project_user">Project User</option>
<option value="project_admin">Project Admin</option>
<option value="admin">Administrator</option>
</select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
<button class="primary" onclick="createUser()">Create user</button> <button class="primary" onclick="createUser()">Create user</button>
</div> </div>
<div id="users-create-msg" class="note"></div> <div id="users-create-msg" class="note"></div>
</div> </div>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub" style="margin-bottom:10px">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL --> <!-- NOTIFICATIONS / EMAIL -->
<div class="card"> <div class="card">
<h2>Notifications &amp; email</h2> <h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div> <div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
<div id="settings-box" class="note">Loading…</div> <div id="settings-box" class="note">Loading…</div>
<div id="notif-box" class="note" style="margin-top:14px"></div> <div id="notif-box" class="note" style="margin-top:14px"></div>
</div> </div>
@@ -195,5 +208,7 @@
</div> </div>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body> </body>
</html> </html>

View File

@@ -9,6 +9,7 @@
function reveal(){ function reveal(){
document.getElementById('admin-main').style.display=''; document.getElementById('admin-main').style.display='';
fillProjectRoleOptions();
checkHealth(); checkHealth();
loadUsers(); loadUsers();
loadSettings(); loadSettings();
@@ -179,10 +180,27 @@ async function loadUsers(){
renderUsers(json, meId); renderUsers(json, meId);
} }
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
const PERM_ROLES = ['admin','project_admin','project_user'];
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
// Job functions on a project. Descriptive only — no permissions attached.
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
'Field Technician'];
// Accounts created before permissions roles existed carry the legacy value 'user'.
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
function fillProjectRoleOptions(){
const sel=document.getElementById('nu-project-role'); if(!sel) return;
sel.innerHTML='<option value="">Project role…</option>'+
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
}
function renderUsers(list, meId){ function renderUsers(list, meId){
const wrap=document.getElementById('users-table'); const wrap=document.getElementById('users-table');
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; } if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—'; const fmt = s => s ? wpFormatDateTime(s) : '—';
let rows = list.map(u=>{ let rows = list.map(u=>{
const me = u.id===meId; const me = u.id===meId;
const active = u.is_active; const active = u.is_active;
@@ -195,17 +213,33 @@ function renderUsers(list, meId){
// Role can be changed at any time via an inline dropdown. Your own row is // Role can be changed at any time via an inline dropdown. Your own row is
// locked (a shown-as-tag) so an admin can't accidentally demote themselves. // locked (a shown-as-tag) so an admin can't accidentally demote themselves.
const escUname = uesc(u.username).replace(/'/g,"\\'"); const escUname = uesc(u.username).replace(/'/g,"\\'");
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
// a tag) so an admin can't accidentally demote themselves.
const role = normRole(u.role);
const roleCell = me const roleCell = me
? '<span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span><span class="me-tag">locked</span>' ? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(u.role==='admin'?' is-admin':'')+'" title="Change this users role" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+ : '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value="user"'+(u.role==='user'?' selected':'')+'>user</option>'+ PERM_ROLES.map(function(r){
'<option value="admin"'+(u.role==='admin'?' selected':'')+'>admin</option>'+ return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
}).join('')+
'</select>'; '</select>';
// PROJECT role — the person's job function. Descriptive only; grants nothing.
const pr = u.project_role || '';
const projRoleCell =
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
PROJECT_ROLES.map(function(r){
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
}).join('')+
// Keep a title that isn't on the list (set via the API or an older record).
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
'</select>';
return '<tr>'+ return '<tr>'+
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+ '<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
'<td>'+uesc(u.full_name||'')+'</td>'+ '<td>'+uesc(u.full_name||'')+'</td>'+
'<td>'+uesc(u.email||'')+'</td>'+ '<td>'+uesc(u.email||'')+'</td>'+
'<td>'+roleCell+'</td>'+ '<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+ '<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+ '<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+ '<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
@@ -216,8 +250,19 @@ function renderUsers(list, meId){
'</tr>'; '</tr>';
}).join(''); }).join('');
wrap.innerHTML='<table class="users"><thead><tr>'+ wrap.innerHTML='<table class="users"><thead><tr>'+
'<th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+ '<th>Username</th><th>Name</th><th>Email</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>'; '<th title="What this account may do in the app">Permissions</th>'+
'<th title="Job function on the project — descriptive only">Project role</th>'+
'<th>Status</th><th>Last login</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>'+
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
'<em>Administrator</em>: manages users, settings and every project. '+
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
'change a completed SOP, and delete the project. '+
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
'but cannot delete WPs or change the SOP once it\'s complete. '+
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
'team pickers and notification routing, and grants nothing on its own.</div>';
} }
async function createUser(){ async function createUser(){
@@ -226,11 +271,12 @@ async function createUser(){
const full_name=document.getElementById('nu-fullname').value.trim(); const full_name=document.getElementById('nu-fullname').value.trim();
const email=document.getElementById('nu-email').value.trim(); const email=document.getElementById('nu-email').value.trim();
const role=document.getElementById('nu-role').value; const role=document.getElementById('nu-role').value;
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
const password=document.getElementById('nu-password').value; const password=document.getElementById('nu-password').value;
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; } if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; } if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
msg.style.color='var(--muted)'; msg.textContent='Creating…'; msg.style.color='var(--muted)'; msg.textContent='Creating…';
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,password}); const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
if(status===200){ if(status===200){
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.'; msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value=''); ['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
@@ -263,7 +309,15 @@ async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role}); const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status===200){ loadUsers(); } if(status===200){ loadUsers(); }
else { else {
alert('Could not change role for '+username+': '+((json && json.detail)||('HTTP '+status))); alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers();
}
}
async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
if(status===200){ loadUsers(); }
else {
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadUsers(); loadUsers();
} }
} }
@@ -340,7 +394,7 @@ function renderComments(){
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0)); (!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; } if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||''))); rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
const fmt = s => s ? new Date(s).toLocaleString() : '—'; const fmt = s => s ? wpFormatDateTime(s) : '—';
const where = c => { const where = c => {
const bits = []; const bits = [];
if(c.page) bits.push(uesc(c.page)); if(c.page) bits.push(uesc(c.page));
@@ -378,7 +432,7 @@ function renderAudit(){
let rows = _audit.filter(e => (!type || e.entity_type===type) && let rows = _audit.filter(e => (!type || e.entity_type===type) &&
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0)); (!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; } if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—'; const fmt = s => s ? wpFormatDateTime(s) : '—';
const det = e => { const det = e => {
const d = e.detail || {}; const d = e.detail || {};
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
@@ -403,7 +457,122 @@ async function loadSettings(){
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; } if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
_settings = json; renderSettings(); _settings = json; renderSettings();
} }
// Feature flags live in the same settings record but get their own card — they're
// not email, and they change what every project sees.
function renderFeatures(){
const s = _settings, box = document.getElementById('features-box');
if(!box) return;
const bim = !!s.bim_enabled;
box.innerHTML =
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700">'+
'<input type="checkbox" id="set-bim"'+(bim?' checked':'')+' onchange="saveFeatures()"> '+
'BIM / VDC tooling is <span style="color:'+(bim?'var(--green)':'var(--muted)')+'">'+(bim?'ON':'OFF')+'</span>'+
'</label>'+
'<div class="note" style="margin-top:8px">When OFF, the SOP creator hides the BIM/VDC section entirely and '+
'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+
'stops being shown or offered, so no project can be put on the BIM path while it\'s off.</div>'+
'<div id="features-msg" class="note" style="margin-top:6px"></div>'+
// Localization defaults. A user's own "Language & time" preference wins over
// these; these decide what everyone else sees instead of the browser's guess.
'<h2 style="margin-top:22px">Localization defaults</h2>'+
'<div class="sub" style="margin-bottom:10px">How dates, times and numbers are written for users who haven\'t '+
'set their own preference. Each user can override this from <strong>Language &amp; time</strong> in the '+
'top-right menu.</div>'+
'<div class="urow">'+
'<select id="set-locale" style="min-width:220px"></select>'+
'<select id="set-tz" style="min-width:240px"></select>'+
'<button class="primary" onclick="saveLocalization()">Save defaults</button>'+
'<span id="l10n-msg" class="note" style="margin:0"></span>'+
'</div>'+
'<div class="note" id="l10n-preview" style="margin-top:8px"></div>';
fillLocalization();
}
// Locale shortlist mirrors wp-format.js so the admin default and the per-user
// preference offer the same choices.
const L10N_LOCALES = [['','Browser default'],['en-US','en-US — 8/3/2026, 2:07 PM'],
['en-GB','en-GB — 03/08/2026, 14:07'],['en-CA','en-CA'],['es-MX','es-MX'],['es-US','es-US'],
['fr-CA','fr-CA'],['de-DE','de-DE'],['ja-JP','ja-JP'],['ko-KR','ko-KR'],['zh-TW','zh-TW']];
const L10N_ZONES = ['America/Chicago','America/New_York','America/Denver','America/Phoenix',
'America/Los_Angeles','America/Boise','Asia/Tokyo','Asia/Taipei','Asia/Seoul','Asia/Singapore',
'Europe/Dublin','Europe/London','UTC'];
function fillLocalization(){
const s = _settings;
const loc = document.getElementById('set-locale');
const tz = document.getElementById('set-tz');
if(!loc || !tz) return;
const curL = s.default_locale || '', curZ = s.default_timezone || '';
loc.innerHTML = L10N_LOCALES.map(p =>
'<option value="'+uesc(p[0])+'"'+(p[0]===curL?' selected':'')+'>'+uesc(p[1])+'</option>').join('');
if(curL && !L10N_LOCALES.some(p=>p[0]===curL)) loc.add(new Option(curL, curL, true, true));
let browserZone = '';
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch(e){}
tz.innerHTML = '<option value=""'+(curZ?'':' selected')+'>Browser default'+
(browserZone?' ('+uesc(browserZone)+')':'')+'</option>'+
L10N_ZONES.map(z => '<option value="'+uesc(z)+'"'+(z===curZ?' selected':'')+'>'+uesc(z)+'</option>').join('')+
(curZ && L10N_ZONES.indexOf(curZ)<0 ? '<option value="'+uesc(curZ)+'" selected>'+uesc(curZ)+'</option>' : '');
const preview = () => {
const el = document.getElementById('l10n-preview'); if(!el) return;
let out;
try {
out = new Intl.DateTimeFormat(loc.value||undefined, {year:'numeric',month:'short',day:'numeric',
hour:'2-digit',minute:'2-digit',timeZone:tz.value||undefined}).format(new Date());
} catch(e){ out = 'not supported by this browser'; }
el.textContent = 'Preview — right now reads: ' + out;
};
loc.onchange = preview; tz.onchange = preview; preview();
// Offer the server's full zone list once it arrives (it validates against the
// same list, so anything offered here will be accepted).
api('GET','/api/timezones').then(({status,json}) => {
if(status!==200 || !Array.isArray(json) || !json.length) return;
const rest = json.filter(z => L10N_ZONES.indexOf(z) < 0);
if(!rest.length) return;
const g = document.createElement('optgroup'); g.label = 'All time zones';
rest.forEach(z => g.appendChild(new Option(z, z, false, z === curZ)));
tz.appendChild(g);
if(curZ) tz.value = curZ;
});
}
async function saveLocalization(){
const msg = document.getElementById('l10n-msg');
const patch = {
default_locale: document.getElementById('set-locale').value,
default_timezone: document.getElementById('set-tz').value,
};
msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('PUT','/api/settings', patch);
if(status===200){
_settings = json; renderSettings();
const m = document.getElementById('l10n-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else {
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
msg.style.color = 'var(--red)';
}
}
async function saveFeatures(){
const el = document.getElementById('set-bim');
const msg = document.getElementById('features-msg');
if(msg){ msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
const { status, json } = await api('PUT','/api/settings', { bim_enabled: !!(el && el.checked) });
if(status===200){
_settings = json; renderFeatures();
const m = document.getElementById('features-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else if(msg){
msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)';
}
}
function renderSettings(){ function renderSettings(){
renderFeatures();
const s = _settings, box = document.getElementById('settings-box'); const s = _settings, box = document.getElementById('settings-box');
const on = !!s.email_enabled; const on = !!s.email_enabled;
const pwOk = !!s.smtp_password_set; const pwOk = !!s.smtp_password_set;
@@ -458,7 +627,7 @@ async function loadNotifications(){
const { status, json } = await api('GET','/api/notifications?all=1&limit=50'); const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; } if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; } if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
const fmt = s => s ? new Date(s).toLocaleString() : '—'; const fmt = s => s ? wpFormatDateTime(s) : '—';
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)'; const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+ box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+ '<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
@@ -486,7 +655,7 @@ function loadUsage(){
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1; if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts; if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
}); });
const fmt = s => s ? new Date(s).toLocaleString() : '—'; const fmt = s => s ? wpFormatDateTime(s) : '—';
let html = '<table class="kv">'+ let html = '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+ '<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+ '<tr><th>Events</th><td>'+evs.length+'</td></tr>'+

View File

@@ -75,7 +75,7 @@
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' + '<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' + '<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' + '<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 8 characters)</label>' + '<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' + '<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' + '<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' + '<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
@@ -100,7 +100,7 @@
var n1 = document.getElementById('wp-pw-new').value; var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value; var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; } if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 8) { msg('New password must be at least 8 characters.', false); return; } if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; } if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', { fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -115,6 +115,46 @@
}; };
}; };
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
// user can't use, so nobody clicks a button just to get a 403.
// 'user' is the legacy value for what is now 'project_user'.
window.wpRole = function () {
var r = (window.WP_USER && window.WP_USER.role) || '';
return r === 'user' ? 'project_user' : r;
};
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
window.wpIsProjectAdmin = function () {
var r = window.wpRole();
return r === 'admin' || r === 'project_admin';
};
// Deleting a work package, deleting a project, and editing a completed SOP are
// all Project Admin actions (see server require_project_admin).
window.wpCanDeleteWP = window.wpIsProjectAdmin;
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
// ── app feature flags ──────────────────────────────────────────────────────
// Cached per page load. Pages that must know before rendering should await
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
window.WP_FLAGS = null;
var _flagsPromise = null;
window.wpFlags = function () {
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
if (_flagsPromise) return _flagsPromise;
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : {}; })
.catch(function () { return {}; }) // offline: fall through to defaults
.then(function (f) {
window.WP_FLAGS = f || {};
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
return window.WP_FLAGS;
});
return _flagsPromise;
};
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
// stale cache errs toward hiding the unfinished tooling rather than showing it.
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
function isDarkBg(el) { function isDarkBg(el) {
try { try {
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/); var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
@@ -144,7 +184,11 @@
who.style.color = dark ? '#ffffff' : '#161616'; who.style.color = dark ? '#ffffff' : '#161616';
wrap.appendChild(who); wrap.appendChild(who);
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname); var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
if (user.role === 'admin' && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); } if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
if (typeof window.wpPreferences === 'function') {
wrap.appendChild(sep());
wrap.appendChild(link('Language & time', function () { window.wpPreferences(); }));
}
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); })); wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); })); wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
return wrap; return wrap;
@@ -183,6 +227,7 @@
window.WP_USER = user; window.WP_USER = user;
reveal(); reveal();
if (window.WP_USER) { if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {} try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
if (document.body) addLogoutPill(window.WP_USER); if (document.body) addLogoutPill(window.WP_USER);
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); }); else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });

View File

@@ -9,6 +9,7 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { -webkit-text-size-adjust: 100%; } body { -webkit-text-size-adjust: 100%; }
@@ -82,5 +83,7 @@
<script src="project-data.js"></script> <script src="project-data.js"></script>
<script src="help.js"></script> <script src="help.js"></script>
<script src="field.js"></script> <script src="field.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body> </body>
</html> </html>

View File

@@ -12,7 +12,7 @@ function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&amp;').repla
function nsKey(id) { return 'wp_iwp_v1__' + id; } function nsKey(id) { return 'wp_iwp_v1__' + id; }
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; } function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; } function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
function fmtTs(s) { try { return new Date(s).toLocaleString(); } catch (e) { return s || ''; } } function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } } function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); } function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }

View File

@@ -9,6 +9,7 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -661,5 +662,7 @@
.catch(() => {}); .catch(() => {});
} }
</script> </script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

@@ -59,10 +59,18 @@
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only .catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
}, },
// Deleting a project cascades its SOPs and work packages, and the server
// allows it only for a Project Admin. Drop it from the local cache ONLY if
// the server actually deleted it (or it was already gone) — removing it on a
// 403 would hide a project that still exists for everyone else.
remove: function (id) { remove: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' }) return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
.then(function () { cacheRemove(id); }) .then(function (r) {
.catch(function () { cacheRemove(id); }); if (r.ok || r.status === 404) { cacheRemove(id); return true; }
return r.json().catch(function () { return null; }).then(function (j) {
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
});
});
}, },
// ── active project context ──────────────────────────────────────────────── // ── active project context ────────────────────────────────────────────────

View File

@@ -11,13 +11,17 @@
in the background when online). in the background when online).
*/ */
'use strict'; 'use strict';
const CACHE = 'wp-suite-shell-v1'; // Bumped when the shell file list changes, so clients fetch the new assets
// instead of serving a half-old shell from the previous cache.
const CACHE = 'wp-suite-shell-v2';
const SHELL = [ const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html', '/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html', '/field.html', '/login.html', '/admin.html',
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css', '/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
'/wp-chrome.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js', '/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js', '/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
'/prime-controls-logo.jpg', '/favicon.ico', '/prime-controls-logo.jpg', '/favicon.ico',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
]; ];

View File

@@ -26,7 +26,12 @@ function onSizePresetChange(){
let state = { let state = {
bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only. bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only.
project: {name:'', number:'', client:'', division:'', site:''}, project: {name:'', number:'', client:'', division:'', site:''},
// Leadership names are kept as display strings (so existing SOPs and exports
// still read the same) alongside the user-account id each one resolves to.
// The ids are what let the Creator offer these people as a WP owner and what
// notification routing uses — a typed name can't be emailed.
team: {pm:'', apm:'', cm:'', qm:''}, team: {pm:'', apm:'', cm:'', qm:''},
teamIds: {pm:'', apm:'', cm:'', qm:''},
teamMembers: [], teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}], signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [], wpTypes: [],
@@ -202,6 +207,31 @@ const BIM_SOURCES = [
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the // types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the
// project produces both install (IWP) and BIM (EWP) packages. OFF strips the // project produces both install (IWP) and BIM (EWP) packages. OFF strips the
// bim-flagged items. Everything stays editable. // bim-flagged items. Everything stays editable.
// ── BIM/VDC app switch (admin console → Features) ─────────────────────────────
// BIM is off until it's ready for the field. With the flag off we hide the
// per-project toggle so no new project can be put on the BIM path — but we never
// strip a SOP that already has it on, because that would silently delete its BIM
// types, gates and sequence steps. Such a SOP just stops offering BIM until the
// flag comes back.
function applyBimFlag(){
const wrap = document.getElementById('bim-toggle-wrap');
const note = document.getElementById('bim-disabled-note');
const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false;
if(wrap) wrap.style.display = enabled ? '' : 'none';
if(note){
const stale = !enabled && !!state.bimEnabled;
note.style.display = enabled ? 'none' : '';
note.innerHTML = stale
? '<strong>BIM / VDC is switched off for the whole suite.</strong> This SOP already has BIM enabled, so its ' +
'BIM package types, gates and sequence steps are kept as they are — but they aren\'t offered while the ' +
'feature is off. An administrator can turn it back on under Features in the Admin Console.'
: '<strong>BIM / VDC packages aren\'t available yet.</strong> Every project is install-only (IWP) for now. ' +
'An administrator can enable the BIM tooling under Features in the Admin Console once it\'s ready.';
}
}
// Re-check once the flags land (they arrive asynchronously after auth).
document.addEventListener('wp-flags-ready', applyBimFlag);
function setBimEnabled(on){ function setBimEnabled(on){
state.bimEnabled = !!on; state.bimEnabled = !!on;
if(on) enableBIM(); else disableBIM(); if(on) enableBIM(); else disableBIM();
@@ -259,6 +289,8 @@ window.addEventListener('DOMContentLoaded',()=>{
restoreSavedSOP(); restoreSavedSOP();
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
loadProjectUsers(); // team pickers: who's on this project
applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
const tab = params.get('tab'); const tab = params.get('tab');
if(params.get('view') === 'dashboard') switchTool('dashboard'); if(params.get('view') === 'dashboard') switchTool('dashboard');
@@ -369,6 +401,9 @@ function restoreSavedSOP(){
state = savedState; state = savedState;
sop = savedSop; sop = savedSop;
sopComplete = true; sopComplete = true;
// A SOP saved before the team was account-backed has no teamIds; default them
// so the pickers render (the stored names show as "(no account)" until linked).
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
// Re-render dynamic lists from restored state. // Re-render dynamic lists from restored state.
renderWPTypes(); renderWPTypes();
@@ -390,10 +425,9 @@ function repopulateForm(){
set('proj_client', state.project.client); set('proj_client', state.project.client);
set('proj_division', state.project.division); set('proj_division', state.project.division);
set('proj_site', state.project.site); set('proj_site', state.project.site);
set('proj_pm', state.team.pm); // The four leadership slots are user-account pickers, not text inputs —
set('proj_apm', state.team.apm); // renderTeamPickers() builds their options and marks the current selection.
set('proj_cm', state.team.cm); renderTeamPickers();
set('proj_qm', state.team.qm);
if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); } if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); }
if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); } if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); }
set('gov_woformat', state.governance.woformat); set('gov_woformat', state.governance.woformat);
@@ -516,6 +550,7 @@ function renderWPTypes(){
container.innerHTML = `<div class="wp-types-header"> container.innerHTML = `<div class="wp-types-header">
<div>Work Order Type</div> <div>Work Order Type</div>
<div style="text-align:center;">Enabled</div> <div style="text-align:center;">Enabled</div>
<div>Spec Section</div>
<div>Special Rules / Notes</div> <div>Special Rules / Notes</div>
<div>WO Complete Approval</div> <div>WO Complete Approval</div>
</div>`; </div>`;
@@ -531,6 +566,7 @@ function renderWPTypes(){
row.innerHTML = ` row.innerHTML = `
${nameCell} ${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="e.g. 26_05_33_00" title="Specification section for this WP type. The Creator fills it in automatically on every package of this type, so nobody types it per package." value="${(t.specSection||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].specSection=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-family:var(--mono,monospace); font-size:12.5px;">
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`; `;
@@ -562,20 +598,106 @@ function removeWPType(i){
renderWPTypes(); renderWPTypes();
} }
// ── PROJECT TEAM (drawn from user accounts on the project) ────────────────
// The people nameable on a SOP are the project's members (plus admins), so every
// name on the team resolves to an account the suite can assign work to and email.
// Their `project_role` (job function, set in the Admin Console) is offered as the
// default title for an additional team member.
let projectUsers = []; // [{id, full_name, username, email, project_role}]
let projectUsersLoaded = false;
function userLabel(u){
const name = u.full_name || u.username || '';
return u.project_role ? `${name}${u.project_role}` : name;
}
function userById(id){ return projectUsers.find(u => u.id === id) || null; }
async function loadProjectUsers(){
const pid = (typeof ProjectData !== 'undefined' && ProjectData.getActiveId) ? ProjectData.getActiveId() : '';
if(pid){
try {
const r = await fetch('/api/projects/' + encodeURIComponent(pid) + '/members', {credentials:'same-origin'});
if(r.ok) projectUsers = await r.json();
} catch(e){ /* offline — fall back to whatever the SOP already stored */ }
}
projectUsersLoaded = true;
renderTeamPickers();
renderTeamMembers();
}
// One <select> per leadership slot. A name already on the SOP that no longer
// matches an account is kept as a selected option (tagged) rather than silently
// dropped — an old SOP shouldn't lose its PM because they left the project.
function renderTeamPickers(){
const warn = document.getElementById('team-accounts-warn');
const orphans = [];
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
const curId = state.teamIds[key] || '';
const curName = state.team[key] || '';
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
// A stored name with no matching account (typed on an older SOP, or the
// person has since been removed from the project).
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
orphans.push(curName);
}
sel.innerHTML = html;
sel.onchange = function(){ setTeamLead(key, this.value); };
});
if(!warn) return;
if(projectUsersLoaded && !projectUsers.length){
warn.style.display = '';
warn.innerHTML = 'No user accounts are assigned to this project yet, so there is nobody to pick. ' +
'Assign people to the project in the <a href="admin.html" target="_blank" rel="noopener">Admin Console</a> ' +
'(User administration → Projects), then reopen this step.';
} else if(orphans.length){
warn.style.display = '';
warn.textContent = 'Named on this SOP but not a user account on the project: ' + orphans.join(', ') +
'. They cannot be assigned work packages or emailed until they are added as a user and assigned to this project.';
} else {
warn.style.display = 'none';
}
}
function setTeamLead(key, userId){
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
const u = userById(userId);
state.teamIds[key] = u ? u.id : '';
state.team[key] = u ? (u.full_name || u.username) : '';
renderTeamPickers();
}
function renderTeamMembers(){ function renderTeamMembers(){
const container = document.getElementById('team-members-list'); const container = document.getElementById('team-members-list');
if(!container) return; if(!container) return;
const opts = (cur) => '<option value="">— pick a person —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===cur?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
container.innerHTML = state.teamMembers.map((m,i)=>` container.innerHTML = state.teamMembers.map((m,i)=>`
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);"> <div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <select onchange="setExtraTeamMember(${i}, this.value)" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">${opts(m.userId||'')}</select>
<input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'&quot;')}" onchange="state.teamMembers[${i}].name=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" placeholder="Role / title on this project" value="${escAttr(m.role)}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button> <button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button>
</div> </div>
${(m.name && !m.userId) ? `<div style="font-size:12px; margin:-0.25rem 0 0.75rem 0.25rem; color:var(--warning);">“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.</div>` : ''}
`).join(''); `).join('');
} }
// Picking the person fills the title from their project role but leaves it
// editable — the same person can wear a different hat on a given project.
function setExtraTeamMember(i, userId){
const m = state.teamMembers[i]; if(!m) return;
const u = userById(userId);
m.userId = u ? u.id : '';
m.name = u ? (u.full_name || u.username) : '';
if(u && !m.role) m.role = u.project_role || '';
renderTeamMembers();
}
function addTeamMember(){ function addTeamMember(){
state.teamMembers.push({role:'',name:''}); state.teamMembers.push({role:'',name:'',userId:''});
renderTeamMembers(); renderTeamMembers();
} }
@@ -621,6 +743,7 @@ function renderStandardConstraints(){
_constraintsSeeded = true; _constraintsSeeded = true;
} }
const active = name => state.constraints.some(c=>c.name===name); const active = name => state.constraints.some(c=>c.name===name);
const critical = name => { const c = state.constraints.find(x=>x.name===name); return !!(c && c.critical); };
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>` 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}" ${active(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;">
@@ -628,11 +751,33 @@ function renderStandardConstraints(){
<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>
${criticalToggle(c.name, active(c.name), critical(c.name))}
</div> </div>
`).join(''); `).join('');
renderCustomConstraints(); renderCustomConstraints();
} }
// A CRITICAL constraint is one whose reopening after release is announced by
// email (PM + CM + the package owner) rather than only dropping the package to
// Issue (Hold). Only meaningful for a constraint that's switched on.
function criticalToggle(name, enabled, isCritical){
const id = 'crit_' + name.replace(/[^A-Za-z0-9]+/g,'_');
const tip = 'Critical: if this constraint reopens after the work package has been released, ' +
'notify the PM, CM and the package owner by email.';
return `<label for="${id}" title="${escAttr(tip)}" style="display:flex; align-items:center; gap:0.4rem; white-space:nowrap; font-size:12px; font-weight:600; cursor:${enabled?'pointer':'not-allowed'}; opacity:${enabled?1:0.45}; color:${isCritical?'var(--danger)':'var(--text-light)'};">
<input type="checkbox" id="${id}" ${isCritical?'checked':''} ${enabled?'':'disabled'} onchange="toggleCriticalConstraint(this.dataset.name, this.checked)" data-name="${escAttr(name)}" style="width:16px; height:16px; cursor:inherit;">
${isCritical ? '⚠ Critical' : 'Critical?'}
</label>`;
}
function toggleCriticalConstraint(name, on){
const c = state.constraints.find(x=>x.name===name);
if(!c) return;
c.critical = !!on;
renderStandardConstraints();
track(on ? 'constraint_marked_critical' : 'constraint_unmarked_critical', {name});
}
// Render the custom (non-standard) constraints into their own list with remove buttons. // Render the custom (non-standard) constraints into their own list with remove buttons.
function renderCustomConstraints(){ function renderCustomConstraints(){
const el = document.getElementById('custom-constraints-list'); if(!el) return; const el = document.getElementById('custom-constraints-list'); if(!el) return;
@@ -641,7 +786,10 @@ function renderCustomConstraints(){
el.innerHTML = customs.length ? customs.map(c=>` 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;"> <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> <strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button> <button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`; </div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
} }
@@ -653,7 +801,13 @@ function removeCustomConstraint(name){
function toggleConstraint(name){ function toggleConstraint(name){
const idx = state.constraints.findIndex(c=>c.name===name); const idx = state.constraints.findIndex(c=>c.name===name);
if(idx>=0) state.constraints.splice(idx,1); if(idx>=0) state.constraints.splice(idx,1);
else state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name)); else {
// Copy the library entry — pushing the shared object would let one project's
// `critical` flag leak into every other project's default constraint set.
const def = STANDARD_10_CONSTRAINTS.find(c=>c.name===name);
if(def) state.constraints.push({...def});
}
renderStandardConstraints(); // the Critical toggle enables/disables with the row
} }
function showConstraintLibrary(){ function showConstraintLibrary(){
@@ -921,7 +1075,19 @@ function validateStep(n){
} }
// ── SOP COMPLETION ──────────────────────────────────────────────────────────── // ── SOP COMPLETION ────────────────────────────────────────────────────────────
// Re-saving a SOP that is already complete changes the project's baseline, which
// the server restricts to a Project Admin. Check before doing the work so the
// answer is a clear message rather than a 403 from the sync outbox.
function canEditCompletedSOP(){
return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true;
}
function completeSOP(){ function completeSOP(){
if(sopComplete && !canEditCompletedSOP()){
alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
return;
}
if(!validateStep(10)) return; if(!validateStep(10)) return;
collectStepData(); collectStepData();
@@ -937,8 +1103,15 @@ function completeSOP(){
apm: state.team.apm, apm: state.team.apm,
cm: state.team.cm, cm: state.team.cm,
qm: state.team.qm, qm: state.team.qm,
// User-account ids for the same four people. These are what the Creator
// uses to offer an owner and what notification routing needs — a display
// name alone can't be assigned work or emailed.
pmId: state.teamIds.pm || '',
apmId: state.teamIds.apm || '',
cmId: state.teamIds.cm || '',
qmId: state.teamIds.qm || '',
site: state.project.site, site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name)) teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
}, },
roles: state.signoffRoles.filter(r=>r.role), roles: state.signoffRoles.filter(r=>r.role),
governance: { governance: {
@@ -956,6 +1129,9 @@ function completeSOP(){
enabled: true, enabled: true,
notes: t.notes || '', notes: t.notes || '',
approval: t.approval || '', approval: t.approval || '',
// Spec section for this type — the Creator fills the WP's Specification
// Section from it, so it's authored once here instead of per package.
specSection: t.specSection || '',
bim: !!t.bim bim: !!t.bim
})), })),
sources: state.sources.filter(s=>s.label), sources: state.sources.filter(s=>s.label),
@@ -978,7 +1154,11 @@ function completeSOP(){
kind: s.kind || 'step' kind: s.kind || 'step'
})), })),
costCodes: LABOR_COST_CODES, costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({name: c.name, description: c.description || '', bim: !!c.bim})) constraints: state.constraints.map(c=>({
name: c.name, description: c.description || '', bim: !!c.bim,
// Critical → reopening after release is emailed, not just flagged on the package.
critical: !!c.critical
}))
}; };
sopComplete = true; sopComplete = true;

View File

@@ -328,8 +328,8 @@ body {
.wp-type-row { .wp-type-row {
display: grid; display: grid;
grid-template-columns: 1.2fr 90px 2fr 1.5fr; grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
gap: 1rem; gap: 0.85rem;
align-items: center; align-items: center;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--bg); background: var(--bg);
@@ -340,8 +340,8 @@ body {
.wp-types-header { .wp-types-header {
display: grid; display: grid;
grid-template-columns: 1.2fr 90px 2fr 1.5fr; grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
gap: 1rem; gap: 0.85rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;

View File

@@ -9,6 +9,7 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="work-package-suite-styles.css"> <link rel="stylesheet" href="work-package-suite-styles.css">
</head> </head>
<body> <body>
@@ -103,23 +104,28 @@
<!-- STEP 2: PROJECT TEAM --> <!-- STEP 2: PROJECT TEAM -->
<div class="step" id="sop-step-2" style="display: none;"> <div class="step" id="sop-step-2" style="display: none;">
<h2>2. Project Team Leadership</h2> <h2>2. Project Team Leadership</h2>
<div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div> <div class="notice">Pick the key project leaders from the people assigned to this project. Choosing a
<strong>user account</strong> (rather than typing a name) is what lets the Work Package Creator offer them
as an owner and lets the suite email them — so add anyone missing to the project first, in the
<a href="admin.html" target="_blank" rel="noopener">Admin Console</a>.</div>
<div id="team-accounts-warn" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div class="field-grid"> <div class="field-grid">
<div class="field"> <div class="field">
<label>Project Manager (PM)</label> <label>Project Manager (PM)</label>
<input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez"> <select id="proj_pm" class="team-pick" data-team="pm"></select>
</div> </div>
<div class="field"> <div class="field">
<label>Assistant Project Manager (APM)</label> <label>Assistant Project Manager (APM)</label>
<input type="text" id="proj_apm" placeholder="e.g., Assistant PM name"> <select id="proj_apm" class="team-pick" data-team="apm"></select>
</div> </div>
<div class="field"> <div class="field">
<label>Construction Manager (CM)</label> <label>Construction Manager (CM)</label>
<input type="text" id="proj_cm" placeholder="e.g., K. Boyd"> <select id="proj_cm" class="team-pick" data-team="cm"></select>
<div class="field-hint">Kept on the distribution list of every work package by default.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Quality Manager (QM)</label> <label>Quality Manager (QM)</label>
<input type="text" id="proj_qm" placeholder="e.g., D. Nguyen"> <select id="proj_qm" class="team-pick" data-team="qm"></select>
</div> </div>
</div> </div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;"> <div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
@@ -159,12 +165,15 @@
<!-- STEP 4: WORK PACKAGE TYPES --> <!-- STEP 4: WORK PACKAGE TYPES -->
<div class="step" id="sop-step-4" style="display: none;"> <div class="step" id="sop-step-4" style="display: none;">
<h2>4. Work Package Types</h2> <h2>4. Work Package Types</h2>
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div> <div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.
<label style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;"> <strong>Spec Section</strong> is filled onto every work package of that type automatically, so nobody types it per package.</div>
<label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;"> <input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
<span><strong>Include BIM / VDC work packages on this project</strong><br> <span><strong>Include BIM / VDC work packages on this project</strong><br>
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span> <span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types &amp; release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
</label> </label>
<!-- Shown instead of the toggle when an admin has the BIM tooling switched off app-wide. -->
<div id="bim-disabled-note" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div id="wp-types-table" style="margin-top: 1.5rem;"></div> <div id="wp-types-table" style="margin-top: 1.5rem;"></div>
</div> </div>
@@ -414,5 +423,7 @@
<script src="project-data.js"></script> <script src="project-data.js"></script>
<script src="help.js"></script> <script src="help.js"></script>
<script src="work-package-suite-app.js"></script> <script src="work-package-suite-app.js"></script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body> </body>
</html> </html>

211
html/wp-chrome.css Normal file
View File

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

335
html/wp-chrome.js Normal file
View File

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

View File

@@ -92,7 +92,7 @@ function importSOP(ev){
function applySOP(){ function applySOP(){
renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims(); renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims();
buildDisciplinePicker(); renderScope(); onHoursChange(); buildDisciplinePicker(); renderScope(); onHoursChange();
renderSopRefLinks(); renderSpecFolderLink(); renderSopRefLinks(); renderSpecFolderLink(); applySpecFromType(); initSopHintTips();
// SOP-inherited Quality fields — populated then locked (editable only with a logged reason) // SOP-inherited Quality fields — populated then locked (editable only with a logged reason)
if(SOP.quality){ if(SOP.quality){
document.getElementById('wp_qc').value=[SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — '); document.getElementById('wp_qc').value=[SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
@@ -112,7 +112,11 @@ function applySOP(){
// Per-package kind. A project whose SOP has bimEnabled produces both install (IWP) // Per-package kind. A project whose SOP has bimEnabled produces both install (IWP)
// and BIM (EWP) packages; the kind selector tailors which fields, WP types, and // and BIM (EWP) packages; the kind selector tailors which fields, WP types, and
// release gates apply. Install-only projects never show the selector. // release gates apply. Install-only projects never show the selector.
function bimSOP(){ return !!(SOP && SOP.bimEnabled); } // A project is on the BIM path only if its SOP enabled it AND an administrator
// has the BIM/VDC tooling switched on app-wide (Admin Console → Features). With
// the flag off, a SOP that already has BIM keeps its data but every package here
// behaves as install-only — no IWP/EWP choice, no BIM fields or gates.
function bimSOP(){ return !!(SOP && SOP.bimEnabled) && (typeof wpBimEnabled === 'function' ? wpBimEnabled() : false); }
function isEwp(){ return bimSOP() && pkgKind === 'ewp'; } function isEwp(){ return bimSOP() && pkgKind === 'ewp'; }
function setKind(k){ function setKind(k){
if(pkgKind === k) return; if(pkgKind === k) return;
@@ -122,19 +126,46 @@ function setKind(k){
numberDirty = false; updateNumber(); updateReleaseBanner(); numberDirty = false; updateNumber(); updateReleaseBanner();
track('kind_changed', {kind: pkgKind}); track('kind_changed', {kind: pkgKind});
} }
function applyKind(){ // Which cards this package kind uses. Split out from applyKind() because
// showForm() clears every card's inline display and has to restore just the
// visibility — without rebuilding the type picker and constraint rows.
function applyKindVisibility(){
const bimProj = bimSOP(), ewp = isEwp(); const bimProj = bimSOP(), ewp = isEwp();
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; }; const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
show('kind-row', bimProj); show('kind-row', bimProj);
show('bim-card', ewp); // LOD / model area / clash / scan show('bim-card', ewp); // model area / clash + IFF # / scan
show('asset-card', !ewp); // controls.dev assets show('asset-card', !ewp); // controls.dev assets
show('material-card', !ewp); // bill of materials show('material-card', !ewp); // bill of materials
show('mimo-card', !ewp); // kitting / MIMO show('mimo-card', !ewp); // kitting / MIMO
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
}
function applyKind(){
const bimProj = bimSOP();
applyKindVisibility();
if(bimProj) setRadio('pkgkind', pkgKind); if(bimProj) setRadio('pkgkind', pkgKind);
buildTypePicker(); // filtered by kind buildTypePicker(); // filtered by kind
buildConstraints(); // filtered by kind buildConstraints(); // filtered by kind
} }
// The IFF number is the GC's sign-off reference, so it only becomes meaningful
// once coordination reaches "Signed off (IFF)" — at which point it's required.
function iffRequired(){ return (gv('wp_clash') || '') === 'Signed off (IFF)'; }
function onClashChange(){
const hint = document.getElementById('iff-hint');
const inp = document.getElementById('wp_iff');
if(!hint || !inp) return;
const need = iffRequired();
if(need && !inp.value.trim()){
hint.textContent = 'Required — coordination is signed off, so record the IFF number.';
hint.style.color = 'var(--accent-amber)';
} else if(need){
hint.textContent = '';
} else {
hint.textContent = 'Recorded when the GC signs the model package off.';
hint.style.color = '';
}
}
function buildCostCodes(){ function buildCostCodes(){
const sel=document.getElementById('wp_cost'); const cur=sel.value; const sel=document.getElementById('wp_cost'); const cur=sel.value;
sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code}${esc(desc)}</option>`;}).join(''); sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code}${esc(desc)}</option>`;}).join('');
@@ -206,7 +237,284 @@ function renderSopRefLinks(){
function renderSpecFolderLink(){ function renderSpecFolderLink(){
const el=document.getElementById('spec-folder-link'); if(!el) return; const el=document.getElementById('spec-folder-link'); if(!el) return;
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label)); const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
el.innerHTML = spec ? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : ''; const link = spec
? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>`
: '';
// Say where the value came from, since the field itself is read-only now.
const src = gv('wp_spec')
? `<span style="color:var(--text-dim)">from the SOPs WP type${link ? ' · ' : ''}</span>`
: `<span style="color:var(--text-dim)">no spec section set on this WP type in the SOP</span>`;
el.innerHTML = src + link;
}
// ── PEOPLE PICKERS: Assignees + Distribution ──────────────────────────────────
// Both were free-text lists. They're now multi-selects over the project team named
// on the SOP (site comments 8/3), while still accepting a typed name for someone
// with no user account — subcontractors and GC contacts have to stay addable.
//
// Stored shape keeps BOTH: `assignees`/`distribution` remain comma-joined display
// strings (print, export and the dashboard already read those), and
// `assigneeIds`/`distributionIds` carry the account ids that notification routing
// will need. The hidden inputs keep gv()/set() working unchanged.
let pkgPeople = { assignees: [], distribution: [] }; // [{id,name}] — id '' = typed name
function peopleFieldId(kind){ return kind === 'assignees' ? 'wp_assignees' : 'wp_distribution'; }
// The SOP team first (they're this project's named people), then anyone else on
// the project. Mirrors the Owner picker's ordering.
function peopleOptions(){
const team = sopTeamIds();
const onTeam = projectMembers.filter(u => team.includes(u.id));
const others = projectMembers.filter(u => !team.includes(u.id));
return { onTeam, others };
}
function cmMember(){
const cmId = (SOP && SOP.project && SOP.project.cmId) || '';
return cmId ? (projectMembers.find(u => u.id === cmId) || null) : null;
}
function syncPeopleField(kind){
const el = document.getElementById(peopleFieldId(kind));
if(el) el.value = pkgPeople[kind].map(p => p.name).filter(Boolean).join(', ');
}
function renderPeoplePicker(kind){
const box = document.getElementById('pick_' + kind);
if(!box) return;
const cm = cmMember();
const isCm = p => !!(cm && p.id === cm.id && kind === 'distribution');
const chips = pkgPeople[kind].map((p, i) => {
// The CM stays on distribution by default but can be dropped per package, so
// the chip is marked rather than locked.
const tag = isCm(p) ? ' pp-locked' : '';
const title = isCm(p) ? 'Construction Manager — included by default' : (p.id ? '' : 'Typed name (no user account)');
return `<span class="pp-chip${tag}" title="${esc(title)}"><span class="pp-name">${esc(p.name)}</span>` +
`<button type="button" class="pp-x" title="Remove" onclick="removePerson('${kind}',${i})">✕</button></span>`;
}).join('');
const { onTeam, others } = peopleOptions();
const opt = u => {
const on = pkgPeople[kind].some(p => p.id === u.id);
return `<label class="pp-opt"><input type="checkbox" ${on?'checked':''} onchange="togglePerson('${kind}','${esc(u.id)}',this.checked)">` +
`<span>${esc(u.full_name||u.username)}${u.project_role?` <span class="pp-role">${esc(u.project_role)}</span>`:''}</span></label>`;
};
const menu =
(onTeam.length ? `<div class="pp-group">Project team (from SOP)</div>${onTeam.map(opt).join('')}` : '') +
(others.length ? `<div class="pp-group">${onTeam.length?'Others on this project':'On this project'}</div>${others.map(opt).join('')}` : '') +
(!onTeam.length && !others.length ? `<div class="pp-group">No project members found</div>` : '') +
`<div class="pp-free">
<input type="text" placeholder="Add a name not on the list…" onkeydown="if(event.key==='Enter'){event.preventDefault();addTypedPerson('${kind}',this);}">
<div class="field-hint">Someone with no user account — they can't be emailed by the suite.</div>
</div>`;
box.innerHTML = chips +
`<span class="pp-add">
<button type="button" class="pp-add-btn" onclick="togglePeopleMenu('${kind}')">+ Add</button>
<div class="pp-menu" id="ppmenu_${kind}" hidden>${menu}</div>
</span>`;
syncPeopleField(kind);
}
function togglePeopleMenu(kind){
const m = document.getElementById('ppmenu_' + kind);
if(!m) return;
const open = m.hidden;
document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; });
m.hidden = !open;
}
document.addEventListener('click', e => {
if(!e.target.closest || !e.target.closest('.pp-add')){
document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; });
}
});
function togglePerson(kind, userId, on){
const u = projectMembers.find(x => x.id === userId);
if(!u) return;
const list = pkgPeople[kind];
const ix = list.findIndex(p => p.id === userId);
if(on && ix < 0) list.push({ id: u.id, name: u.full_name || u.username });
else if(!on && ix >= 0) list.splice(ix, 1);
renderPeoplePicker(kind);
}
function addTypedPerson(kind, input){
const name = (input.value || '').trim();
if(!name) return;
if(!pkgPeople[kind].some(p => p.name.toLowerCase() === name.toLowerCase())){
pkgPeople[kind].push({ id: '', name });
}
input.value = '';
renderPeoplePicker(kind);
}
function removePerson(kind, i){
pkgPeople[kind].splice(i, 1);
renderPeoplePicker(kind);
}
// Parse whatever is stored on a package back into chips. Names that match a
// project member are re-linked to that account; the rest stay as typed names.
function loadPeopleFromPkg(p){
const parse = (str, ids) => {
const names = String(str || '').split(',').map(x => x.trim()).filter(Boolean);
const byId = (ids || []).map(id => projectMembers.find(u => u.id === id)).filter(Boolean)
.map(u => ({ id: u.id, name: u.full_name || u.username }));
const out = byId.slice();
names.forEach(n => {
if(out.some(x => x.name.toLowerCase() === n.toLowerCase())) return;
const u = projectMembers.find(m => (m.full_name || m.username || '').toLowerCase() === n.toLowerCase());
out.push(u ? { id: u.id, name: u.full_name || u.username } : { id: '', name: n });
});
return out;
};
pkgPeople.assignees = parse(p && p.assignees, p && p.assigneeIds);
pkgPeople.distribution = parse(p && p.distribution, p && p.distributionIds);
renderPeoplePicker('assignees');
renderPeoplePicker('distribution');
}
// A new package starts with the project's CM on distribution (site comment 8/3).
function resetPeopleForNewPackage(){
pkgPeople = { assignees: [], distribution: [] };
const cm = cmMember();
if(cm) pkgPeople.distribution.push({ id: cm.id, name: cm.full_name || cm.username });
renderPeoplePicker('assignees');
renderPeoplePicker('distribution');
}
// ── PREDECESSOR WORK PACKAGES ─────────────────────────────────────────────────
// "Package Predecessor" used to be a free-text SOP phase label, which couldn't
// express "WP04 waits on WP02" and gated nothing. It's now a set of references to
// other packages on the project, and an unclosed predecessor makes a package not
// release-ready (server: enforce_release_gates). The SOP phase survives as the
// descriptive "Sequence phase" field beside it.
let pkgPredecessors = []; // [wpId]
let pkgGateOverride = null; // {reason, at, by} once a planner releases early
function wpById(id){ return savedPackages.find(p => p.id === id) || null; }
// Everything this package already blocks, directly or through a chain — offering
// any of them as a predecessor would create a cycle, so they're excluded.
function descendantsOf(id){
const out = new Set();
const stack = [id];
while(stack.length){
const cur = stack.pop();
savedPackages.forEach(p => {
if((p.predecessors || []).includes(cur) && !out.has(p.id)){
out.add(p.id);
stack.push(p.id);
}
});
}
return out;
}
function predCandidates(){
const self = editingId || '';
const banned = self ? descendantsOf(self) : new Set();
return savedPackages.filter(p => p.id !== self && !banned.has(p.id));
}
// Predecessors that aren't Closed yet. Missing ones (deleted since) don't block —
// a deleted package must not freeze everything downstream of it.
function blockingPredecessors(){
return pkgPredecessors.map(id => wpById(id)).filter(p => p && p.status !== 'Closed');
}
function predLabel(p){ return (p.number || '(unnumbered)') + ' — ' + (p.subject || 'untitled'); }
function renderPredPicker(){
const box = document.getElementById('pick_predecessors');
if(!box) return;
const chips = pkgPredecessors.map((id, i) => {
const p = wpById(id);
const gone = !p;
const done = p && p.status === 'Closed';
const title = gone ? 'This package no longer exists — it does not block release.'
: (done ? 'Closed — cleared' : p.status + ' — blocks release until Closed');
const cls = done ? ' pp-locked' : '';
const name = gone ? '(deleted package)' : (p.number || p.subject || id);
return `<span class="pp-chip${cls}" title="${esc(title)}"><span class="pp-name">${done?'✓ ':''}${esc(name)}</span>` +
`<button type="button" class="pp-x" title="Remove" onclick="removePredecessor(${i})">✕</button></span>`;
}).join('');
const cands = predCandidates();
const opts = cands.map(p => {
const on = pkgPredecessors.includes(p.id);
return `<label class="pp-opt"><input type="checkbox" ${on?'checked':''} onchange="togglePredecessor('${esc(p.id)}',this.checked)">` +
`<span>${esc(p.number || '(unnumbered)')} <span class="pp-role">${esc(p.status || 'Draft')}</span><br>` +
`<span class="pp-role">${esc((p.subject||'').slice(0,60))}</span></span></label>`;
}).join('');
box.innerHTML = chips +
`<span class="pp-add">
<button type="button" class="pp-add-btn" onclick="togglePeopleMenu('predecessors')">+ Add</button>
<div class="pp-menu" id="ppmenu_predecessors" hidden>
${cands.length ? `<div class="pp-group">Work packages on this project</div>${opts}`
: `<div class="pp-group">No other packages saved yet</div>`}
</div>
</span>`;
renderPredHint();
}
function renderPredHint(){
const el = document.getElementById('pred-hint');
if(!el) return;
const blocking = blockingPredecessors();
if(!pkgPredecessors.length){
el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
el.style.color = '';
} else if(blocking.length){
el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
'. Release is gated until they are Closed.';
el.style.color = 'var(--red)';
} else {
el.textContent = '✓ All predecessors are Closed.';
el.style.color = 'var(--accent-green)';
}
}
function togglePredecessor(id, on){
const ix = pkgPredecessors.indexOf(id);
if(on && ix < 0) pkgPredecessors.push(id);
else if(!on && ix >= 0) pkgPredecessors.splice(ix, 1);
// The set changed, so a previously-granted override no longer describes reality.
pkgGateOverride = null;
renderPredPicker();
updateReleaseBanner();
}
function removePredecessor(i){
pkgPredecessors.splice(i, 1);
pkgGateOverride = null;
renderPredPicker();
updateReleaseBanner();
}
// ── SOP-inherited hints → label tooltips (site comment 8/3) ───────────────────
// The blue "from SOP types" subtext under a field becomes a small SOP chip on the
// label, with the detail on hover. The hint elements stay in the DOM (hidden) so
// the code that writes into them keeps working; an observer mirrors their text
// into the chip's tooltip.
function initSopHintTips(){
document.querySelectorAll('.field .field-hint.sop-hint').forEach(hint => {
if(hint.dataset.tipped) return;
const field = hint.closest('.field');
const label = field && field.querySelector('label');
if(!label) return;
hint.dataset.tipped = '1';
const chip = document.createElement('span');
chip.className = 'sop-chip';
chip.textContent = 'SOP';
chip.tabIndex = 0; // reachable by keyboard, not hover-only
label.appendChild(chip);
const sync = () => {
const t = (hint.textContent || '').trim();
chip.dataset.tip = t ? 'From the project SOP — ' + t : 'Inherited from the project SOP.';
};
sync();
try { new MutationObserver(sync).observe(hint, {childList:true, characterData:true, subtree:true}); }
catch(e){ /* no observer: the tooltip just won't track later edits */ }
});
} }
function buildTypePicker(){ function buildTypePicker(){
let types = enabledTypes(); let types = enabledTypes();
@@ -217,7 +525,24 @@ function buildTypePicker(){
} }
function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); } function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); }
function onTypeChange(){ function onTypeChange(){
updateNumber(); track('type_selected'); updateNumber(); applySpecFromType(); track('type_selected');
}
// Specification Section is authored once per WP type on the SOP (site comments
// 8/3: "Spec section in general information can be removed" + "Add link to spec
// section based on SOP"). The field is read-only here and follows the type, so it
// can't drift package to package. A value stored on an older package is kept if
// its type has no spec section on the SOP.
function specForType(name){
const t = ((SOP && SOP.woTypes) || []).find(x => x && x.name === name);
return (t && t.specSection) || '';
}
function applySpecFromType(){
const el = document.getElementById('wp_spec'); if(!el) return;
const fromSop = specForType(gv('wp_type'));
if(fromSop) el.value = fromSop;
else if(!el.dataset.legacy) el.value = '';
renderSpecFolderLink();
} }
// ── WP NUMBER (auto-built from per-WP dimensions + type + sequence) ─────────── // ── WP NUMBER (auto-built from per-WP dimensions + type + sequence) ───────────
@@ -550,13 +875,27 @@ function addPastedFileLinks(){
// ── CONSTRAINTS + RELEASE GATE ─────────────────────────────────────────────── // ── CONSTRAINTS + RELEASE GATE ───────────────────────────────────────────────
function buildConstraints(){ function buildConstraints(){
const names=constraintNames().map(n=> typeof n==='string' ? n : ((n&&n.name)||String(n))); // Carry the SOP's definition through, not just the name: `critical` decides
// preserve existing statuses if rebuilding // whether reopening this constraint after release is emailed, and `description`
// is the tooltip. Statuses already set on the package are preserved.
const defs=constraintNames().map(n=> (typeof n==='string') ? {name:n} : (n||{}));
const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c); const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c);
pkgConstraints=names.map(n=> prev[n] || {name:n, status:'open', comment:''}); pkgConstraints=defs.filter(d=>d.name).map(d=>{
const p=prev[d.name];
return {
name: d.name,
status: p ? p.status : 'open',
comment: p ? (p.comment||'') : '',
critical: !!d.critical,
description: d.description || (p && p.description) || ''
};
});
const tb=document.getElementById('constraint-body'); tb.innerHTML=''; const tb=document.getElementById('constraint-body'); tb.innerHTML='';
pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr'); pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr');
tr.innerHTML=`<td class="row-label">${esc(c.name)}</td> const critTag = c.critical
? ` <span class="crit-tag" title="Critical constraint — if this reopens after release the PM, CM and owner are notified by email.">⚠ critical</span>`
: '';
tr.innerHTML=`<td class="row-label">${esc(c.name)}${critTag}</td>
<td><span class="cstatus"> <td><span class="cstatus">
<button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button> <button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button>
<button class="${c.status==='cleared'?'on-cleared':''}" onclick="setConstraint(${i},'cleared')">Cleared</button> <button class="${c.status==='cleared'?'on-cleared':''}" onclick="setConstraint(${i},'cleared')">Cleared</button>
@@ -578,7 +917,13 @@ function setConstraint(i,val){
// issue it and scroll up to the status control so the change is visible. // issue it and scroll up to the status control so the change is visible.
if(before==='open' && val!=='open' && readiness().open===0){ if(before==='open' && val!=='open' && readiness().open===0){
const st=getRadio('status'); const st=getRadio('status');
if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){ // Don't offer to issue while a predecessor is still open — that would walk the
// user straight into the override prompt they didn't ask for.
if(readiness().blocking.length){
renderPredHint();
toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).');
}
else 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?')){ if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
track('status_change',{status:'Issued',via:'constraint_clear'}); track('status_change',{status:'Issued',via:'constraint_clear'});
@@ -588,23 +933,73 @@ function setConstraint(i,val){
} }
} }
} }
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}; } // Release readiness has two gates now: every constraint cleared/N-A, and every
// predecessor package Closed. `ready` means both; `open`/`blocking` say which.
function readiness(){
const open = pkgConstraints.filter(c=>c.status==='open').length;
const blocking = blockingPredecessors();
return {
open, blocking,
total: pkgConstraints.length,
cleared: pkgConstraints.filter(c=>c.status==='cleared').length,
constraintsClear: open === 0,
ready: open === 0 && blocking.length === 0
};
}
function updateReleaseBanner(){ function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status'); const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt; let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; } if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
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 if(r.constraintsClear){
// Constraints are done; what's left is upstream work.
cls='rb-notready';
txt=`⚠ Not release-ready — waiting on ${r.blocking.map(p=>esc(p.number||p.id)+' ('+esc(p.status)+')').join(', ')}.`;
}
else {
cls='rb-notready';
const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : '';
txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`;
}
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`; b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
updateStickyStatus(); updateStickyStatus();
} }
// Releasing with an unclosed predecessor is allowed but must be explained. The
// reason rides on the package (data.gateOverride) and the server writes it to the
// audit log. Returns false if the user backed out.
function confirmEarlyRelease(blocking){
const list=blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n');
const reason=prompt('These predecessor packages are not Closed yet:\n\n'+list+
'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+
'Why is it being released now? (Cancel to stop.)');
if(reason===null || !reason.trim()) return false;
pkgGateOverride={
reason: reason.trim(),
at: new Date().toISOString(),
by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
blocking: blocking.map(p=>p.number||p.id)
};
track('predecessor_gate_overridden');
return true;
}
function onStatusChange(target){ function onStatusChange(target){
const idx=STATUS_ORDER.indexOf(target); const idx=STATUS_ORDER.indexOf(target);
if(idx>=ISSUED_IDX && readiness().open>0){ const r=readiness();
// Constraints are a hard gate: nothing releases with one open.
if(idx>=ISSUED_IDX && r.open>0){
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.'); alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
} }
// Predecessors are a gate you can refuse: planners genuinely need to release
// ahead of upstream work closing out. Refusing it requires a reason, which is
// stored on the package and written to the audit log by the server.
if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
if(!confirmEarlyRelease(r.blocking)){
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
}
}
if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts
prevStatus=target; updateReleaseBanner(); track('status_change',{status:target}); prevStatus=target; updateReleaseBanner(); track('status_change',{status:target});
} }
@@ -703,13 +1098,23 @@ function collectPackage(){
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'),
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'), type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'), cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'),
assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
// Account ids behind those names — notification routing needs an account;
// a display name can't be emailed. Typed-only names carry no id.
assigneeIds:pkgPeople.assignees.map(x=>x.id).filter(Boolean),
distributionIds:pkgPeople.distribution.map(x=>x.id).filter(Boolean),
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'), due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims}, work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
disciplines:[...pkgDisciplines], disciplines:[...pkgDisciplines],
scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined, scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined,
discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined, discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined,
hours:gv('wp_hours'), seq:gv('wp_seq'), hours:gv('wp_hours'),
// seq is the SOP sequence phase (descriptive). predecessors are references to
// other packages and are what actually gate release.
seq:gv('wp_seq'),
predecessors:pkgPredecessors.slice(),
gateOverride:pkgGateOverride || undefined,
assets:pkgAssets.filter(a=>a.tag||a.link||a.desc), assets:pkgAssets.filter(a=>a.tag||a.link||a.desc),
materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc), materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc),
kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'), kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'),
@@ -724,7 +1129,10 @@ function collectPackage(){
// AWP traceability: which BIM/model package(s) enabled this install package. // AWP traceability: which BIM/model package(s) enabled this install package.
bimlink:gv('wp_bimlink'), bimlink:gv('wp_bimlink'),
// BIM/VDC package details (only meaningful on a BIM SOP). // BIM/VDC package details (only meaningful on a BIM SOP).
lod:gv('wp_lod'), modelArea:gv('wp_model_area'), clash:gv('wp_clash'), scanLink:gv('wp_scan_link'), // LOD was removed from the form (site comment 8/3). Any value already stored on
// the package is preserved rather than blanked on the next save.
lod:(prev && prev.lod) || '', iff:gv('wp_iff'),
modelArea:gv('wp_model_area'), clash:gv('wp_clash'), scanLink:gv('wp_scan_link'),
project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'', project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'',
// Project homepage links in the tracking / commissioning systems, copied from the // Project homepage links in the tracking / commissioning systems, copied from the
// SOP so they travel with every Work Package created for this project. // SOP so they travel with every Work Package created for this project.
@@ -734,6 +1142,18 @@ function collectPackage(){
} }
function savePackage(view){ function savePackage(view){
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; } if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
// The status can also be set programmatically (the per-discipline roll-up), so
// re-check the predecessor gate at the point of saving.
const _r=readiness();
if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){
if(!confirmEarlyRelease(_r.blocking)) return;
}
if(isEwp() && iffRequired() && !gv('wp_iff').trim()){
alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); }
return;
}
const pkg=collectPackage(); const pkg=collectPackage();
const ix=savedPackages.findIndex(p=>p.id===pkg.id); const ix=savedPackages.findIndex(p=>p.id===pkg.id);
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg); if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
@@ -767,7 +1187,7 @@ function renderPackage(pkg){
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr> <tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
${plinks.length?`<tr><th>Project Systems</th><td>${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('<br>')}</td></tr>`:''} ${plinks.length?`<tr><th>Project Systems</th><td>${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('<br>')}</td></tr>`:''}
${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''} ${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''}
${(pkg.lod||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.lod?'LOD: '+esc(pkg.lod):'', pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):''].filter(Boolean).join('<br>')}</td></tr>`:''} ${(pkg.lod||pkg.iff||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.iff?'IFF #: '+esc(pkg.iff):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):'', pkg.lod?'LOD: '+esc(pkg.lod)+' (legacy)':''].filter(Boolean).join('<br>')}</td></tr>`:''}
</tbody></table>`; </tbody></table>`;
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`; if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; } pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
@@ -786,7 +1206,13 @@ function renderPackage(pkg){
h+=`<h2>3.0 Scope & Work</h2><table><tbody> h+=`<h2>3.0 Scope & Work</h2><table><tbody>
<tr><th style="width:200px">Description of Work</th><td>${scopeHtml}</td></tr> <tr><th style="width:200px">Description of Work</th><td>${scopeHtml}</td></tr>
<tr><th>Labor Est. Hrs.</th><td>${cell(pkg.hours)}</td></tr> <tr><th>Labor Est. Hrs.</th><td>${cell(pkg.hours)}</td></tr>
<tr><th>Package Predecessor</th><td>${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}</td></tr> <tr><th>Predecessor packages</th><td>${
(pkg.predecessors && pkg.predecessors.length)
? pkg.predecessors.map(id=>{ const q=wpById(id); return q ? (esc(q.number||id)+' — '+esc(q.status)) : esc(id)+' (deleted)'; }).join('<br>')
: 'None'
}</td></tr>
<tr><th>Sequence phase</th><td>${pkg.seq?esc(pkg.seq):ns()}</td></tr>
${pkg.gateOverride?`<tr><th>Released early</th><td><strong>Predecessor gate overridden.</strong><br>${esc(pkg.gateOverride.reason||'')}<br><span style="color:var(--text-dim)">${esc(pkg.gateOverride.by||'')} · ${esc(pkg.gateOverride.at?wpFormatDateTime(pkg.gateOverride.at):'')}</span></td></tr>`:''}
</tbody></table>`; </tbody></table>`;
if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline); if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline);
h+=`<h2>4.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th>${showDisc?'<th style="width:120px">Discipline</th>':''}</tr></thead><tbody>`; h+=`<h2>4.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th>${showDisc?'<th style="width:120px">Discipline</th>':''}</tr></thead><tbody>`;
@@ -810,7 +1236,7 @@ function renderPackage(pkg){
</tbody></table>`; </tbody></table>`;
if(pkg.holds&&pkg.holds.length){ if(pkg.holds&&pkg.holds.length){
h+=`<h2>8.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`; h+=`<h2>8.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
pkg.holds.forEach(hd=>{ const when=hd.ts?new Date(hd.ts).toLocaleString():''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns(); pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
h+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; }); h+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
h+=`</tbody></table>`; h+=`</tbody></table>`;
} }
@@ -835,7 +1261,21 @@ 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(); 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 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'}); } function showForm(){
hideDashboard();
// This clears every card's inline display, which also clears the "hidden" set by
// applyKind() — so the kind row and the BIM card would reappear on an
// install-only project. Re-apply the kind visibility right after.
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';
applyKindVisibility(); // visibility only — not a full applyKind() rebuild
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). // Sticky save bar + section-nav chrome (shown only on the editable form view).
function setFormChrome(on){ function setFormChrome(on){
@@ -844,6 +1284,7 @@ function setFormChrome(on){
if(save) save.style.display = on ? 'flex' : 'none'; if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on); document.body.classList.toggle('has-sticky-save', !!on);
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); } if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
else positionSectionNav();
} }
// Make each form card collapsible by clicking its heading (idempotent). // Make each form card collapsible by clicking its heading (idempotent).
function makeCollapsible(){ function makeCollapsible(){
@@ -881,6 +1322,9 @@ function buildSectionNav(){
function positionSectionNav(){ function positionSectionNav(){
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header'); const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px'; if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
// The WP navigator rail sticks below the header + section-nav chrome.
const top=(hdr?hdr.offsetHeight:0)+((nav && nav.style.display!=='none')?nav.offsetHeight:0);
document.documentElement.style.setProperty('--rail-top', top+'px');
} }
let _snLastY=0, _snBound=false; let _snLastY=0, _snBound=false;
function initSectionNavAutoHide(){ function initSectionNavAutoHide(){
@@ -901,7 +1345,8 @@ function updateStickyStatus(){
const r=readiness(); const st=getRadio('status'); 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`; } 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 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`; } else if(r.constraintsClear){ el.className='sticky-status ss-notready'; el.textContent=` Waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`; }
else { el.className='sticky-status ss-notready'; el.textContent=`${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`+(r.blocking.length?` · ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`:''); }
} }
// ── SAVED PACKAGES ─────────────────────────────────────────────────────────── // ── SAVED PACKAGES ───────────────────────────────────────────────────────────
@@ -912,6 +1357,8 @@ function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } } 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');
renderWpNav();
renderPredPicker(); // candidates + blocking state change as packages are saved
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:''; document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
if(!savedPackages.length){ card.style.display='none'; return; } if(!savedPackages.length){ card.style.display='none'; return; }
if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display=''; if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display='';
@@ -921,11 +1368,66 @@ function renderSavedList(){
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>${statusPill(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="link-btn" onclick="showHistoryRow(${i})">history</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="link-btn" onclick="showHistoryRow(${i})">history</button> ${canDeleteWP()?`<button class="row-del" onclick="deletePackage(${i})">✕</button>`:''}</td></tr>`;
}).join(''); }).join('');
} }
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// ── WP NAVIGATOR (left rail) ─────────────────────────────────────────────────
// Every saved package on this project, grouped by status, filterable. Clicking a
// row opens it in the form (same path as the Saved table's "edit").
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
function toggleWpNav(){
document.body.classList.toggle('wp-nav-collapsed');
try{ localStorage.setItem('wp_nav_collapsed', document.body.classList.contains('wp-nav-collapsed')?'1':''); }catch(e){}
}
function renderWpNav(){
const list=document.getElementById('wp-nav-list'); if(!list) return;
const cnt=document.getElementById('wp-nav-count');
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
// Keep the original index — edit/view act on savedPackages by position.
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
if(!q) return true;
const p=r.p;
return [p.number,p.subject,p.type,p.location,p.system].some(v=>String(v||'').toLowerCase().includes(q));
});
if(!rows.length){
list.innerHTML='<div class="wp-nav-empty">'+(savedPackages.length?'No packages match “'+esc(q)+'”.':'No work packages saved yet. Fill the form and Save Draft.')+'</div>';
return;
}
const groups={};
rows.forEach(r=>{ const s=r.p.status||'Draft'; (groups[s]=groups[s]||[]).push(r); });
const keys=Object.keys(groups).sort((a,b)=>{
const ia=WPNAV_ORDER.indexOf(a), ib=WPNAV_ORDER.indexOf(b);
return (ia<0?99:ia)-(ib<0?99:ib);
});
list.innerHTML=keys.map(k=>{
const label = k==='Issue' ? 'Issue (Hold)' : k;
return '<div class="wp-nav-group">'+esc(label)+' · '+groups[k].length+'</div>'+groups[k].map(r=>{
const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length;
// Waiting on an unclosed predecessor is 'not ready' too, not just open constraints.
const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length;
const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open');
const state = p.status==='Issue' ? 'on hold'
: (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready'));
const active = (editingId && p.id===editingId) ? ' active' : '';
return '<button type="button" class="wp-nav-item'+active+'" onclick="wpNavOpen('+r.i+')" title="'+esc((p.number||'')+' — '+(p.subject||''))+'">'+
'<span class="wp-nav-num">'+esc(p.number||'(unnumbered)')+'</span>'+
'<span class="wp-nav-subj">'+esc(p.subject||'untitled')+'</span>'+
'<span class="wp-nav-meta"><span class="wp-nav-dot '+dot+'"></span>'+esc(state)+
(p.type?' · '+esc(p.type):'')+'</span></button>';
}).join('');
}).join('');
}
function wpNavOpen(i){
const p=savedPackages[i]; if(!p) return;
editingId=p.id;
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
renderWpNav();
track('wp_nav_open');
}
// ── WP HISTORY (audit trail) ───────────────────────────────────────────────── // ── WP HISTORY (audit trail) ─────────────────────────────────────────────────
function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); } function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); }
function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); } function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); }
@@ -950,7 +1452,7 @@ async function showHistory(wpId, label){
body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>'; body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>';
return; return;
} }
const fmt=s=>{ try{ return new Date(s).toLocaleString(); }catch(e){ return s||''; } }; const fmt=s=>{ try{ return wpFormatDateTime(s); }catch(e){ return s||''; } };
const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; }; const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; };
body.innerHTML='<div class="hist-list">'+rows.map(e=> body.innerHTML='<div class="hist-list">'+rows.map(e=>
'<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+ '<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+
@@ -958,26 +1460,38 @@ async function showHistory(wpId, label){
'<span class="hist-detail">'+det(e.detail)+'</span></div>'+ '<span class="hist-detail">'+det(e.detail)+'</span></div>'+
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>'; '<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>';
} }
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } // Deleting a work package is a Project Admin action (server: require_project_admin).
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } // A project_user gets no delete button, and archiving is offered instead.
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; }
function deletePackage(i){ const p=savedPackages[i]; if(!p) return;
if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
function clearSaved(){ if(!savedPackages.length) return;
if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); }
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); } function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
function loadPackageIntoForm(p){ function loadPackageIntoForm(p){
pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location); set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution); set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
loadPeopleFromPkg(p);
set('wp_assignee',p.assigneeId); set('wp_assignee',p.assigneeId);
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours); set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc); set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons); set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
set('wp_bimlink',p.bimlink); set('wp_lod',p.lod); set('wp_model_area',p.modelArea); set('wp_clash',p.clash); set('wp_scan_link',p.scanLink); set('wp_bimlink',p.bimlink); set('wp_iff',p.iff); set('wp_model_area',p.modelArea);
set('wp_clash',p.clash); set('wp_scan_link',p.scanLink);
onClashChange();
applyKind(); applyKind();
buildTypePicker(); document.getElementById('wp_type').value=p.type||''; buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||''; buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
set('wp_wbs',p.wbs); set('wp_wbs',p.wbs);
document.getElementById('wp_kit_status').value=p.kitStatus||''; document.getElementById('wp_kit_status').value=p.kitStatus||'';
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||''; buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
pkgPredecessors=Array.isArray(p.predecessors)?p.predecessors.slice():[];
pkgGateOverride=p.gateOverride||null;
renderPredPicker();
setRadio('status',p.status||'Draft'); setRadio('status',p.status||'Draft');
// number dimensions // number dimensions
numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims(); numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims();
@@ -1042,8 +1556,11 @@ function newPackage(){
editingId=null; editingId=null;
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); ['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value=''; document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
['wp_lod','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); ['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
onClashChange();
pkgKind='iwp'; applyKind(); pkgKind='iwp'; applyKind();
resetPeopleForNewPackage();
pkgPredecessors=[]; pkgGateOverride=null; renderPredPicker();
setRadio('status','Draft'); setRadio('status','Draft');
numberDims={}; buildNumberDims(); numberDims={}; buildNumberDims();
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange(); pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
@@ -1055,7 +1572,7 @@ function newPackage(){
pkgHolds=[]; pkgOverrides={}; pkgHolds=[]; pkgOverrides={};
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft'; prevStatus='Draft';
updateNumber(); updateReleaseBanner(); showForm(); track('new_package'); updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); renderWpNav(); track('new_package');
} }
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); } function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
function exportPackages(){ function exportPackages(){
@@ -1122,6 +1639,9 @@ function statusPill(s){
} }
function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } } function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } }
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
// Predecessor packages of `p` that aren't Closed. Deleted ones don't block.
function wpWaitingOn(p){ return (p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed'); }
function wpReleaseBlocked(p){ return wpOpenConstraints(p).length>0 || wpWaitingOn(p).length>0; }
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); }
@@ -1143,7 +1663,7 @@ function renderDashboard(){
estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0; estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0;
if(p.status==='Issue') hold++; if(p.status==='Issue') hold++;
if(meId && p.assigneeId===meId) mine++; if(meId && p.assigneeId===meId) mine++;
if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++; if(!wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue') ready++;
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);
}); });
@@ -1210,7 +1730,7 @@ function renderDashboard(){
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||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false; if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false;
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false; if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false;
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false; if(dashFilter.flag==='ready' && !(!p.split && !wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue')) return false;
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false; if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false; if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
return true; return true;
@@ -1226,14 +1746,18 @@ function renderDashboard(){
pageRows.forEach(p=>{ pageRows.forEach(p=>{
const ix=savedPackages.findIndex(x=>x.id===p.id); const ix=savedPackages.findIndex(x=>x.id===p.id);
const open=wpOpenConstraints(p).length; const open=wpOpenConstraints(p).length;
const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`); const waiting=wpWaitingOn(p);
const gates= p.split?'<span class="badge badge-O">master</span>'
:(open?`<span class="badge badge-O">${open} open</span>`
:(waiting.length?`<span class="badge badge-O" title="${esc(waiting.map(w=>(w.number||w.id)+' — '+w.status).join(', '))}">waits on ${waiting.length}</span>`
:`<span class="badge badge-Y">clear</span>`));
const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns(); const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns();
const pid=esc(p.id); const pid=esc(p.id);
let actions; let actions;
if(p.archived){ if(p.archived){
actions=`<button class="link-btn" onclick="showHistory('${pid}')">history</button> <button class="link-btn" onclick="dashUnarchive('${pid}')">restore</button>`; actions=`<button class="link-btn" onclick="showHistory('${pid}')">history</button> <button class="link-btn" onclick="dashUnarchive('${pid}')">restore</button>`;
} else { } else {
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue'; const canIssue = !p.split && open===0 && !waiting.length && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">issue</button> `:''; const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">issue</button> `:'';
actions=`${issueBtn}<button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button> <button class="link-btn" onclick="dashArchive('${pid}')">archive</button>`; actions=`${issueBtn}<button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button> <button class="link-btn" onclick="dashArchive('${pid}')">archive</button>`;
} }
@@ -1255,6 +1779,15 @@ function renderDashboard(){
function dashIssue(id){ function dashIssue(id){
const p=WPData.get(id); if(!p) return; const p=WPData.get(id); if(!p) return;
if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; } if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; }
const waiting=wpWaitingOn(p);
if(waiting.length){
// Releasing early needs a reason, same as on the form — the dashboard must not
// be the quiet way around the gate.
alert('Cannot issue from here — waiting on:\n\n• '+
waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+
'\n\nOpen the package to release it early with a logged reason.');
return;
}
if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return; if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return;
WPData.issue(id); renderDashboard(); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); WPData.issue(id); renderDashboard(); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
} }
@@ -1300,7 +1833,7 @@ function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=doc
function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; } function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; }
function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); } function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); }
function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); } function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); }
function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?new Date(c.ts).toLocaleString():''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); } function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?wpFormatDateTime(c.ts):''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); }
function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } } function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } }
function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); } function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); }
function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); } function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); }
@@ -1348,26 +1881,58 @@ function bootSOP(){
} }
// Populate the Owner picker with this project's members (+ admins). The list is // Populate the Owner picker with this project's members (+ admins). The list is
// only used to pick an assignee; the server re-validates on save. // only used to pick an assignee; the server re-validates on save.
//
// The SOP's project team is listed first (they're the people this project has
// actually named), with everyone else on the project after them — so the common
// pick is at the top without hiding anyone who could legitimately own a package.
let projectMembers = [];
function sopTeamIds(){
const p = (SOP && SOP.project) || {};
const ids = [p.pmId, p.apmId, p.cmId, p.qmId];
(p.teamMembers || []).forEach(m => ids.push(m && m.userId));
return ids.filter(Boolean);
}
async function loadMembers(){ async function loadMembers(){
const sel=document.getElementById('wp_assignee'); const sel=document.getElementById('wp_assignee');
if(!sel || !activeProjectId) return; if(!sel || !activeProjectId) return;
try { try {
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'}); const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
if(!r.ok) return; if(!r.ok) return;
const list=await r.json(); projectMembers=await r.json();
const cur=sel.value; const cur=sel.value;
const team=sopTeamIds();
const onTeam=projectMembers.filter(u=>team.includes(u.id));
const others=projectMembers.filter(u=>!team.includes(u.id));
const opt=u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}${u.project_role?' — '+esc(u.project_role):''}</option>`;
sel.innerHTML='<option value="">— Unassigned —</option>'+ sel.innerHTML='<option value="">— Unassigned —</option>'+
list.map(u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}</option>`).join(''); (onTeam.length?`<optgroup label="Project team (from SOP)">${onTeam.map(opt).join('')}</optgroup>`:'')+
(others.length?`<optgroup label="${onTeam.length?'Others on this project':'On this project'}">${others.map(opt).join('')}</optgroup>`:'');
if(cur) sel.value=cur; if(cur) sel.value=cur;
else defaultOwnerToMe();
// The people pickers list the same accounts, so (re)render them now that the
// member list has arrived.
if(editingId){ const p=savedPackages.find(x=>x.id===editingId); if(p) loadPeopleFromPkg(p); }
else resetPeopleForNewPackage();
} catch(e){} } catch(e){}
} }
// A new package defaults to the person creating it — they're accountable until
// they hand it over. Only applies to an unsaved, unassigned package, and only if
// they're actually assignable on this project.
function defaultOwnerToMe(){
const sel=document.getElementById('wp_assignee');
if(!sel || sel.value || editingId) return;
const me=(window.WP_USER||{}).id;
if(me && Array.from(sel.options).some(o=>o.value===me)) sel.value=me;
}
function bootData(){ function bootData(){
loadStore(); // reads the localStorage cache (hydrated from the server below) loadStore(); // reads the localStorage cache (hydrated from the server below)
bootSOP(); bootSOP();
setRadio('status','Draft'); setRadio('status','Draft');
loadMembers(); loadMembers();
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
renderSavedList(); renderSavedList();
positionSectionNav();
cmtInit(); cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard). // Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }

View File

@@ -45,6 +45,23 @@
<!-- SECTION NAV (jump links, built from the form cards) --> <!-- SECTION NAV (jump links, built from the form cards) -->
<div class="section-nav-bar" id="section-nav"></div> <div class="section-nav-bar" id="section-nav"></div>
<div class="wp-layout">
<!-- WP NAVIGATOR (left rail — jump between every work package on this project) -->
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
<div class="wp-nav-head">
<div class="wp-nav-title">Work Packages <span class="wp-nav-count" id="wp-nav-count"></span></div>
<button class="wp-nav-collapse" id="wp-nav-collapse" onclick="toggleWpNav()" title="Collapse list" aria-label="Collapse list"></button>
</div>
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter by number, subject, type…" oninput="renderWpNav()">
<div class="wp-nav-list" id="wp-nav-list"></div>
<div class="wp-nav-foot">
<button class="add-btn" onclick="newPackage()">+ New</button>
<button class="add-btn" onclick="showDashboard()">📊 Dashboard</button>
</div>
</aside>
<button class="wp-nav-reopen" id="wp-nav-reopen" onclick="toggleWpNav()" title="Show work packages" aria-label="Show work packages"></button>
<div class="main"> <div class="main">
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) --> <!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
@@ -88,10 +105,16 @@
</div> </div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div> <div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div> <div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div> <div class="people-pick" id="pick_assignees"></div>
<input type="hidden" id="wp_assignees"></div>
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
<div class="people-pick" id="pick_distribution"></div>
<input type="hidden" id="wp_distribution"></div>
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div> <div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div> <div class="field"><label>Specification Section</label>
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
<div class="field-hint" id="spec-folder-link"></div></div>
</div> </div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div> <div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div> <div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
@@ -102,11 +125,12 @@
<div class="sub-heading">BIM / Model Details</div> <div class="sub-heading">BIM / Model Details</div>
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div> <div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>Level of Detail (LOD)</label>
<select id="wp_lod"><option value=""></option><option>LOD 100 — Conceptual</option><option>LOD 200 — Approximate</option><option>LOD 300 — Precise</option><option>LOD 350 — Precise + interfaces</option><option>LOD 400 — Fabrication</option><option>LOD 500 — As-built</option></select></div>
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div> <div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
<div class="field"><label>Clash / Coordination Status</label> <div class="field"><label>Clash / Coordination Status</label>
<select id="wp_clash"><option value=""></option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div> <select id="wp_clash" onchange="onClashChange()"><option value=""></option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
<div class="field"><label>IFF #<span class="help-tip" data-tip="Issued-For-Fabrication/Field number — the GC sign-off reference for this model package. Required once the coordination status is Signed off (IFF).">i</span></label>
<input type="text" id="wp_iff" placeholder="e.g. IFF-2026-0142" oninput="onClashChange()">
<div class="field-hint" id="iff-hint"></div></div>
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div> <div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
</div> </div>
</div> </div>
@@ -140,7 +164,11 @@
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button> <button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
<div class="field-grid" style="margin-top:14px"> <div class="field-grid" style="margin-top:14px">
<div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div> <div class="field"><label>Labor Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
<div class="field"><label>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</div></div> <div class="field"><label>Predecessor work packages<span class="help-tip" data-tip="The packages that must be Closed before this one can be released. A package with an open predecessor is not release-ready — you can still release it, but the override is logged.">i</span></label>
<div class="people-pick" id="pick_predecessors"></div>
<div class="field-hint" id="pred-hint"></div></div>
<div class="field"><label>Sequence phase <span class="help-tip" data-tip="Which phase of the SOP's construction sequence this package belongs to. Descriptive — it does not gate release; predecessor packages do.">i</span></label>
<select id="wp_seq"></select><div class="field-hint sop-hint">from the SOP construction sequence</div></div>
</div> </div>
</div> </div>
@@ -268,6 +296,7 @@
</div> </div>
</div> </div>
</div>
<!-- HOLD LOG MODAL (comment 7) --> <!-- HOLD LOG MODAL (comment 7) -->
<div class="modal-overlay" id="hold-modal"> <div class="modal-overlay" id="hold-modal">
@@ -322,5 +351,6 @@
<script src="project-data.js"></script> <script src="project-data.js"></script>
<script src="help.js"></script> <script src="help.js"></script>
<script src="wp-creation-app.js"></script> <script src="wp-creation-app.js"></script>
<script src="wp-format.js"></script>
</body> </body>
</html> </html>

View File

@@ -99,8 +99,11 @@
.step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); } .step-tab.done { color: var(--accent-green); background: var(--accent-green-dim); }
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; } .step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
/* ── MAIN ── */ /* ── MAIN ──
.main { max-width: 1000px; margin: 0 auto; padding: 28px 32px 64px; } The form sits in a wide two-column shell: a sticky work-package navigator on
the left and the form itself filling the rest of the screen. */
.wp-layout { display: flex; align-items: flex-start; gap: 0; max-width: 1760px; margin: 0 auto; }
.main { flex: 1 1 auto; min-width: 0; max-width: none; margin: 0; padding: 28px 32px 64px; }
.section { display: none; } .section { display: none; }
.section.active { display: block; animation: fade .25s ease; } .section.active { display: block; animation: fade .25s ease; }
@@ -126,6 +129,11 @@
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; } .field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; }
.field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; } .field-grid.col3 { grid-template-columns: 1fr 1fr 1fr; }
.field-grid.col1 { grid-template-columns: 1fr; } .field-grid.col1 { grid-template-columns: 1fr; }
/* On a wide screen let the two-up grids flow into 34 columns instead of
stretching two fields across the whole card. */
@media (min-width: 1200px) {
.field-grid:not(.col1):not(.col3) { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
}
.field { display: flex; flex-direction: column; gap: 6px; } .field { display: flex; flex-direction: column; gap: 6px; }
.field.span2 { grid-column: span 2; } .field.span2 { grid-column: span 2; }
@@ -427,7 +435,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; } border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */ /* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:1080px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px; .ctx-bar { max-width:1760px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; } border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; } .ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; } .ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -439,7 +447,7 @@
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); } .ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; } .link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
.mode-wrap { max-width:1080px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; } .mode-wrap { max-width:1760px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; } .mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface); .mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
color:var(--text-muted); cursor:pointer; } color:var(--text-muted); cursor:pointer; }
@@ -463,7 +471,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */ /* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; } .sop-hint { color:var(--accent) !important; }
.release-banner { max-width:1080px; margin:0 auto; padding:0 28px; } .release-banner { max-width:1760px; margin:0 auto; padding:0 28px; }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600; .release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; } display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; } .rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
@@ -580,6 +588,107 @@
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; } 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); } .sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
/* ── WP NAVIGATOR (left rail) ─────────────────────────────────────────
Sticky list of every work package on the project. Click one to open it in
the form; the one being edited is highlighted. */
.wp-nav { flex:0 0 262px; width:262px; align-self:flex-start; position:sticky; top:var(--rail-top,0px);
height:calc(100vh - var(--rail-top,0px)); display:flex; flex-direction:column;
background:var(--surface); border-right:1px solid var(--border); }
.wp-nav-head { display:flex; align-items:center; gap:8px; padding:14px 14px 8px; }
.wp-nav-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); }
.wp-nav-count { color:var(--text-dim); font-weight:600; letter-spacing:0; }
.wp-nav-collapse, .wp-nav-reopen { background:transparent; border:1px solid var(--border); border-radius:4px;
color:var(--text-muted); cursor:pointer; font-size:14px; line-height:1; padding:2px 7px; margin-left:auto; }
.wp-nav-collapse:hover, .wp-nav-reopen:hover { border-color:var(--accent); color:var(--accent); }
.wp-nav-search { margin:0 14px 10px; padding:6px 9px; font-size:12px; font-family:inherit;
border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text); }
.wp-nav-search:focus { outline:none; border-color:var(--accent); }
.wp-nav-list { flex:1 1 auto; overflow-y:auto; padding:0 8px 8px; }
.wp-nav-group { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
color:var(--text-dim); padding:10px 6px 5px; }
.wp-nav-item { display:block; width:100%; text-align:left; background:transparent; border:0;
border-left:3px solid transparent; border-radius:4px; padding:6px 8px; cursor:pointer;
font-family:inherit; color:var(--text); }
.wp-nav-item:hover { background:var(--surface2); }
.wp-nav-item.active { background:var(--accent-dim); border-left-color:var(--accent); }
.wp-nav-num { display:block; font-family:var(--mono); font-size:11px; font-weight:600; color:var(--accent); }
.wp-nav-subj { display:block; font-size:12px; color:var(--text-muted); line-height:1.35;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.wp-nav-meta { display:flex; align-items:center; gap:6px; margin-top:3px; font-size:10px; color:var(--text-dim); }
.wp-nav-dot { width:7px; height:7px; border-radius:50%; background:var(--text-dim); flex:0 0 auto; }
.wp-nav-dot.ok { background:var(--accent-green); }
.wp-nav-dot.open { background:var(--accent-amber); }
.wp-nav-dot.hold { background:var(--red); }
.wp-nav-empty { padding:12px 8px; font-size:12px; color:var(--text-dim); }
.wp-nav-foot { border-top:1px solid var(--border); padding:9px 12px; display:flex; gap:8px; flex-wrap:wrap; }
.wp-nav-reopen { display:none; position:sticky; top:var(--rail-top,0px); margin:10px 0 0 8px; align-self:flex-start; z-index:20; }
/* Keep the rail's footer clear of the fixed save bar. */
body.has-sticky-save .wp-nav { height:calc(100vh - var(--rail-top,0px) - 56px); }
body.wp-nav-collapsed .wp-nav { display:none; }
body.wp-nav-collapsed .wp-nav-reopen { display:block; }
@media (max-width: 1100px) {
.wp-nav { display:none; }
.wp-nav-reopen { display:none !important; }
}
/* ── SOP-inherited marker ───────────────────────────────────────────────────
The "from SOP types" subtext used to sit under the field. It's now a small
chip on the label with the detail in a hover tooltip (site comment 8/3).
The chip stays VISIBLE rather than hover-only: on a field tablet there is no
hover, and "this value came from the SOP" is the part people need to see. */
.field-hint.sop-hint { display: none; }
.sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px;
background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb;
font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
vertical-align:middle; cursor:help; position:relative; }
.sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%;
transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px;
font-weight:400; letter-spacing:0; text-transform:none; line-height:1.4; white-space:normal;
width:max-content; max-width:260px; text-align:left; z-index:9999; opacity:0;
pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
.sop-chip::before { content:''; position:absolute; bottom:135%; left:50%;
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616;
opacity:0; transition:opacity .12s; z-index:9999; }
.sop-chip:hover::after, .sop-chip:hover::before,
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
/* ── people picker (Assignees / Distribution) ───────────────────────────────
Multi-select over the SOP project team instead of a free-text list. */
.people-pick { border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
.people-pick:focus-within { outline:2px solid var(--accent); outline-offset:-2px; }
.pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px;
background:var(--surface2); border:1px solid var(--border); border-radius:12px;
font-size:12px; max-width:100%; }
.pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); }
.pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted);
font-size:12px; line-height:1; padding:0 1px; }
.pp-chip .pp-x:hover { color:var(--red); }
.pp-add { position:relative; }
.pp-add-btn { background:none; border:1px dashed var(--border-strong); border-radius:12px;
color:var(--text-muted); font:inherit; font-size:12px; padding:2px 9px; cursor:pointer; }
.pp-add-btn:hover { border-color:var(--accent); color:var(--accent); }
.pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px;
max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong);
box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; }
.pp-menu[hidden] { display:none; }
.pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
color:var(--text-dim); padding:7px 10px 3px; }
.pp-opt { display:flex; align-items:center; gap:8px; padding:5px 10px; font-size:13px; cursor:pointer; }
.pp-opt:hover { background:var(--surface2); }
.pp-opt input { width:15px; height:15px; cursor:pointer; }
.pp-opt .pp-role { color:var(--text-dim); font-size:11.5px; }
.pp-free { border-top:1px solid var(--border); margin-top:5px; padding:7px 10px 3px; }
.pp-free input { width:100%; padding:5px 7px; font:inherit; font-size:12.5px;
border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; }
/* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
/* Sticky save bar */ /* Sticky save bar */
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center; .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; justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;

232
html/wp-format.js Normal file
View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import os
import re import re
import uuid import uuid
from datetime import timedelta, timezone from datetime import timedelta, timezone
from time import monotonic
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -111,7 +112,7 @@ def check_id(v: Optional[str]) -> None:
# mutating endpoints raise 403 on no access. # mutating endpoints raise 403 on no access.
def accessible_project_ids(db: Session, user: "models.User"): def accessible_project_ids(db: Session, user: "models.User"):
"""Return the set of project ids the user may access, or None for 'all' (admin).""" """Return the set of project ids the user may access, or None for 'all' (admin)."""
if user.role == "admin": if auth.is_admin(user):
return None return None
rows = db.scalars( rows = db.scalars(
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id) select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
@@ -120,7 +121,7 @@ def accessible_project_ids(db: Session, user: "models.User"):
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None: def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
if user.role == "admin": if auth.is_admin(user):
return return
if not project_id: if not project_id:
# Non-admins may not read/mutate resources with no project assignment # Non-admins may not read/mutate resources with no project assignment
@@ -136,6 +137,19 @@ def require_project_access(db: Session, user: "models.User", project_id: Optiona
raise HTTPException(status_code=403, detail="You don't have access to this project") raise HTTPException(status_code=403, detail="You don't have access to this project")
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
what: str = "this action") -> None:
"""Destructive / baseline-changing operations: deleting a work package or a
project, and editing a SOP that has already been completed. Requires project
access AND the project_admin (or admin) permissions role."""
require_project_access(db, user, project_id)
if not auth.is_project_admin(user):
raise HTTPException(
status_code=403,
detail=f"{what} requires the Project Admin permissions role",
)
def scope_to_access(stmt, column, db: Session, user: "models.User"): def scope_to_access(stmt, column, db: Session, user: "models.User"):
"""Restrict a SELECT to the user's accessible projects (no-op for admins).""" """Restrict a SELECT to the user's accessible projects (no-op for admins)."""
ids = accessible_project_ids(db, user) ids = accessible_project_ids(db, user)
@@ -174,7 +188,7 @@ def require_assignable(db: Session, user_id: str, project_id: Optional[str]) ->
u = db.get(models.User, user_id) u = db.get(models.User, user_id)
if not u or not u.is_active: if not u or not u.is_active:
raise HTTPException(status_code=400, detail="Assignee is not a valid user") raise HTTPException(status_code=400, detail="Assignee is not a valid user")
if u.role == "admin": if auth.is_admin(u):
return return
ok = db.scalar( ok = db.scalar(
select(models.ProjectMember.id).where( select(models.ProjectMember.id).where(
@@ -251,6 +265,9 @@ class SettingsIn(BaseModel):
from_addr: Optional[str] = None from_addr: Optional[str] = None
from_name: Optional[str] = None from_name: Optional[str] = None
app_base_url: Optional[str] = None app_base_url: Optional[str] = None
bim_enabled: Optional[bool] = None
default_locale: Optional[str] = None
default_timezone: Optional[str] = None
class TestEmailIn(BaseModel): class TestEmailIn(BaseModel):
@@ -296,7 +313,28 @@ class NewUserIn(BaseModel):
password: str password: str
full_name: str = "" full_name: str = ""
email: str = "" email: str = ""
role: str = "user" # 'admin' | 'user' role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
project_role: str = "" # job function on the project (no permissions)
class ProjectRoleIn(BaseModel):
project_role: str = ""
class PreferencesIn(BaseModel):
# Empty string clears the preference (fall back to the app default, then the
# browser). None means "leave this one alone".
locale: Optional[str] = None
timezone: Optional[str] = None
class ForgotPasswordIn(BaseModel):
username: str = "" # username or email
class ResetPasswordIn(BaseModel):
token: str
new_password: str
class PasswordChangeIn(BaseModel): class PasswordChangeIn(BaseModel):
@@ -313,7 +351,7 @@ class ActiveIn(BaseModel):
class RoleIn(BaseModel): class RoleIn(BaseModel):
role: str # 'admin' | 'user' role: str # permissions role — see auth.ROLES
class ProjectAssignIn(BaseModel): class ProjectAssignIn(BaseModel):
@@ -367,12 +405,165 @@ def logout(response: Response):
return {"ok": True} return {"ok": True}
# ── Self-service password reset (needs email switched on) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
# restart — deliberately simple; the token expiry is the real control.
_reset_last: dict[str, float] = {}
def _reset_throttled(request: Request, username: str) -> bool:
now = monotonic()
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
prev = _reset_last.get(key)
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
return True
_reset_last[key] = now
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
cutoff = now - RESET_COOLDOWN_SECONDS
for k in [k for k, t in _reset_last.items() if t < cutoff]:
_reset_last.pop(k, None)
return False
def reset_body(user: "models.User", link: str, minutes: int) -> str:
# No account detail beyond the username, and no customer data — same rule as
# the assignment mail. The link is the only sensitive thing in here.
who = user.full_name or user.username
return (
f"Hi {who},\n\n"
f"A password reset was requested for your Work Package Suite account "
f"({user.username}).\n\n"
f"Set a new password:\n{link}\n\n"
f"The link expires in {minutes} minutes and can only be used once. "
f"If you didn't request this, you can ignore this email — your current "
f"password still works.\n"
)
@app.get("/api/auth/reset-available")
def reset_available(db: Session = Depends(get_db)):
"""Whether the login page should offer 'Forgot password'. Self-service reset
depends entirely on outbound email, so it's off unless email is enabled AND
SMTP is configured — otherwise the only route is an admin reset."""
s = notify.get_settings(db)
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
@app.post("/api/auth/forgot-password")
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
"""Email a reset link. Always returns the same 200 response whether or not the
account exists — this endpoint is unauthenticated, so it must not become a
username/email oracle. Failures are recorded in the audit log instead."""
s = notify.get_settings(db)
if not (s.get("email_enabled") and notify.smtp_ready(s)):
raise HTTPException(
status_code=503,
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
)
if _reset_throttled(request, body.username):
# Same shape as the success response — no oracle, no mail bomb.
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
user = auth.find_user(db, body.username)
if user and user.is_active and user.email:
base = (s.get("app_base_url") or "").rstrip("/")
token = auth.create_reset_token(user)
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
sent = notify.send_now(
db, user.email,
"Work Package Suite — reset your password",
reset_body(user, link, auth.RESET_MINUTES),
)
log_event(db, user.username, "password_reset_requested", "user", user.id,
summary=user.username, detail={"emailed": bool(sent)})
db.commit()
else:
# Log the miss for the admin's benefit; the caller can't tell the difference.
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
summary=(body.username or "")[:200],
detail={"reason": "no account, inactive, or no email on file"})
db.commit()
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
@app.post("/api/auth/reset-password")
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
"""Complete a reset using the emailed token. The token carries the user's
token_version, and finishing a reset bumps it — so the link is single-use and
every existing session for that account is signed out."""
claims = auth.decode_reset_token(body.token or "")
if not claims:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
# A completed reset also clears any login lockout — the person has proven
# control of the mailbox, so there's nothing left to throttle.
user.failed_attempts = 0
user.locked_until = None
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
db.commit()
return {"ok": True}
@app.get("/api/auth/me") @app.get("/api/auth/me")
def whoami(user: models.User = Depends(auth.get_current_user)): def whoami(user: models.User = Depends(auth.get_current_user)):
"""Who is logged in. The frontend guard calls this on every page load.""" """Who is logged in. The frontend guard calls this on every page load."""
return {"user": user.to_dict()} return {"user": user.to_dict()}
# ── Display preferences (self-service) ─────────────────────────────────────────
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$")
def valid_timezone(tz: str) -> bool:
"""True if this is a real IANA zone name on this machine."""
try:
from zoneinfo import ZoneInfo
ZoneInfo(tz)
return True
except Exception: # noqa: BLE001 — unknown key, missing tzdata, bad type
return False
@app.get("/api/timezones")
def list_timezones(_user: models.User = Depends(auth.get_current_user)):
"""IANA zone names for the preferences picker, so the list matches what the
server will actually accept."""
try:
from zoneinfo import available_timezones
return sorted(available_timezones())
except Exception: # noqa: BLE001 — no tzdata: let the client fall back
return []
@app.post("/api/auth/preferences")
def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""A user's own locale / timezone. Empty string clears the preference; the app
default (admin console) applies next, then the browser's own settings."""
if body.locale is not None:
loc = body.locale.strip()
if loc and not _LOCALE_RE.match(loc):
raise HTTPException(status_code=400, detail="Locale must be a language tag like 'en-US' or 'es'.")
user.locale = loc[:20]
if body.timezone is not None:
tz = body.timezone.strip()
if tz and not valid_timezone(tz):
raise HTTPException(status_code=400, detail="Unknown time zone. Pick one from the list.")
user.timezone = tz[:60]
db.commit()
db.refresh(user)
return {"user": user.to_dict()}
@app.post("/api/auth/password") @app.post("/api/auth/password")
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not auth.verify_password(body.current_password, user.password_hash): if not auth.verify_password(body.current_password, user.password_hash):
@@ -401,8 +592,8 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
problem = auth.password_problem(body.password, body.username, body.email) problem = auth.password_problem(body.password, body.username, body.email)
if problem: if problem:
raise HTTPException(status_code=400, detail=problem) raise HTTPException(status_code=400, detail=problem)
if body.role not in ("admin", "user"): if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
if auth.find_user(db, body.username): if auth.find_user(db, body.username):
raise HTTPException(status_code=409, detail="A user with that username already exists") raise HTTPException(status_code=409, detail="A user with that username already exists")
u = models.User( u = models.User(
@@ -412,9 +603,11 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
full_name=body.full_name.strip(), full_name=body.full_name.strip(),
password_hash=auth.hash_password(body.password), password_hash=auth.hash_password(body.password),
role=body.role, role=body.role,
project_role=body.project_role.strip()[:120],
) )
db.add(u) db.add(u)
log_event(db, _admin, "user_created", "user", u.id, summary=u.username, detail={"role": u.role}) log_event(db, _admin, "user_created", "user", u.id, summary=u.username,
detail={"role": u.role, "project_role": u.project_role})
db.commit() db.commit()
db.refresh(u) db.refresh(u)
return u.to_dict() return u.to_dict()
@@ -450,20 +643,22 @@ def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(a
@app.post("/api/auth/users/{user_id}/role") @app.post("/api/auth/users/{user_id}/role")
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Change a user's role (admin ↔ user). Admins can do this at any time. """Change a user's PERMISSIONS role (admin / project_admin / project_user).
Their job function on the project is separate — see set_user_project_role.
Guards: you can't change your own role (avoids self-lockout), and the last Guards: you can't change your own role (avoids self-lockout), and the last
remaining admin can't be demoted (keeps the app manageable).""" remaining admin can't be demoted (keeps the app manageable)."""
if body.role not in ("admin", "user"): if body.role not in auth.ROLES:
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'") raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
u = db.get(models.User, user_id) u = db.get(models.User, user_id)
if not u: if not u:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if u.id == admin.id: if u.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot change your own role") raise HTTPException(status_code=400, detail="You cannot change your own role")
if u.role == "admin" and body.role != "admin": if auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
other_admins = db.scalars( other_admins = db.scalars(
select(models.User.id).where( select(models.User.id).where(
(models.User.role == "admin") (models.User.role == auth.ROLE_ADMIN)
& (models.User.id != u.id) & (models.User.id != u.id)
& (models.User.is_active.is_(True)) & (models.User.is_active.is_(True))
) )
@@ -479,6 +674,23 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
return u.to_dict() return u.to_dict()
@app.post("/api/auth/users/{user_id}/project-role")
def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Set a user's job function on the project (Project Manager, Superintendent,
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
and notification routing read, so it's worth keeping accurate."""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
old = u.project_role or ""
u.project_role = (body.project_role or "").strip()[:120]
log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username,
detail={"from": old, "to": u.project_role})
db.commit()
db.refresh(u)
return u.to_dict()
@app.delete("/api/auth/users/{user_id}") @app.delete("/api/auth/users/{user_id}")
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
u = db.get(models.User, user_id) u = db.get(models.User, user_id)
@@ -545,7 +757,7 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
project_id=proj.id, summary=(proj.name or proj.number or proj.id)) project_id=proj.id, summary=(proj.name or proj.number or proj.id))
db.commit() db.commit()
# A project created by a non-admin auto-grants its creator access. # A project created by a non-admin auto-grants its creator access.
if is_new and user.role != "admin": if is_new and not auth.is_admin(user):
grant_project_access(db, user.id, proj.id) grant_project_access(db, user.id, proj.id)
db.commit() db.commit()
db.refresh(proj) db.refresh(proj)
@@ -573,7 +785,10 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
proj = db.get(models.Project, project_id) proj = db.get(models.Project, project_id)
if not proj: if not proj:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id) # Cascades to every SOP and work package on the project — Project Admin only.
require_project_admin(db, user, proj.id, "Deleting a project")
log_event(db, user, "deleted", "project", proj.id, project_id=proj.id,
summary=(proj.name or proj.number or proj.id))
db.delete(proj) db.delete(proj)
db.commit() db.commit()
return {"deleted": project_id} return {"deleted": project_id}
@@ -587,6 +802,11 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user),
sop = db.get(models.Sop, body.id) if body.id else None sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None: if sop is not None:
require_project_access(db, user, sop.project_id) require_project_access(db, user, sop.project_id)
# The SOP is the project's baseline: once it's been completed, changing it
# is a Project Admin action. Authoring and revising a draft is open to any
# project member, including marking it complete the first time.
if sop.complete:
require_project_admin(db, user, sop.project_id, "Changing a completed SOP")
is_new = sop is None is_new = sop is None
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"))
@@ -645,7 +865,7 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
sop = db.get(models.Sop, sop_id) sop = db.get(models.Sop, sop_id)
if not sop: if not sop:
raise HTTPException(status_code=404, detail="SOP not found") raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id) require_project_admin(db, user, sop.project_id, "Deleting a SOP")
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id, log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
summary=(sop.name or sop.number or sop.id)) summary=(sop.name or sop.number or sop.id))
db.delete(sop) db.delete(sop)
@@ -653,6 +873,183 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
return {"deleted": sop_id} return {"deleted": sop_id}
# ── Release gates (constraints + predecessors) ─────────────────────────────────
# Status ladder, mirrored in the front end (wp-creation-app.js STATUS_ORDER).
STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"]
ISSUED_IDX = STATUS_ORDER.index("Issued")
DONE_STATUS = "Closed"
def _released(status: Optional[str]) -> bool:
"""Has this package been released to the field (Issued or anything after)?"""
try:
return STATUS_ORDER.index(status or "") >= ISSUED_IDX
except ValueError:
return False
def predecessor_ids(data: Optional[dict]) -> list[str]:
"""Work-package ids this package waits on. Ignores anything malformed rather
than failing a save — a bad id simply can't block."""
raw = (data or {}).get("predecessors") or []
if not isinstance(raw, list):
return []
return [p for p in raw if isinstance(p, str) and _ID_RE.match(p)]
def gate_override(data: Optional[dict]) -> Optional[dict]:
"""A deliberate, reasoned override of the predecessor gate. Planners legitimately
need to stage packages ahead of the work finishing, so the gate is refusable —
but only explicitly, and it lands in the audit log."""
ov = (data or {}).get("gateOverride")
if isinstance(ov, dict) and str(ov.get("reason") or "").strip():
return ov
return None
def blocking_predecessors(db: Session, wp_id: Optional[str], data: Optional[dict]) -> list[dict]:
"""Predecessors that are not Closed yet. A predecessor that no longer exists is
NOT blocking — a deleted package must not freeze everything downstream."""
ids = [p for p in predecessor_ids(data) if p != wp_id]
if not ids:
return []
rows = db.scalars(select(models.WorkPackage).where(models.WorkPackage.id.in_(ids))).all()
return [
{"id": r.id, "number": r.number, "subject": r.subject, "status": r.status}
for r in rows if r.status != DONE_STATUS
]
def check_predecessor_cycle(db: Session, wp_id: str, data: Optional[dict]) -> None:
"""Refuse a predecessor set that would make A wait on itself (directly or
through a chain). Walks the graph from the proposed predecessors; the visited
set also bounds the walk, so a cycle that already exists elsewhere in the data
can't spin here."""
start = [p for p in predecessor_ids(data)]
if wp_id in start:
raise HTTPException(status_code=400, detail="A work package cannot be its own predecessor.")
seen, stack = set(), list(start)
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
row = db.get(models.WorkPackage, cur)
if row is None:
continue
nxt = predecessor_ids(row.data)
if wp_id in nxt:
raise HTTPException(
status_code=400,
detail=f"That would create a circular dependency ({row.number or row.id} already waits on this package).",
)
stack.extend(n for n in nxt if n not in seen)
def enforce_release_gates(db: Session, wp_id: Optional[str], data: Optional[dict],
new_status: str, old_status: Optional[str]) -> None:
"""Refuse a move to Issued (or beyond) while a release gate is unmet. Applied to
every path that can set a status — the plain upsert included, since that's how
the browser and the offline outbox save."""
if not _released(new_status) or _released(old_status):
return # not a release transition
constraints = (data or {}).get("constraints") or []
open_names = [c.get("name") for c in constraints if isinstance(c, dict) and c.get("status") == "open"]
if open_names:
raise HTTPException(status_code=409, detail={
"message": "Open constraints block release", "open": open_names,
})
blockers = blocking_predecessors(db, wp_id, data)
if blockers and not gate_override(data):
raise HTTPException(status_code=409, detail={
"message": "Predecessors are not closed yet",
"blocking": [f"{b['number'] or b['id']} ({b['status']})" for b in blockers],
})
# ── Critical-constraint notification ───────────────────────────────────────────
# A constraint flagged CRITICAL on the SOP, reopened after the package was
# released, is announced by email. We detect it by comparing the incoming
# constraints against the stored ones during the normal upsert rather than adding a
# separate endpoint: the browser saves through the sync outbox, which only replays
# POST /api/wps, so anything hung off another route would be lost offline.
def reopened_critical(old_data: Optional[dict], new_data: Optional[dict]) -> list[str]:
old = {}
for c in (old_data or {}).get("constraints") or []:
if isinstance(c, dict) and c.get("name"):
old[c["name"]] = c.get("status")
out = []
for c in (new_data or {}).get("constraints") or []:
if not isinstance(c, dict) or not c.get("critical"):
continue
name = c.get("name")
if not name or c.get("status") != "open":
continue
if old.get(name) not in (None, "open"): # was cleared/na, now open
out.append(name)
return out
def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
"""PM + CM user ids from the project's most recent complete SOP."""
if not project_id:
return []
sop = db.scalars(
select(models.Sop)
.where((models.Sop.project_id == project_id) & (models.Sop.complete.is_(True)))
.order_by(models.Sop.updated_at.desc())
.limit(1)
).first()
if not sop:
return []
proj = (sop.data or {}).get("project") or {}
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
actor: "models.User", link: str) -> str:
# Constraint names and a WP number only — no package contents, same rule as the
# assignment mail.
who = user.full_name or user.username
by = actor.full_name or actor.username
which = ", ".join(names)
return (
f"Hi {who},\n\n"
f"A critical constraint was reopened on {wp.number or 'a work package'} "
f"after it was released to the field, so the package is on hold.\n\n"
f"Constraint: {which}\n"
f"Reopened by: {by}\n\n"
f"Open the package:\n{link}\n"
)
def notify_critical_reopen(db: Session, wp: "models.WorkPackage", names: list[str],
actor: "models.User") -> list["models.Notification"]:
"""Owner + PM + CM + everyone on the package's distribution list, minus whoever
did it (they already know) and minus duplicates."""
ids = []
if wp.assignee_id:
ids.append(wp.assignee_id)
ids.extend(project_sop_team(db, wp.project_id))
ids.extend([i for i in ((wp.data or {}).get("distributionIds") or []) if isinstance(i, str)])
seen, out = set(), []
link = wp_link(db, wp)
for uid in ids:
if uid in seen or uid == actor.id:
continue
seen.add(uid)
u = db.get(models.User, uid)
if not u or not u.is_active:
continue
out.append(notify.enqueue(
db, user=u, kind="wp_constraint_reopened",
subject=f"On hold: {wp.number or 'work package'} — critical constraint reopened",
body=hold_body(u, wp, names, actor, link),
link=link, wp_id=wp.id, project_id=wp.project_id,
))
return out
# ── Work Packages ──────────────────────────────────────────────────────────── # ── Work Packages ────────────────────────────────────────────────────────────
@app.post("/api/wps") @app.post("/api/wps")
def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
@@ -666,9 +1063,15 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
is_new = wp is None is_new = wp is None
old_status = None if is_new else wp.status old_status = None if is_new else wp.status
old_assignee = None if is_new else wp.assignee_id old_assignee = None if is_new else wp.assignee_id
old_data = None if is_new else (wp.data or {})
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)
# Predecessors: reject a cycle, and refuse a release while a gate is unmet.
# Both run before anything is written so a rejected save changes nothing.
wp_id_for_checks = body.id or wp.id
check_predecessor_cycle(db, wp_id_for_checks, body.data)
enforce_release_gates(db, wp_id_for_checks, body.data, body.status, old_status)
wp.project_id = body.project_id 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
@@ -690,6 +1093,25 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
_act, _detail = "updated", {"status": wp.status} _act, _detail = "updated", {"status": wp.status}
log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id, log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail=_detail) summary=(wp.number or wp.subject or wp.id), detail=_detail)
notifs = []
# A reasoned override of the predecessor gate is worth its own audit line —
# "released early, and here's why" is exactly what a reviewer looks for later.
ov = gate_override(body.data)
if ov and _released(wp.status) and not _released(old_status):
blockers = blocking_predecessors(db, wp.id, body.data)
log_event(db, user, "gate_overridden", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id),
detail={"reason": str(ov.get("reason"))[:300],
"blocking": [b["number"] or b["id"] for b in blockers]})
# A critical constraint reopened after release: log it and tell the people who
# need to know (owner, PM, CM, distribution).
if not is_new and _released(old_status):
reopened = reopened_critical(old_data, wp.data)
if reopened:
log_event(db, user, "constraint_reopened", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id),
detail={"critical": reopened, "status": wp.status})
notifs.extend(notify_critical_reopen(db, wp, reopened, user))
# Notify a newly-assigned owner (skip self-assignment). # Notify a newly-assigned owner (skip self-assignment).
notif = None notif = None
if new_assignee and new_assignee != old_assignee and new_assignee != user.id: if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
@@ -707,7 +1129,9 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
db.commit() db.commit()
db.refresh(wp) db.refresh(wp)
if notif is not None: if notif is not None:
background_tasks.add_task(notify.deliver, notif.id) notifs.append(notif)
for n in notifs:
background_tasks.add_task(notify.deliver, n.id)
return wp.to_dict() return wp.to_dict()
@@ -816,7 +1240,9 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id) # Deleting a work package is irreversible — Project Admin only. A project_user
# who wants one out of the way can archive it instead (reversible).
require_project_admin(db, user, wp.project_id, "Deleting a work package")
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id, log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id)) summary=(wp.number or wp.subject or wp.id))
db.delete(wp) db.delete(wp)
@@ -826,16 +1252,13 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
@app.post("/api/wps/{wp_id}/issue") @app.post("/api/wps/{wp_id}/issue")
def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Release a Work Package to the field. Refuses if any constraint is still """Release a Work Package to the field. Refuses while a release gate is unmet:
open (the AWP release gate).""" any open constraint, or a predecessor package that isn't Closed."""
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id) require_project_access(db, user, wp.project_id)
constraints = (wp.data or {}).get("constraints") or [] enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status)
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
if open_names:
raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names})
wp.status = "Issued" wp.status = "Issued"
wp.issued_at = models.utcnow() wp.issued_at = models.utcnow()
log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id, log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id,
@@ -852,6 +1275,8 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id) require_project_access(db, user, wp.project_id)
old_status = wp.status old_status = wp.status
# Same gates as /issue — this route must not be a way around them.
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
wp.status = body.status wp.status = body.status
if body.status == "Issued" and wp.issued_at is None: if body.status == "Issued" and wp.issued_at is None:
wp.issued_at = models.utcnow() wp.issued_at = models.utcnow()
@@ -918,15 +1343,109 @@ def list_audit(
return [e.to_dict() for e in rows] return [e.to_dict() for e in rows]
# ── Global search ──────────────────────────────────────────────────────────────
def _like_term(q: str) -> str:
"""Escape LIKE wildcards so a user searching for '100%' or 'a_b' gets what they
typed rather than a pattern. Paired with escape='\\' on the comparison."""
return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_").lower() + "%"
@app.get("/api/search")
def global_search(
q: str = Query("", min_length=0, max_length=200),
limit: int = Query(8, ge=1, le=25),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
"""Type-ahead across projects, work packages and SOPs, scoped to the projects
the caller may access. Matches work-package number, subject, type and status,
project name/number/client/site, and SOP name/number."""
term = (q or "").strip()
if len(term) < 2:
return {"query": term, "projects": [], "wps": [], "sops": []}
pat = _like_term(term)
esc = "\\"
proj_stmt = select(models.Project).where(
func.lower(models.Project.name).like(pat, escape=esc)
| func.lower(models.Project.number).like(pat, escape=esc)
| func.lower(models.Project.client).like(pat, escape=esc)
| func.lower(models.Project.site).like(pat, escape=esc)
)
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
wp_stmt = select(models.WorkPackage).where(
(models.WorkPackage.archived_at.is_(None))
& (
func.lower(models.WorkPackage.number).like(pat, escape=esc)
| func.lower(models.WorkPackage.subject).like(pat, escape=esc)
| func.lower(models.WorkPackage.type).like(pat, escape=esc)
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
)
)
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
sop_stmt = select(models.Sop).where(
func.lower(models.Sop.name).like(pat, escape=esc)
| func.lower(models.Sop.number).like(pat, escape=esc)
)
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
# Project names for the WP/SOP rows, so a result reads unambiguously when the
# same WP number exists on two jobs.
pids = {w.project_id for w in wps} | {s.project_id for s in sops}
pids.discard(None)
names = {}
if pids:
for p in db.scalars(select(models.Project).where(models.Project.id.in_(pids))).all():
names[p.id] = p.name or p.number or p.id
return {
"query": term,
"projects": [{"id": p.id, "name": p.name, "number": p.number, "client": p.client} for p in projects],
"wps": [{
"id": w.id, "number": w.number, "subject": w.subject, "type": w.type,
"status": w.status, "project_id": w.project_id,
"project_name": names.get(w.project_id, ""),
} for w in wps],
"sops": [{
"id": s.id, "name": s.name, "number": s.number, "complete": s.complete,
"project_id": s.project_id, "project_name": names.get(s.project_id, ""),
} for s in sops],
}
# ── Settings (admin) ──────────────────────────────────────────────────────────── # ── Settings (admin) ────────────────────────────────────────────────────────────
@app.get("/api/settings") @app.get("/api/settings")
def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
return notify.public_settings(db) return notify.public_settings(db)
@app.get("/api/app-flags")
def get_app_flags(_user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Feature flags every signed-in page reads (e.g. whether the BIM/VDC tooling
is switched on). No secrets — safe for any authenticated user."""
return notify.app_flags(db)
@app.put("/api/settings") @app.put("/api/settings")
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)): def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
patch = {k: v for k, v in body.model_dump().items() if v is not None} patch = {k: v for k, v in body.model_dump().items() if v is not None}
# Localization defaults are validated the same way a user's own preference is,
# so a typo can't leave every page formatting dates against a bogus zone.
loc = (patch.get("default_locale") or "").strip()
if loc and not _LOCALE_RE.match(loc):
raise HTTPException(status_code=400, detail="Default locale must be a language tag like 'en-US'.")
tz = (patch.get("default_timezone") or "").strip()
if tz and not valid_timezone(tz):
raise HTTPException(status_code=400, detail="Unknown default time zone.")
if "default_locale" in patch:
patch["default_locale"] = loc
if "default_timezone" in patch:
patch["default_timezone"] = tz
saved = notify.save_settings(db, patch) saved = notify.save_settings(db, patch)
log_event(db, admin, "settings_updated", "settings", "notifications", log_event(db, admin, "settings_updated", "settings", "notifications",
summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))}) summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))})
@@ -955,7 +1474,7 @@ def send_test_email(body: TestEmailIn, admin: models.User = Depends(auth.require
def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500), def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500),
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Notification) stmt = select(models.Notification)
if not (all and user.role == "admin"): if not (all and auth.is_admin(user)):
stmt = stmt.where(models.Notification.user_id == user.id) stmt = stmt.where(models.Notification.user_id == user.id)
rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all() rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all()
return [n.to_dict() for n in rows] return [n.to_dict() for n in rows]
@@ -967,13 +1486,15 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
require_project_access(db, user, project_id) require_project_access(db, user, project_id)
member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all()) member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all())
members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else [] members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else []
admins = db.scalars(select(models.User).where(models.User.role == "admin")).all() admins = db.scalars(select(models.User).where(models.User.role == auth.ROLE_ADMIN)).all()
out, seen = [], set() out, seen = [], set()
for u in list(members) + list(admins): for u in list(members) + list(admins):
if u.id in seen or not u.is_active: if u.id in seen or not u.is_active:
continue continue
seen.add(u.id) seen.add(u.id)
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email}) out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
"email": u.email, "project_role": u.project_role or "",
"role": auth.normalize_role(u.role)})
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower()) out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
return out return out

View File

@@ -16,7 +16,19 @@ Security model:
set; if it is missing we fall back to a random per-process key (which logs a set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works. warning and invalidates every session on restart) so dev still works.
Roles: 'admin' (may manage users) and 'user'. Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
""" """
import os import os
import secrets import secrets
@@ -39,6 +51,48 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256" JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again. # How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for app admins and project admins — the two roles allowed to delete
work packages and change a completed SOP."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
# Password policy (shared by the API and the CLI). # Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12")) MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
@@ -134,11 +188,43 @@ def create_token(user: "models.User") -> str:
def decode_token(token: str) -> Optional[dict]: def decode_token(token: str) -> Optional[dict]:
"""Return the token claims if the signature and expiry are valid, else None.""" """Return the token claims if the signature and expiry are valid, else None.
Session cookies only — a token of any other type is rejected."""
try: try:
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError: except jwt.PyJWTError:
return None return None
# A password-reset token must never be usable as a session cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ──────────────────────────────────────────────────────────── # ── cookie helpers ────────────────────────────────────────────────────────────
@@ -206,7 +292,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User": def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if user.role != "admin": if not is_admin(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user return user

View File

@@ -121,8 +121,15 @@ class WorkPackage(Base):
class User(Base): class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt """A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with; `role` is hash (see server/auth.py). `username` is what people sign in with.
either 'admin' (can manage users) or 'user'."""
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
• project_role the person's JOB FUNCTION on the project (Project Manager,
Superintendent, QA/QC, …). Carries no permissions; it's what
the SOP team pickers and notification routing read.
"""
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(String(40), primary_key=True) id: Mapped[str] = mapped_column(String(40), primary_key=True)
@@ -130,7 +137,14 @@ class User(Base):
email: Mapped[str] = mapped_column(String(200), default="") email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="") full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="") password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user' role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
# Display preferences. Empty means "fall back to the app default, then to the
# browser". A stored value follows the person between devices, which matters on
# shared field tablets where the browser locale isn't theirs.
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -146,7 +160,9 @@ class User(Base):
"""Public view of a user — NEVER includes the password hash.""" """Public view of a user — NEVER includes the password hash."""
return { return {
"id": self.id, "username": self.username, "email": self.email, "id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role, "is_active": self.is_active, "full_name": self.full_name, "role": self.role,
"project_role": self.project_role or "", "is_active": self.is_active,
"locale": self.locale or "", "timezone": self.timezone or "",
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at), "created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
} }

View File

@@ -33,9 +33,23 @@ DEFAULTS = {
"from_addr": "", "from_addr": "",
"from_name": "Work Package Suite", "from_name": "Work Package Suite",
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links "app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
# with it off, the SOP creator hides the BIM section entirely and every SOP is
# install-only, so no project can be put on the BIM path by accident.
"bim_enabled": False,
# Localization defaults for dates, times and numbers. Empty = use each
# browser's own locale / timezone. A user's own preference wins over these.
"default_locale": "", # BCP47, e.g. en-US
"default_timezone": "", # IANA, e.g. America/Chicago
} }
# Settings the app needs before anyone is signed in, or that carry no secrets and
# are safe for any authenticated user to read (feature flags + localization
# defaults + whether self-service password reset can work at all).
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
def get_settings(db: Session) -> dict: def get_settings(db: Session) -> dict:
row = db.get(models.AppSetting, SETTINGS_KEY) row = db.get(models.AppSetting, SETTINGS_KEY)
s = dict(DEFAULTS) s = dict(DEFAULTS)
@@ -65,6 +79,16 @@ def public_settings(db: Session) -> dict:
return s return s
def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
`password_reset_enabled` tells the login page whether a self-service reset can
actually deliver mail — there's no point offering the link otherwise."""
s = get_settings(db)
out = {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
def smtp_ready(s: dict) -> bool: def smtp_ready(s: dict) -> bool:
return bool(s.get("smtp_host") and s.get("from_addr")) return bool(s.get("smtp_host") and s.get("from_addr"))
@@ -91,6 +115,22 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
srv.send_message(msg) srv.send_message(msg)
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
"""Send one email immediately, outside the outbox. Used for password resets —
a reset link must never sit in a queue, and it must not be persisted in the
notifications table where an admin could read it and take over the account.
Returns True if it went out."""
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
return False
try:
send_email(s, to_addr, subject, body)
return True
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
log.warning("password-reset email to %s failed: %s", to_addr, e)
return False
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str, def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification": link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready + """Record a notification. Marked 'pending' only if email is enabled + SMTP ready +