Compare commits
2 Commits
b38348e6ae
...
6c3098922f
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3098922f | |||
| fcba74b584 |
@@ -446,3 +446,26 @@ For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
|
|||||||
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
|
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
|
||||||
§ *Local dev*. The front end alone can also be served statically from `html/`
|
§ *Local dev*. The front end alone can also be served statically from `html/`
|
||||||
(it falls back to browser storage when the API isn't reachable).
|
(it falls back to browser storage when the API isn't reachable).
|
||||||
|
|
||||||
|
## Per-project permissions
|
||||||
|
|
||||||
|
`users.role` is the account's **default** permissions role. A membership row can
|
||||||
|
override it **per project** (`project_members.role`), so someone can be Project
|
||||||
|
Admin on one job and a plain Project User on another. Empty means "inherit the
|
||||||
|
account's role", which is how every pre-existing membership behaves.
|
||||||
|
|
||||||
|
Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
|
||||||
|
so deleting a work package, changing a completed SOP and deleting a project are all
|
||||||
|
judged **on that project**. An app `admin` is admin everywhere and bypasses
|
||||||
|
membership entirely.
|
||||||
|
|
||||||
|
Set it in **Admin console → User administration → Project access** (its own column,
|
||||||
|
showing how many projects each account can reach). The dialog ticks project access
|
||||||
|
and picks the role on each; `/api/auth/users/{id}/projects` takes
|
||||||
|
`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
|
||||||
|
project-scoped roles. Changes are audit-logged as `project_access_changed`.
|
||||||
|
|
||||||
|
**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
|
||||||
|
the project's members plus app admins, each with their effective role on that
|
||||||
|
project. A project with nobody assigned shows only the admins, which is why
|
||||||
|
assigning people is the first step on a new job.
|
||||||
|
|||||||
@@ -178,6 +178,9 @@ async function loadUsers(){
|
|||||||
banner.style.display='none';
|
banner.style.display='none';
|
||||||
const meId = await currentUserId();
|
const meId = await currentUserId();
|
||||||
renderUsers(json, meId);
|
renderUsers(json, meId);
|
||||||
|
// Fill in the project-access counts, then repaint that column.
|
||||||
|
await loadProjectCounts(json);
|
||||||
|
renderUsers(json, meId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
||||||
@@ -197,6 +200,42 @@ function fillProjectRoleOptions(){
|
|||||||
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user project access gets its own column: it was buried among the action
|
||||||
|
// buttons, which is exactly where you'd fail to find "which projects can this
|
||||||
|
// person see, and what may they do there".
|
||||||
|
let _userProjectCounts = {}; // user id -> number of assigned projects
|
||||||
|
|
||||||
|
function projAccessCell(u){
|
||||||
|
const uname = uesc(u.username).replace(/'/g, "\\'");
|
||||||
|
if(normRole(u.role) === 'admin'){
|
||||||
|
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
|
||||||
|
}
|
||||||
|
const n = _userProjectCounts[u.id];
|
||||||
|
const label = (n === undefined) ? 'Projects…'
|
||||||
|
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
|
||||||
|
return '<button class="mini' + (n === 0 ? ' danger' : '') +
|
||||||
|
'" onclick="manageProjects(\'' + u.id + '\',\'' + uname + '\')"' +
|
||||||
|
' title="Choose which projects this user can access, and their role on each">' +
|
||||||
|
label + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project's role dropdown only matters while that project is ticked.
|
||||||
|
function projRowToggled(cb){
|
||||||
|
const row = cb.closest('div');
|
||||||
|
const sel = row && row.querySelector('select');
|
||||||
|
if(sel) sel.disabled = !cb.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts for that column. One call per user, but only for non-admins and only on a
|
||||||
|
// refresh — the admin console is not a hot path.
|
||||||
|
async function loadProjectCounts(list){
|
||||||
|
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
|
||||||
|
await Promise.all(targets.map(async u => {
|
||||||
|
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
|
||||||
|
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
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; }
|
||||||
@@ -240,10 +279,10 @@ function renderUsers(list, meId){
|
|||||||
'<td>'+uesc(u.email||'')+'</td>'+
|
'<td>'+uesc(u.email||'')+'</td>'+
|
||||||
'<td>'+roleCell+'</td>'+
|
'<td>'+roleCell+'</td>'+
|
||||||
'<td>'+projRoleCell+'</td>'+
|
'<td>'+projRoleCell+'</td>'+
|
||||||
|
'<td>'+projAccessCell(u)+'</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">'+
|
||||||
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
|
|
||||||
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
|
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
|
||||||
disableBtn+delBtn+
|
disableBtn+delBtn+
|
||||||
'</div></td>'+
|
'</div></td>'+
|
||||||
@@ -253,6 +292,7 @@ function renderUsers(list, meId){
|
|||||||
'<th>Username</th><th>Name</th><th>Email</th>'+
|
'<th>Username</th><th>Name</th><th>Email</th>'+
|
||||||
'<th title="What this account may do in the app">Permissions</th>'+
|
'<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 title="Job function on the project — descriptive only">Project role</th>'+
|
||||||
|
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
|
||||||
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||||
'</tr></thead><tbody>'+rows+'</tbody></table>'+
|
'</tr></thead><tbody>'+rows+'</tbody></table>'+
|
||||||
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||||||
@@ -333,25 +373,45 @@ async function deleteUser(id, username){
|
|||||||
async function manageProjects(id, username){
|
async function manageProjects(id, username){
|
||||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||||
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||||||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
|
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{});
|
||||||
}
|
}
|
||||||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||||
function openProjectModal(userId, username, projects, assigned, userObj){
|
function openProjectModal(userId, username, projects, assigned, userObj, roles){
|
||||||
closeProjectModal();
|
closeProjectModal();
|
||||||
const isAdmin = userObj && userObj.role==='admin';
|
const isAdmin = userObj && normRole(userObj.role)==='admin';
|
||||||
const items = projects.length ? projects.map(p =>
|
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
|
||||||
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
|
roles = roles || {};
|
||||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
|
// Each project row: access tick + the role ON THAT project. "Same as account"
|
||||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
// inherits the account's Permissions, so the common case needs no thought.
|
||||||
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
|
const items = projects.length ? projects.map(p => {
|
||||||
|
const on = assigned.has(p.id);
|
||||||
|
const cur = roles[p.id] || '';
|
||||||
|
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
|
||||||
|
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
|
||||||
|
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
|
||||||
|
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||||
|
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||||
|
'</select>';
|
||||||
|
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
|
||||||
|
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
|
||||||
|
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
|
||||||
|
' onchange="projRowToggled(this)">'+
|
||||||
|
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||||
|
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
||||||
|
'</label>'+ sel +
|
||||||
|
'</div>';
|
||||||
|
}).join('') : '<div class="note">No projects exist yet.</div>';
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.id = 'proj-modal';
|
modal.id = 'proj-modal';
|
||||||
modal.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;';
|
modal.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;';
|
||||||
modal.innerHTML =
|
modal.innerHTML =
|
||||||
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
'<div style="background:#fff;border-radius:10px;max-width:660px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
|
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access & permissions — '+uesc(username)+'</div>'+
|
||||||
'<div style="padding:14px 18px;overflow:auto;">'+
|
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||||
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
|
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can access every project regardless of assignment.</div>'
|
||||||
|
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role on each. '+
|
||||||
|
'<strong>Project Admin</strong> can delete work packages, change a completed SOP and delete that project; '+
|
||||||
|
'<strong>Project User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
|
||||||
'<div id="proj-list">'+items+'</div>'+
|
'<div id="proj-list">'+items+'</div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||||
@@ -364,8 +424,13 @@ function openProjectModal(userId, username, projects, assigned, userObj){
|
|||||||
const saveBtn = document.getElementById('proj-save');
|
const saveBtn = document.getElementById('proj-save');
|
||||||
if(saveBtn) saveBtn.onclick = async () => {
|
if(saveBtn) saveBtn.onclick = async () => {
|
||||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||||||
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
|
const roleMap = {};
|
||||||
if(status===200) closeProjectModal();
|
ids.forEach(pid => {
|
||||||
|
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
|
||||||
|
if(sel && sel.value) roleMap[pid] = sel.value;
|
||||||
|
});
|
||||||
|
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
|
||||||
|
if(status===200){ closeProjectModal(); loadUsers(); }
|
||||||
else alert('Save failed (HTTP '+status+').');
|
else alert('Save failed (HTTP '+status+').');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ 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; }
|
||||||
|
// Predecessor packages that aren't Closed yet. A package waiting on upstream work
|
||||||
|
// is not release-ready either, so the field list must not call it Ready — the
|
||||||
|
// server would refuse to issue it (see enforce_release_gates).
|
||||||
|
function waitingCount(p, all) {
|
||||||
|
var preds = (p && p.predecessors) || [];
|
||||||
|
if (!preds.length) return 0;
|
||||||
|
var byId = {};
|
||||||
|
(all || []).forEach(function (x) { byId[x.id] = x; });
|
||||||
|
return preds.filter(function (id) { var q = byId[id]; return q && q.status !== 'Closed'; }).length;
|
||||||
|
}
|
||||||
function fmtTs(s) { try { return wpFormatDateTime(s); } 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); }
|
||||||
@@ -57,8 +67,12 @@ function renderList() {
|
|||||||
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
|
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
|
||||||
box.innerHTML = rows.map(function (p) {
|
box.innerHTML = rows.map(function (p) {
|
||||||
var open = openCount(p);
|
var open = openCount(p);
|
||||||
var cls = p.status === 'Issue' ? 'hold' : (open === 0 ? 'ready' : '');
|
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
|
||||||
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>' : (open ? '<span class="pill warn">' + open + ' open</span>' : '<span class="pill ok">Ready</span>');
|
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
|
||||||
|
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
|
||||||
|
: (open ? '<span class="pill warn">' + open + ' open</span>'
|
||||||
|
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
|
||||||
|
: '<span class="pill ok">Ready</span>'));
|
||||||
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
||||||
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
||||||
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// Bumped when the shell file list changes, so clients fetch the new assets
|
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||||
// instead of serving a half-old shell from the previous cache.
|
// instead of serving a half-old shell from the previous cache.
|
||||||
const CACHE = 'wp-suite-shell-v2';
|
const CACHE = 'wp-suite-shell-v3';
|
||||||
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',
|
||||||
|
|||||||
@@ -342,19 +342,25 @@ function loadSampleData(){
|
|||||||
document.getElementById('proj_division').value = 'Semiconductor';
|
document.getElementById('proj_division').value = 'Semiconductor';
|
||||||
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
||||||
|
|
||||||
// Populate Step 2
|
// Populate Step 2. The leadership slots are account pickers now, so the sample's
|
||||||
document.getElementById('proj_pm').value = 'Mariano Sanchez';
|
// fictional names can't be "selected" — setting .value on a <select> with no
|
||||||
document.getElementById('proj_apm').value = 'Assistant PM';
|
// matching option silently does nothing. Store them as names without an account,
|
||||||
document.getElementById('proj_cm').value = 'K. Boyd';
|
// which is exactly how the picker shows a person who isn't a suite user yet.
|
||||||
document.getElementById('proj_qm').value = 'D. Nguyen';
|
state.team.pm = 'Mariano Sanchez';
|
||||||
|
state.team.apm = 'Assistant PM';
|
||||||
|
state.team.cm = 'K. Boyd';
|
||||||
|
state.team.qm = 'D. Nguyen';
|
||||||
|
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||||
|
renderTeamPickers();
|
||||||
|
|
||||||
// Step 3 — standard required roles
|
// Step 3 — standard required roles
|
||||||
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
|
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
|
||||||
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
|
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
|
||||||
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
|
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
|
||||||
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
|
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
|
||||||
document.getElementById('role_super_name').value = 'John Smith';
|
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
|
||||||
document.getElementById('role_foreman_name').value = 'Mike Jones';
|
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
|
||||||
|
renderSignoffRolePickers();
|
||||||
|
|
||||||
// Populate Step 5
|
// Populate Step 5
|
||||||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
||||||
@@ -425,11 +431,12 @@ 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);
|
||||||
// The four leadership slots are user-account pickers, not text inputs —
|
// The leadership slots and sign-off names are account pickers, not text inputs —
|
||||||
// renderTeamPickers() builds their options and marks the current selection.
|
// these build their options and mark the current selection.
|
||||||
renderTeamPickers();
|
renderTeamPickers();
|
||||||
if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); }
|
renderSignoffRolePickers();
|
||||||
if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); }
|
if(state.signoffRoles[0]) set('role_super_title', state.signoffRoles[0].role);
|
||||||
|
if(state.signoffRoles[1]) set('role_foreman_title', state.signoffRoles[1].role);
|
||||||
set('gov_woformat', state.governance.woformat);
|
set('gov_woformat', state.governance.woformat);
|
||||||
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
// gov_wosize is now a <select>; if a saved value isn't one of the presets
|
||||||
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
// (e.g. legacy free text), add it as an option so the round-trip preserves it.
|
||||||
@@ -472,11 +479,33 @@ function switchTool(tool){
|
|||||||
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||||||
|
|
||||||
if(contentTool === 'wp') renderWPTab(isDash);
|
if(contentTool === 'wp') renderWPTab(isDash);
|
||||||
|
applyEmbedLayout(contentTool === 'wp');
|
||||||
|
|
||||||
updateStepUI();
|
updateStepUI();
|
||||||
updateProjectDisplay();
|
updateProjectDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The embedded creator/dashboard fills the window below the app chrome, so there
|
||||||
|
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
|
||||||
|
// page — and the creator's sticky bars have a real viewport to stick to.
|
||||||
|
function applyEmbedLayout(on){
|
||||||
|
const area = document.querySelector('.content-area');
|
||||||
|
const frame = document.getElementById('wp-frame');
|
||||||
|
if(area) area.classList.toggle('embed-full', !!on);
|
||||||
|
if(frame) frame.classList.toggle('fill', !!on);
|
||||||
|
document.body.classList.toggle('embed-full', !!on);
|
||||||
|
if(on) measureChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height of the app bar + tab strip, so the iframe can be exactly the rest.
|
||||||
|
function measureChrome(){
|
||||||
|
const hdr = document.querySelector('.header');
|
||||||
|
const nav = document.querySelector('.main-nav');
|
||||||
|
const h = (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
|
||||||
|
document.documentElement.style.setProperty('--wp-chrome-h', h + 'px');
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', () => { if(document.body.classList.contains('embed-full')) measureChrome(); }, {passive:true});
|
||||||
|
|
||||||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
||||||
// wantDash=true opens the creator straight to the dashboard view.
|
// wantDash=true opens the creator straight to the dashboard view.
|
||||||
function renderWPTab(wantDash){
|
function renderWPTab(wantDash){
|
||||||
@@ -623,6 +652,7 @@ async function loadProjectUsers(){
|
|||||||
projectUsersLoaded = true;
|
projectUsersLoaded = true;
|
||||||
renderTeamPickers();
|
renderTeamPickers();
|
||||||
renderTeamMembers();
|
renderTeamMembers();
|
||||||
|
renderSignoffRolePickers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// One <select> per leadership slot. A name already on the SOP that no longer
|
// One <select> per leadership slot. A name already on the SOP that no longer
|
||||||
@@ -662,6 +692,62 @@ function renderTeamPickers(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
|
||||||
|
// name that has no matching account as a selected "(no account)" option so older
|
||||||
|
// SOPs — and the sample's fictional names — are never silently dropped.
|
||||||
|
function userSelectOptions(curId, curName){
|
||||||
|
let html = '<option value="">— not assigned —</option>' +
|
||||||
|
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
|
||||||
|
if(curName && !userById(curId)){
|
||||||
|
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign-off roles (step 3) name the people who must sign a package, so they use the
|
||||||
|
// same picker as the leadership slots — a signature belongs to an account.
|
||||||
|
function renderSignoffRolePickers(){
|
||||||
|
[['role_super_name', 0], ['role_foreman_name', 1]].forEach(([id, ix]) => {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
const r = state.signoffRoles[ix];
|
||||||
|
if(!sel || !r) return;
|
||||||
|
sel.innerHTML = userSelectOptions(r.userId || '', r.name || '');
|
||||||
|
sel.onchange = function(){
|
||||||
|
if(this.value === '__orphan__') return;
|
||||||
|
const u = userById(this.value);
|
||||||
|
r.userId = u ? u.id : '';
|
||||||
|
r.name = u ? (u.full_name || u.username) : '';
|
||||||
|
renderSignoffRolePickers();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
renderOptionalRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the four leadership pickers back into state. `state.team[key]` always holds
|
||||||
|
// a display NAME and `state.teamIds[key]` the account id; a name kept from an older
|
||||||
|
// SOP whose person has no account (the "(no account)" option) is left alone.
|
||||||
|
function syncTeamFromPickers(){
|
||||||
|
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||||||
|
['pm','apm','cm','qm'].forEach(key => {
|
||||||
|
const sel = document.getElementById('proj_' + key);
|
||||||
|
if(!sel) return;
|
||||||
|
if(sel.value === '__orphan__') return; // legacy typed name — keep it
|
||||||
|
const u = userById(sel.value);
|
||||||
|
state.teamIds[key] = u ? u.id : '';
|
||||||
|
if(u) state.team[key] = u.full_name || u.username;
|
||||||
|
else if(sel.value === '') state.team[key] = ''; // explicitly unassigned
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptionalRolePerson(ix, value){
|
||||||
|
const r = state.signoffRoles[ix];
|
||||||
|
if(!r || value === '__orphan__') return;
|
||||||
|
const u = userById(value);
|
||||||
|
r.userId = u ? u.id : '';
|
||||||
|
r.name = u ? (u.full_name || u.username) : '';
|
||||||
|
renderOptionalRoles();
|
||||||
|
}
|
||||||
|
|
||||||
function setTeamLead(key, userId){
|
function setTeamLead(key, userId){
|
||||||
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
|
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
|
||||||
const u = userById(userId);
|
const u = userById(userId);
|
||||||
@@ -715,7 +801,7 @@ function renderOptionalRoles(){
|
|||||||
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
|
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
|
||||||
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
|
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
|
<select onchange="setOptionalRolePerson(${state.signoffRoles.indexOf(r)}, this.value)">${userSelectOptions(r.userId||'', r.name||'')}</select>
|
||||||
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
|
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -1019,17 +1105,19 @@ function collectStepData(){
|
|||||||
state.project.site = document.getElementById('proj_site').value;
|
state.project.site = document.getElementById('proj_site').value;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
state.team.pm = document.getElementById('proj_pm').value;
|
// These are user-account pickers now, so their .value is an ID, not a name.
|
||||||
state.team.apm = document.getElementById('proj_apm').value;
|
// Reading them straight into state.team would put an id where the display
|
||||||
state.team.cm = document.getElementById('proj_cm').value;
|
// name belongs (and it would then print on the SOP as `user_ab12…`).
|
||||||
state.team.qm = document.getElementById('proj_qm').value;
|
syncTeamFromPickers();
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
// The two required roles now have editable titles (default Superintendent/Foreman).
|
// The two required roles now have editable titles (default Superintendent/Foreman).
|
||||||
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
|
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
|
||||||
state.signoffRoles[0].name = document.getElementById('role_super_name').value;
|
// Titles are still free text; the NAMES are account pickers whose .value is
|
||||||
state.signoffRoles[1].role = (document.getElementById('role_foreman_title').value || 'Role 2').trim();
|
// an id, so they're maintained by their own onchange (see
|
||||||
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
|
// renderSignoffRolePickers) rather than read as text here.
|
||||||
|
state.signoffRoles[0].role = document.getElementById('role_super_title').value || 'Superintendent';
|
||||||
|
state.signoffRoles[1].role = document.getElementById('role_foreman_title').value || 'Foreman';
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
state.governance.woformat = document.getElementById('gov_woformat').value;
|
state.governance.woformat = document.getElementById('gov_woformat').value;
|
||||||
@@ -1113,7 +1201,10 @@ function completeSOP(){
|
|||||||
site: state.project.site,
|
site: state.project.site,
|
||||||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
|
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).map(r=>({
|
||||||
|
role: r.role, name: r.name || '',
|
||||||
|
userId: r.userId || '' // who signs — an account, so it can be notified
|
||||||
|
})),
|
||||||
governance: {
|
governance: {
|
||||||
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
|
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
|
||||||
woSize: state.governance.wosize,
|
woSize: state.governance.wosize,
|
||||||
|
|||||||
@@ -167,15 +167,54 @@ body {
|
|||||||
|
|
||||||
.tab-icon { font-size: 16px; }
|
.tab-icon { font-size: 16px; }
|
||||||
|
|
||||||
/* CONTENT AREA */
|
/* CONTENT AREA
|
||||||
|
The SOP wizard reads better with a bound on line length, but 1000px on a 1920
|
||||||
|
screen wasted half the display — and it also squeezed the embedded Work Package
|
||||||
|
Creator (an iframe living in here) into a ~930px column with its own scrollbar
|
||||||
|
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
|
||||||
|
(see .content-area.embed-full below). */
|
||||||
.content-area {
|
.content-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 1000px;
|
max-width: 1700px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Work Package Creation / Dashboard: the iframe fills the window below the app
|
||||||
|
chrome and owns the only scrollbar, so the creator's sticky save bar and
|
||||||
|
navigator drawer position against a real viewport instead of scrolling away. */
|
||||||
|
.content-area.embed-full {
|
||||||
|
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
|
||||||
|
flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
|
||||||
|
child's `height:100%` resolves to auto and the iframe collapses to its 150px
|
||||||
|
default. Opting out of flex sizing makes the height definite. */
|
||||||
|
flex: none;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
height: calc(100vh - var(--wp-chrome-h, 96px));
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.content-area.embed-full > .tool.active {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0; /* let it shrink instead of overflowing the shell */
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#wp-frame {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
#wp-frame.fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
|
||||||
|
body.embed-full { overflow: hidden; }
|
||||||
|
|
||||||
.tool {
|
.tool {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -242,10 +281,15 @@ body {
|
|||||||
border-left: 4px solid var(--primary);
|
border-left: 4px solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FIELDS */
|
/* FIELDS
|
||||||
|
The wizard's fields were one per row, which looked right in a 1000px column but
|
||||||
|
stretches a text input across the screen now that the content area is wide. Flow
|
||||||
|
them into as many ~340px columns as fit; `.col1` still forces a single column for
|
||||||
|
the fields that genuinely want the width (long text, textareas). */
|
||||||
.field-grid {
|
.field-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-grid.col1 { grid-template-columns: 1fr; }
|
.field-grid.col1 { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -145,14 +145,14 @@
|
|||||||
<input type="checkbox" id="role_super" checked disabled>
|
<input type="checkbox" id="role_super" checked disabled>
|
||||||
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
<select id="role_super_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="role-required">
|
<div class="role-required">
|
||||||
<div class="role-checkbox">
|
<div class="role-checkbox">
|
||||||
<input type="checkbox" id="role_foreman" checked disabled>
|
<input type="checkbox" id="role_foreman" checked disabled>
|
||||||
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
<select id="role_foreman_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></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;">
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
|
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
||||||
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
|
<iframe id="wp-frame" title="Work Package Creator" style="display:none"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1322,9 +1322,10 @@ 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.
|
// The navigator drawer + its handle hang below the page header. Deliberately NOT
|
||||||
const top=(hdr?hdr.offsetHeight:0)+((nav && nav.style.display!=='none')?nav.offsetHeight:0);
|
// including the section-nav height: that bar is sticky, so at scroll 0 it sits
|
||||||
document.documentElement.style.setProperty('--rail-top', top+'px');
|
// further down the page and the handle would float over the chrome.
|
||||||
|
document.documentElement.style.setProperty('--rail-top', (hdr?hdr.offsetHeight:0)+'px');
|
||||||
}
|
}
|
||||||
let _snLastY=0, _snBound=false;
|
let _snLastY=0, _snBound=false;
|
||||||
function initSectionNavAutoHide(){
|
function initSectionNavAutoHide(){
|
||||||
@@ -1377,14 +1378,85 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
|||||||
// Every saved package on this project, grouped by status, filterable. Clicking a
|
// 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").
|
// 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'];
|
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
||||||
|
// ── drawer behaviour ─────────────────────────────────────────────────────────
|
||||||
|
// Hover the edge handle to open, move the pointer away to close. Pinning keeps it
|
||||||
|
// open and shifts the form across; the pin is remembered. Touch devices have no
|
||||||
|
// hover, so tapping the handle opens it and it stays until you pick something,
|
||||||
|
// tap outside, or press Escape.
|
||||||
|
let _navCloseTimer = null;
|
||||||
|
|
||||||
|
function openWpNav(){
|
||||||
|
clearTimeout(_navCloseTimer);
|
||||||
|
document.body.classList.add('wp-nav-open');
|
||||||
|
const h = document.getElementById('wp-nav-handle');
|
||||||
|
if(h) h.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
function closeWpNav(force){
|
||||||
|
clearTimeout(_navCloseTimer);
|
||||||
|
if(navPinned() && !force) return; // pinned stays put unless explicitly closed
|
||||||
|
if(force) setNavPinned(false);
|
||||||
|
document.body.classList.remove('wp-nav-open');
|
||||||
|
const h = document.getElementById('wp-nav-handle');
|
||||||
|
if(h) h.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
function toggleWpNav(){
|
function toggleWpNav(){
|
||||||
document.body.classList.toggle('wp-nav-collapsed');
|
if(document.body.classList.contains('wp-nav-open') || navPinned()) closeWpNav(true);
|
||||||
try{ localStorage.setItem('wp_nav_collapsed', document.body.classList.contains('wp-nav-collapsed')?'1':''); }catch(e){}
|
else openWpNav();
|
||||||
|
}
|
||||||
|
function navPinned(){ return document.body.classList.contains('wp-nav-pinned'); }
|
||||||
|
function setNavPinned(on){
|
||||||
|
document.body.classList.toggle('wp-nav-pinned', !!on);
|
||||||
|
const btn = document.getElementById('wp-nav-pin');
|
||||||
|
if(btn){
|
||||||
|
btn.classList.toggle('is-on', !!on);
|
||||||
|
btn.title = on ? 'Unpin (let it hide again)' : 'Keep this list open';
|
||||||
|
}
|
||||||
|
try{ localStorage.setItem('wp_nav_pinned', on ? '1' : ''); }catch(e){}
|
||||||
|
positionSectionNav(); // pinned changes nothing about --rail-top, but keep them in step
|
||||||
|
}
|
||||||
|
function toggleWpNavPin(){
|
||||||
|
const on = !navPinned();
|
||||||
|
setNavPinned(on);
|
||||||
|
if(on) openWpNav(); else closeWpNav(true);
|
||||||
|
track(on ? 'wp_nav_pinned' : 'wp_nav_unpinned');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWpNavDrawer(){
|
||||||
|
const nav = document.getElementById('wp-nav');
|
||||||
|
const handle = document.getElementById('wp-nav-handle');
|
||||||
|
if(!nav || !handle || nav.dataset.bound) return;
|
||||||
|
nav.dataset.bound = '1';
|
||||||
|
|
||||||
|
// Hover in / out. A short close delay stops the drawer snapping shut while the
|
||||||
|
// pointer crosses the gap between handle and panel.
|
||||||
|
const armClose = () => {
|
||||||
|
clearTimeout(_navCloseTimer);
|
||||||
|
_navCloseTimer = setTimeout(() => closeWpNav(false), 350);
|
||||||
|
};
|
||||||
|
handle.addEventListener('mouseenter', openWpNav);
|
||||||
|
handle.addEventListener('mouseleave', armClose);
|
||||||
|
nav.addEventListener('mouseenter', () => clearTimeout(_navCloseTimer));
|
||||||
|
nav.addEventListener('mouseleave', armClose);
|
||||||
|
// Opening from the keyboard should work too.
|
||||||
|
handle.addEventListener('focus', openWpNav);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNav(false); });
|
||||||
|
// Tap/click outside closes it (the touch path, where there's no mouseleave).
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if(navPinned()) return;
|
||||||
|
if(!document.body.classList.contains('wp-nav-open')) return;
|
||||||
|
if(nav.contains(e.target) || handle.contains(e.target)) return;
|
||||||
|
closeWpNav(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
try{ if(localStorage.getItem('wp_nav_pinned')) setNavPinned(true); }catch(e){}
|
||||||
}
|
}
|
||||||
function renderWpNav(){
|
function renderWpNav(){
|
||||||
const list=document.getElementById('wp-nav-list'); if(!list) return;
|
const list=document.getElementById('wp-nav-list'); if(!list) return;
|
||||||
const cnt=document.getElementById('wp-nav-count');
|
const cnt=document.getElementById('wp-nav-count');
|
||||||
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
|
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
|
||||||
|
const badge=document.getElementById('wp-nav-handle-count');
|
||||||
|
if(badge) badge.textContent = savedPackages.length;
|
||||||
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
|
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
|
||||||
// Keep the original index — edit/view act on savedPackages by position.
|
// Keep the original index — edit/view act on savedPackages by position.
|
||||||
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
|
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
|
||||||
@@ -1422,6 +1494,7 @@ function renderWpNav(){
|
|||||||
}
|
}
|
||||||
function wpNavOpen(i){
|
function wpNavOpen(i){
|
||||||
const p=savedPackages[i]; if(!p) return;
|
const p=savedPackages[i]; if(!p) return;
|
||||||
|
closeWpNav(false); // get out of the way once you've chosen (unless pinned)
|
||||||
editingId=p.id;
|
editingId=p.id;
|
||||||
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
|
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
|
||||||
renderWpNav();
|
renderWpNav();
|
||||||
@@ -1930,7 +2003,7 @@ function bootData(){
|
|||||||
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){}
|
initWpNavDrawer();
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
positionSectionNav();
|
positionSectionNav();
|
||||||
cmtInit();
|
cmtInit();
|
||||||
|
|||||||
@@ -47,11 +47,17 @@
|
|||||||
|
|
||||||
<div class="wp-layout">
|
<div class="wp-layout">
|
||||||
|
|
||||||
<!-- WP NAVIGATOR (left rail — jump between every work package on this project) -->
|
<!-- WP NAVIGATOR — auto-hiding drawer. The handle is always visible; hover or tap
|
||||||
|
it to slide the list in over the form, or pin it to keep it open. -->
|
||||||
|
<button class="wp-nav-handle" id="wp-nav-handle" onclick="toggleWpNav()"
|
||||||
|
title="Work packages on this project" aria-label="Show work packages" aria-expanded="false">
|
||||||
|
Work Packages <span class="wp-nav-handle-count" id="wp-nav-handle-count">0</span>
|
||||||
|
</button>
|
||||||
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
|
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
|
||||||
<div class="wp-nav-head">
|
<div class="wp-nav-head">
|
||||||
<div class="wp-nav-title">Work Packages <span class="wp-nav-count" id="wp-nav-count"></span></div>
|
<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>
|
<button class="wp-nav-btn" id="wp-nav-pin" onclick="toggleWpNavPin()" title="Keep this list open" aria-label="Pin list open">📌</button>
|
||||||
|
<button class="wp-nav-btn" onclick="closeWpNav(true)" title="Close list" aria-label="Close list">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter by number, subject, type…" oninput="renderWpNav()">
|
<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-list" id="wp-nav-list"></div>
|
||||||
@@ -60,7 +66,6 @@
|
|||||||
<button class="add-btn" onclick="showDashboard()">📊 Dashboard</button>
|
<button class="add-btn" onclick="showDashboard()">📊 Dashboard</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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">
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,13 @@
|
|||||||
.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 ──
|
||||||
The form sits in a wide two-column shell: a sticky work-package navigator on
|
The form uses the full width it's given. The work-package navigator is an
|
||||||
the left and the form itself filling the rest of the screen. */
|
auto-hiding overlay drawer (see below) rather than a column, so it never takes
|
||||||
.wp-layout { display: flex; align-items: flex-start; gap: 0; max-width: 1760px; margin: 0 auto; }
|
width away from the form — which matters most when this page is embedded in the
|
||||||
.main { flex: 1 1 auto; min-width: 0; max-width: none; margin: 0; padding: 28px 32px 64px; }
|
suite's tab and every pixel is shared with the app chrome. */
|
||||||
|
.wp-layout { display: block; width: 100%; margin: 0; }
|
||||||
|
.main { min-width: 0; max-width: none; margin: 0; padding: 22px 28px 72px var(--gutter,34px);
|
||||||
|
transition: padding-left .18s ease; }
|
||||||
|
|
||||||
.section { display: none; }
|
.section { display: none; }
|
||||||
.section.active { display: block; animation: fade .25s ease; }
|
.section.active { display: block; animation: fade .25s ease; }
|
||||||
@@ -435,7 +438,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:1760px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
|
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px var(--gutter,34px); 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; }
|
||||||
@@ -447,7 +450,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:1760px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
|
.mode-wrap { max-width:none; margin:0; 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; }
|
||||||
@@ -471,7 +474,7 @@
|
|||||||
|
|
||||||
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
||||||
.sop-hint { color:var(--accent) !important; }
|
.sop-hint { color:var(--accent) !important; }
|
||||||
.release-banner { max-width:1760px; margin:0 auto; padding:0 28px; }
|
.release-banner { max-width:none; margin:0; padding:0 28px 0 var(--gutter,34px); }
|
||||||
.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; }
|
||||||
@@ -579,7 +582,7 @@
|
|||||||
|
|
||||||
/* Section nav (jump chips) */
|
/* Section nav (jump chips) */
|
||||||
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||||
padding:8px 12px; background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
|
padding:8px 12px 8px var(--gutter,34px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
|
||||||
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
|
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
|
||||||
transition:transform .22s ease; }
|
transition:transform .22s ease; }
|
||||||
.section-nav-bar:empty{ display:none; }
|
.section-nav-bar:empty{ display:none; }
|
||||||
@@ -588,49 +591,6 @@
|
|||||||
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 ───────────────────────────────────────────────────
|
/* ── SOP-inherited marker ───────────────────────────────────────────────────
|
||||||
The "from SOP types" subtext used to sit under the field. It's now a small
|
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).
|
chip on the label with the detail in a hover tooltip (site comment 8/3).
|
||||||
@@ -652,7 +612,7 @@
|
|||||||
.sop-chip:hover::after, .sop-chip:hover::before,
|
.sop-chip:hover::after, .sop-chip:hover::before,
|
||||||
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
|
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
|
||||||
|
|
||||||
/* ── people picker (Assignees / Distribution) ───────────────────────────────
|
/* ── people picker (Assignees / Distribution / Predecessors) ─────────────────
|
||||||
Multi-select over the SOP project team instead of a free-text list. */
|
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);
|
.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; }
|
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
|
||||||
@@ -689,6 +649,106 @@
|
|||||||
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
||||||
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
|
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
|
||||||
|
|
||||||
|
/* ── WP NAVIGATOR — auto-hiding drawer ─────────────────────────────────────
|
||||||
|
Was a fixed 262px column, which (a) stole width from the form and (b) vanished
|
||||||
|
entirely under 1100px — so embedded in the suite tab it was never visible at
|
||||||
|
all. Now it slides in over the content from a slim edge handle: hover (or tap)
|
||||||
|
the handle to open, move away to close, or pin it open if you'd rather it stay.
|
||||||
|
Nothing is taken from the form unless you pin it. */
|
||||||
|
.wp-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--rail-top, 0px);
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 300px;
|
||||||
|
max-width: 86vw;
|
||||||
|
z-index: 120;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border-strong);
|
||||||
|
box-shadow: 6px 0 22px rgba(20,30,50,.16);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform .18s ease;
|
||||||
|
}
|
||||||
|
body.wp-nav-open .wp-nav,
|
||||||
|
body.wp-nav-pinned .wp-nav { transform: none; }
|
||||||
|
/* Pinned: no shadow (it's part of the layout now) and the form shifts over. */
|
||||||
|
body.wp-nav-pinned .wp-nav { box-shadow: none; }
|
||||||
|
|
||||||
|
/* The always-visible edge handle. Vertical so it costs ~26px of width. */
|
||||||
|
.wp-nav-handle {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--rail-top, 0px) + 14px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 119;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 5px;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 0 5px 5px 0;
|
||||||
|
box-shadow: 2px 0 8px rgba(20,30,50,.10);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wp-nav-handle:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.wp-nav-handle .wp-nav-handle-count {
|
||||||
|
writing-mode: horizontal-tb; font-size: 10px; font-weight: 700; letter-spacing: 0;
|
||||||
|
background: var(--accent-dim); color: var(--accent); border-radius: 8px; padding: 0 5px;
|
||||||
|
}
|
||||||
|
body.wp-nav-open .wp-nav-handle,
|
||||||
|
body.wp-nav-pinned .wp-nav-handle { display: none; }
|
||||||
|
|
||||||
|
.wp-nav-head { display:flex; align-items:center; gap:8px; padding:12px 12px 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-btn { background:transparent; border:1px solid var(--border); border-radius:4px;
|
||||||
|
color:var(--text-muted); cursor:pointer; font-size:13px; line-height:1; padding:3px 7px; }
|
||||||
|
.wp-nav-btn:hover { border-color:var(--accent); color:var(--accent); }
|
||||||
|
.wp-nav-btn.is-on { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
||||||
|
.wp-nav-head .wp-nav-btn:first-of-type { margin-left:auto; }
|
||||||
|
.wp-nav-search { margin:0 12px 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; }
|
||||||
|
/* Keep the drawer's footer clear of the fixed save bar. */
|
||||||
|
body.has-sticky-save .wp-nav { bottom: 54px; }
|
||||||
|
/* Pinned: the page chrome shifts with the form so nothing hides behind the panel. */
|
||||||
|
body.wp-nav-pinned { --gutter: 328px; }
|
||||||
|
body.wp-nav-pinned .sticky-save { left: 300px; }
|
||||||
|
|
||||||
|
/* Narrow screens: never pin (there isn't the width) — overlay only. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
body.wp-nav-pinned { --gutter: 34px; } /* no room to pin — overlay only */
|
||||||
|
.wp-nav { width: 290px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* 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;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""per-project member role
|
||||||
|
|
||||||
|
Lets someone be Project Admin on one job and a normal Project User on another.
|
||||||
|
Empty string means "inherit the account's own role" (users.role), which is exactly
|
||||||
|
how every existing membership behaved, so this is a no-op for current data.
|
||||||
|
|
||||||
|
Revision ID: d15b8c4ef207
|
||||||
|
Revises: c93f2b1d7e04
|
||||||
|
Create Date: 2026-08-03 17:58:22.401118
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd15b8c4ef207'
|
||||||
|
down_revision = 'c93f2b1d7e04'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('project_members', sa.Column('role', sa.String(length=20),
|
||||||
|
nullable=False, server_default=''))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('project_members', 'role')
|
||||||
@@ -137,16 +137,36 @@ 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 effective_role(db: Session, user: "models.User", project_id: Optional[str]) -> str:
|
||||||
|
"""The user's permissions role ON THIS PROJECT.
|
||||||
|
|
||||||
|
An app admin is admin everywhere. Otherwise a membership row may carry its own
|
||||||
|
role — so a PM on one job can be a plain Project User on another — and an empty
|
||||||
|
membership role falls back to the account's own role."""
|
||||||
|
if auth.is_admin(user):
|
||||||
|
return auth.ROLE_ADMIN
|
||||||
|
if project_id:
|
||||||
|
row = db.scalars(
|
||||||
|
select(models.ProjectMember).where(
|
||||||
|
(models.ProjectMember.user_id == user.id)
|
||||||
|
& (models.ProjectMember.project_id == project_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row and (row.role or "").strip():
|
||||||
|
return auth.normalize_role(row.role)
|
||||||
|
return auth.normalize_role(user.role)
|
||||||
|
|
||||||
|
|
||||||
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
|
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
|
||||||
what: str = "this action") -> None:
|
what: str = "this action") -> None:
|
||||||
"""Destructive / baseline-changing operations: deleting a work package or a
|
"""Destructive / baseline-changing operations: deleting a work package or a
|
||||||
project, and editing a SOP that has already been completed. Requires project
|
project, and editing a SOP that has already been completed. Requires project
|
||||||
access AND the project_admin (or admin) permissions role."""
|
access AND Project Admin *on that project*."""
|
||||||
require_project_access(db, user, project_id)
|
require_project_access(db, user, project_id)
|
||||||
if not auth.is_project_admin(user):
|
if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail=f"{what} requires the Project Admin permissions role",
|
detail=f"{what} requires the Project Admin role on this project",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +376,9 @@ class RoleIn(BaseModel):
|
|||||||
|
|
||||||
class ProjectAssignIn(BaseModel):
|
class ProjectAssignIn(BaseModel):
|
||||||
project_ids: list[str] = Field(default_factory=list)
|
project_ids: list[str] = Field(default_factory=list)
|
||||||
|
# Optional per-project permissions role, {project_id: role}. Omit or use '' to
|
||||||
|
# inherit the account's own role on that project.
|
||||||
|
roles: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||||
@@ -711,11 +734,13 @@ def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_a
|
|||||||
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")
|
||||||
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
|
rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
|
||||||
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||||
return {
|
return {
|
||||||
"user": u.to_dict(),
|
"user": u.to_dict(),
|
||||||
"assigned": list(assigned),
|
"assigned": [r.project_id for r in rows],
|
||||||
|
# Per-project role overrides, keyed by project id ('' = inherit the account's).
|
||||||
|
"roles": {r.project_id: (r.role or "") for r in rows},
|
||||||
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,11 +752,19 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
|
|||||||
if not u:
|
if not u:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
||||||
|
# Only the two project-scoped roles make sense here: app admin is global, and
|
||||||
|
# anything unrecognised falls back to inheriting the account's own role.
|
||||||
|
allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||||
|
roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
|
||||||
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
||||||
for pid in valid:
|
for pid in valid:
|
||||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
|
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
|
||||||
|
role=roles.get(pid, "")))
|
||||||
|
log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username,
|
||||||
|
detail={"projects": len(valid),
|
||||||
|
"overrides": {p: r for p, r in roles.items() if p in valid}})
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"assigned": sorted(valid)}
|
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
|
||||||
|
|
||||||
|
|
||||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||||
@@ -1494,7 +1527,7 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
|
|||||||
seen.add(u.id)
|
seen.add(u.id)
|
||||||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
|
out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
|
||||||
"email": u.email, "project_role": u.project_role or "",
|
"email": u.email, "project_role": u.project_role or "",
|
||||||
"role": auth.normalize_role(u.role)})
|
"role": effective_role(db, u, project_id)})
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -168,9 +168,13 @@ class User(Base):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectMember(Base):
|
class ProjectMember(Base):
|
||||||
"""Which users may access which projects. A user sees/operates on a project
|
"""Which users may access which projects, and what they may do there. A user
|
||||||
only if a row links them to it (admins bypass this entirely). One row per
|
sees/operates on a project only if a row links them to it (admins bypass this
|
||||||
(user, project) pair."""
|
entirely). One row per (user, project) pair.
|
||||||
|
|
||||||
|
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
||||||
|
one job and a normal Project User on another. Empty means "inherit the account's
|
||||||
|
own role" (User.role), which is how every existing row behaves."""
|
||||||
__tablename__ = "project_members"
|
__tablename__ = "project_members"
|
||||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||||
|
|
||||||
@@ -181,6 +185,7 @@ class ProjectMember(Base):
|
|||||||
project_id: Mapped[str] = mapped_column(
|
project_id: Mapped[str] = mapped_column(
|
||||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user