Archiving already froze a project (the server refuses every write); what did not exist was the way back in. Now: - GET /api/projects?archived=only|all filters the answer BY PER-PROJECT ROLE: a project admin (or super/app admin) on THAT project sees it; everyone else receives an empty list from the same request - archived projects appear nowhere for them, counts and pickers included (the default listing already excluded them for everyone; asking is what got gated). Admin-on-Job-A does not surface archived Job B. - The launcher gains a visibly separate, labelled "Archived projects" section (dashed border, read-only stated in words), rendered only when the server returns rows. Opening one makes it active; the launcher's reconcile learned that an active project whose stored summary says archived:true was opened ON PURPOSE and keeps it, while a project archived out from under someone still drops with the existing explanation. - The creator shows ARCHIVED - READ-ONLY where the project is named (both ctx-bar branches, from the SERVER's answer - the page's project comes from the URL, so a stale local summary is not trusted) and refuses saves with a reason before the round trip. The courtesy; the server's refusal is the rule, verified by calling the endpoints directly (wp upsert AND the material-list write both refuse with "archived" even for an admin). - No unarchive button, no second mechanism, and it fits at 390px. Verification (each probe run alone): NEW tests/archived_check.py 15/15. Regressions: launcher_check 58/58, sample_check 10/10, export_check 20/20, frame_check 38/38. Items: D7 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1138 lines
50 KiB
HTML
1138 lines
50 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>
|
|
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
|
during their own boot. -->
|
|
<script src="wp-url.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. */
|
|
/* A7 / T6.5. Every card says its state, in all THREE states — the version
|
|
before this said "complete" when it was and nothing when it was not, so
|
|
the most common state on a live project was the one with no line at all.
|
|
|
|
Each state carries a glyph and a word as well as a colour, and the three
|
|
colours are the canonical status tokens: green complete, the suite's amber
|
|
for unknown, and secondary text for in-progress. Nothing is declared
|
|
here that theme-light.css does not already hold. */
|
|
.card-status {
|
|
display: inline-block;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--cds-support-success);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
/* Not finished yet — a real answer, and neither green nor a warning. */
|
|
.card-status.card-status-pending { color: var(--cds-text-secondary); }
|
|
/* 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; }
|
|
|
|
.footer p + p { margin-top: 0.4rem; }
|
|
/* "Pilot" is the word that changes what the rest of the sentence means, so it
|
|
is marked rather than left to be read past. */
|
|
.footer-tag {
|
|
display: inline-block;
|
|
background: var(--wp-status-warning-bg);
|
|
color: var(--wp-status-warning-text);
|
|
border: 1px solid var(--cds-support-warning);
|
|
padding: 0 6px;
|
|
margin-right: 6px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* 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 ENTRY POINTS — B3
|
|
The picker card (a "Select a project…" dropdown in its own section) is gone.
|
|
Switching projects is the app bar's switcher, which is on every page; the
|
|
launcher keeps the two things a dropdown could not do — the first-run empty
|
|
state, and creating a project. */
|
|
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
|
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
|
/* Shown once when the project someone had open turns out to have been archived
|
|
rather than deleted — otherwise the launcher 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-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); background: var(--cds-field); }
|
|
.proj-form-grid input[aria-invalid="true"] { border-color: var(--cds-support-error); }
|
|
/* .link-like and .proj-row went with the picker card. Both were named in
|
|
BL-014 as controls falling back to the UA focus ring; two of that entry's
|
|
four sites no longer exist. */
|
|
|
|
/* .field-error — the inline error beside a field — is declared once, in
|
|
theme-light.css, because three surfaces in wave 5 grew one. */
|
|
|
|
/* PIPELINE STRIP — B4 surface (T5.3)
|
|
Four counts, every one of them from /api/wps/metrics. There is deliberately
|
|
no localStorage fallback anywhere in here: a per-browser number that looks
|
|
authoritative is the thing B4 removes, and a stale four beside a live four
|
|
is worse than an error. */
|
|
.pipeline { margin-bottom: 1.5rem; }
|
|
.pipeline-head { font-size: 13px; font-weight: 600; text-transform: none;
|
|
color: var(--cds-text-secondary); margin-bottom: 0.6rem; }
|
|
.pipeline-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1px;
|
|
background: var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle); }
|
|
.pipe-cell { display: flex; flex-direction: column; gap: 0.15rem; padding: 1rem 1.1rem;
|
|
background: var(--cds-layer); text-decoration: none; color: var(--cds-text-primary);
|
|
border-left: 3px solid transparent; transition: background 0.15s; }
|
|
.pipe-cell:hover { background: var(--cds-layer-hover); }
|
|
.pipe-num { font-size: 1.75rem; font-weight: 300; line-height: 1.1; }
|
|
.pipe-label { font-size: 13px; font-weight: 600; }
|
|
.pipe-sub { font-size: 12px; color: var(--cds-text-secondary); }
|
|
/* Each cell's accent says which slice it is, and the label says it in words —
|
|
the number alone is the same shape in all four (C1). */
|
|
.pipe-cell.is-total { border-left-color: var(--cds-interactive-01); }
|
|
.pipe-cell.is-ready { border-left-color: var(--cds-support-success); }
|
|
.pipe-cell.is-onhold { border-left-color: var(--wp-status-warning-text); }
|
|
.pipe-cell.is-overdue { border-left-color: var(--cds-support-error); }
|
|
/* Zero is a real answer for one slice of a project that has work in it. Four
|
|
zeros on a project with no work packages at all is not a reading, it is a
|
|
broken-looking strip, so that case gets a sentence instead. */
|
|
.pipe-empty, .pipe-error { grid-column: 1 / -1; background: var(--cds-layer);
|
|
padding: 1.1rem 1.2rem; font-size: 13px; color: var(--cds-text-secondary); }
|
|
.pipe-error { color: var(--wp-status-warning-text); }
|
|
.pipe-empty a { color: var(--cds-link-primary); }
|
|
.pipe-loading { grid-column: 1 / -1; background: var(--cds-layer); padding: 1.1rem 1.2rem;
|
|
font-size: 13px; color: var(--cds-text-secondary); font-style: italic; }
|
|
|
|
/* FIRST RUN
|
|
A brand-new account has no projects, so it has no tool cards either — this
|
|
is the whole page for that person, and it has to say what to do next. */
|
|
.first-run { border-left: 3px solid var(--cds-interactive-01); }
|
|
.first-run-note { font-size: 13px; margin-top: 1rem; }
|
|
.first-run-sample { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--cds-border-subtle); }
|
|
.first-run-sample h3 { margin-bottom: 0.35rem; }
|
|
|
|
/* 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 ENTRY POINTS — B3 / T5.2
|
|
|
|
The project-picker card that used to sit here — <h2>Project</h2> over a
|
|
"Select a project…" dropdown — is gone. Switching projects is the app
|
|
bar's switcher, which is on every page rather than only this one.
|
|
|
|
What a dropdown could NOT do is what stayed: the first-run empty state,
|
|
and creating a project. The empty state was built INSIDE the picker card,
|
|
so removing the card first would have stranded every new account on a
|
|
page whose only instruction was to pick from an empty list.
|
|
|
|
Exactly one of these three is visible at a time; #proj-status says which
|
|
and is where the archived note lands.
|
|
══════════════════════════════════════════════════════════════════════ -->
|
|
<div id="proj-status"><div class="proj-loading">Loading projects…</div></div>
|
|
|
|
<!-- D7 / T9.8: the way back into an archived project - PROJECT ADMINS ONLY
|
|
(the server filters; everyone else gets an empty list and this section
|
|
never renders). Visually its own thing, so nobody opens one thinking
|
|
it is live: the server refuses every write regardless. -->
|
|
<section class="section" id="archived-projects" hidden
|
|
style="border:1px dashed var(--cds-border-subtle-01); background:var(--cds-layer-01); opacity:.92">
|
|
<h2>Archived projects</h2>
|
|
<p style="font-size:13px; color:var(--cds-text-secondary)">Read-only. Visible to project
|
|
admins only. Opening one lets you read everything; nothing on it can be changed while
|
|
it stays archived.</p>
|
|
<div id="archived-projects-list"></div>
|
|
</section>
|
|
|
|
<!-- (a) no projects at all -->
|
|
<section class="section first-run" id="first-run" hidden>
|
|
<h2>No projects yet</h2>
|
|
<p>A project holds one SOP — the baseline every work package on the job
|
|
inherits — and the work packages written against it. Everything else in
|
|
the suite hangs off a project, so this is the first thing to make.
|
|
Fill in the form below, or start from the sample underneath it.</p>
|
|
</section>
|
|
|
|
<!-- (b) projects exist, none chosen -->
|
|
<section class="section" id="pick-prompt" hidden>
|
|
<h2>Choose a project</h2>
|
|
<p id="pick-prompt-sub">Pick the job you are working on from the project
|
|
switcher in the bar at the top of the page. It is there on every page, so
|
|
you can change job without coming back here.</p>
|
|
<div class="proj-actions">
|
|
<button type="button" class="card-button" id="open-switcher-btn">Open the project switcher</button>
|
|
<button type="button" class="close-btn" id="new-project-btn">New project</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- (c) the create form. Open on first run, on demand after that. -->
|
|
<section class="section" id="new-project" hidden>
|
|
<h2 id="new-project-head">Create a project</h2>
|
|
<form id="np-form" novalidate>
|
|
<div class="proj-form-grid">
|
|
<label for="np_name">Project name *
|
|
<input type="text" id="np_name" name="np_name" required
|
|
aria-describedby="np_name_err" placeholder="e.g. Micron — INC Construction">
|
|
</label>
|
|
<label for="np_number">Project number
|
|
<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
|
<label for="np_client">Client
|
|
<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
|
<label for="np_division">Division
|
|
<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
|
<label for="np_site">Site / location
|
|
<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
|
</div>
|
|
<!-- The error belongs at the field, not in a dialog that names no field
|
|
and highlights nothing. role="alert" so it is heard (C1 / S10). -->
|
|
<div class="field-error" id="np_name_err" role="alert"></div>
|
|
<div class="proj-actions">
|
|
<button type="submit" class="card-button">Create & select</button>
|
|
<button type="button" class="close-btn" id="np-cancel">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<!-- (d) the sample. Offered on first run, where it is the alternative to
|
|
filling in a form about a job you may not have started yet. It stays
|
|
reachable afterwards from the create form's own section. -->
|
|
<section class="section" id="sample-offer" hidden>
|
|
<h2>Not ready to set one up?</h2>
|
|
<p>The sample project is a fully configured example — a finished SOP and a
|
|
few work packages — so you can see what the tools do before committing a
|
|
real job to them. It is labelled as a sample everywhere it appears, and it
|
|
can be deleted later.</p>
|
|
<div class="proj-actions">
|
|
<button type="button" class="close-btn" id="use-sample-btn">Use the sample project</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- PIPELINE STRIP — B4 surface / T5.3
|
|
Four counts from /api/wps/metrics, each one a link into the dashboard
|
|
filtered to that slice. The URL carries the filter (?view=dashboard&
|
|
flag=…), so a cell can be sent to someone rather than only clicked.
|
|
aria-live because it refreshes in place when the project changes. -->
|
|
<section class="pipeline" id="pipeline" hidden aria-labelledby="pipeline-head">
|
|
<h2 class="pipeline-head" id="pipeline-head">Work package pipeline</h2>
|
|
<div class="pipeline-strip" id="pipeline-strip" aria-live="polite" aria-busy="true"></div>
|
|
</section>
|
|
|
|
<!-- 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 — A7 / T6.5
|
|
Was "Work Package Suite v1.0 | Prime Controls - Business Technology Group |
|
|
Pilot Use Only", which left three questions open: v1.0 of what, who is
|
|
Business Technology Group to this page, and what does Pilot Use Only
|
|
restrict. Each line now says one thing and says whose it is. -->
|
|
<footer class="footer">
|
|
<p><strong>Work Package Suite</strong> — built and maintained by the Prime Controls
|
|
Business Technology Group.</p>
|
|
<p><span class="footer-tag">Pilot</span> This is a pilot release. Work packages created
|
|
here are real and are kept, but the tools around them are still changing.</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 = [];
|
|
|
|
// D7: render the archived list for whoever the server says may see one.
|
|
function renderArchivedProjects(){
|
|
ProjectData.listArchivedProjects().then(rows => {
|
|
const sec = $('archived-projects');
|
|
const list = $('archived-projects-list');
|
|
if(!sec || !list) return;
|
|
if(!rows.length){ sec.hidden = true; return; }
|
|
sec.hidden = false;
|
|
list.innerHTML = rows.map(p =>
|
|
`<button type="button" class="card-button" style="display:block; width:100%; text-align:left; margin-bottom:8px"
|
|
data-open-archived="${esc(p.id)}">
|
|
${esc(p.name || p.id)} ${p.number ? '· ' + esc(p.number) : ''}
|
|
<span style="font-size:11px; color:var(--cds-text-secondary)"> — archived, read-only</span>
|
|
</button>`).join('');
|
|
list.querySelectorAll('[data-open-archived]').forEach(b => {
|
|
b.addEventListener('click', () => {
|
|
const p = rows.find(x => x.id === b.dataset.openArchived);
|
|
if(p){ ProjectData.setActive(p); location.reload(); }
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function initProjects(){
|
|
renderArchivedProjects();
|
|
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();
|
|
// S3: if a project is active but the URL does not say so, make the URL say
|
|
// so — with replace, not push, because the user did not navigate here. This
|
|
// is what makes "copy the address bar" produce a link that lands somebody
|
|
// else on the same project rather than on whatever they last had open.
|
|
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
|
|
WPUrl.replace({ project: active.id });
|
|
}
|
|
// D7: a project OPENED FROM THE ARCHIVED LIST is active on purpose - its
|
|
// stored summary says archived:true, and only someone the server let see
|
|
// that list could have stored it. A project archived out from under
|
|
// someone still drops and gets explained, exactly as before.
|
|
const dropped = (active && !_projects.some(p => p.id === active.id)
|
|
&& !active.archived) ? active : null;
|
|
if(dropped) ProjectData.setActive(null);
|
|
_listLoaded = true;
|
|
renderProjectEntry();
|
|
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('proj-status');
|
|
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(() => {});
|
|
}
|
|
|
|
// ── PROJECT ENTRY POINTS (B3 / T5.2) ──────────────────────────────────────
|
|
// Three states, one visible at a time. The picker card that used to render
|
|
// here is gone; the app bar's switcher does that job on every page, and this
|
|
// page keeps the two things a dropdown could not do.
|
|
//
|
|
// ORDER MATTERS, and it is the whole reason B3 exists: the first-run empty
|
|
// state used to live INSIDE the picker card. Deleting the card without
|
|
// building this first would have left a brand-new account on a page whose
|
|
// only instruction was to choose from a list with nothing in it.
|
|
const $ = id => document.getElementById(id);
|
|
let _createOpenedManually = false;
|
|
let _focusCreateOnRender = false;
|
|
// Nothing renders until the list is in. "No projects yet" is a claim, and
|
|
// making it while the request is still in flight would show every new-account
|
|
// page to every returning user for a beat.
|
|
let _listLoaded = false;
|
|
|
|
function show(id, on){ const el = $(id); if(el) el.hidden = !on; }
|
|
|
|
function renderProjectEntry(){
|
|
if(!_listLoaded) return;
|
|
const status = $('proj-status');
|
|
const loading = status && status.querySelector('.proj-loading');
|
|
if(loading) loading.remove();
|
|
|
|
const firstRun = !_projects.length;
|
|
const active = ProjectData.getActive();
|
|
|
|
show('first-run', firstRun);
|
|
show('sample-offer', firstRun);
|
|
// On first run the form IS the page — there is nothing else to do here, so
|
|
// hiding it behind a button would be one click of ceremony in front of the
|
|
// only path forward.
|
|
show('new-project', firstRun || _createOpenedManually);
|
|
show('pick-prompt', !firstRun && !active && !_createOpenedManually);
|
|
|
|
const head = $('new-project-head');
|
|
if(head) head.textContent = firstRun ? 'Create your first project' : 'Create a project';
|
|
// Nothing to cancel back to on first run.
|
|
show('np-cancel', !firstRun);
|
|
|
|
if(_focusCreateOnRender && !$('new-project').hidden){
|
|
_focusCreateOnRender = false;
|
|
const n = $('np_name');
|
|
if(n){ n.focus(); n.scrollIntoView({behavior:'smooth', block:'center'}); }
|
|
}
|
|
}
|
|
|
|
function showCreateProject(){
|
|
_createOpenedManually = true;
|
|
_focusCreateOnRender = true;
|
|
renderProjectEntry();
|
|
}
|
|
function hideCreateProject(){
|
|
_createOpenedManually = false;
|
|
setFieldError('np_name', '');
|
|
renderProjectEntry();
|
|
}
|
|
|
|
// An error at the field, associated with aria-describedby and announced —
|
|
// rather than a native dialog that names no field and highlights nothing.
|
|
// Same shape T5.8 gives the SOP wizard (C1).
|
|
function setFieldError(fieldId, message){
|
|
const field = $(fieldId), box = $(fieldId + '_err');
|
|
if(box) box.textContent = message || '';
|
|
if(field){
|
|
if(message) field.setAttribute('aria-invalid', 'true');
|
|
else field.removeAttribute('aria-invalid');
|
|
}
|
|
}
|
|
|
|
function saveNewProject(){
|
|
const v = id => ($(id)?.value || '').trim();
|
|
const name = v('np_name');
|
|
if(!name){
|
|
setFieldError('np_name', 'Enter a project name — it is the only field this needs.');
|
|
$('np_name')?.focus();
|
|
return;
|
|
}
|
|
setFieldError('np_name', '');
|
|
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); });
|
|
}
|
|
|
|
// The switcher is wp-chrome.js's, injected into the app bar after this script
|
|
// runs. Reaching for its button is deliberate: the alternative is a second
|
|
// project list on this page, which is the card B3 removes.
|
|
function openProjectSwitcher(){
|
|
const btn = document.querySelector('.wpc-proj-btn');
|
|
if(btn){ btn.click(); btn.focus(); return true; }
|
|
return false;
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
$('np-form')?.addEventListener('submit', function(e){ e.preventDefault(); saveNewProject(); });
|
|
$('np-cancel')?.addEventListener('click', hideCreateProject);
|
|
$('new-project-btn')?.addEventListener('click', showCreateProject);
|
|
$('use-sample-btn')?.addEventListener('click', useSampleProject);
|
|
$('open-switcher-btn')?.addEventListener('click', function(){
|
|
if(!openProjectSwitcher()){
|
|
// No chrome mounted (it returns early inside an iframe, and it loads
|
|
// after this file). Say so rather than doing nothing on a click.
|
|
const sub = $('pick-prompt-sub');
|
|
if(sub) sub.textContent = 'The project switcher is in the bar at the top of the page.';
|
|
}
|
|
});
|
|
// The app bar's popover links here for "new project". Honour it, so that
|
|
// link lands on the form rather than on a page that no longer has one.
|
|
if(location.hash === '#new-project') showCreateProject();
|
|
});
|
|
|
|
// S3: which project you are in is addressable, so a launcher link carries it
|
|
// and Back returns to the project you were looking at before.
|
|
function urlSyncProject(id, replace){
|
|
if(typeof WPUrl === 'undefined') return;
|
|
(replace ? WPUrl.replace : WPUrl.push).call(WPUrl, { project: id || '' });
|
|
}
|
|
|
|
// selectProject() went with the picker card's <select>. The app bar's switcher
|
|
// reloads with ?project=<id> rather than swapping state in place, so there is
|
|
// nothing left on this page that chooses a project without navigating.
|
|
|
|
function afterProjectChosen(p){
|
|
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
|
ProjectData.setActive(p);
|
|
_createOpenedManually = false;
|
|
urlSyncProject(p.id, false);
|
|
renderProjectEntry();
|
|
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');
|
|
|
|
if(!active){
|
|
cards.style.display = 'none';
|
|
const strip = document.getElementById('pipeline');
|
|
if(strip) strip.hidden = true;
|
|
heroTitle.textContent = 'Work Package Suite';
|
|
// Two different situations, and telling someone to "select a project"
|
|
// when there are none to select is the thing B3 is about.
|
|
heroSub.textContent = _projects.length
|
|
? 'Choose a project to begin — or create one.'
|
|
: 'Create your first project to begin.';
|
|
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';
|
|
|
|
// 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);
|
|
renderPipeline(active);
|
|
}
|
|
|
|
// ── PIPELINE STRIP (B4 surface / T5.3) ────────────────────────────────────
|
|
// Four counts, all four from /api/wps/metrics. There is deliberately no cache
|
|
// fallback: B4's whole point is that a per-browser number which looks
|
|
// authoritative is worse than no number, and the same is true of a remembered
|
|
// one shown next to live ones.
|
|
//
|
|
// The flags match wp-creation-app.js's DASH_FLAGS. A cell linking to a filter
|
|
// the dashboard does not recognise is a dead link that still looks live.
|
|
const PIPE_CELLS = [
|
|
{flag: '', cls: 'is-total', label: 'Work packages', key: 'total',
|
|
sub: 'everything on this project'},
|
|
{flag: 'ready', cls: 'is-ready', label: 'Release ready', key: 'release_ready',
|
|
sub: 'no open constraints, nothing waiting'},
|
|
{flag: 'onhold', cls: 'is-onhold', label: 'On hold', key: 'on_hold',
|
|
sub: 'raised as an issue'},
|
|
{flag: 'overdue', cls: 'is-overdue', label: 'Overdue', key: 'overdue',
|
|
sub: 'past due and not closed'},
|
|
];
|
|
|
|
// B7/T7.1: this pointed at the suite page, which loaded the dashboard into an
|
|
// iframe and forwarded the filter across the boundary because the frame's src
|
|
// carried only the project. The creator is a page, so link at it directly.
|
|
// The old address still resolves - the suite page forwards it - but a link
|
|
// built today should not need forwarding.
|
|
function dashHref(projectId, flag){
|
|
const q = new URLSearchParams({ view: 'dashboard', project: projectId });
|
|
if(flag) q.set('flag', flag);
|
|
return 'wp-creation-index.html?' + q.toString();
|
|
}
|
|
|
|
function renderPipeline(active){
|
|
const host = document.getElementById('pipeline');
|
|
const strip = document.getElementById('pipeline-strip');
|
|
if(!host || !strip) return;
|
|
host.hidden = false;
|
|
strip.setAttribute('aria-busy', 'true');
|
|
strip.innerHTML = '<div class="pipe-loading">Counting work packages…</div>';
|
|
|
|
fetch('/api/wps/metrics?project_id=' + encodeURIComponent(active.id),
|
|
{ headers: { 'Accept': 'application/json' } })
|
|
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(m => {
|
|
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
|
|
strip.setAttribute('aria-busy', 'false');
|
|
if(!m.total){
|
|
// Four zeros on a project with no work packages is not a reading, it
|
|
// is a strip that looks broken. Say the true thing instead.
|
|
strip.innerHTML = `<div class="pipe-empty">No work packages on this project yet.
|
|
<a href="${esc(dashHref(active.id, ''))}">Open the creator</a> to write the first one.</div>`;
|
|
return;
|
|
}
|
|
strip.innerHTML = PIPE_CELLS.map(c => `
|
|
<a class="pipe-cell ${c.cls}" href="${esc(dashHref(active.id, c.flag))}">
|
|
<span class="pipe-num">${Number(m[c.key] || 0)}</span>
|
|
<span class="pipe-label">${esc(c.label)}</span>
|
|
<span class="pipe-sub">${esc(c.sub)}</span>
|
|
</a>`).join('');
|
|
})
|
|
.catch(err => {
|
|
if(ProjectData.getActiveId() !== active.id) return;
|
|
strip.setAttribute('aria-busy', 'false');
|
|
strip.innerHTML = `<div class="pipe-error">⚠ Could not load work package counts —
|
|
${esc((err && err.message) || 'offline')}. These numbers come from the server;
|
|
nothing stale is shown in their place.</div>`;
|
|
});
|
|
}
|
|
|
|
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectEntry(); applyActiveProject(); }
|
|
|
|
// Back / Forward between projects.
|
|
if(typeof WPUrl !== 'undefined'){
|
|
WPUrl.onChange(function(state, viaPop){
|
|
if(!viaPop) return;
|
|
const want = state.project || '';
|
|
if(want === (ProjectData.getActiveId() || '')) return;
|
|
if(!want){ ProjectData.setActive(null); }
|
|
else {
|
|
const p = (_projects||[]).find(x=>x.id===want);
|
|
ProjectData.setActive(p || { id: want });
|
|
}
|
|
renderProjectEntry(); 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');
|
|
|
|
// One status line per card, replaced in place rather than removed and
|
|
// re-added — a card that briefly has no status line reads as "no status",
|
|
// which is one of the three real answers and must not be shown by accident.
|
|
//
|
|
// role="status" so the change is announced: the line is written by a fetch
|
|
// that lands after the page has settled, which is exactly the case
|
|
// aria-live exists for (S10 / T4.5). Polite, because a project's SOP being
|
|
// incomplete is information, not an interruption.
|
|
const cardStatus = (card, text, cls) => {
|
|
if(!card) return null;
|
|
let el = card.querySelector('.card-status');
|
|
if(!el){
|
|
el = document.createElement('div');
|
|
el.className = 'card-status';
|
|
el.setAttribute('role', 'status');
|
|
card.insertBefore(el, card.firstChild);
|
|
}
|
|
el.className = 'card-status' + (cls ? ' ' + cls : '');
|
|
el.textContent = text;
|
|
return el;
|
|
};
|
|
const setStatus = (text, cls) => cardStatus(sopCard, text, cls);
|
|
const setWpStatus = (text, cls) => cardStatus(wpCard, text, cls);
|
|
|
|
// A7 / T6.5. Two things were doing one job badly.
|
|
//
|
|
// The SOP card said "✓ SOP complete" when it was and NOTHING when it was
|
|
// not, so the most common state on a live project was the one with no
|
|
// status line at all. And the Work Package card carried its status in its
|
|
// BUTTON — "Complete SOP first", "Checking…" — which is a button describing
|
|
// a state instead of naming what pressing it does.
|
|
//
|
|
// Now: every card says its state in its own status line, in all three
|
|
// states, and every button says what it does. The buttons no longer change
|
|
// text at all.
|
|
sopBtn.textContent = 'Open tool';
|
|
if(wpBtn) wpBtn.textContent = 'Open creator';
|
|
setStatus('Checking the SOP…', 'card-status-pending');
|
|
setWpStatus('Checking the SOP…', 'card-status-pending');
|
|
|
|
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
|
|
if(sum.sop_complete){
|
|
sopCard.classList.add('complete');
|
|
sopBtn.textContent = 'Review';
|
|
setStatus('✓ Complete' + (sum.sop_name ? ' — ' + sum.sop_name : ''));
|
|
setWpStatus('✓ Ready — the SOP is complete');
|
|
} else {
|
|
if(wpCard) wpCard.classList.add('disabled');
|
|
setStatus('• Not finished — the project baseline is still being set up',
|
|
'card-status-pending');
|
|
setWpStatus('• Not available yet — finish the SOP first',
|
|
'card-status-pending');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
if(ProjectData.getActiveId() !== active.id) return;
|
|
// Explicitly unknown, and it looks like neither of the other two. The
|
|
// Creator is left reachable rather than disabled: locking somebody out
|
|
// of their work because a status request failed is worse than letting
|
|
// the tool tell them itself.
|
|
const why = '⚠ Unknown — could not reach the server (' + ((err && err.message) || 'offline') + ')';
|
|
setStatus(why, 'card-status-error');
|
|
setWpStatus(why, 'card-status-error');
|
|
});
|
|
}
|
|
|
|
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>
|