Compare commits
4 Commits
39b48055ff
...
b38348e6ae
| Author | SHA1 | Date | |
|---|---|---|---|
| b38348e6ae | |||
| 61d1cf4bff | |||
| 79b0e955b4 | |||
| 1d004cab75 |
133
DEPLOYMENT.md
133
DEPLOYMENT.md
@@ -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
|
||||||
|
|||||||
@@ -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 & email</h2>
|
<h2>Notifications & 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>
|
||||||
|
|||||||
197
html/admin.js
197
html/admin.js
@@ -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 user’s 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>';
|
||||||
|
// 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>';
|
'</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 & 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>'+
|
||||||
|
|||||||
@@ -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); });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').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); }
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +96,13 @@
|
|||||||
<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>
|
||||||
|
<div id="error" class="error" role="alert"></div>
|
||||||
|
<div id="ok" class="ok" role="status"></div>
|
||||||
|
|
||||||
|
<!-- SIGN IN -->
|
||||||
|
<section id="view-login">
|
||||||
<h1>Sign in</h1>
|
<h1>Sign in</h1>
|
||||||
<p class="sub">Work Package Suite</p>
|
<p class="sub">Work Package Suite</p>
|
||||||
|
|
||||||
<div id="error" class="error" role="alert"></div>
|
|
||||||
|
|
||||||
<form id="login-form" autocomplete="on">
|
<form id="login-form" autocomplete="on">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
@@ -93,13 +114,46 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="submit" type="submit">Sign in</button>
|
<button id="submit" type="submit">Sign in</button>
|
||||||
</form>
|
</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">
|
||||||
|
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>
|
</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 & 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>
|
||||||
|
|||||||
204
html/login.js
204
html/login.js
@@ -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; }
|
|
||||||
return r.json().catch(function () { return null; }).then(function (j) {
|
|
||||||
if (r.status === 401) showError('Invalid username or password.');
|
|
||||||
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
|
|
||||||
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
|
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = 'Sign in';
|
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');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,'"')}" 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,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||||
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||||
`;
|
`;
|
||||||
@@ -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,'"')}" 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,'"')}" 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 & 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 & 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
211
html/wp-chrome.css
Normal 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
335
html/wp-chrome.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -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 SOP’s 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(); }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 3–4 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
232
html/wp-format.js
Normal 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 & 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 & 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); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
575
server/app.py
575
server/app.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 +
|
||||||
|
|||||||
Reference in New Issue
Block a user