The defect B4 names is not that the numbers were wrong. On one browser with one
cache they were right. It is that they were derived from the caller's own
localStorage, so two people on the same project saw different numbers and neither
was told.
SERVER. /api/wps/metrics now returns everything the creator's dashboard shows -
total, mine, release-ready, on hold, overdue, est/actual hours, by_status,
by_discipline, progress (overall and per discipline) and the gating list. It
already existed for a subset; the rest was being summed in the browser.
Two things moved to the server rather than being duplicated there:
- PROGRESS_WEIGHT, the status-to-percentage table. It was PROGRESS_W in
wp-creation-app.js; the JS copy is deleted rather than left in place, because
two copies of a weighting table is how the two drift apart.
- "release-ready" now counts `waitingOn` predecessors as blocking, which the
browser did and the old endpoint did not. Without that the phrase would have
changed meaning the moment the dashboard stopped computing it locally.
New GET /api/projects/{id}/summary gives the launcher the SOP state it was
reading out of localStorage.
by_location is shaped for CR-018 in wave 6, per the task's instruction not to
build a shape that cannot group by building/floor/sector. It reports its own
dimensions alongside the groups:
{"dimensions": ["location"], "groups": [{"key": {...}, "total": n,
"release_ready": n, "on_hold": n, "overdue": n, "by_status": {...}}]}
Today a package carries one free-text `location`, so that is the one dimension.
_location_key() already prefers structured building/floor/sector when present, so
CR-004 changes the dimensions and the keys and leaves the response shape alone.
CLIENT. The launcher's SOP card and the dashboard's tiles, chips, progress bars
and gating panel all read the server. There is deliberately no cache fallback: a
silently-stale number that looks authoritative is the thing being removed, so a
failed request renders an explicit error and a retry.
Writes flush through the outbox before the counts are re-read (dashRefreshAfterWrite).
Without that the refresh races the push and shows pre-write totals - the same
stale number arriving by a different route.
THE ONE COUNT STILL COMPUTED LOCALLY, stated rather than skipped: the board table
is a LIST of the packages this browser holds, which is what keeps the field view
working offline, and its header counts rows. Rather than pretend otherwise, it is
reconciled against the server's total and shows "this browser has N of M" when
they differ - usually a save that has not finished syncing. Nobody now sees a
number that disagrees with the project without being told, which is what B4 asks
for.
VERIFICATION. tests/aggregates_check.py, 16 checks, all passing. It tests what was
broken rather than whether the totals are right - the latter passed before this
change:
- root and pat get byte-identical aggregates for the same project
- the dashboard shows the server's total after localStorage is POISONED with a
different package list; it cannot do that if it is summing the cache
- a simulated outage renders "Counts unavailable", names the failure, offers a
retry, renders no tiles beside it, and announces via role=alert
- the launcher reports the SOP complete against a cache that says otherwise,
and says "Could not check" when the request fails
- by_location's groups are keyed by its declared dimensions, carry their own
rollups, and sum to the project total
browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
750 lines
30 KiB
HTML
750 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Work Package Suite — Prime Controls</title>
|
|
<script src="auth-guard.js"></script>
|
|
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
|
timestamps during their own boot. -->
|
|
<script src="wp-format.js"></script>
|
|
<link rel="icon" href="favicon.ico" sizes="any">
|
|
<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="wp-sidenav.css">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
background: var(--cds-background);
|
|
color: var(--cds-text-primary);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* CONTAINER */
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 2.5rem 2rem 3rem;
|
|
}
|
|
|
|
/* HERO */
|
|
.hero {
|
|
margin-bottom: 2.5rem;
|
|
}
|
|
|
|
.hero h1 {
|
|
font-size: 2.25rem;
|
|
font-weight: 300;
|
|
letter-spacing: -0.01em;
|
|
margin-bottom: 0.5rem;
|
|
color: var(--cds-text-primary);
|
|
}
|
|
|
|
.hero p {
|
|
font-size: 1rem;
|
|
color: var(--cds-text-secondary);
|
|
max-width: 760px;
|
|
}
|
|
|
|
/* CARDS */
|
|
.cards-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.card {
|
|
background: var(--cds-layer);
|
|
border: 1px solid var(--cds-border-subtle);
|
|
border-left: 4px solid var(--cds-border-strong);
|
|
padding: 1.5rem;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
text-decoration: none;
|
|
color: var(--cds-text-primary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.card:hover {
|
|
border-left-color: var(--cds-interactive-01);
|
|
background: var(--cds-layer-hover);
|
|
}
|
|
|
|
.card-badge {
|
|
display: inline-block;
|
|
background: var(--cds-button-primary);
|
|
color: var(--cds-text-on-color);
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
width: fit-content;
|
|
}
|
|
|
|
.card h3 {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.card p {
|
|
color: var(--cds-text-secondary);
|
|
margin-bottom: 1.5rem;
|
|
flex: 1;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.card-button {
|
|
display: inline-block;
|
|
align-self: flex-start;
|
|
background: var(--cds-button-primary);
|
|
color: var(--cds-text-on-color);
|
|
padding: 0.7rem 1.25rem;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
transition: background 0.2s;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.card-button:hover {
|
|
background: var(--cds-hover-primary);
|
|
}
|
|
|
|
/* COMPLETE STATE (SOP done) */
|
|
.card.complete {
|
|
border-left-color: var(--cds-support-success);
|
|
}
|
|
/* A5: the card-button stays primary when the SOP completes. The completion
|
|
is still said, twice — the card's left border turns green and .card-status
|
|
appears — which is green doing its job as a status rather than as an
|
|
action colour. */
|
|
.card-status {
|
|
display: inline-block;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--cds-support-success);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
/* B4: an unreachable server is a third state, and it has to look like neither
|
|
of the other two. Not green, not silence. */
|
|
.card-status.card-status-error { color: var(--wp-status-warning-text); }
|
|
.card.disabled {
|
|
opacity: 0.6;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* SECTION */
|
|
.section {
|
|
background: var(--cds-layer);
|
|
padding: 1.75rem;
|
|
margin-bottom: 1.5rem;
|
|
border: 1px solid var(--cds-border-subtle);
|
|
}
|
|
|
|
.section h2 {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
margin-bottom: 1.5rem;
|
|
color: var(--cds-text-primary);
|
|
}
|
|
|
|
.section h3 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.section p {
|
|
color: var(--cds-text-secondary);
|
|
margin-bottom: 1rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
/* FOOTER */
|
|
.footer {
|
|
background: var(--cds-ui-01);
|
|
color: var(--cds-text-secondary);
|
|
padding: 2rem;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
border-top: 1px solid var(--cds-border-subtle);
|
|
}
|
|
|
|
.footer a {
|
|
color: var(--cds-link-primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.footer a:hover { text-decoration: underline; }
|
|
|
|
/* COMMENTS SECTION */
|
|
.comments-section {
|
|
background: var(--cds-layer);
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
border: 1px solid var(--cds-border-subtle);
|
|
}
|
|
|
|
.comments-toggle {
|
|
padding: 0.7rem 1.25rem;
|
|
background: var(--cds-button-primary);
|
|
color: var(--cds-text-on-color);
|
|
border: none;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.comments-toggle:hover { background: var(--cds-hover-primary); }
|
|
|
|
.comments-panel {
|
|
display: none;
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background: var(--cds-layer-accent);
|
|
border: 1px solid var(--cds-border-subtle);
|
|
}
|
|
|
|
.comments-panel.open { display: block; }
|
|
|
|
.comments-panel input,
|
|
.comments-panel textarea {
|
|
width: 100%;
|
|
padding: 0.7rem;
|
|
border: 1px solid var(--cds-border-strong);
|
|
background: var(--cds-field);
|
|
color: var(--cds-text-primary);
|
|
font-family: inherit;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.comments-panel input:focus,
|
|
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
|
|
|
.comments-panel textarea {
|
|
resize: vertical;
|
|
min-height: 80px;
|
|
}
|
|
|
|
.comment-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.comment-buttons button {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.submit-btn {
|
|
background: var(--cds-button-primary);
|
|
color: var(--cds-text-on-color);
|
|
}
|
|
|
|
.submit-btn:hover { background: var(--cds-hover-primary); }
|
|
|
|
.close-btn {
|
|
background: var(--cds-layer-selected);
|
|
color: var(--cds-text-primary);
|
|
border: 1px solid var(--cds-border-strong);
|
|
}
|
|
|
|
.close-btn:hover { background: var(--cds-layer-selected-hover); }
|
|
|
|
.comments-list {
|
|
margin-top: 1rem;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.comment-item {
|
|
padding: 0.75rem;
|
|
background: var(--cds-background);
|
|
border: 1px solid var(--cds-border-subtle);
|
|
margin-bottom: 0.5rem;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.comment-meta {
|
|
font-size: 11px;
|
|
color: var(--cds-text-secondary);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.comment-text {
|
|
color: var(--cds-text-primary);
|
|
}
|
|
|
|
/* PROJECT PICKER */
|
|
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
|
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
|
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
|
border: 1px solid var(--cds-border-strong); background: var(--cds-field); }
|
|
.proj-empty { background: var(--cds-ui-01); border: 1px dashed var(--cds-border-strong);
|
|
padding: 1.25rem; }
|
|
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
|
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
|
/* Shown once when the project someone had open turns out to have been archived
|
|
rather than deleted — otherwise the picker just silently resets on them. */
|
|
.proj-archived-note { background: var(--wp-status-warning-bg); border: 1px solid var(--cds-support-warning); color: var(--wp-status-warning-text);
|
|
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
|
|
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03); background: var(--cds-ui-01); }
|
|
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
|
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
|
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong); }
|
|
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
|
.link-like { background: none; border: none; color: var(--cds-link-01); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
|
|
|
/* RESPONSIVE */
|
|
@media (max-width: 768px) {
|
|
.hero h1 { font-size: 1.75rem; }
|
|
.cards-grid { grid-template-columns: 1fr; }
|
|
.container { padding: 1.5rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- HEADER -->
|
|
<header class="wp-appbar">
|
|
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
|
|
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
|
<span class="wp-appbar-title">Work Package Suite</span>
|
|
</a>
|
|
<div class="wp-appbar-spacer"></div>
|
|
<nav class="wp-appbar-actions">
|
|
<a class="wp-appbar-link" href="#overview">Overview</a>
|
|
<a class="wp-appbar-link" href="#comments">Feedback</a>
|
|
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- MAIN CONTENT -->
|
|
<div class="container">
|
|
|
|
<!-- HERO -->
|
|
<div class="hero">
|
|
<h1 id="hero-title">Work Package Suite</h1>
|
|
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
|
</div>
|
|
|
|
<!-- PROJECT SELECTION -->
|
|
<div class="section" id="project-section">
|
|
<h2>Project</h2>
|
|
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
|
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
|
</div>
|
|
|
|
<!-- TOOL CARDS (shown once a project is active) -->
|
|
<div class="cards-grid" id="overview" style="display:none">
|
|
|
|
<!-- SOP CONFIG -->
|
|
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
|
<h3>SOP Configuration</h3>
|
|
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
|
|
<button class="card-button" id="card-sop-btn">Open tool</button>
|
|
</a>
|
|
|
|
<!-- WP CREATOR -->
|
|
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
|
|
<h3>Work Package Creator</h3>
|
|
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
|
|
<button class="card-button" id="card-wp-btn">Open tool</button>
|
|
</a>
|
|
|
|
<!-- WP DASHBOARD -->
|
|
<a href="work-package-suite.html?view=dashboard" class="card" id="card-dash">
|
|
<h3>Work Package Dashboard</h3>
|
|
<p>Track status and gating across every Work Package — release-readiness, on-hold packages, overdue work, hours, and breakdowns by status and discipline. Issue release-ready packages in one click.</p>
|
|
<button class="card-button" id="card-dash-btn">Open dashboard</button>
|
|
</a>
|
|
|
|
<!-- FIELD VIEW -->
|
|
<a href="field.html" class="card" id="card-field">
|
|
<h3>Field View</h3>
|
|
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
|
|
<button class="card-button" id="card-field-btn">Open field view</button>
|
|
</a>
|
|
|
|
<!-- USER DIRECTORY -->
|
|
<a href="users.html" class="card" id="card-users">
|
|
<h3>User Directory</h3>
|
|
<p>Who is on this project — names, job functions and how to reach them. Administrators and Project Super Users also create accounts, set permissions and grant project access from here.</p>
|
|
<button class="card-button" id="card-users-btn">Open directory</button>
|
|
</a>
|
|
|
|
</div>
|
|
|
|
<!-- COMMENTS SECTION -->
|
|
<div class="comments-section" id="comments">
|
|
<button class="comments-toggle" onclick="toggleComments()">Leave feedback</button>
|
|
<div class="comments-panel" id="comments-panel">
|
|
<div style="margin-bottom: 1rem;">
|
|
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
|
|
<input type="text" id="commenter-name" placeholder="Your name">
|
|
</div>
|
|
<div style="margin-bottom: 1rem;">
|
|
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Feedback</label>
|
|
<textarea id="comment-text" placeholder="Your feedback here..."></textarea>
|
|
</div>
|
|
<div class="comment-buttons">
|
|
<button class="submit-btn" onclick="submitComment()">Submit</button>
|
|
<button class="close-btn" onclick="exportFeedback()">⤓ Export</button>
|
|
<button class="close-btn" onclick="document.getElementById('feedback-import').click()">⤒ Import</button>
|
|
<button class="close-btn" onclick="toggleComments()">Close</button>
|
|
<input type="file" id="feedback-import" accept="application/json" style="display:none" onchange="importFeedback(event)">
|
|
</div>
|
|
<div class="comments-list" id="comments-list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- FOOTER -->
|
|
<footer class="footer">
|
|
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
|
|
</footer>
|
|
|
|
<script src="feedback-config.js"></script>
|
|
<script src="project-data.js"></script>
|
|
<script src="help.js"></script>
|
|
<script>
|
|
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
|
const esc = ProjectData.esc;
|
|
let _projects = [];
|
|
|
|
function initProjects(){
|
|
ProjectData.list().then(list => {
|
|
_projects = list || [];
|
|
// A deep link names the project explicitly, and every other page in the
|
|
// suite already honours ?project=<id>. This one did not, so arriving here
|
|
// with a link on a browser that had nothing stored showed "Select a
|
|
// project" while the URL said otherwise. Honour it before reconciling.
|
|
const wanted = new URLSearchParams(location.search).get('project');
|
|
if(wanted){
|
|
const target = _projects.find(p => p.id === wanted);
|
|
if(target) ProjectData.setActive(target);
|
|
}
|
|
// Reconcile the active project against the list; clear if it's gone.
|
|
const active = ProjectData.getActive();
|
|
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
|
|
if(dropped) ProjectData.setActive(null);
|
|
renderProjectPicker();
|
|
applyActiveProject();
|
|
// "No longer in the list" used to mean one thing — deleted. Now it also
|
|
// means archived, and resetting someone to "Select a project" with no word
|
|
// about it sends them hunting for a job that is merely finished.
|
|
if(dropped) explainDroppedProject(dropped);
|
|
});
|
|
}
|
|
|
|
function explainDroppedProject(p){
|
|
// The project is still readable when it's archived; a delete (or access being
|
|
// taken away) fails here, and that case genuinely has nothing to say.
|
|
ProjectData.get(p.id).then(full => {
|
|
if(!full || !full.archived) return;
|
|
const box = document.getElementById('project-picker');
|
|
if(!box || document.getElementById('proj-archived-note')) return;
|
|
const note = document.createElement('div');
|
|
note.id = 'proj-archived-note';
|
|
note.className = 'proj-archived-note';
|
|
note.innerHTML = `<strong>${esc(p.name || 'The project you had open')}</strong> has been
|
|
archived — it is read-only and no longer listed here. An administrator can unarchive it
|
|
from the Admin Console.`;
|
|
box.insertBefore(note, box.firstChild);
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function createFormHtml(){
|
|
return `<div class="proj-form" id="proj-form" style="display:none">
|
|
<div class="proj-form-grid">
|
|
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
|
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
|
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
|
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
|
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
|
</div>
|
|
<div class="proj-actions">
|
|
<button class="card-button" onclick="saveNewProject()">Create & select</button>
|
|
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderProjectPicker(){
|
|
const box = document.getElementById('project-picker');
|
|
const activeId = ProjectData.getActiveId();
|
|
if(!_projects.length){
|
|
box.innerHTML = `<div class="proj-empty">
|
|
<p>No projects yet. Create your first project, or start from a sample.</p>
|
|
<div class="proj-actions">
|
|
<button class="card-button" onclick="showCreateProject()">+ Create project</button>
|
|
<button class="close-btn" onclick="useSampleProject()">Use sample project</button>
|
|
</div>
|
|
</div>` + createFormHtml();
|
|
return;
|
|
}
|
|
const opts = _projects.map(p =>
|
|
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
|
).join('');
|
|
box.innerHTML = `<div class="proj-row">
|
|
<select id="project-select" onchange="selectProject(this.value)">
|
|
<option value="">Select a project…</option>${opts}
|
|
</select>
|
|
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
|
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
|
</div>
|
|
<div id="active-project-info"></div>` + createFormHtml();
|
|
}
|
|
|
|
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
|
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
|
|
|
function saveNewProject(){
|
|
const v = id => (document.getElementById(id)?.value || '').trim();
|
|
const name = v('np_name');
|
|
if(!name){ alert('Project name is required.'); return; }
|
|
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
|
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
|
}
|
|
|
|
function useSampleProject(){
|
|
const existing = _projects.find(p => p.sample);
|
|
if(existing){ afterProjectChosen(existing); return; }
|
|
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
|
}
|
|
|
|
function selectProject(id){
|
|
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
|
const p = _projects.find(x => x.id === id);
|
|
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
|
}
|
|
|
|
function afterProjectChosen(p){
|
|
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
|
ProjectData.setActive(p);
|
|
renderProjectPicker();
|
|
applyActiveProject();
|
|
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
|
}
|
|
|
|
// Show/hide the tool cards and stamp the active project into their links.
|
|
function applyActiveProject(){
|
|
const active = ProjectData.getActive();
|
|
const cards = document.getElementById('overview');
|
|
const heroTitle = document.getElementById('hero-title');
|
|
const heroSub = document.getElementById('hero-sub');
|
|
const info = document.getElementById('active-project-info');
|
|
|
|
if(!active){
|
|
cards.style.display = 'none';
|
|
heroTitle.textContent = 'Work Package Suite';
|
|
heroSub.textContent = 'Select a project to begin — or create one.';
|
|
if(info) info.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const q = '&project=' + encodeURIComponent(active.id);
|
|
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
|
|
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
|
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
|
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
|
setHref('card-field', 'field.html?src=home');
|
|
|
|
cards.style.display = '';
|
|
heroTitle.textContent = active.name || 'Work Package Suite';
|
|
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
|
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
|
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
|
|
|
// Pull the project's shared SOP/WPs into the local cache so the tools boot
|
|
// with data. The card status below does NOT come from that cache — see
|
|
// reflectSOPStatus.
|
|
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).catch(()=>{}); }
|
|
reflectSOPStatus(active);
|
|
}
|
|
|
|
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
|
|
|
// Reflect SOP completion on the tool cards (B4).
|
|
//
|
|
// This used to read `wp_suite_sop_complete` out of localStorage. That key is a
|
|
// per-browser mirror of the server, so the card answered "has THIS browser seen
|
|
// the SOP completed", not "is the SOP complete" — and the two diverge the moment
|
|
// a colleague finishes the SOP on their own machine. The card said "Complete SOP
|
|
// first" and nothing indicated the answer was stale, which is the failure mode
|
|
// B4 describes: a per-browser number that looks authoritative.
|
|
//
|
|
// There is deliberately no cache fallback. If the server cannot be reached the
|
|
// card says so; it does not guess, and it does not show a remembered answer as
|
|
// though it were current.
|
|
function reflectSOPStatus(active){
|
|
const sopCard = document.getElementById('card-sop');
|
|
const sopBtn = document.getElementById('card-sop-btn');
|
|
const wpCard = document.getElementById('card-wp');
|
|
const wpBtn = document.getElementById('card-wp-btn');
|
|
if(!sopCard) return;
|
|
|
|
// reset (re-render can run multiple times)
|
|
sopCard.classList.remove('complete');
|
|
wpCard && wpCard.classList.remove('disabled');
|
|
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
|
|
|
const setStatus = (text, cls) => {
|
|
const el = document.createElement('div');
|
|
el.className = 'card-status' + (cls ? ' ' + cls : '');
|
|
el.textContent = text;
|
|
sopCard.insertBefore(el, sopCard.firstChild);
|
|
return el;
|
|
};
|
|
|
|
// Neutral while in flight: neither "complete" nor "not complete" is known yet,
|
|
// and asserting either would be the same lie in a different direction.
|
|
sopBtn.textContent = 'Open tool';
|
|
if(wpBtn) wpBtn.textContent = 'Checking…';
|
|
|
|
fetch('/api/projects/' + encodeURIComponent(active.id) + '/summary',
|
|
{ headers: { 'Accept': 'application/json' } })
|
|
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(sum => {
|
|
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
|
|
const old = sopCard.querySelector('.card-status'); if(old) old.remove();
|
|
if(sum.sop_complete){
|
|
sopCard.classList.add('complete');
|
|
sopBtn.textContent = 'Review';
|
|
setStatus('✓ SOP complete' + (sum.sop_name ? ' — ' + sum.sop_name : ''));
|
|
if(wpBtn) wpBtn.textContent = 'Open creator';
|
|
} else {
|
|
if(wpCard) wpCard.classList.add('disabled');
|
|
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
|
}
|
|
})
|
|
.catch(err => {
|
|
if(ProjectData.getActiveId() !== active.id) return;
|
|
const old = sopCard.querySelector('.card-status'); if(old) old.remove();
|
|
// Explicitly unknown. The Creator is left reachable rather than disabled:
|
|
// locking someone out of their work because a status request failed is a
|
|
// worse outcome than letting the tool tell them itself.
|
|
setStatus('⚠ Could not check SOP status — ' + (err && err.message || 'offline'), 'card-status-error');
|
|
if(wpBtn) wpBtn.textContent = 'Open creator';
|
|
});
|
|
}
|
|
|
|
initProjects();
|
|
|
|
let allComments = [];
|
|
|
|
function toggleComments() {
|
|
const panel = document.getElementById('comments-panel');
|
|
panel.classList.toggle('open');
|
|
if (panel.classList.contains('open')) loadComments();
|
|
}
|
|
|
|
function submitComment() {
|
|
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
|
const text = document.getElementById('comment-text').value.trim();
|
|
|
|
if (!text) {
|
|
alert('Please enter feedback.');
|
|
return;
|
|
}
|
|
|
|
const comment = {
|
|
name,
|
|
text,
|
|
timestamp: new Date().toLocaleString()
|
|
};
|
|
|
|
allComments.push(comment);
|
|
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
|
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
|
|
|
document.getElementById('comment-text').value = '';
|
|
loadComments();
|
|
}
|
|
|
|
function exportFeedback() {
|
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
|
const data = saved ? JSON.parse(saved) : [];
|
|
if (!data.length) { alert('No feedback to export yet.'); return; }
|
|
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'wp-suite-feedback-home-' + new Date().toISOString().slice(0, 10) + '.json';
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
}
|
|
|
|
function importFeedback(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 feedback found in that file.'); return; }
|
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
|
allComments = saved ? JSON.parse(saved) : [];
|
|
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
|
let added = 0;
|
|
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
|
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
|
loadComments();
|
|
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
|
} catch (e) { alert('Could not read that file.'); }
|
|
ev.target.value = '';
|
|
};
|
|
r.readAsText(f);
|
|
}
|
|
|
|
function renderComments() {
|
|
const list = document.getElementById('comments-list');
|
|
if (allComments.length === 0) {
|
|
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
|
|
} else {
|
|
list.innerHTML = allComments.map(c => `
|
|
<div class="comment-item">
|
|
<div class="comment-meta"><strong>${(c.name||'Anonymous').replace(/</g,'<')}</strong> • ${c.timestamp||''}</div>
|
|
<div class="comment-text">${(c.text||'').replace(/</g,'<').replace(/>/g,'>')}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
|
|
function loadComments() {
|
|
// Server is authoritative (so feedback is shared across users); fall back to
|
|
// the local cache if the API is unreachable.
|
|
const saved = localStorage.getItem('wp_suite_index_comments');
|
|
if (saved) { try { allComments = JSON.parse(saved) || []; } catch(e) { allComments = []; } }
|
|
renderComments();
|
|
fetch('/api/comments?source=home_feedback', { headers: { 'Accept': 'application/json' } })
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(rows => {
|
|
if (Array.isArray(rows)) {
|
|
allComments = rows.map(c => ({ name: c.author, text: c.text, timestamp: c.created_at ? new Date(c.created_at).toLocaleString() : '' }));
|
|
renderComments();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
</script>
|
|
<script src="wp-chrome.js"></script>
|
|
<script src="wp-sidenav.js"></script>
|
|
</body>
|
|
</html>
|