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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user