Fix the WP navigator and the squeezed embedded layout; add per-project permissions
Layout — the reported "skinny scrolling windows" - .content-area capped the whole suite at 1000px, so on a 1920 screen the embedded Work Package Creator ran in a ~930px column with its own scrollbar inside the page's. The wizard now caps at 1700px and the Creator/Dashboard tab goes full-bleed: the iframe fills the window below the app chrome and owns the only scrollbar. Needed `flex: none` on the content area — as a `flex: 1` item its flex-basis overrode `height`, leaving the used height indefinite so the child's `height: 100%` collapsed the iframe to its 150px default. - The SOP wizard's fields were one per row; they now flow into ~340px columns. Navigator — now an auto-hiding drawer - It was a fixed 262px column that stole width from the form AND was hidden below 1100px, so embedded (the normal path) it never appeared at all — that's the "broken side menu". It's now an overlay drawer behind a slim always-visible edge handle: hover or tap to open, move away / Escape / pick a package to close, or pin it to keep it open (pinned shifts the form and the page chrome across, and is remembered). A gutter keeps the handle off the section-nav chips. Bugs found while checking the site over - collectStepData() still read the SOP team fields as text inputs, but wave 1 made them account pickers — so it wrote a user ID into state.team.pm where the display NAME belongs, and the SOP would print `user_ab12…` as the PM. Now synced properly from the pickers. - loadSampleData() set .value on those selects with fictional names; setting an unmatched value on a <select> silently does nothing, so the sample lost its team. It now stores them as names without an account, which the picker shows as "(no account)". - My earlier CSS block replacement had deleted the SOP-chip, people-picker and critical-tag styles. Restored. Same picker everywhere the SOP names someone - Sign-off roles (step 3, required and optional) are account pickers now, storing userId alongside the name, so a signature belongs to an account that can be notified. Titles stay free text. Per-project permissions (asked for: "change project permissions for individual users") - project_members.role overrides the account's role on that project, so a PM on one job can be a Project User on another. Empty = inherit; app admin is admin everywhere. effective_role() feeds require_project_admin, so WP delete, completed- SOP edits and project delete are all judged per project. - Project access is now its own column in the admin console (it was buried among the action buttons, which is why it couldn't be found), showing the project count per account; the dialog sets access plus the role on each project. - The members endpoint reports each person's effective role on that project. Verified: 157 API checks across five suites on clean databases (44 permissions + 22 password reset + 34 search/localization + 39 gates/notifications + 18 new per-project permission checks), 16 drawer-behaviour + 4 pinned-mode UI checks driven in headless Chrome, and probes confirming the team/sign-off pickers populate and no longer corrupt state.team on step navigation. Screenshots reviewed at 1920x1080. Service-worker cache bumped to v3 so browsers pick up the new shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,9 @@ async function loadUsers(){
|
||||
banner.style.display='none';
|
||||
const meId = await currentUserId();
|
||||
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.
|
||||
@@ -197,6 +200,42 @@ function fillProjectRoleOptions(){
|
||||
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){
|
||||
const wrap=document.getElementById('users-table');
|
||||
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>'+roleCell+'</td>'+
|
||||
'<td>'+projRoleCell+'</td>'+
|
||||
'<td>'+projAccessCell(u)+'</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"><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>'+
|
||||
disableBtn+delBtn+
|
||||
'</div></td>'+
|
||||
@@ -253,6 +292,7 @@ function renderUsers(list, meId){
|
||||
'<th>Username</th><th>Name</th><th>Email</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="Which projects this user can access, and their role on each">Project access</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> — '+
|
||||
@@ -333,25 +373,45 @@ async function deleteUser(id, username){
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
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 openProjectModal(userId, username, projects, assigned, userObj){
|
||||
function openProjectModal(userId, username, projects, assigned, userObj, roles){
|
||||
closeProjectModal();
|
||||
const isAdmin = userObj && userObj.role==='admin';
|
||||
const items = projects.length ? projects.map(p =>
|
||||
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
|
||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
|
||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
||||
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
|
||||
const isAdmin = userObj && normRole(userObj.role)==='admin';
|
||||
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
|
||||
roles = roles || {};
|
||||
// Each project row: access tick + the role ON THAT project. "Same as account"
|
||||
// inherits the account's Permissions, so the common case needs no thought.
|
||||
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');
|
||||
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.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="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
|
||||
'<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 & permissions — '+uesc(username)+'</div>'+
|
||||
'<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>'+
|
||||
'<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');
|
||||
if(saveBtn) saveBtn.onclick = async () => {
|
||||
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});
|
||||
if(status===200) closeProjectModal();
|
||||
const roleMap = {};
|
||||
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+').');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
'use strict';
|
||||
// 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 CACHE = 'wp-suite-shell-v3';
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||
'/field.html', '/login.html', '/admin.html',
|
||||
|
||||
@@ -342,19 +342,25 @@ function loadSampleData(){
|
||||
document.getElementById('proj_division').value = 'Semiconductor';
|
||||
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
||||
|
||||
// Populate Step 2
|
||||
document.getElementById('proj_pm').value = 'Mariano Sanchez';
|
||||
document.getElementById('proj_apm').value = 'Assistant PM';
|
||||
document.getElementById('proj_cm').value = 'K. Boyd';
|
||||
document.getElementById('proj_qm').value = 'D. Nguyen';
|
||||
// Populate Step 2. The leadership slots are account pickers now, so the sample's
|
||||
// fictional names can't be "selected" — setting .value on a <select> with no
|
||||
// matching option silently does nothing. Store them as names without an account,
|
||||
// which is exactly how the picker shows a person who isn't a suite user yet.
|
||||
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
|
||||
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
|
||||
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
|
||||
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
|
||||
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
|
||||
document.getElementById('role_super_name').value = 'John Smith';
|
||||
document.getElementById('role_foreman_name').value = 'Mike Jones';
|
||||
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
|
||||
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
|
||||
renderSignoffRolePickers();
|
||||
|
||||
// Populate Step 5
|
||||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
||||
@@ -425,11 +431,12 @@ function repopulateForm(){
|
||||
set('proj_client', state.project.client);
|
||||
set('proj_division', state.project.division);
|
||||
set('proj_site', state.project.site);
|
||||
// The four leadership slots are user-account pickers, not text inputs —
|
||||
// renderTeamPickers() builds their options and marks the current selection.
|
||||
// The leadership slots and sign-off names are account pickers, not text inputs —
|
||||
// these build their options and mark the current selection.
|
||||
renderTeamPickers();
|
||||
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); }
|
||||
renderSignoffRolePickers();
|
||||
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);
|
||||
// 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.
|
||||
@@ -472,11 +479,33 @@ function switchTool(tool){
|
||||
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||||
|
||||
if(contentTool === 'wp') renderWPTab(isDash);
|
||||
applyEmbedLayout(contentTool === 'wp');
|
||||
|
||||
updateStepUI();
|
||||
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.
|
||||
// wantDash=true opens the creator straight to the dashboard view.
|
||||
function renderWPTab(wantDash){
|
||||
@@ -623,6 +652,7 @@ async function loadProjectUsers(){
|
||||
projectUsersLoaded = true;
|
||||
renderTeamPickers();
|
||||
renderTeamMembers();
|
||||
renderSignoffRolePickers();
|
||||
}
|
||||
|
||||
// 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){
|
||||
if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing
|
||||
const u = userById(userId);
|
||||
@@ -715,7 +801,7 @@ function renderOptionalRoles(){
|
||||
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
|
||||
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
|
||||
</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>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -1019,17 +1105,19 @@ function collectStepData(){
|
||||
state.project.site = document.getElementById('proj_site').value;
|
||||
break;
|
||||
case 2:
|
||||
state.team.pm = document.getElementById('proj_pm').value;
|
||||
state.team.apm = document.getElementById('proj_apm').value;
|
||||
state.team.cm = document.getElementById('proj_cm').value;
|
||||
state.team.qm = document.getElementById('proj_qm').value;
|
||||
// These are user-account pickers now, so their .value is an ID, not a name.
|
||||
// Reading them straight into state.team would put an id where the display
|
||||
// name belongs (and it would then print on the SOP as `user_ab12…`).
|
||||
syncTeamFromPickers();
|
||||
break;
|
||||
case 3:
|
||||
// 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].name = document.getElementById('role_super_name').value;
|
||||
state.signoffRoles[1].role = (document.getElementById('role_foreman_title').value || 'Role 2').trim();
|
||||
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
|
||||
// Titles are still free text; the NAMES are account pickers whose .value is
|
||||
// an id, so they're maintained by their own onchange (see
|
||||
// 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;
|
||||
case 5:
|
||||
state.governance.woformat = document.getElementById('gov_woformat').value;
|
||||
@@ -1113,7 +1201,10 @@ function completeSOP(){
|
||||
site: state.project.site,
|
||||
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: {
|
||||
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
|
||||
woSize: state.governance.wosize,
|
||||
|
||||
@@ -167,15 +167,54 @@ body {
|
||||
|
||||
.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 {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1000px;
|
||||
max-width: 1700px;
|
||||
margin: 0 auto;
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
@@ -242,10 +281,15 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
}
|
||||
|
||||
.field-grid.col1 { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -145,14 +145,14 @@
|
||||
<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>
|
||||
</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 class="role-required">
|
||||
<div class="role-checkbox">
|
||||
<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>
|
||||
</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 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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
@@ -1322,9 +1322,10 @@ function buildSectionNav(){
|
||||
function positionSectionNav(){
|
||||
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
|
||||
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');
|
||||
// The navigator drawer + its handle hang below the page header. Deliberately NOT
|
||||
// including the section-nav height: that bar is sticky, so at scroll 0 it sits
|
||||
// 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;
|
||||
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
|
||||
// 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'];
|
||||
// ── 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(){
|
||||
document.body.classList.toggle('wp-nav-collapsed');
|
||||
try{ localStorage.setItem('wp_nav_collapsed', document.body.classList.contains('wp-nav-collapsed')?'1':''); }catch(e){}
|
||||
if(document.body.classList.contains('wp-nav-open') || navPinned()) closeWpNav(true);
|
||||
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(){
|
||||
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 badge=document.getElementById('wp-nav-handle-count');
|
||||
if(badge) badge.textContent = 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=>{
|
||||
@@ -1422,6 +1494,7 @@ function renderWpNav(){
|
||||
}
|
||||
function wpNavOpen(i){
|
||||
const p=savedPackages[i]; if(!p) return;
|
||||
closeWpNav(false); // get out of the way once you've chosen (unless pinned)
|
||||
editingId=p.id;
|
||||
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
|
||||
renderWpNav();
|
||||
@@ -1930,7 +2003,7 @@ function bootData(){
|
||||
bootSOP();
|
||||
setRadio('status','Draft');
|
||||
loadMembers();
|
||||
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
|
||||
initWpNavDrawer();
|
||||
renderSavedList();
|
||||
positionSectionNav();
|
||||
cmtInit();
|
||||
|
||||
@@ -47,11 +47,17 @@
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -60,7 +66,6 @@
|
||||
<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">
|
||||
|
||||
|
||||
@@ -100,10 +100,13 @@
|
||||
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
|
||||
|
||||
/* ── MAIN ──
|
||||
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; }
|
||||
The form uses the full width it's given. The work-package navigator is an
|
||||
auto-hiding overlay drawer (see below) rather than a column, so it never takes
|
||||
width away from the form — which matters most when this page is embedded in the
|
||||
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.active { display: block; animation: fade .25s ease; }
|
||||
@@ -435,7 +438,7 @@
|
||||
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
|
||||
|
||||
/* ── 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; }
|
||||
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
||||
.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); }
|
||||
.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-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; }
|
||||
@@ -471,7 +474,7 @@
|
||||
|
||||
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
||||
.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;
|
||||
display:flex; align-items:center; gap:10px; }
|
||||
.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-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);
|
||||
transition:transform .22s ease; }
|
||||
.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; }
|
||||
.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).
|
||||
@@ -652,7 +612,7 @@
|
||||
.sop-chip:hover::after, .sop-chip:hover::before,
|
||||
.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. */
|
||||
.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; }
|
||||
@@ -689,6 +649,106 @@
|
||||
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
||||
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{ 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;
|
||||
|
||||
Reference in New Issue
Block a user