Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
Site comments (8/3) - BIM card: LOD removed, IFF # added next to the coordination status, and required once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable. A LOD already stored on a package is preserved and shown as legacy, not blanked. - The blue "from SOP types" subtext under a field is now a SOP chip on the label with the detail in a tooltip. The chip stays visible rather than hover-only: field tablets have no hover, and "this came from the SOP" is the part that matters. The hint elements stay in the DOM (hidden) so the code writing to them keeps working; an observer mirrors their text into the tooltip. - Specification Section is no longer typed per package. Each WP type carries a spec section on the SOP; the field is read-only in the Creator and follows the type, with the SOP's spec folder linked underneath. This reads both spec comments as one intent — stop typing it, derive it. - Assignees and Distribution are multi-selects over the SOP project team, showing each person's job function, with the CM pre-added to Distribution (removable per package) and a free-text option for people with no account. The stored display strings are unchanged so print/export/dashboard keep working; account ids ride alongside for the notification work in wave 3. Localization + time - Per-user locale/timezone (Language & time in the user menu), an app-wide default in the admin console, then the browser. Timezones are validated against the server's zoneinfo and the picker is fed from it. Calendar dates are formatted from their parts so a due date never reads a day early in another zone. - Every displayed timestamp now goes through the shared helpers. Top-bar chrome - Project switcher beside the logo and a centered global search, injected into either generation of top bar; skipped in an iframe so the embedded Creator doesn't get a second one. Ctrl/Cmd-K focuses search. - GET /api/search covers work packages, projects and SOPs, scoped to the caller's projects, hiding archived packages, with LIKE wildcards escaped. Fixed along the way: showForm() cleared every card's inline display, which undid applyKind() — so the Package Type and BIM cards reappeared on an install-only project. Split out applyKindVisibility() and re-apply it there. Verified: 100 API checks on a fresh database (44 permissions + 22 password reset + 34 search/localization), 24 driven UI checks against the real Creator page in headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags, BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -346,6 +346,38 @@ hides the BIM/VDC section and every project is install-only (IWP). A SOP that
|
||||
already has BIM enabled keeps its data — it just stops being offered — so turning
|
||||
the flag off never deletes BIM types, gates, or sequence steps.
|
||||
|
||||
## Localization (dates, times, numbers)
|
||||
|
||||
Three levels, most specific first — resolved in `html/wp-format.js`:
|
||||
|
||||
1. **the user's own preference** — *Language & time* in the top-right menu
|
||||
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
|
||||
2. **the app default** — Admin console → Features → *Localization defaults*
|
||||
(`default_locale` / `default_timezone`)
|
||||
3. **the browser**, as before
|
||||
|
||||
Timezone names are validated against the server's own `zoneinfo` database, and the
|
||||
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
|
||||
Calendar dates (a due date, a kitting date) are formatted from their parts and are
|
||||
**never** shifted by a timezone — only real instants (MIMO windows, history,
|
||||
notifications) are converted. Use the shared helpers (`wpFormatDate`,
|
||||
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
|
||||
`toLocaleString()`, or a page will quietly ignore the preference.
|
||||
|
||||
## Top-bar chrome (project switcher + search)
|
||||
|
||||
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
|
||||
global search into whichever top bar a page has — the dark `.wp-appbar` or the
|
||||
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
|
||||
not get a second bar.
|
||||
|
||||
- Switching project reloads the current page with `?project=<id>`; every page
|
||||
already resolves its project from that parameter.
|
||||
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
|
||||
(`scope_to_access`) and hides archived work packages. LIKE wildcards in the query
|
||||
are escaped, so searching `100%` matches a literal `100%`. Two-character minimum.
|
||||
- Ctrl/Cmd-K focuses the field from anywhere.
|
||||
|
||||
## Schema migrations (Alembic)
|
||||
|
||||
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<style>
|
||||
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
|
||||
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
|
||||
@@ -207,5 +208,7 @@
|
||||
</div>
|
||||
|
||||
<script src="admin.js"></script>
|
||||
<script src="wp-format.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -200,7 +200,7 @@ function fillProjectRoleOptions(){
|
||||
function renderUsers(list, meId){
|
||||
const wrap=document.getElementById('users-table');
|
||||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
let rows = list.map(u=>{
|
||||
const me = u.id===meId;
|
||||
const active = u.is_active;
|
||||
@@ -394,7 +394,7 @@ function renderComments(){
|
||||
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
|
||||
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
|
||||
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
const where = c => {
|
||||
const bits = [];
|
||||
if(c.page) bits.push(uesc(c.page));
|
||||
@@ -432,7 +432,7 @@ function renderAudit(){
|
||||
let rows = _audit.filter(e => (!type || e.entity_type===type) &&
|
||||
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
|
||||
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
const det = e => {
|
||||
const d = e.detail || {};
|
||||
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
|
||||
@@ -471,7 +471,90 @@ function renderFeatures(){
|
||||
'<div class="note" style="margin-top:8px">When OFF, the SOP creator hides the BIM/VDC section entirely and '+
|
||||
'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+
|
||||
'stops being shown or offered, so no project can be put on the BIM path while it\'s off.</div>'+
|
||||
'<div id="features-msg" class="note" style="margin-top:6px"></div>';
|
||||
'<div id="features-msg" class="note" style="margin-top:6px"></div>'+
|
||||
|
||||
// Localization defaults. A user's own "Language & time" preference wins over
|
||||
// these; these decide what everyone else sees instead of the browser's guess.
|
||||
'<h2 style="margin-top:22px">Localization defaults</h2>'+
|
||||
'<div class="sub" style="margin-bottom:10px">How dates, times and numbers are written for users who haven\'t '+
|
||||
'set their own preference. Each user can override this from <strong>Language & time</strong> in the '+
|
||||
'top-right menu.</div>'+
|
||||
'<div class="urow">'+
|
||||
'<select id="set-locale" style="min-width:220px"></select>'+
|
||||
'<select id="set-tz" style="min-width:240px"></select>'+
|
||||
'<button class="primary" onclick="saveLocalization()">Save defaults</button>'+
|
||||
'<span id="l10n-msg" class="note" style="margin:0"></span>'+
|
||||
'</div>'+
|
||||
'<div class="note" id="l10n-preview" style="margin-top:8px"></div>';
|
||||
fillLocalization();
|
||||
}
|
||||
|
||||
// Locale shortlist mirrors wp-format.js so the admin default and the per-user
|
||||
// preference offer the same choices.
|
||||
const L10N_LOCALES = [['','Browser default'],['en-US','en-US — 8/3/2026, 2:07 PM'],
|
||||
['en-GB','en-GB — 03/08/2026, 14:07'],['en-CA','en-CA'],['es-MX','es-MX'],['es-US','es-US'],
|
||||
['fr-CA','fr-CA'],['de-DE','de-DE'],['ja-JP','ja-JP'],['ko-KR','ko-KR'],['zh-TW','zh-TW']];
|
||||
const L10N_ZONES = ['America/Chicago','America/New_York','America/Denver','America/Phoenix',
|
||||
'America/Los_Angeles','America/Boise','Asia/Tokyo','Asia/Taipei','Asia/Seoul','Asia/Singapore',
|
||||
'Europe/Dublin','Europe/London','UTC'];
|
||||
|
||||
function fillLocalization(){
|
||||
const s = _settings;
|
||||
const loc = document.getElementById('set-locale');
|
||||
const tz = document.getElementById('set-tz');
|
||||
if(!loc || !tz) return;
|
||||
const curL = s.default_locale || '', curZ = s.default_timezone || '';
|
||||
loc.innerHTML = L10N_LOCALES.map(p =>
|
||||
'<option value="'+uesc(p[0])+'"'+(p[0]===curL?' selected':'')+'>'+uesc(p[1])+'</option>').join('');
|
||||
if(curL && !L10N_LOCALES.some(p=>p[0]===curL)) loc.add(new Option(curL, curL, true, true));
|
||||
|
||||
let browserZone = '';
|
||||
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch(e){}
|
||||
tz.innerHTML = '<option value=""'+(curZ?'':' selected')+'>Browser default'+
|
||||
(browserZone?' ('+uesc(browserZone)+')':'')+'</option>'+
|
||||
L10N_ZONES.map(z => '<option value="'+uesc(z)+'"'+(z===curZ?' selected':'')+'>'+uesc(z)+'</option>').join('')+
|
||||
(curZ && L10N_ZONES.indexOf(curZ)<0 ? '<option value="'+uesc(curZ)+'" selected>'+uesc(curZ)+'</option>' : '');
|
||||
|
||||
const preview = () => {
|
||||
const el = document.getElementById('l10n-preview'); if(!el) return;
|
||||
let out;
|
||||
try {
|
||||
out = new Intl.DateTimeFormat(loc.value||undefined, {year:'numeric',month:'short',day:'numeric',
|
||||
hour:'2-digit',minute:'2-digit',timeZone:tz.value||undefined}).format(new Date());
|
||||
} catch(e){ out = 'not supported by this browser'; }
|
||||
el.textContent = 'Preview — right now reads: ' + out;
|
||||
};
|
||||
loc.onchange = preview; tz.onchange = preview; preview();
|
||||
|
||||
// Offer the server's full zone list once it arrives (it validates against the
|
||||
// same list, so anything offered here will be accepted).
|
||||
api('GET','/api/timezones').then(({status,json}) => {
|
||||
if(status!==200 || !Array.isArray(json) || !json.length) return;
|
||||
const rest = json.filter(z => L10N_ZONES.indexOf(z) < 0);
|
||||
if(!rest.length) return;
|
||||
const g = document.createElement('optgroup'); g.label = 'All time zones';
|
||||
rest.forEach(z => g.appendChild(new Option(z, z, false, z === curZ)));
|
||||
tz.appendChild(g);
|
||||
if(curZ) tz.value = curZ;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLocalization(){
|
||||
const msg = document.getElementById('l10n-msg');
|
||||
const patch = {
|
||||
default_locale: document.getElementById('set-locale').value,
|
||||
default_timezone: document.getElementById('set-tz').value,
|
||||
};
|
||||
msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
|
||||
const { status, json } = await api('PUT','/api/settings', patch);
|
||||
if(status===200){
|
||||
_settings = json; renderSettings();
|
||||
const m = document.getElementById('l10n-msg');
|
||||
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
|
||||
} else {
|
||||
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
|
||||
msg.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFeatures(){
|
||||
@@ -544,7 +627,7 @@ async function loadNotifications(){
|
||||
const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
|
||||
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
|
||||
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
|
||||
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
|
||||
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
|
||||
@@ -572,7 +655,7 @@ function loadUsage(){
|
||||
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
|
||||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||||
});
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
let html = '<table class="kv">'+
|
||||
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
||||
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
||||
|
||||
@@ -185,6 +185,10 @@
|
||||
wrap.appendChild(who);
|
||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
||||
if (typeof window.wpPreferences === 'function') {
|
||||
wrap.appendChild(sep());
|
||||
wrap.appendChild(link('Language & time', function () { window.wpPreferences(); }));
|
||||
}
|
||||
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
|
||||
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
||||
return wrap;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { -webkit-text-size-adjust: 100%; }
|
||||
@@ -82,5 +83,7 @@
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="field.js"></script>
|
||||
<script src="wp-format.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,7 +12,7 @@ function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').repla
|
||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||||
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||||
function fmtTs(s) { try { return new Date(s).toLocaleString(); } catch (e) { return s || ''; } }
|
||||
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
|
||||
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
|
||||
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
@@ -661,5 +662,7 @@
|
||||
.catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script src="wp-format.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,13 +11,17 @@
|
||||
in the background when online).
|
||||
*/
|
||||
'use strict';
|
||||
const CACHE = 'wp-suite-shell-v1';
|
||||
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||
// instead of serving a half-old shell from the previous cache.
|
||||
const CACHE = 'wp-suite-shell-v2';
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||
'/field.html', '/login.html', '/admin.html',
|
||||
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
||||
'/wp-chrome.css',
|
||||
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
||||
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
||||
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
|
||||
'/prime-controls-logo.jpg', '/favicon.ico',
|
||||
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
||||
];
|
||||
|
||||
@@ -550,6 +550,7 @@ function renderWPTypes(){
|
||||
container.innerHTML = `<div class="wp-types-header">
|
||||
<div>Work Order Type</div>
|
||||
<div style="text-align:center;">Enabled</div>
|
||||
<div>Spec Section</div>
|
||||
<div>Special Rules / Notes</div>
|
||||
<div>WO Complete Approval</div>
|
||||
</div>`;
|
||||
@@ -565,6 +566,7 @@ function renderWPTypes(){
|
||||
row.innerHTML = `
|
||||
${nameCell}
|
||||
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
|
||||
<input type="text" placeholder="e.g. 26_05_33_00" title="Specification section for this WP type. The Creator fills it in automatically on every package of this type, so nobody types it per package." value="${(t.specSection||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].specSection=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-family:var(--mono,monospace); font-size:12.5px;">
|
||||
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'"')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
`;
|
||||
@@ -1127,6 +1129,9 @@ function completeSOP(){
|
||||
enabled: true,
|
||||
notes: t.notes || '',
|
||||
approval: t.approval || '',
|
||||
// Spec section for this type — the Creator fills the WP's Specification
|
||||
// Section from it, so it's authored once here instead of per package.
|
||||
specSection: t.specSection || '',
|
||||
bim: !!t.bim
|
||||
})),
|
||||
sources: state.sources.filter(s=>s.label),
|
||||
|
||||
@@ -328,8 +328,8 @@ body {
|
||||
|
||||
.wp-type-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
@@ -340,8 +340,8 @@ body {
|
||||
|
||||
.wp-types-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 90px 2fr 1.5fr;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1.1fr 80px 1.3fr 1.6fr 1.2fr;
|
||||
gap: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -164,7 +165,8 @@
|
||||
<!-- STEP 4: WORK PACKAGE TYPES -->
|
||||
<div class="step" id="sop-step-4" style="display: none;">
|
||||
<h2>4. Work Package Types</h2>
|
||||
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
|
||||
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.
|
||||
<strong>Spec Section</strong> is filled onto every work package of that type automatically, so nobody types it per package.</div>
|
||||
<label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
|
||||
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
|
||||
<span><strong>Include BIM / VDC work packages on this project</strong><br>
|
||||
@@ -421,5 +423,7 @@
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="work-package-suite-app.js"></script>
|
||||
<script src="wp-format.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
211
html/wp-chrome.css
Normal file
211
html/wp-chrome.css
Normal file
@@ -0,0 +1,211 @@
|
||||
/* ============================================================================
|
||||
SHARED APP CHROME — project switcher + global search
|
||||
----------------------------------------------------------------------------
|
||||
Injected by wp-chrome.js into whichever top bar a page has: the dark UI-shell
|
||||
bar (.wp-appbar on index / admin / field) or the older light bars (.header on
|
||||
the SOP suite and the WP creator). The two live side by side, so every colour
|
||||
here comes from a variable that wp-chrome.js sets per host bar — the markup and
|
||||
behaviour are identical on both.
|
||||
============================================================================ */
|
||||
.wp-chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0; /* lets the search shrink instead of overflowing */
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* Light host bar (the two tool pages) */
|
||||
.wp-chrome {
|
||||
--wpc-fg: #161616;
|
||||
--wpc-fg-dim: #525252;
|
||||
--wpc-bg: #ffffff;
|
||||
--wpc-bg-soft: #f4f4f4;
|
||||
--wpc-border: #c6c6c6;
|
||||
--wpc-hover: #e8e8e8;
|
||||
--wpc-accent: #0f62fe;
|
||||
}
|
||||
/* Dark host bar (the UI-shell appbar) */
|
||||
.wp-chrome[data-bar="dark"] {
|
||||
--wpc-fg: #ffffff;
|
||||
--wpc-fg-dim: #c6c6c6;
|
||||
--wpc-bg: #262626;
|
||||
--wpc-bg-soft: #393939;
|
||||
--wpc-border: #6f6f6f;
|
||||
--wpc-hover: #353535;
|
||||
--wpc-accent: #78a9ff;
|
||||
}
|
||||
|
||||
/* ── project switcher ─────────────────────────────────────────────────────── */
|
||||
.wpc-proj { position: relative; flex: 0 0 auto; }
|
||||
.wpc-proj-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 280px;
|
||||
padding: 5px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--wpc-fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wpc-proj-btn:hover { background: var(--wpc-hover); border-color: var(--wpc-border); }
|
||||
.wpc-proj-btn[aria-expanded="true"] { background: var(--wpc-hover); border-color: var(--wpc-border); }
|
||||
.wpc-proj-labels { min-width: 0; }
|
||||
.wpc-proj-kicker {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--wpc-fg-dim);
|
||||
}
|
||||
.wpc-proj-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
.wpc-caret { flex: 0 0 auto; align-self: flex-end; margin-bottom: 3px; font-size: 10px;
|
||||
line-height: 1; color: var(--wpc-fg-dim); }
|
||||
|
||||
/* ── dropdown / results panel (shared shell) ──────────────────────────────── */
|
||||
.wpc-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
min-width: 320px;
|
||||
max-width: min(460px, 92vw);
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
color: #161616;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 8px 28px rgba(20, 30, 50, .22);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wpc-pop[hidden] { display: none; }
|
||||
.wpc-pop-head {
|
||||
padding: 9px 12px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .07em;
|
||||
text-transform: uppercase;
|
||||
color: #6f6f6f;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.wpc-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: #161616;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wpc-item:hover, .wpc-item.is-active { background: #f4f4f4; }
|
||||
.wpc-item.is-current { border-left-color: #0f62fe; background: #edf5ff; }
|
||||
.wpc-item-title { display: block; font-weight: 600; }
|
||||
.wpc-item-sub { display: block; font-size: 11.5px; color: #6f6f6f; }
|
||||
.wpc-item-mono { font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; font-size: 12px; color: #0f62fe; }
|
||||
.wpc-empty { padding: 14px 12px; font-size: 13px; color: #6f6f6f; }
|
||||
.wpc-pop-foot {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wpc-foot-btn {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #c6c6c6;
|
||||
background: #fff;
|
||||
color: #161616;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wpc-foot-btn:hover { border-color: #0f62fe; color: #0f62fe; }
|
||||
|
||||
/* ── global search ────────────────────────────────────────────────────────── */
|
||||
/* Centered in the bar: the wrapper takes the free space and centres a capped box,
|
||||
which keeps the field mid-screen without absolute positioning (so it can never
|
||||
sit on top of the bar's own buttons). */
|
||||
.wpc-search {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.wpc-search-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
height: 34px;
|
||||
background: var(--wpc-bg);
|
||||
border: 1px solid var(--wpc-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; }
|
||||
.wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; }
|
||||
.wpc-search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--wpc-fg);
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.wpc-search-input::placeholder { color: var(--wpc-fg-dim); }
|
||||
.wpc-kbd {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--wpc-fg-dim);
|
||||
border: 1px solid var(--wpc-border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wpc-search .wpc-pop { left: 50%; transform: translateX(-50%); min-width: min(560px, 92vw); }
|
||||
.wpc-clear {
|
||||
flex: 0 0 auto; background: transparent; border: 0; cursor: pointer;
|
||||
color: var(--wpc-fg-dim); font: inherit; font-size: 14px; line-height: 1; padding: 2px 4px;
|
||||
}
|
||||
.wpc-clear:hover { color: var(--wpc-fg); }
|
||||
|
||||
/* ── narrow screens ───────────────────────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.wpc-search-box { max-width: none; }
|
||||
.wpc-kbd { display: none; }
|
||||
.wpc-proj-btn { max-width: 190px; }
|
||||
.wpc-proj-name { max-width: 150px; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
/* Keep the switcher (you must be able to change project) and let the search
|
||||
collapse to an icon-width field rather than pushing the bar out of shape. */
|
||||
.wpc-proj-kicker { display: none; }
|
||||
.wpc-search { flex: 1 1 120px; }
|
||||
}
|
||||
335
html/wp-chrome.js
Normal file
335
html/wp-chrome.js
Normal file
@@ -0,0 +1,335 @@
|
||||
/* Shared app chrome for the Work Package Suite: a project switcher beside the
|
||||
Prime logo and a global search centered in the top bar.
|
||||
|
||||
One script for every page because there are two generations of top bar — the
|
||||
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
|
||||
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
|
||||
flip a colour set based on how dark the host bar is.
|
||||
|
||||
Search hits GET /api/search, which scopes results to the projects the signed-in
|
||||
user may access — so this is a convenience, never a way to see another job.
|
||||
|
||||
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
|
||||
second bar inside the frame would be nonsense. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
if (inIframe) return;
|
||||
|
||||
var SEARCH_MIN = 2; // characters before we ask the server
|
||||
var DEBOUNCE_MS = 180;
|
||||
|
||||
function el(tag, cls, html) {
|
||||
var n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (html != null) n.innerHTML = html;
|
||||
return n;
|
||||
}
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function isDark(node) {
|
||||
try {
|
||||
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return false;
|
||||
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// ── where to put the chrome ────────────────────────────────────────────────
|
||||
// Returns {host, insertBefore} or null. The insertion point matters: on the
|
||||
// dark bar we sit before the spacer (so search takes the middle); on the light
|
||||
// bars we sit between the left block and the right-hand buttons.
|
||||
function findMount() {
|
||||
var appbar = document.querySelector('.wp-appbar');
|
||||
if (appbar) {
|
||||
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
|
||||
}
|
||||
var header = document.querySelector('.header');
|
||||
if (header) {
|
||||
// The suite page wraps its own left/right groups; the creator's bar is a
|
||||
// flat row of buttons whose first button carries margin-left:auto.
|
||||
var right = header.querySelector('.header-right');
|
||||
if (right) return { host: header, before: right };
|
||||
var firstBtn = header.querySelector('.btn, button');
|
||||
return { host: header, before: firstBtn };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── project switcher ───────────────────────────────────────────────────────
|
||||
var projects = [];
|
||||
|
||||
function activeProject() {
|
||||
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function projectLabel(p) {
|
||||
if (!p) return 'Select a project';
|
||||
var n = p.name || '(unnamed)';
|
||||
return p.number ? (p.number + ' — ' + n) : n;
|
||||
}
|
||||
|
||||
// Switching project reloads the current page with ?project=<id>. Every page
|
||||
// already resolves its project from that param (falling back to the stored
|
||||
// active id), so a reload is both the simplest and the safest route — no page
|
||||
// has to re-hydrate half its state in place.
|
||||
function switchProject(p) {
|
||||
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
|
||||
var url = new URL(location.href);
|
||||
url.searchParams.set('project', p.id);
|
||||
url.hash = '';
|
||||
location.assign(url.toString());
|
||||
}
|
||||
|
||||
function buildProjectSwitcher() {
|
||||
var wrap = el('div', 'wpc-proj');
|
||||
var btn = el('button', 'wpc-proj-btn');
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-haspopup', 'listbox');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.title = 'Switch project';
|
||||
var cur = activeProject();
|
||||
btn.innerHTML =
|
||||
'<span class="wpc-proj-labels">' +
|
||||
'<span class="wpc-proj-kicker">Project</span>' +
|
||||
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
|
||||
'</span><span class="wpc-caret">▾</span>';
|
||||
var pop = el('div', 'wpc-pop');
|
||||
pop.hidden = true;
|
||||
wrap.appendChild(btn);
|
||||
wrap.appendChild(pop);
|
||||
|
||||
function render() {
|
||||
var curId = (activeProject() || {}).id || '';
|
||||
var rows = projects.map(function (p) {
|
||||
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
|
||||
'" data-pid="' + esc(p.id) + '">' +
|
||||
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
||||
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
|
||||
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
pop.innerHTML =
|
||||
'<div class="wpc-pop-head">Switch project</div>' +
|
||||
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
|
||||
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
|
||||
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
|
||||
item.addEventListener('click', function () {
|
||||
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
|
||||
if (p) switchProject(p);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function open() {
|
||||
render();
|
||||
pop.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
function close() {
|
||||
pop.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (pop.hidden) open(); else close();
|
||||
});
|
||||
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
||||
|
||||
// Refresh the label once the project list (and any active project) is known.
|
||||
wrap.wpcRefresh = function () {
|
||||
var c = activeProject();
|
||||
var nameEl = btn.querySelector('.wpc-proj-name');
|
||||
if (nameEl) nameEl.textContent = projectLabel(c);
|
||||
if (!pop.hidden) render();
|
||||
};
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function loadProjects(switcher) {
|
||||
// ProjectData.list() already hits the API and falls back to its local cache
|
||||
// when offline, so there's no second request to make here.
|
||||
var p;
|
||||
try {
|
||||
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
|
||||
} catch (e) { p = null; }
|
||||
if (!p) {
|
||||
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : []; });
|
||||
}
|
||||
Promise.resolve(p)
|
||||
.then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); })
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// ── global search ──────────────────────────────────────────────────────────
|
||||
function buildSearch() {
|
||||
var wrap = el('div', 'wpc-search');
|
||||
var box = el('div', 'wpc-search-box');
|
||||
box.innerHTML =
|
||||
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
|
||||
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
|
||||
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
|
||||
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
|
||||
'<span class="wpc-kbd">Ctrl K</span>';
|
||||
var pop = el('div', 'wpc-pop');
|
||||
pop.hidden = true;
|
||||
wrap.appendChild(box);
|
||||
wrap.appendChild(pop);
|
||||
|
||||
var input = box.querySelector('.wpc-search-input');
|
||||
var clear = box.querySelector('.wpc-clear');
|
||||
var timer = null, seq = 0, items = [], activeIx = -1;
|
||||
|
||||
function close() { pop.hidden = true; activeIx = -1; }
|
||||
|
||||
function highlight() {
|
||||
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
|
||||
n.classList.toggle('is-active', i === activeIx);
|
||||
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
// A work package lives inside the suite's Creator tab, so open the suite on
|
||||
// that project with the package requested; a SOP opens the SOP tab.
|
||||
function hrefFor(hit) {
|
||||
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
|
||||
if (hit.kind === 'wp') {
|
||||
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
|
||||
'&wp=' + encodeURIComponent(hit.id);
|
||||
}
|
||||
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
|
||||
}
|
||||
|
||||
function go(hit) {
|
||||
if (!hit) return;
|
||||
if (hit.kind === 'project') {
|
||||
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
|
||||
if (p) { switchProject(p); return; }
|
||||
}
|
||||
// Set the active project only from a full record — writing a stub would
|
||||
// clobber the cached project (name, number, client) other pages read. The
|
||||
// ?project= param in the URL is what actually switches context.
|
||||
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
|
||||
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
|
||||
location.assign(hrefFor(hit));
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
items = [];
|
||||
var html = '';
|
||||
function group(title, rows) {
|
||||
if (!rows.length) return;
|
||||
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
|
||||
}
|
||||
group('Work packages', (data.wps || []).map(function (w) {
|
||||
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
|
||||
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
|
||||
esc(w.subject || '') + '</span>' +
|
||||
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
||||
'</button>';
|
||||
}));
|
||||
group('Projects', (data.projects || []).map(function (p) {
|
||||
items.push({ kind: 'project', id: p.id });
|
||||
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
||||
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
|
||||
'</button>';
|
||||
}));
|
||||
group('SOPs', (data.sops || []).map(function (s) {
|
||||
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
|
||||
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
|
||||
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
||||
'</button>';
|
||||
}));
|
||||
if (!items.length) {
|
||||
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
|
||||
}
|
||||
pop.innerHTML = html;
|
||||
pop.hidden = false;
|
||||
activeIx = items.length ? 0 : -1;
|
||||
highlight();
|
||||
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
|
||||
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
|
||||
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
|
||||
});
|
||||
}
|
||||
|
||||
function run(q) {
|
||||
var mine = ++seq;
|
||||
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (mine !== seq) return; // a newer keystroke already won
|
||||
if (!data) { close(); return; }
|
||||
renderResults(data);
|
||||
})
|
||||
.catch(function () {
|
||||
if (mine !== seq) return;
|
||||
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
|
||||
pop.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var q = input.value.trim();
|
||||
clear.hidden = !q;
|
||||
clearTimeout(timer);
|
||||
if (q.length < SEARCH_MIN) { close(); return; }
|
||||
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
|
||||
});
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') { close(); input.blur(); return; }
|
||||
if (pop.hidden || !items.length) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
|
||||
});
|
||||
input.addEventListener('focus', function () {
|
||||
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
|
||||
});
|
||||
clear.addEventListener('click', function () {
|
||||
input.value = ''; clear.hidden = true; close(); input.focus();
|
||||
});
|
||||
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
||||
|
||||
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
|
||||
// use). Ignored while typing in another field so it can't steal a shortcut.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ── mount ──────────────────────────────────────────────────────────────────
|
||||
function mount() {
|
||||
if (document.querySelector('.wp-chrome')) return;
|
||||
var m = findMount();
|
||||
if (!m) return;
|
||||
var chrome = el('div', 'wp-chrome');
|
||||
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
|
||||
var switcher = buildProjectSwitcher();
|
||||
chrome.appendChild(switcher);
|
||||
chrome.appendChild(buildSearch());
|
||||
if (m.before) m.host.insertBefore(chrome, m.before);
|
||||
else m.host.appendChild(chrome);
|
||||
loadProjects(switcher);
|
||||
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
|
||||
}
|
||||
|
||||
// Wait for the auth guard: an unauthenticated page is about to redirect, and
|
||||
// /api/search would 401 anyway.
|
||||
if (window.WP_USER) mount();
|
||||
else document.addEventListener('wp-auth-ready', mount);
|
||||
})();
|
||||
@@ -92,7 +92,7 @@ function importSOP(ev){
|
||||
function applySOP(){
|
||||
renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims();
|
||||
buildDisciplinePicker(); renderScope(); onHoursChange();
|
||||
renderSopRefLinks(); renderSpecFolderLink();
|
||||
renderSopRefLinks(); renderSpecFolderLink(); applySpecFromType(); initSopHintTips();
|
||||
// SOP-inherited Quality fields — populated then locked (editable only with a logged reason)
|
||||
if(SOP.quality){
|
||||
document.getElementById('wp_qc').value=[SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
|
||||
@@ -126,19 +126,46 @@ function setKind(k){
|
||||
numberDirty = false; updateNumber(); updateReleaseBanner();
|
||||
track('kind_changed', {kind: pkgKind});
|
||||
}
|
||||
function applyKind(){
|
||||
// Which cards this package kind uses. Split out from applyKind() because
|
||||
// showForm() clears every card's inline display and has to restore just the
|
||||
// visibility — without rebuilding the type picker and constraint rows.
|
||||
function applyKindVisibility(){
|
||||
const bimProj = bimSOP(), ewp = isEwp();
|
||||
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
|
||||
show('kind-row', bimProj);
|
||||
show('bim-card', ewp); // LOD / model area / clash / scan
|
||||
show('bim-card', ewp); // model area / clash + IFF # / scan
|
||||
show('asset-card', !ewp); // controls.dev assets
|
||||
show('material-card', !ewp); // bill of materials
|
||||
show('mimo-card', !ewp); // kitting / MIMO
|
||||
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
|
||||
}
|
||||
|
||||
function applyKind(){
|
||||
const bimProj = bimSOP();
|
||||
applyKindVisibility();
|
||||
if(bimProj) setRadio('pkgkind', pkgKind);
|
||||
buildTypePicker(); // filtered by kind
|
||||
buildConstraints(); // filtered by kind
|
||||
}
|
||||
// The IFF number is the GC's sign-off reference, so it only becomes meaningful
|
||||
// once coordination reaches "Signed off (IFF)" — at which point it's required.
|
||||
function iffRequired(){ return (gv('wp_clash') || '') === 'Signed off (IFF)'; }
|
||||
function onClashChange(){
|
||||
const hint = document.getElementById('iff-hint');
|
||||
const inp = document.getElementById('wp_iff');
|
||||
if(!hint || !inp) return;
|
||||
const need = iffRequired();
|
||||
if(need && !inp.value.trim()){
|
||||
hint.textContent = 'Required — coordination is signed off, so record the IFF number.';
|
||||
hint.style.color = 'var(--accent-amber)';
|
||||
} else if(need){
|
||||
hint.textContent = '';
|
||||
} else {
|
||||
hint.textContent = 'Recorded when the GC signs the model package off.';
|
||||
hint.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildCostCodes(){
|
||||
const sel=document.getElementById('wp_cost'); const cur=sel.value;
|
||||
sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code} — ${esc(desc)}</option>`;}).join('');
|
||||
@@ -210,7 +237,176 @@ function renderSopRefLinks(){
|
||||
function renderSpecFolderLink(){
|
||||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
||||
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
|
||||
el.innerHTML = spec ? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
||||
const link = spec
|
||||
? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>`
|
||||
: '';
|
||||
// Say where the value came from, since the field itself is read-only now.
|
||||
const src = gv('wp_spec')
|
||||
? `<span style="color:var(--text-dim)">from the SOP’s WP type${link ? ' · ' : ''}</span>`
|
||||
: `<span style="color:var(--text-dim)">no spec section set on this WP type in the SOP</span>`;
|
||||
el.innerHTML = src + link;
|
||||
}
|
||||
|
||||
// ── PEOPLE PICKERS: Assignees + Distribution ──────────────────────────────────
|
||||
// Both were free-text lists. They're now multi-selects over the project team named
|
||||
// on the SOP (site comments 8/3), while still accepting a typed name for someone
|
||||
// with no user account — subcontractors and GC contacts have to stay addable.
|
||||
//
|
||||
// Stored shape keeps BOTH: `assignees`/`distribution` remain comma-joined display
|
||||
// strings (print, export and the dashboard already read those), and
|
||||
// `assigneeIds`/`distributionIds` carry the account ids that notification routing
|
||||
// will need. The hidden inputs keep gv()/set() working unchanged.
|
||||
let pkgPeople = { assignees: [], distribution: [] }; // [{id,name}] — id '' = typed name
|
||||
|
||||
function peopleFieldId(kind){ return kind === 'assignees' ? 'wp_assignees' : 'wp_distribution'; }
|
||||
|
||||
// The SOP team first (they're this project's named people), then anyone else on
|
||||
// the project. Mirrors the Owner picker's ordering.
|
||||
function peopleOptions(){
|
||||
const team = sopTeamIds();
|
||||
const onTeam = projectMembers.filter(u => team.includes(u.id));
|
||||
const others = projectMembers.filter(u => !team.includes(u.id));
|
||||
return { onTeam, others };
|
||||
}
|
||||
|
||||
function cmMember(){
|
||||
const cmId = (SOP && SOP.project && SOP.project.cmId) || '';
|
||||
return cmId ? (projectMembers.find(u => u.id === cmId) || null) : null;
|
||||
}
|
||||
|
||||
function syncPeopleField(kind){
|
||||
const el = document.getElementById(peopleFieldId(kind));
|
||||
if(el) el.value = pkgPeople[kind].map(p => p.name).filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function renderPeoplePicker(kind){
|
||||
const box = document.getElementById('pick_' + kind);
|
||||
if(!box) return;
|
||||
const cm = cmMember();
|
||||
const isCm = p => !!(cm && p.id === cm.id && kind === 'distribution');
|
||||
const chips = pkgPeople[kind].map((p, i) => {
|
||||
// The CM stays on distribution by default but can be dropped per package, so
|
||||
// the chip is marked rather than locked.
|
||||
const tag = isCm(p) ? ' pp-locked' : '';
|
||||
const title = isCm(p) ? 'Construction Manager — included by default' : (p.id ? '' : 'Typed name (no user account)');
|
||||
return `<span class="pp-chip${tag}" title="${esc(title)}"><span class="pp-name">${esc(p.name)}</span>` +
|
||||
`<button type="button" class="pp-x" title="Remove" onclick="removePerson('${kind}',${i})">✕</button></span>`;
|
||||
}).join('');
|
||||
const { onTeam, others } = peopleOptions();
|
||||
const opt = u => {
|
||||
const on = pkgPeople[kind].some(p => p.id === u.id);
|
||||
return `<label class="pp-opt"><input type="checkbox" ${on?'checked':''} onchange="togglePerson('${kind}','${esc(u.id)}',this.checked)">` +
|
||||
`<span>${esc(u.full_name||u.username)}${u.project_role?` <span class="pp-role">${esc(u.project_role)}</span>`:''}</span></label>`;
|
||||
};
|
||||
const menu =
|
||||
(onTeam.length ? `<div class="pp-group">Project team (from SOP)</div>${onTeam.map(opt).join('')}` : '') +
|
||||
(others.length ? `<div class="pp-group">${onTeam.length?'Others on this project':'On this project'}</div>${others.map(opt).join('')}` : '') +
|
||||
(!onTeam.length && !others.length ? `<div class="pp-group">No project members found</div>` : '') +
|
||||
`<div class="pp-free">
|
||||
<input type="text" placeholder="Add a name not on the list…" onkeydown="if(event.key==='Enter'){event.preventDefault();addTypedPerson('${kind}',this);}">
|
||||
<div class="field-hint">Someone with no user account — they can't be emailed by the suite.</div>
|
||||
</div>`;
|
||||
box.innerHTML = chips +
|
||||
`<span class="pp-add">
|
||||
<button type="button" class="pp-add-btn" onclick="togglePeopleMenu('${kind}')">+ Add</button>
|
||||
<div class="pp-menu" id="ppmenu_${kind}" hidden>${menu}</div>
|
||||
</span>`;
|
||||
syncPeopleField(kind);
|
||||
}
|
||||
|
||||
function togglePeopleMenu(kind){
|
||||
const m = document.getElementById('ppmenu_' + kind);
|
||||
if(!m) return;
|
||||
const open = m.hidden;
|
||||
document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; });
|
||||
m.hidden = !open;
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if(!e.target.closest || !e.target.closest('.pp-add')){
|
||||
document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; });
|
||||
}
|
||||
});
|
||||
|
||||
function togglePerson(kind, userId, on){
|
||||
const u = projectMembers.find(x => x.id === userId);
|
||||
if(!u) return;
|
||||
const list = pkgPeople[kind];
|
||||
const ix = list.findIndex(p => p.id === userId);
|
||||
if(on && ix < 0) list.push({ id: u.id, name: u.full_name || u.username });
|
||||
else if(!on && ix >= 0) list.splice(ix, 1);
|
||||
renderPeoplePicker(kind);
|
||||
}
|
||||
|
||||
function addTypedPerson(kind, input){
|
||||
const name = (input.value || '').trim();
|
||||
if(!name) return;
|
||||
if(!pkgPeople[kind].some(p => p.name.toLowerCase() === name.toLowerCase())){
|
||||
pkgPeople[kind].push({ id: '', name });
|
||||
}
|
||||
input.value = '';
|
||||
renderPeoplePicker(kind);
|
||||
}
|
||||
|
||||
function removePerson(kind, i){
|
||||
pkgPeople[kind].splice(i, 1);
|
||||
renderPeoplePicker(kind);
|
||||
}
|
||||
|
||||
// Parse whatever is stored on a package back into chips. Names that match a
|
||||
// project member are re-linked to that account; the rest stay as typed names.
|
||||
function loadPeopleFromPkg(p){
|
||||
const parse = (str, ids) => {
|
||||
const names = String(str || '').split(',').map(x => x.trim()).filter(Boolean);
|
||||
const byId = (ids || []).map(id => projectMembers.find(u => u.id === id)).filter(Boolean)
|
||||
.map(u => ({ id: u.id, name: u.full_name || u.username }));
|
||||
const out = byId.slice();
|
||||
names.forEach(n => {
|
||||
if(out.some(x => x.name.toLowerCase() === n.toLowerCase())) return;
|
||||
const u = projectMembers.find(m => (m.full_name || m.username || '').toLowerCase() === n.toLowerCase());
|
||||
out.push(u ? { id: u.id, name: u.full_name || u.username } : { id: '', name: n });
|
||||
});
|
||||
return out;
|
||||
};
|
||||
pkgPeople.assignees = parse(p && p.assignees, p && p.assigneeIds);
|
||||
pkgPeople.distribution = parse(p && p.distribution, p && p.distributionIds);
|
||||
renderPeoplePicker('assignees');
|
||||
renderPeoplePicker('distribution');
|
||||
}
|
||||
|
||||
// A new package starts with the project's CM on distribution (site comment 8/3).
|
||||
function resetPeopleForNewPackage(){
|
||||
pkgPeople = { assignees: [], distribution: [] };
|
||||
const cm = cmMember();
|
||||
if(cm) pkgPeople.distribution.push({ id: cm.id, name: cm.full_name || cm.username });
|
||||
renderPeoplePicker('assignees');
|
||||
renderPeoplePicker('distribution');
|
||||
}
|
||||
|
||||
// ── SOP-inherited hints → label tooltips (site comment 8/3) ───────────────────
|
||||
// The blue "from SOP types" subtext under a field becomes a small SOP chip on the
|
||||
// label, with the detail on hover. The hint elements stay in the DOM (hidden) so
|
||||
// the code that writes into them keeps working; an observer mirrors their text
|
||||
// into the chip's tooltip.
|
||||
function initSopHintTips(){
|
||||
document.querySelectorAll('.field .field-hint.sop-hint').forEach(hint => {
|
||||
if(hint.dataset.tipped) return;
|
||||
const field = hint.closest('.field');
|
||||
const label = field && field.querySelector('label');
|
||||
if(!label) return;
|
||||
hint.dataset.tipped = '1';
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'sop-chip';
|
||||
chip.textContent = 'SOP';
|
||||
chip.tabIndex = 0; // reachable by keyboard, not hover-only
|
||||
label.appendChild(chip);
|
||||
const sync = () => {
|
||||
const t = (hint.textContent || '').trim();
|
||||
chip.dataset.tip = t ? 'From the project SOP — ' + t : 'Inherited from the project SOP.';
|
||||
};
|
||||
sync();
|
||||
try { new MutationObserver(sync).observe(hint, {childList:true, characterData:true, subtree:true}); }
|
||||
catch(e){ /* no observer: the tooltip just won't track later edits */ }
|
||||
});
|
||||
}
|
||||
function buildTypePicker(){
|
||||
let types = enabledTypes();
|
||||
@@ -221,7 +417,24 @@ function buildTypePicker(){
|
||||
}
|
||||
function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); }
|
||||
function onTypeChange(){
|
||||
updateNumber(); track('type_selected');
|
||||
updateNumber(); applySpecFromType(); track('type_selected');
|
||||
}
|
||||
|
||||
// Specification Section is authored once per WP type on the SOP (site comments
|
||||
// 8/3: "Spec section in general information can be removed" + "Add link to spec
|
||||
// section based on SOP"). The field is read-only here and follows the type, so it
|
||||
// can't drift package to package. A value stored on an older package is kept if
|
||||
// its type has no spec section on the SOP.
|
||||
function specForType(name){
|
||||
const t = ((SOP && SOP.woTypes) || []).find(x => x && x.name === name);
|
||||
return (t && t.specSection) || '';
|
||||
}
|
||||
function applySpecFromType(){
|
||||
const el = document.getElementById('wp_spec'); if(!el) return;
|
||||
const fromSop = specForType(gv('wp_type'));
|
||||
if(fromSop) el.value = fromSop;
|
||||
else if(!el.dataset.legacy) el.value = '';
|
||||
renderSpecFolderLink();
|
||||
}
|
||||
|
||||
// ── WP NUMBER (auto-built from per-WP dimensions + type + sequence) ───────────
|
||||
@@ -721,7 +934,12 @@ function collectPackage(){
|
||||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'),
|
||||
assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
// Account ids behind those names — notification routing needs an account;
|
||||
// a display name can't be emailed. Typed-only names carry no id.
|
||||
assigneeIds:pkgPeople.assignees.map(x=>x.id).filter(Boolean),
|
||||
distributionIds:pkgPeople.distribution.map(x=>x.id).filter(Boolean),
|
||||
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
|
||||
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
|
||||
disciplines:[...pkgDisciplines],
|
||||
@@ -742,7 +960,10 @@ function collectPackage(){
|
||||
// AWP traceability: which BIM/model package(s) enabled this install package.
|
||||
bimlink:gv('wp_bimlink'),
|
||||
// BIM/VDC package details (only meaningful on a BIM SOP).
|
||||
lod:gv('wp_lod'), modelArea:gv('wp_model_area'), clash:gv('wp_clash'), scanLink:gv('wp_scan_link'),
|
||||
// LOD was removed from the form (site comment 8/3). Any value already stored on
|
||||
// the package is preserved rather than blanked on the next save.
|
||||
lod:(prev && prev.lod) || '', iff:gv('wp_iff'),
|
||||
modelArea:gv('wp_model_area'), clash:gv('wp_clash'), scanLink:gv('wp_scan_link'),
|
||||
project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'',
|
||||
// Project homepage links in the tracking / commissioning systems, copied from the
|
||||
// SOP so they travel with every Work Package created for this project.
|
||||
@@ -752,6 +973,12 @@ function collectPackage(){
|
||||
}
|
||||
function savePackage(view){
|
||||
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
|
||||
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
||||
if(isEwp() && iffRequired() && !gv('wp_iff').trim()){
|
||||
alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
|
||||
const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); }
|
||||
return;
|
||||
}
|
||||
const pkg=collectPackage();
|
||||
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
||||
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
||||
@@ -785,7 +1012,7 @@ function renderPackage(pkg){
|
||||
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
|
||||
${plinks.length?`<tr><th>Project Systems</th><td>${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('<br>')}</td></tr>`:''}
|
||||
${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''}
|
||||
${(pkg.lod||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.lod?'LOD: '+esc(pkg.lod):'', pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):''].filter(Boolean).join('<br>')}</td></tr>`:''}
|
||||
${(pkg.lod||pkg.iff||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.iff?'IFF #: '+esc(pkg.iff):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):'', pkg.lod?'LOD: '+esc(pkg.lod)+' (legacy)':''].filter(Boolean).join('<br>')}</td></tr>`:''}
|
||||
</tbody></table>`;
|
||||
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
|
||||
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
|
||||
@@ -828,7 +1055,7 @@ function renderPackage(pkg){
|
||||
</tbody></table>`;
|
||||
if(pkg.holds&&pkg.holds.length){
|
||||
h+=`<h2>8.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
||||
pkg.holds.forEach(hd=>{ const when=hd.ts?new Date(hd.ts).toLocaleString():''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
|
||||
pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
|
||||
h+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
|
||||
h+=`</tbody></table>`;
|
||||
}
|
||||
@@ -853,7 +1080,21 @@ function printPackage(){
|
||||
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
||||
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
|
||||
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){
|
||||
hideDashboard();
|
||||
// This clears every card's inline display, which also clears the "hidden" set by
|
||||
// applyKind() — so the kind row and the BIM card would reappear on an
|
||||
// install-only project. Re-apply the kind visibility right after.
|
||||
document.querySelectorAll('.main > .card').forEach(e=>e.style.display='');
|
||||
document.querySelector('.main > .nav-row').style.display='flex';
|
||||
document.getElementById('pkg-output').style.display='none';
|
||||
document.getElementById('saved-card').style.display = savedPackages.length?'':'none';
|
||||
applyKindVisibility(); // visibility only — not a full applyKind() rebuild
|
||||
buildDisciplinePicker(); renderScope();
|
||||
setFormChrome(true);
|
||||
currentView='Work Package Form';
|
||||
window.scrollTo({top:0,behavior:'smooth'});
|
||||
}
|
||||
|
||||
// Sticky save bar + section-nav chrome (shown only on the editable form view).
|
||||
function setFormChrome(on){
|
||||
@@ -1025,7 +1266,7 @@ async function showHistory(wpId, label){
|
||||
body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>';
|
||||
return;
|
||||
}
|
||||
const fmt=s=>{ try{ return new Date(s).toLocaleString(); }catch(e){ return s||''; } };
|
||||
const fmt=s=>{ try{ return wpFormatDateTime(s); }catch(e){ return s||''; } };
|
||||
const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; };
|
||||
body.innerHTML='<div class="hist-list">'+rows.map(e=>
|
||||
'<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+
|
||||
@@ -1048,11 +1289,14 @@ function loadPackageIntoForm(p){
|
||||
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
|
||||
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
|
||||
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
|
||||
loadPeopleFromPkg(p);
|
||||
set('wp_assignee',p.assigneeId);
|
||||
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
|
||||
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
|
||||
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
|
||||
set('wp_bimlink',p.bimlink); set('wp_lod',p.lod); set('wp_model_area',p.modelArea); set('wp_clash',p.clash); set('wp_scan_link',p.scanLink);
|
||||
set('wp_bimlink',p.bimlink); set('wp_iff',p.iff); set('wp_model_area',p.modelArea);
|
||||
set('wp_clash',p.clash); set('wp_scan_link',p.scanLink);
|
||||
onClashChange();
|
||||
applyKind();
|
||||
buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
|
||||
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
|
||||
@@ -1123,8 +1367,10 @@ function newPackage(){
|
||||
editingId=null;
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
|
||||
['wp_lod','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
onClashChange();
|
||||
pkgKind='iwp'; applyKind();
|
||||
resetPeopleForNewPackage();
|
||||
setRadio('status','Draft');
|
||||
numberDims={}; buildNumberDims();
|
||||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||||
@@ -1381,7 +1627,7 @@ function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=doc
|
||||
function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; }
|
||||
function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); }
|
||||
function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
||||
function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?new Date(c.ts).toLocaleString():''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); }
|
||||
function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?wpFormatDateTime(c.ts):''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).join(''); }
|
||||
function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } }
|
||||
function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); }
|
||||
function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); }
|
||||
@@ -1457,6 +1703,10 @@ async function loadMembers(){
|
||||
(others.length?`<optgroup label="${onTeam.length?'Others on this project':'On this project'}">${others.map(opt).join('')}</optgroup>`:'');
|
||||
if(cur) sel.value=cur;
|
||||
else defaultOwnerToMe();
|
||||
// The people pickers list the same accounts, so (re)render them now that the
|
||||
// member list has arrived.
|
||||
if(editingId){ const p=savedPackages.find(x=>x.id===editingId); if(p) loadPeopleFromPkg(p); }
|
||||
else resetPeopleForNewPackage();
|
||||
} catch(e){}
|
||||
}
|
||||
// A new package defaults to the person creating it — they're accountable until
|
||||
|
||||
@@ -105,10 +105,16 @@
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
|
||||
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
|
||||
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
|
||||
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
|
||||
<div class="people-pick" id="pick_assignees"></div>
|
||||
<input type="hidden" id="wp_assignees"></div>
|
||||
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
|
||||
<div class="people-pick" id="pick_distribution"></div>
|
||||
<input type="hidden" id="wp_distribution"></div>
|
||||
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
|
||||
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
|
||||
<div class="field"><label>Specification Section</label>
|
||||
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
|
||||
<div class="field-hint" id="spec-folder-link"></div></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
|
||||
@@ -119,11 +125,12 @@
|
||||
<div class="sub-heading">BIM / Model Details</div>
|
||||
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Level of Detail (LOD)</label>
|
||||
<select id="wp_lod"><option value="">—</option><option>LOD 100 — Conceptual</option><option>LOD 200 — Approximate</option><option>LOD 300 — Precise</option><option>LOD 350 — Precise + interfaces</option><option>LOD 400 — Fabrication</option><option>LOD 500 — As-built</option></select></div>
|
||||
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
|
||||
<div class="field"><label>Clash / Coordination Status</label>
|
||||
<select id="wp_clash"><option value="">—</option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
|
||||
<select id="wp_clash" onchange="onClashChange()"><option value="">—</option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
|
||||
<div class="field"><label>IFF #<span class="help-tip" data-tip="Issued-For-Fabrication/Field number — the GC sign-off reference for this model package. Required once the coordination status is Signed off (IFF).">i</span></label>
|
||||
<input type="text" id="wp_iff" placeholder="e.g. IFF-2026-0142" oninput="onClashChange()">
|
||||
<div class="field-hint" id="iff-hint"></div></div>
|
||||
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,5 +347,6 @@
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
<script src="wp-format.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -631,6 +631,59 @@
|
||||
.wp-nav-reopen { display:none !important; }
|
||||
}
|
||||
|
||||
/* ── SOP-inherited marker ───────────────────────────────────────────────────
|
||||
The "from SOP types" subtext used to sit under the field. It's now a small
|
||||
chip on the label with the detail in a hover tooltip (site comment 8/3).
|
||||
The chip stays VISIBLE rather than hover-only: on a field tablet there is no
|
||||
hover, and "this value came from the SOP" is the part people need to see. */
|
||||
.field-hint.sop-hint { display: none; }
|
||||
.sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px;
|
||||
background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb;
|
||||
font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
|
||||
vertical-align:middle; cursor:help; position:relative; }
|
||||
.sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%;
|
||||
transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px;
|
||||
font-weight:400; letter-spacing:0; text-transform:none; line-height:1.4; white-space:normal;
|
||||
width:max-content; max-width:260px; text-align:left; z-index:9999; opacity:0;
|
||||
pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||
.sop-chip::before { content:''; position:absolute; bottom:135%; left:50%;
|
||||
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616;
|
||||
opacity:0; transition:opacity .12s; z-index:9999; }
|
||||
.sop-chip:hover::after, .sop-chip:hover::before,
|
||||
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
|
||||
|
||||
/* ── people picker (Assignees / Distribution) ───────────────────────────────
|
||||
Multi-select over the SOP project team instead of a free-text list. */
|
||||
.people-pick { border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
|
||||
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
|
||||
.people-pick:focus-within { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px;
|
||||
background:var(--surface2); border:1px solid var(--border); border-radius:12px;
|
||||
font-size:12px; max-width:100%; }
|
||||
.pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); }
|
||||
.pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted);
|
||||
font-size:12px; line-height:1; padding:0 1px; }
|
||||
.pp-chip .pp-x:hover { color:var(--red); }
|
||||
.pp-add { position:relative; }
|
||||
.pp-add-btn { background:none; border:1px dashed var(--border-strong); border-radius:12px;
|
||||
color:var(--text-muted); font:inherit; font-size:12px; padding:2px 9px; cursor:pointer; }
|
||||
.pp-add-btn:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px;
|
||||
max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong);
|
||||
box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; }
|
||||
.pp-menu[hidden] { display:none; }
|
||||
.pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
|
||||
color:var(--text-dim); padding:7px 10px 3px; }
|
||||
.pp-opt { display:flex; align-items:center; gap:8px; padding:5px 10px; font-size:13px; cursor:pointer; }
|
||||
.pp-opt:hover { background:var(--surface2); }
|
||||
.pp-opt input { width:15px; height:15px; cursor:pointer; }
|
||||
.pp-opt .pp-role { color:var(--text-dim); font-size:11.5px; }
|
||||
.pp-free { border-top:1px solid var(--border); margin-top:5px; padding:7px 10px 3px; }
|
||||
.pp-free input { width:100%; padding:5px 7px; font:inherit; font-size:12.5px;
|
||||
border:1px solid var(--border); border-radius:3px; }
|
||||
.pp-free .field-hint { margin-top:4px; }
|
||||
|
||||
/* Critical constraint marker (from the SOP) */
|
||||
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
|
||||
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
|
||||
|
||||
232
html/wp-format.js
Normal file
232
html/wp-format.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/* Localization + time formatting for the Work Package Suite.
|
||||
|
||||
Every date the app shows should agree, wherever it's rendered. Three sources,
|
||||
most specific first:
|
||||
1. the signed-in user's own preference (users.locale / users.timezone)
|
||||
2. the app default set by an admin (Admin console → Localization)
|
||||
3. the browser's own locale / timezone (the previous behaviour)
|
||||
|
||||
Why store it server-side: on a shared field tablet the browser's locale isn't
|
||||
the person's, and a package due date that reads a day early because the device
|
||||
sits in another zone is a real scheduling problem — not a cosmetic one.
|
||||
|
||||
Exposes:
|
||||
wpFormatDate(v) → 3 Aug 2026 (date only)
|
||||
wpFormatDateTime(v) → 3 Aug 2026, 14:07 (date + time)
|
||||
wpFormatTime(v) → 14:07
|
||||
wpFormatNumber(v) → locale-grouped number
|
||||
wpTimeZoneLabel() → the zone in effect, for a UI hint
|
||||
wpPreferences() → opens the preferences dialog
|
||||
All formatters take an ISO string, Date, or epoch ms, and return '' for empty
|
||||
input (never 'Invalid Date'), so they're safe to drop into a template. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function prefs() {
|
||||
var u = window.WP_USER || {};
|
||||
var f = window.WP_FLAGS || {};
|
||||
return {
|
||||
locale: (u.locale || f.default_locale || '') || undefined,
|
||||
timezone: (u.timezone || f.default_timezone || '') || undefined
|
||||
};
|
||||
}
|
||||
|
||||
// A date-only value ('2026-08-03') is a calendar date, not an instant. Parsed as
|
||||
// UTC midnight by the platform, it can render as the previous day in a western
|
||||
// zone — so format these from their parts and never apply a timezone.
|
||||
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function toDate(v) {
|
||||
if (v == null || v === '') return null;
|
||||
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
||||
if (typeof v === 'number') { var n = new Date(v); return isNaN(n.getTime()) ? null : n; }
|
||||
var s = String(v).trim();
|
||||
if (!s) return null;
|
||||
var d = new Date(s);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function fmt(v, opts, forceNoTz) {
|
||||
var s = (typeof v === 'string') ? v.trim() : v;
|
||||
var dateOnly = (typeof s === 'string') && DATE_ONLY.test(s);
|
||||
var d = dateOnly ? new Date(s + 'T12:00:00') : toDate(s); // noon: immune to ±12h shifts
|
||||
if (!d) return '';
|
||||
var p = prefs();
|
||||
var o = {};
|
||||
for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k];
|
||||
if (p.timezone && !dateOnly && !forceNoTz) o.timeZone = p.timezone;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(p.locale, o).format(d);
|
||||
} catch (e) {
|
||||
// Bad locale/zone (e.g. a preference set before tzdata was available):
|
||||
// fall back to the platform default rather than showing nothing.
|
||||
try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e2) { return String(v); }
|
||||
}
|
||||
}
|
||||
|
||||
window.wpFormatDate = function (v) {
|
||||
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
};
|
||||
window.wpFormatDateTime = function (v) {
|
||||
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
window.wpFormatTime = function (v) {
|
||||
return fmt(v, { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
window.wpFormatNumber = function (v, opts) {
|
||||
if (v == null || v === '' || isNaN(+v)) return '';
|
||||
try { return new Intl.NumberFormat(prefs().locale, opts || {}).format(+v); }
|
||||
catch (e) { return String(v); }
|
||||
};
|
||||
window.wpTimeZoneLabel = function () {
|
||||
var p = prefs();
|
||||
if (p.timezone) return p.timezone;
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'browser default'; }
|
||||
catch (e) { return 'browser default'; }
|
||||
};
|
||||
window.wpLocaleLabel = function () {
|
||||
var p = prefs();
|
||||
if (p.locale) return p.locale;
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().locale || 'browser default'; }
|
||||
catch (e) { return 'browser default'; }
|
||||
};
|
||||
|
||||
// ── preferences dialog ─────────────────────────────────────────────────────
|
||||
var COMMON_LOCALES = [
|
||||
['', 'Browser default'],
|
||||
['en-US', 'English (United States) — 8/3/2026, 2:07 PM'],
|
||||
['en-GB', 'English (United Kingdom) — 03/08/2026, 14:07'],
|
||||
['en-CA', 'English (Canada)'],
|
||||
['es-MX', 'Español (México)'],
|
||||
['es-US', 'Español (Estados Unidos)'],
|
||||
['fr-CA', 'Français (Canada)'],
|
||||
['de-DE', 'Deutsch (Deutschland)'],
|
||||
['ja-JP', '日本語 (日本)'],
|
||||
['ko-KR', '한국어 (대한민국)'],
|
||||
['zh-TW', '中文 (台灣)']
|
||||
];
|
||||
// Zones the fabs and offices actually sit in, offered before the full list.
|
||||
var COMMON_ZONES = [
|
||||
'America/Chicago', 'America/New_York', 'America/Denver', 'America/Phoenix',
|
||||
'America/Los_Angeles', 'America/Boise', 'Asia/Tokyo', 'Asia/Taipei',
|
||||
'Asia/Seoul', 'Asia/Singapore', 'Europe/Dublin', 'Europe/London', 'UTC'
|
||||
];
|
||||
|
||||
window.wpPreferences = function () {
|
||||
if (document.getElementById('wp-prefs-modal')) return;
|
||||
var u = window.WP_USER || {};
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'wp-prefs-modal';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
||||
'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' +
|
||||
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;background:#fff;';
|
||||
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
|
||||
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
|
||||
ov.innerHTML =
|
||||
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Language & time</div>' +
|
||||
'<div style="padding:4px 18px 16px;">' +
|
||||
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
|
||||
'<label style="' + lbl + '">Language & number format</label>' +
|
||||
'<select id="wp-prefs-locale" style="' + fld + '"></select>' +
|
||||
'<div style="' + hint + '">Sets how dates and numbers are written. It does not translate the app.</div>' +
|
||||
'<label style="' + lbl + '">Time zone</label>' +
|
||||
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
|
||||
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
|
||||
'Calendar dates like a due date are never shifted.</div>' +
|
||||
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var e = document.getElementById('wp-prefs-msg');
|
||||
e.style.display = 'block'; e.textContent = text;
|
||||
e.style.background = ok ? '#defbe6' : '#fff1f1';
|
||||
e.style.color = ok ? '#0e6027' : '#da1e28';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
|
||||
var locSel = document.getElementById('wp-prefs-locale');
|
||||
var tzSel = document.getElementById('wp-prefs-tz');
|
||||
var preview = document.getElementById('wp-prefs-preview');
|
||||
|
||||
locSel.innerHTML = COMMON_LOCALES.map(function (p) {
|
||||
return '<option value="' + p[0] + '"' + (p[0] === (u.locale || '') ? ' selected' : '') + '>' + p[1] + '</option>';
|
||||
}).join('');
|
||||
// A stored locale that isn't in the shortlist stays selectable.
|
||||
if (u.locale && !COMMON_LOCALES.some(function (p) { return p[0] === u.locale; })) {
|
||||
locSel.add(new Option(u.locale, u.locale, true, true));
|
||||
}
|
||||
|
||||
function fillZones(all) {
|
||||
var cur = u.timezone || '';
|
||||
var browser = '';
|
||||
try { browser = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
|
||||
var html = '<option value=""' + (cur ? '' : ' selected') + '>Browser default' +
|
||||
(browser ? ' (' + browser + ')' : '') + '</option>';
|
||||
html += '<optgroup label="Common">' + COMMON_ZONES.map(function (z) {
|
||||
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
||||
}).join('') + '</optgroup>';
|
||||
var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; });
|
||||
if (rest.length) {
|
||||
html += '<optgroup label="All time zones">' + rest.map(function (z) {
|
||||
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
||||
}).join('') + '</optgroup>';
|
||||
} else if (cur && COMMON_ZONES.indexOf(cur) < 0) {
|
||||
html += '<option value="' + cur + '" selected>' + cur + '</option>';
|
||||
}
|
||||
tzSel.innerHTML = html;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// Preview uses the picked values, not the saved ones, so the effect is visible
|
||||
// before committing.
|
||||
function updatePreview() {
|
||||
var l = locSel.value || undefined, z = tzSel.value || undefined;
|
||||
var now = new Date();
|
||||
var out;
|
||||
try {
|
||||
out = new Intl.DateTimeFormat(l, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', timeZone: z
|
||||
}).format(now);
|
||||
} catch (e) { out = 'Not supported by this browser'; }
|
||||
preview.innerHTML = '<strong>Preview</strong><br>Right now: ' +
|
||||
String(out).replace(/[<>]/g, '') +
|
||||
'<br>A due date (2026-08-03) always reads: ' + window.wpFormatDate('2026-08-03');
|
||||
}
|
||||
locSel.addEventListener('change', updatePreview);
|
||||
tzSel.addEventListener('change', updatePreview);
|
||||
|
||||
// The picker offers exactly what the server will accept.
|
||||
fetch('/api/timezones', { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(fillZones)
|
||||
.catch(function () { fillZones([]); });
|
||||
|
||||
document.getElementById('wp-prefs-cancel').onclick = close;
|
||||
document.getElementById('wp-prefs-save').onclick = function () {
|
||||
var body = { locale: locSel.value || '', timezone: tzSel.value || '' };
|
||||
fetch('/api/auth/preferences', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) { msg((res.j && res.j.detail) || ('Could not save (HTTP ' + res.status + ').'), false); return; }
|
||||
if (res.j && res.j.user) window.WP_USER = res.j.user;
|
||||
try { localStorage.setItem('wp_auth_cache', JSON.stringify({ user: window.WP_USER, at: Date.now() })); } catch (e) {}
|
||||
msg('Saved. Reloading so every date on the page agrees…', true);
|
||||
// Dates are formatted at render time all over the app; a reload is the
|
||||
// honest way to apply the change everywhere at once.
|
||||
setTimeout(function () { location.reload(); }, 700);
|
||||
})
|
||||
.catch(function () { msg('Could not reach the server.', false); });
|
||||
};
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,29 @@
|
||||
"""user locale + timezone preferences
|
||||
|
||||
Per-user display preferences. Empty means "use the app default (admin console),
|
||||
then the browser". Stored server-side so they follow the person between devices —
|
||||
shared field tablets are the case that matters.
|
||||
|
||||
Revision ID: c93f2b1d7e04
|
||||
Revises: b41c7ae90d52
|
||||
Create Date: 2026-08-03 16:44:10.882931
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c93f2b1d7e04'
|
||||
down_revision = 'b41c7ae90d52'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('locale', sa.String(length=20), nullable=False, server_default=''))
|
||||
op.add_column('users', sa.Column('timezone', sa.String(length=60), nullable=False, server_default=''))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'timezone')
|
||||
op.drop_column('users', 'locale')
|
||||
140
server/app.py
140
server/app.py
@@ -266,6 +266,8 @@ class SettingsIn(BaseModel):
|
||||
from_name: Optional[str] = None
|
||||
app_base_url: Optional[str] = None
|
||||
bim_enabled: Optional[bool] = None
|
||||
default_locale: Optional[str] = None
|
||||
default_timezone: Optional[str] = None
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
@@ -319,6 +321,13 @@ class ProjectRoleIn(BaseModel):
|
||||
project_role: str = ""
|
||||
|
||||
|
||||
class PreferencesIn(BaseModel):
|
||||
# Empty string clears the preference (fall back to the app default, then the
|
||||
# browser). None means "leave this one alone".
|
||||
locale: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
username: str = "" # username or email
|
||||
|
||||
@@ -511,6 +520,50 @@ def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
# ── Display preferences (self-service) ─────────────────────────────────────────
|
||||
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$")
|
||||
|
||||
|
||||
def valid_timezone(tz: str) -> bool:
|
||||
"""True if this is a real IANA zone name on this machine."""
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
ZoneInfo(tz)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — unknown key, missing tzdata, bad type
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/timezones")
|
||||
def list_timezones(_user: models.User = Depends(auth.get_current_user)):
|
||||
"""IANA zone names for the preferences picker, so the list matches what the
|
||||
server will actually accept."""
|
||||
try:
|
||||
from zoneinfo import available_timezones
|
||||
return sorted(available_timezones())
|
||||
except Exception: # noqa: BLE001 — no tzdata: let the client fall back
|
||||
return []
|
||||
|
||||
|
||||
@app.post("/api/auth/preferences")
|
||||
def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""A user's own locale / timezone. Empty string clears the preference; the app
|
||||
default (admin console) applies next, then the browser's own settings."""
|
||||
if body.locale is not None:
|
||||
loc = body.locale.strip()
|
||||
if loc and not _LOCALE_RE.match(loc):
|
||||
raise HTTPException(status_code=400, detail="Locale must be a language tag like 'en-US' or 'es'.")
|
||||
user.locale = loc[:20]
|
||||
if body.timezone is not None:
|
||||
tz = body.timezone.strip()
|
||||
if tz and not valid_timezone(tz):
|
||||
raise HTTPException(status_code=400, detail="Unknown time zone. Pick one from the list.")
|
||||
user.timezone = tz[:60]
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
@@ -1087,6 +1140,81 @@ def list_audit(
|
||||
return [e.to_dict() for e in rows]
|
||||
|
||||
|
||||
# ── Global search ──────────────────────────────────────────────────────────────
|
||||
def _like_term(q: str) -> str:
|
||||
"""Escape LIKE wildcards so a user searching for '100%' or 'a_b' gets what they
|
||||
typed rather than a pattern. Paired with escape='\\' on the comparison."""
|
||||
return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_").lower() + "%"
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
def global_search(
|
||||
q: str = Query("", min_length=0, max_length=200),
|
||||
limit: int = Query(8, ge=1, le=25),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Type-ahead across projects, work packages and SOPs, scoped to the projects
|
||||
the caller may access. Matches work-package number, subject, type and status,
|
||||
project name/number/client/site, and SOP name/number."""
|
||||
term = (q or "").strip()
|
||||
if len(term) < 2:
|
||||
return {"query": term, "projects": [], "wps": [], "sops": []}
|
||||
pat = _like_term(term)
|
||||
esc = "\\"
|
||||
|
||||
proj_stmt = select(models.Project).where(
|
||||
func.lower(models.Project.name).like(pat, escape=esc)
|
||||
| func.lower(models.Project.number).like(pat, escape=esc)
|
||||
| func.lower(models.Project.client).like(pat, escape=esc)
|
||||
| func.lower(models.Project.site).like(pat, escape=esc)
|
||||
)
|
||||
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
|
||||
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
|
||||
|
||||
wp_stmt = select(models.WorkPackage).where(
|
||||
(models.WorkPackage.archived_at.is_(None))
|
||||
& (
|
||||
func.lower(models.WorkPackage.number).like(pat, escape=esc)
|
||||
| func.lower(models.WorkPackage.subject).like(pat, escape=esc)
|
||||
| func.lower(models.WorkPackage.type).like(pat, escape=esc)
|
||||
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
|
||||
)
|
||||
)
|
||||
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
|
||||
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
|
||||
|
||||
sop_stmt = select(models.Sop).where(
|
||||
func.lower(models.Sop.name).like(pat, escape=esc)
|
||||
| func.lower(models.Sop.number).like(pat, escape=esc)
|
||||
)
|
||||
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
|
||||
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
|
||||
|
||||
# Project names for the WP/SOP rows, so a result reads unambiguously when the
|
||||
# same WP number exists on two jobs.
|
||||
pids = {w.project_id for w in wps} | {s.project_id for s in sops}
|
||||
pids.discard(None)
|
||||
names = {}
|
||||
if pids:
|
||||
for p in db.scalars(select(models.Project).where(models.Project.id.in_(pids))).all():
|
||||
names[p.id] = p.name or p.number or p.id
|
||||
|
||||
return {
|
||||
"query": term,
|
||||
"projects": [{"id": p.id, "name": p.name, "number": p.number, "client": p.client} for p in projects],
|
||||
"wps": [{
|
||||
"id": w.id, "number": w.number, "subject": w.subject, "type": w.type,
|
||||
"status": w.status, "project_id": w.project_id,
|
||||
"project_name": names.get(w.project_id, ""),
|
||||
} for w in wps],
|
||||
"sops": [{
|
||||
"id": s.id, "name": s.name, "number": s.number, "complete": s.complete,
|
||||
"project_id": s.project_id, "project_name": names.get(s.project_id, ""),
|
||||
} for s in sops],
|
||||
}
|
||||
|
||||
|
||||
# ── Settings (admin) ────────────────────────────────────────────────────────────
|
||||
@app.get("/api/settings")
|
||||
def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
@@ -1103,6 +1231,18 @@ def get_app_flags(_user: models.User = Depends(auth.get_current_user), db: Sessi
|
||||
@app.put("/api/settings")
|
||||
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
patch = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
# Localization defaults are validated the same way a user's own preference is,
|
||||
# so a typo can't leave every page formatting dates against a bogus zone.
|
||||
loc = (patch.get("default_locale") or "").strip()
|
||||
if loc and not _LOCALE_RE.match(loc):
|
||||
raise HTTPException(status_code=400, detail="Default locale must be a language tag like 'en-US'.")
|
||||
tz = (patch.get("default_timezone") or "").strip()
|
||||
if tz and not valid_timezone(tz):
|
||||
raise HTTPException(status_code=400, detail="Unknown default time zone.")
|
||||
if "default_locale" in patch:
|
||||
patch["default_locale"] = loc
|
||||
if "default_timezone" in patch:
|
||||
patch["default_timezone"] = tz
|
||||
saved = notify.save_settings(db, patch)
|
||||
log_event(db, admin, "settings_updated", "settings", "notifications",
|
||||
summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))})
|
||||
|
||||
@@ -140,6 +140,11 @@ class User(Base):
|
||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||
# Job function on the project — free text, offered from a suggested list.
|
||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||
# Display preferences. Empty means "fall back to the app default, then to the
|
||||
# browser". A stored value follows the person between devices, which matters on
|
||||
# shared field tablets where the browser locale isn't theirs.
|
||||
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
|
||||
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
@@ -157,6 +162,7 @@ class User(Base):
|
||||
"id": self.id, "username": self.username, "email": self.email,
|
||||
"full_name": self.full_name, "role": self.role,
|
||||
"project_role": self.project_role or "", "is_active": self.is_active,
|
||||
"locale": self.locale or "", "timezone": self.timezone or "",
|
||||
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -37,13 +37,17 @@ DEFAULTS = {
|
||||
# with it off, the SOP creator hides the BIM section entirely and every SOP is
|
||||
# install-only, so no project can be put on the BIM path by accident.
|
||||
"bim_enabled": False,
|
||||
# Localization defaults for dates, times and numbers. Empty = use each
|
||||
# browser's own locale / timezone. A user's own preference wins over these.
|
||||
"default_locale": "", # BCP47, e.g. en-US
|
||||
"default_timezone": "", # IANA, e.g. America/Chicago
|
||||
}
|
||||
|
||||
|
||||
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
||||
# are safe for any authenticated user to read (feature flags + whether
|
||||
# self-service password reset can work at all).
|
||||
PUBLIC_KEYS = ("bim_enabled",)
|
||||
# are safe for any authenticated user to read (feature flags + localization
|
||||
# defaults + whether self-service password reset can work at all).
|
||||
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
||||
|
||||
|
||||
def get_settings(db: Session) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user