create-pmos 0.126.0 → 0.128.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-reports-test.js +314 -0
- package/workspace/fixtures/one-home-baseline.json +29 -9
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/layout.md +2 -2
- package/workspace/guide/reports-dashboards.md +57 -0
- package/workspace/lib/git-info.js +19 -1
- package/workspace/lib/report-dashboards.js +194 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-c3-reports.js +108 -0
- package/workspace/lib/session-origin.js +35 -10
- package/workspace/lib/transcript-read.js +17 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-cal-strategy.js +2 -1
- package/workspace/public/pm/c3-demo-rep3.js +97 -0
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-reports.css +62 -0
- package/workspace/public/pm/c3-reports.js +454 -0
- package/workspace/public/pm/c3-shell.js +3 -1
- package/workspace/server.js +30 -3
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/console3-reports.json +6 -0
- package/workspace/test-lists/local-test-kit.json +5 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* pm/c3-reports.css — the Reports dashboards of the console3 page (c3-reports.js links it once, as ensureC3Css links console3.css).
|
|
2
|
+
* Tokens only. Corners: 6px on a control, 8px on a container, else 0 (R27 as amended 2026-10-07). The console3 primitives (.card, .seg,
|
|
3
|
+
* .fold, .nrow, .xp) come from console3.css; this file adds the page's own layout under .rp3. */
|
|
4
|
+
.rp3{display:grid;gap:var(--sp-4);min-width:0;align-content:start}
|
|
5
|
+
.rp3-intro{margin:0;color:var(--muted-foreground);font-size:var(--fs-3)}
|
|
6
|
+
.rp3-pane{display:grid;gap:var(--sp-4);min-width:0;align-content:start}
|
|
7
|
+
.rp3-pane[hidden]{display:none}
|
|
8
|
+
.rp3 .rp3-tabs{justify-self:start;flex-wrap:wrap;max-width:100%}
|
|
9
|
+
.rp3 .rp3-tabs button[aria-selected="true"]{background:var(--background);color:var(--foreground);box-shadow:0 0 0 1px var(--border)}
|
|
10
|
+
.rp3 .rp3-tabs .tag{margin-left:2px}
|
|
11
|
+
.rp3-bar{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;min-width:0}
|
|
12
|
+
.rp3 .rp3-scope{flex-wrap:wrap;max-width:100%}
|
|
13
|
+
.rp3-pick{display:inline-flex;gap:8px;align-items:center;min-width:0;max-width:100%;font-size:var(--fs-2)}
|
|
14
|
+
.rp3-select{min-width:0;max-width:100%;min-height:32px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground);font:var(--fs-2) var(--mono);padding:4px 8px}
|
|
15
|
+
.rp3-select:disabled{opacity:.6;cursor:not-allowed}
|
|
16
|
+
.rp3-head{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;justify-content:space-between;min-width:0}
|
|
17
|
+
.rp3-ttl{display:grid;gap:2px;min-width:0}
|
|
18
|
+
.rp3-ttl h2{margin:0;font:600 var(--fs-5)/1.3 var(--sans);overflow-wrap:anywhere}
|
|
19
|
+
.rp3-ttl .dim{font:var(--fs-2) var(--mono)}
|
|
20
|
+
.rp3-acts{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
21
|
+
.rp3-btn{display:inline-flex;gap:6px;align-items:center;min-height:32px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground);font:500 var(--fs-2) var(--mono);cursor:pointer}
|
|
22
|
+
.rp3-btn[aria-pressed="true"],.rp3-btn.rp3-pri{background:var(--foreground);color:var(--background);border-color:var(--foreground)}
|
|
23
|
+
.rp3-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
24
|
+
.rp3-btn:focus-visible,.rp3-select:focus-visible,.rp3-about:focus-visible,.rp3-librow:focus-visible{outline:2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));outline-offset:2px}
|
|
25
|
+
.rp3-ib{padding:4px 6px}
|
|
26
|
+
.rp3-rot .ic{transform:rotate(90deg)}
|
|
27
|
+
@media (hover:hover){
|
|
28
|
+
.rp3-btn:not(:disabled):hover{border-color:var(--ring)}
|
|
29
|
+
.rp3-about:hover{color:var(--foreground)}
|
|
30
|
+
}
|
|
31
|
+
@media (pointer:coarse){
|
|
32
|
+
.rp3-btn,.rp3-select{min-height:44px}
|
|
33
|
+
.rp3-about{min-height:44px}
|
|
34
|
+
.rp3-librow{min-height:48px}
|
|
35
|
+
}
|
|
36
|
+
.rp3-note{margin:0;font-size:var(--fs-3)}
|
|
37
|
+
.rp3-status{margin:0;font-size:var(--fs-2)}
|
|
38
|
+
.rp3-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,440px),1fr));gap:var(--sp-4);align-items:start;min-width:0}
|
|
39
|
+
.rp3-w{display:grid;gap:6px;min-width:0;align-content:start}
|
|
40
|
+
.rp3-w>.card{min-width:0}
|
|
41
|
+
.rp3-w svg{max-width:100%}
|
|
42
|
+
.rp3.editing .rp3-w{outline:1px dashed var(--ring);outline-offset:6px;border-radius:8px}
|
|
43
|
+
.rp3-wh{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;justify-content:space-between;min-width:0}
|
|
44
|
+
.rp3-wt{font:600 var(--fs-3) var(--sans);overflow-wrap:anywhere}
|
|
45
|
+
.rp3-about{display:flex;gap:6px;align-items:center;color:var(--muted-foreground);font:var(--fs-2) var(--mono);cursor:pointer;padding:4px 2px;border-radius:6px}
|
|
46
|
+
.rp3-case{display:grid;gap:6px;padding:8px 2px 4px;font-size:var(--fs-3)}
|
|
47
|
+
.rp3-case p{margin:0}
|
|
48
|
+
.rp3-add .sh h3{margin:0;font:600 var(--fs-3) var(--sans)}
|
|
49
|
+
.rp3-addrow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:8px 2px;border-bottom:1px solid var(--border)}
|
|
50
|
+
.rp3-librow .tt{font-size:var(--fs-4)}
|
|
51
|
+
.rp3-libcard{padding-top:10px;min-width:0}
|
|
52
|
+
.rp3-share{display:grid;gap:var(--sp-4);min-width:0}
|
|
53
|
+
.rp3-frame{border:1px solid var(--border);background:var(--card);border-radius:8px;padding:14px;display:grid;gap:12px;min-width:0}
|
|
54
|
+
.rp3-frame h4{margin:0 0 4px;padding-bottom:4px;border-bottom:1px solid var(--border);font:500 var(--fs-1) var(--mono);text-transform:uppercase;letter-spacing:.07em;color:var(--muted-foreground)}
|
|
55
|
+
.rp3-frame h5{margin:8px 0 2px;font:600 var(--fs-3) var(--sans)}
|
|
56
|
+
.rp3-frame p{margin:0;overflow-wrap:anywhere}
|
|
57
|
+
.rp3-hl{font:600 var(--fs-4)/1.35 var(--sans)}
|
|
58
|
+
.rp3-nums{margin:4px 0 0;padding-left:18px;color:var(--muted-foreground);font-size:var(--fs-2)}
|
|
59
|
+
.rp3-why{margin:0;border-left:3px solid var(--warning);background:var(--muted);padding:8px 10px;font-size:var(--fs-2)}
|
|
60
|
+
.rp3-wrap{flex-wrap:wrap}
|
|
61
|
+
.ph .rp3-grid,.rp3.ph .rp3-grid{grid-template-columns:minmax(0,1fr)}
|
|
62
|
+
.ph .rp3-addrow,.rp3.ph .rp3-addrow{grid-template-columns:minmax(0,1fr)}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/* pm/c3-reports.js — the console3 Reports page: dashboards made of reports (initiative console3-reports, PRD R1 to R7).
|
|
2
|
+
* Flow reference: the restructure mockup's 33-reports.js (dashboards with scope buttons, edit mode, library, share sheet). Not a pixel spec.
|
|
3
|
+
*
|
|
4
|
+
* mountReports(host, ctx) -> { dispose() }
|
|
5
|
+
* ctx = { ws, pmGet?, drafts?(hostEl), goInit?(id, title) }
|
|
6
|
+
* ws the workspace id; every read names it
|
|
7
|
+
* pmGet the shell's GET helper (default: http.js pmGet)
|
|
8
|
+
* drafts mounts the old Reports content (the four families, the claims check, the outbox) into hostEl. Called once, the
|
|
9
|
+
* first time the Drafts tab opens. Without it the Drafts tab says the drafts are not available on this page.
|
|
10
|
+
* goInit opens an initiative from a row inside a report (a bet, a commitment). Default: the Initiative dashboard of it.
|
|
11
|
+
*
|
|
12
|
+
* Three tabs: Dashboards | Library | Drafts.
|
|
13
|
+
* Dashboards scope buttons Portfolio | Board | Initiative, a chooser for the board or the initiative, then one widget per report of the
|
|
14
|
+
* layout (or the scope's default). A widget is the Calendar's own builder (c3-cal-strategy.js PANEL_BUILDERS), drawn from
|
|
15
|
+
* GET /api/report-dashboards/data?scope=&id=&ws= (kept a minute per scope). A widget is a reference (data-ref-row); its
|
|
16
|
+
* "About this report" line opens the report's case in place and links to its home in the library.
|
|
17
|
+
* Edit: up and down arrows, Remove (with Undo), Add from the report library, Reset to default. Every change POSTs
|
|
18
|
+
* /api/report-dashboards/layout { key, reports } (reports: null resets). A failed POST is toasted; the page keeps the change.
|
|
19
|
+
* Share: a sheet (c3-sheet.js) with the report frame (Headline, Sections, Needs your call, Sources) for Leadership or Team.
|
|
20
|
+
* "Save as draft" queues ONE draft through POST /api/outbox/draft. Nothing is sent. The schedule control is off and says why.
|
|
21
|
+
* Library one row per report (data-home): the title, the question, the source and where it is used. The row opens the card in place.
|
|
22
|
+
* Drafts ctx.drafts(host).
|
|
23
|
+
* DATA: GET /api/report-dashboards?ws= -> { library, layouts, defaults, boards, initiatives }. In demo mode c3-demo-rep3.js answers both reads
|
|
24
|
+
* and the layout POST. Nothing here invents a number: a report with no rows in the scope draws its builder's own empty card. */
|
|
25
|
+
import { el } from './dom.js';
|
|
26
|
+
import { button } from './primitives.js';
|
|
27
|
+
import { pmGet as httpGet, pmPost } from './http.js';
|
|
28
|
+
import { ensureC3Css, c3ic, c3Phone } from './pm-work-view.js';
|
|
29
|
+
import { PANEL_BUILDERS, todayStr } from './c3-cal-strategy.js';
|
|
30
|
+
import { makeXp, attachExpand } from './c3-expand.js';
|
|
31
|
+
import { openSheet } from './c3-sheet.js';
|
|
32
|
+
import { toast } from './c3-toast.js';
|
|
33
|
+
|
|
34
|
+
const SCOPES = [['portfolio', 'Portfolio'], ['board', 'Board'], ['initiative', 'Initiative']];
|
|
35
|
+
const TABS = [['dash', 'Dashboards', 'report'], ['lib', 'Library', 'doc'], ['drafts', 'Drafts', 'out']];
|
|
36
|
+
const ICON = { north: 'target', outcomes: 'target', signals: 'alert', delivery: 'clock', maturity: 'flag', bets: 'flag', synergy: 'init' };
|
|
37
|
+
const TTL = 60000;
|
|
38
|
+
|
|
39
|
+
export function ensureReportsCss() {
|
|
40
|
+
if (typeof document === 'undefined' || document.querySelector('link[data-c3-reports]')) return;
|
|
41
|
+
const l = document.createElement('link');
|
|
42
|
+
l.rel = 'stylesheet';
|
|
43
|
+
l.href = new URL('./c3-reports.css', import.meta.url).href;
|
|
44
|
+
l.setAttribute('data-c3-reports', '');
|
|
45
|
+
document.head.appendChild(l);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ── the data of one scope: one read per scope and id, kept a minute (as strategyPanel keeps /api/c3-strategy) ───────────── */
|
|
49
|
+
const cache = new Map(); const inflight = new Map();
|
|
50
|
+
const dataKey = (scope, id) => scope + '|' + (id || '');
|
|
51
|
+
function readData(get, ws, scope, id) {
|
|
52
|
+
const k = dataKey(scope, id); const c = cache.get(k);
|
|
53
|
+
if (c && Date.now() - c.at < TTL) return Promise.resolve(c.model);
|
|
54
|
+
if (!inflight.has(k)) {
|
|
55
|
+
const url = '/api/report-dashboards/data?scope=' + encodeURIComponent(scope) + (id ? '&id=' + encodeURIComponent(id) : '') + (ws ? '&ws=' + encodeURIComponent(ws) : '');
|
|
56
|
+
inflight.set(k, Promise.resolve(get(url)).then((r) => {
|
|
57
|
+
inflight.delete(k);
|
|
58
|
+
const model = r && r.ok ? r : { ok: false, error: (r && (r.error || r.reason)) || 'not available' };
|
|
59
|
+
if (model.ok) cache.set(k, { at: Date.now(), model });
|
|
60
|
+
return model;
|
|
61
|
+
}, (e) => { inflight.delete(k); return { ok: false, error: String((e && e.message) || e) }; }));
|
|
62
|
+
}
|
|
63
|
+
return inflight.get(k);
|
|
64
|
+
}
|
|
65
|
+
const cachedData = (scope, id) => { const c = cache.get(dataKey(scope, id)); return c && Date.now() - c.at < TTL ? c.model : null; };
|
|
66
|
+
|
|
67
|
+
/* The model the builders read, from the data answer, the way c3-strategy-panels.js build() makes it. */
|
|
68
|
+
function modelOf(raw) {
|
|
69
|
+
const M = { ...raw, outcomes: raw.outcomes || [], signals: raw.signals || [] };
|
|
70
|
+
if (!M.signalsAll) M.signalsAll = M.signals.map((s) => ({ ...s }));
|
|
71
|
+
return M;
|
|
72
|
+
}
|
|
73
|
+
const builderOf = (rep) => (rep && (rep.builder || rep.id)) || '';
|
|
74
|
+
function drawReport(rep, raw, bctx) {
|
|
75
|
+
const M = modelOf(raw); const live = !M.demo;
|
|
76
|
+
const c = { ...bctx, today: bctx.today || M.today || todayStr(Date.now()), data: { range: M.range || null, gantt: [] } };
|
|
77
|
+
const b = builderOf(rep);
|
|
78
|
+
try {
|
|
79
|
+
switch (b) {
|
|
80
|
+
case 'north': return PANEL_BUILDERS.north(M, 'year', c.today, false);
|
|
81
|
+
case 'outcomes': return PANEL_BUILDERS.outcomes(M);
|
|
82
|
+
case 'signals': return PANEL_BUILDERS.signals(M, 6, c, live);
|
|
83
|
+
case 'synergy': return PANEL_BUILDERS.synergy(M);
|
|
84
|
+
case 'maturity': return PANEL_BUILDERS.maturity(M);
|
|
85
|
+
case 'bets': return PANEL_BUILDERS.bets(M, c);
|
|
86
|
+
case 'delivery': return PANEL_BUILDERS.delivery ? PANEL_BUILDERS.delivery(M, c, live) : failCard(rep, 'its builder is not registered in c3-cal-strategy.js');
|
|
87
|
+
default: return failCard(rep, 'no builder draws a report with the id ' + b);
|
|
88
|
+
}
|
|
89
|
+
} catch (e) { return failCard(rep, String((e && e.message) || e)); }
|
|
90
|
+
}
|
|
91
|
+
const failCard = (rep, why) => el('section', { class: 'card', 'data-rp3-fail': rep ? rep.id : '' }, el('h3', null, c3ic(ICON[builderOf(rep)] || 'report'), rep ? rep.title : 'Report'),
|
|
92
|
+
el('div', { class: 'fold' }, c3ic('alert', 'sm'), el('span', null, 'This report could not be drawn: ' + why + '.')));
|
|
93
|
+
|
|
94
|
+
/* ── the text of a report, for the share frame: one sentence, the numbers behind it, and what needs a call ─────────────── */
|
|
95
|
+
const fmt = (v) => (v == null ? '' : Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100));
|
|
96
|
+
const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many);
|
|
97
|
+
const ST_TXT = { done: 'done', ok: 'on time', risk: 'at risk', late: 'delayed', over: 'overdue', park: 'parked', prop: 'proposed', none: 'no status' };
|
|
98
|
+
const SEV_TXT = { pivot: 'at the pivot line', drift: 'drifting', watch: 'on watch', up: 'upside' };
|
|
99
|
+
export function readingOf(rep, raw) {
|
|
100
|
+
const M = modelOf(raw || {}); const b = builderOf(rep);
|
|
101
|
+
const out = { line: '', nums: [], needs: [] };
|
|
102
|
+
if (b === 'north') {
|
|
103
|
+
const N = M.north; const P = N && N.points;
|
|
104
|
+
if (!P || P.length < 2) { out.line = 'No north-star metric is recorded.'; return out; }
|
|
105
|
+
const p = P.find((x) => x.at === N.now) || P[P.length - 1]; const u = N.unit || '';
|
|
106
|
+
if (p.actual == null || !p.plan) { out.line = (N.label || 'The north star') + ' has no reading yet.'; return out; }
|
|
107
|
+
const v = Math.round((p.actual / p.plan - 1) * 100);
|
|
108
|
+
out.line = (N.label || 'The north star') + (v === 0 ? ' is on plan.' : ' is ' + Math.abs(v) + '% ' + (v < 0 ? 'behind' : 'ahead of') + ' plan.');
|
|
109
|
+
out.nums.push('Now ' + fmt(p.actual) + u + ', plan ' + fmt(p.plan) + u + '.');
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
if (b === 'outcomes') {
|
|
113
|
+
const L = M.outcomes;
|
|
114
|
+
if (!L.length) { out.line = 'No key result is scored.'; return out; }
|
|
115
|
+
const on = L.filter((k) => k.status === 'ok').length;
|
|
116
|
+
out.line = on + ' of ' + L.length + ' outcomes ' + (on === 1 ? 'is' : 'are') + ' on course.';
|
|
117
|
+
for (const k of L) {
|
|
118
|
+
const u = k.unit || '';
|
|
119
|
+
out.nums.push(k.status === 'unscored' ? k.name + ': not scored.' : k.name + ': now ' + fmt(k.now) + u + ', should be ' + fmt(k.expected) + u + ', target ' + fmt(k.target) + u + '.');
|
|
120
|
+
if (k.status === 'off') out.needs.push(k.name + ' is off course.');
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
if (b === 'signals') {
|
|
125
|
+
const L = M.signals;
|
|
126
|
+
if (!L.length) { out.line = 'No signal is open.'; return out; }
|
|
127
|
+
const by = {}; for (const s of L) by[s.sev] = (by[s.sev] || 0) + 1;
|
|
128
|
+
out.line = plural(L.length, 'signal is', 'signals are') + ' open: ' + Object.keys(by).map((k) => by[k] + ' ' + (SEV_TXT[k] || k)).join(', ') + '.';
|
|
129
|
+
for (const s of L) { out.nums.push(s.t + '. ' + (s.ev || '')); if (s.sev === 'pivot' || s.sev === 'drift') out.needs.push(s.t + '.'); }
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
if (b === 'delivery') {
|
|
133
|
+
const R = (M.delivery && M.delivery.rows) || [];
|
|
134
|
+
if (!R.length) { out.line = 'No commitment has a promised date this quarter.'; return out; }
|
|
135
|
+
const n = (f) => R.filter(f).length;
|
|
136
|
+
out.line = n((r) => r.st === 'ok' || r.st === 'done') + ' of ' + R.length + ' commitments are on time or done, ' + n((r) => r.st === 'risk') + ' at risk, ' + n((r) => r.st === 'late' || r.st === 'over') + ' delayed or overdue.';
|
|
137
|
+
for (const r of R) { out.nums.push(r.n + ': ' + (ST_TXT[r.st] || r.st) + '.'); if (r.st === 'late' || r.st === 'over') out.needs.push(r.n + ' is ' + ST_TXT[r.st] + '.'); }
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
if (b === 'maturity') {
|
|
141
|
+
const T = M.maturity;
|
|
142
|
+
if (!T || !T.pillars || !T.pillars.length) { out.line = 'No pillar maturity is recorded.'; return out; }
|
|
143
|
+
out.line = T.pillars.filter((p) => p.proj >= p.tgt).length + ' of ' + T.pillars.length + ' pillars are on course for ' + T.target_label + '.';
|
|
144
|
+
for (const p of T.pillars) out.nums.push(p.n + ': stage ' + p.now + ', target ' + p.tgt + ', projected ' + p.proj + '.');
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
if (b === 'bets') {
|
|
148
|
+
const T = M.timeline;
|
|
149
|
+
if (!T || !T.rows || !T.rows.length) { out.line = 'No bet is recorded.'; return out; }
|
|
150
|
+
const cp = (T.checkpoints || [])[0];
|
|
151
|
+
out.line = plural(T.rows.length, 'bet is', 'bets are') + ' on the plan. ' + (cp ? 'The next checkpoint is ' + cp.label + '.' : 'No checkpoint is recorded.');
|
|
152
|
+
for (const r of T.rows) out.nums.push(r.title + ': ' + (ST_TXT[r.st] || 'no status') + '.');
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
if (b === 'synergy') {
|
|
156
|
+
const S = M.synergy;
|
|
157
|
+
if (!S || !S.rows || !S.rows.length) { out.line = 'No bet is recorded.'; return out; }
|
|
158
|
+
if (!S.columns || !S.columns.length) { out.line = 'No key result exists to measure the bets against.'; return out; }
|
|
159
|
+
const active = S.rows.filter((r) => !r.parked);
|
|
160
|
+
const gaps = S.columns.filter((_, j) => !active.some((r) => r.weights[j] > 0));
|
|
161
|
+
out.line = plural(active.length, 'active bet covers', 'active bets cover') + ' ' + (S.columns.length - gaps.length) + ' of ' + S.columns.length + ' ' + (S.unit || 'key results') + '.';
|
|
162
|
+
for (const r of S.rows) out.nums.push(r.title + (r.parked ? ': parked.' : ': active.'));
|
|
163
|
+
for (const g of gaps) out.needs.push('No active bet covers ' + g.n + '.');
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
out.line = 'This report has no text form.';
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* The report frame of a dashboard, as data (the sheet and the draft body are both drawn from it). */
|
|
171
|
+
export function frameOf({ title, reports, raw, audience, today }) {
|
|
172
|
+
const lead = audience !== 'Team';
|
|
173
|
+
const sections = reports.map((r) => { const t = readingOf(r, raw); return { id: r.id, title: r.title, line: t.line, nums: lead ? [] : t.nums, needs: t.needs }; });
|
|
174
|
+
const needs = [...new Set(sections.flatMap((s) => s.needs))];
|
|
175
|
+
const headline = !sections.length ? 'This dashboard has no reports yet.'
|
|
176
|
+
: needs.length ? needs[0] + (needs.length > 1 ? ' ' + plural(needs.length - 1, 'more thing needs', 'more things need') + ' your call.' : '') : sections[0].line;
|
|
177
|
+
const sources = [...new Set(reports.map((r) => r.source).filter(Boolean))];
|
|
178
|
+
return { title, audience: lead ? 'Leadership' : 'Team', today, headline, sections, needs, sources };
|
|
179
|
+
}
|
|
180
|
+
export function draftBody(F) {
|
|
181
|
+
const L = [F.title + ' for ' + F.audience + ', ' + F.today, 'Headline: ' + F.headline, '', 'Sections'];
|
|
182
|
+
for (const s of F.sections) { L.push(s.title, '- ' + s.line + ' [reported]'); for (const n of s.nums) L.push('- ' + n + ' [reported]'); }
|
|
183
|
+
L.push('', 'Needs your call');
|
|
184
|
+
if (F.needs.length) for (const n of F.needs) L.push('- ' + n + ' [reported]'); else L.push('Nothing needs your call in this scope.');
|
|
185
|
+
L.push('', 'Sources: ' + (F.sources.length ? F.sources.join('; ') : 'none') + '. Read on ' + F.today + '.');
|
|
186
|
+
return L.join('\n');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ── the page ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
190
|
+
/* every control comes from primitives.js button(); the children are the console3 icon and text, which keep the suites' hooks */
|
|
191
|
+
const mk = (variant, attrs, kids) => { const { onclick, disabled, class: cls, ...rest } = attrs || {}; const b = button({ label: '', variant, size: 'sm', onClick: onclick, disabled, attrs: { ...rest, class: cls } }); b.replaceChildren(...kids.filter((k) => k != null)); return b; };
|
|
192
|
+
const btn = (attrs, ...kids) => mk('outline', { ...attrs, class: 'rp3-btn' }, kids);
|
|
193
|
+
const iconBtn = (label, icon, attrs, cls) => mk('ghost', { title: label, 'aria-label': label, ...attrs, class: 'rp3-btn rp3-ib' + (cls ? ' ' + cls : '') }, [c3ic(icon, 'sm')]);
|
|
194
|
+
const pill = (attrs, ...kids) => mk('ghost', attrs, kids);
|
|
195
|
+
|
|
196
|
+
export function mountReports(host, ctx) {
|
|
197
|
+
ensureC3Css(); ensureReportsCss();
|
|
198
|
+
const C = ctx || {};
|
|
199
|
+
const get = C.pmGet || httpGet;
|
|
200
|
+
const S = { tab: 'dash', scope: 'portfolio', pick: { board: '', initiative: '' }, meta: null, metaErr: null, layouts: {}, edit: false, drafts: false, alive: true, libOpen: new Set() };
|
|
201
|
+
const root = el('div', { class: 'rp3', 'data-rp3': '' });
|
|
202
|
+
const stopPhone = host.closest && host.closest('.c3') ? () => {} : c3Phone(root);
|
|
203
|
+
if (!(host.closest && host.closest('.c3'))) root.classList.add('c3');
|
|
204
|
+
const tabBar = el('div', { class: 'seg rp3-tabs', role: 'tablist', 'aria-label': 'Reports' });
|
|
205
|
+
const panes = { dash: el('div', { class: 'rp3-pane', role: 'tabpanel', 'data-rp3-pane': 'dash' }), lib: el('div', { class: 'rp3-pane', role: 'tabpanel', 'data-rp3-pane': 'lib', hidden: true }), drafts: el('div', { class: 'rp3-pane', role: 'tabpanel', 'data-rp3-pane': 'drafts', hidden: true }) };
|
|
206
|
+
root.append(el('p', { class: 'rp3-intro' }, 'Dashboards made of reports. Each report answers one question from the repository\'s records.'), tabBar, panes.dash, panes.lib, panes.drafts);
|
|
207
|
+
host.replaceChildren(root);
|
|
208
|
+
|
|
209
|
+
const lib = () => (S.meta && S.meta.library) || [];
|
|
210
|
+
const repOf = (id) => lib().find((r) => r.id === id) || null;
|
|
211
|
+
const keyOf = (scope, id) => (scope == 'portfolio' ? 'portfolio' : scope + ':' + id);
|
|
212
|
+
const listOf = (scope) => (scope === 'board' ? (S.meta && S.meta.boards) || [] : scope === 'initiative' ? (S.meta && S.meta.initiatives) || [] : []);
|
|
213
|
+
const curId = () => (S.scope == 'portfolio' ? '' : S.pick[S.scope]);
|
|
214
|
+
const nameOf = (scope, id) => { const x = listOf(scope).find((b) => b.id === id); return x ? x.title || x.id : id; };
|
|
215
|
+
const dashTitle = () => (S.scope == 'portfolio' ? 'Portfolio' : (S.scope === 'board' ? 'Board: ' : 'Initiative: ') + nameOf(S.scope, curId()));
|
|
216
|
+
const fits = (r, scope) => !r.scopes || r.scopes.includes(scope);
|
|
217
|
+
const known = (ids) => (ids || []).filter((id) => repOf(id));
|
|
218
|
+
const defaultOf = (scope) => known(S.meta && S.meta.defaults && S.meta.defaults[scope]);
|
|
219
|
+
const hasOwn = (scope, id) => Object.prototype.hasOwnProperty.call(S.layouts, keyOf(scope, id));
|
|
220
|
+
const layoutOf = (scope, id) => (hasOwn(scope, id) ? known(S.layouts[keyOf(scope, id)]) : defaultOf(scope));
|
|
221
|
+
const goInit = (id, title) => {
|
|
222
|
+
if (C.goInit) { C.goInit(id, title); return; }
|
|
223
|
+
if (!listOf('initiative').some((x) => x.id === id)) { toast('No dashboard is kept for ' + (title || id) + '.'); return; }
|
|
224
|
+
S.scope = 'initiative'; S.pick.initiative = id; S.edit = false; drawDash();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/* tabs */
|
|
228
|
+
function drawTabs() {
|
|
229
|
+
tabBar.replaceChildren(...TABS.map(([id, label, icon]) => pill({ type: 'button', role: 'tab', 'aria-selected': String(S.tab === id), tabindex: S.tab === id ? '0' : '-1', 'data-rp3-tab': id, onclick: () => showTab(id) }, c3ic(icon, 'sm'), label,
|
|
230
|
+
id === 'lib' && S.meta ? el('span', { class: 'tag' }, String(lib().length)) : null)));
|
|
231
|
+
}
|
|
232
|
+
function showTab(id) {
|
|
233
|
+
S.tab = id; drawTabs();
|
|
234
|
+
for (const k of Object.keys(panes)) panes[k].hidden = k !== id;
|
|
235
|
+
if (id === 'lib') drawLib();
|
|
236
|
+
if (id === 'drafts' && !S.drafts) {
|
|
237
|
+
S.drafts = true;
|
|
238
|
+
if (typeof C.drafts === 'function') { const h = el('div', { class: 'rp3-drafts', 'data-rp3-drafts': '' }); panes.drafts.replaceChildren(h); C.drafts(h); }
|
|
239
|
+
else panes.drafts.replaceChildren(el('div', { class: 'fold' }, c3ic('info', 'sm'), 'The report drafts are not available on this page.'));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ── Dashboards ── */
|
|
244
|
+
function scopeBar() {
|
|
245
|
+
const seg = el('div', { class: 'seg rp3-scope', role: 'group', 'aria-label': 'Scope' }, SCOPES.map(([id, label]) => pill({ 'aria-pressed': String(S.scope === id), 'data-rp3-scope': id,
|
|
246
|
+
onclick: () => { if (S.scope === id) return; S.scope = id; S.edit = false; drawDash(); } }, label)));
|
|
247
|
+
const out = [seg];
|
|
248
|
+
if (S.scope !== 'portfolio') {
|
|
249
|
+
const L = listOf(S.scope);
|
|
250
|
+
const lab = S.scope === 'board' ? 'Board' : 'Initiative';
|
|
251
|
+
const sel = el('select', { class: 'rp3-select', 'aria-label': 'Choose the ' + lab.toLowerCase(), 'data-rp3-pick': S.scope, disabled: !L.length,
|
|
252
|
+
onchange: (e) => { S.pick[S.scope] = e.target.value; S.edit = false; drawDash(); } },
|
|
253
|
+
L.length ? L.map((x) => el('option', { value: x.id, selected: x.id === S.pick[S.scope] }, S.scope === 'initiative' && x.board ? (x.title || x.id) + ' (' + nameOf('board', x.board) + ')' : (x.title || x.id))) : el('option', { value: '' }, 'None recorded'));
|
|
254
|
+
out.push(el('label', { class: 'rp3-pick' }, el('span', { class: 'dim' }, lab), sel));
|
|
255
|
+
}
|
|
256
|
+
return el('div', { class: 'rp3-bar' }, out);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function widget(rep, i, n, model) {
|
|
260
|
+
const body = !model ? el('p', { class: 'dim mono rp3-status', role: 'status', 'data-rp3-loading': rep.id }, 'Reading the repository')
|
|
261
|
+
: !model.ok ? el('p', { class: 'dim mono rp3-status', role: 'status', 'data-rp3-error': rep.id }, 'This report could not be read: ' + model.error)
|
|
262
|
+
: drawReport(rep, model, { goInit });
|
|
263
|
+
const w = el('article', { class: 'rp3-w', 'data-ref-row': rep.id, 'data-rp3-w': rep.id });
|
|
264
|
+
if (S.edit) {
|
|
265
|
+
w.append(el('div', { class: 'rp3-wh' }, el('b', { class: 'rp3-wt' }, rep.title), el('span', { class: 'rp3-acts' },
|
|
266
|
+
iconBtn('Move ' + rep.title + ' up', 'prev', { 'data-rp3-up': rep.id, disabled: i === 0, onclick: () => move(rep.id, -1) }, 'rp3-rot'),
|
|
267
|
+
iconBtn('Move ' + rep.title + ' down', 'next', { 'data-rp3-down': rep.id, disabled: i === n - 1, onclick: () => move(rep.id, 1) }, 'rp3-rot'),
|
|
268
|
+
btn({ 'data-rp3-rm': rep.id, 'aria-label': 'Remove ' + rep.title, onclick: () => remove(rep.id) }, c3ic('x', 'sm'), 'Remove'))));
|
|
269
|
+
}
|
|
270
|
+
w.append(body);
|
|
271
|
+
const about = el('div', { class: 'rp3-about', 'data-rp3-about': rep.id }, c3ic('next', 'sm chev'), el('span', null, 'About this report'));
|
|
272
|
+
const xp = makeXp(el('div', { class: 'rp3-case' },
|
|
273
|
+
el('p', null, el('b', null, rep.title + '. '), rep.question || ''),
|
|
274
|
+
el('p', { class: 'dim' }, 'Source: ' + (rep.source || 'not named') + '. Used on: ' + usedOn(rep.id) + '.'),
|
|
275
|
+
btn({ 'data-rp3-golib': rep.id, onclick: () => openInLibrary(rep.id) }, c3ic('doc', 'sm'), 'Open in the library')));
|
|
276
|
+
attachExpand(about, xp, { key: 'rp3w-' + rep.id });
|
|
277
|
+
w.append(about, xp);
|
|
278
|
+
return w;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function drawDash() {
|
|
282
|
+
if (!S.alive) return;
|
|
283
|
+
const P = panes.dash;
|
|
284
|
+
if (S.metaErr) { P.replaceChildren(el('div', { class: 'fold', role: 'status', 'data-rp3-error': 'meta' }, c3ic('alert', 'sm'), 'The dashboards could not be read: ' + S.metaErr)); return; }
|
|
285
|
+
if (!S.meta) { P.replaceChildren(el('p', { class: 'dim mono rp3-status', role: 'status', 'data-rp3-loading': 'meta' }, 'Reading the repository')); return; }
|
|
286
|
+
if (S.scope !== 'portfolio' && !listOf(S.scope).some((x) => x.id === S.pick[S.scope])) S.pick[S.scope] = (listOf(S.scope)[0] || {}).id || '';
|
|
287
|
+
const id = curId();
|
|
288
|
+
const noScope = S.scope !== 'portfolio' && !id;
|
|
289
|
+
const ids = noScope ? [] : layoutOf(S.scope, id);
|
|
290
|
+
const own = !noScope && hasOwn(S.scope, id);
|
|
291
|
+
root.classList.toggle('editing', S.edit);
|
|
292
|
+
const head = el('div', { class: 'rp3-head' },
|
|
293
|
+
el('div', { class: 'rp3-ttl' }, el('h2', { 'data-rp3-title': '' }, noScope ? (S.scope === 'board' ? 'Board' : 'Initiative') : dashTitle()),
|
|
294
|
+
el('span', { class: 'dim', 'data-rp3-count': String(ids.length) }, noScope ? '' : plural(ids.length, 'report', 'reports') + (own ? ', your layout' : ', the default layout'))),
|
|
295
|
+
noScope ? null : el('span', { class: 'rp3-acts' },
|
|
296
|
+
btn({ 'data-rp3-share': '', onclick: () => share(ids) }, c3ic('out', 'sm'), 'Share'),
|
|
297
|
+
S.edit ? btn({ 'data-rp3-reset': '', disabled: !own, title: own ? 'Return this scope to its default layout' : 'This scope shows its default layout', onclick: () => resetLayout() }, c3ic('undo', 'sm'), 'Reset to default') : null,
|
|
298
|
+
btn({ 'data-rp3-edit': '', 'aria-pressed': String(S.edit), onclick: () => { S.edit = !S.edit; drawDash(); } }, c3ic('edit', 'sm'), S.edit ? 'Done editing' : 'Edit dashboard')));
|
|
299
|
+
const kids = [scopeBar(), head];
|
|
300
|
+
if (noScope) {
|
|
301
|
+
kids.push(el('div', { class: 'fold', 'data-rp3-empty': S.scope }, c3ic('moon', 'sm'), S.scope === 'board'
|
|
302
|
+
? 'No board is recorded. A board is the domain of an initiative\'s anchor. It appears here when an initiative names one.'
|
|
303
|
+
: 'No initiative is recorded. An initiative appears here when the repository holds its record.'));
|
|
304
|
+
P.replaceChildren(...kids); return;
|
|
305
|
+
}
|
|
306
|
+
if (S.edit) kids.push(el('p', { class: 'dim rp3-note' }, 'Use the arrows to move a report. Remove takes it off this dashboard only: it stays in the library. Your layout is kept for this scope in the runtime state, not in the repository.'));
|
|
307
|
+
const model = cachedData(S.scope, id);
|
|
308
|
+
const grid = el('div', { class: 'rp3-grid', 'data-rp3-grid': keyOf(S.scope, id) });
|
|
309
|
+
const reps = ids.map(repOf);
|
|
310
|
+
reps.forEach((r, i) => grid.append(widget(r, i, reps.length, model)));
|
|
311
|
+
kids.push(reps.length ? grid : el('div', { class: 'fold', 'data-rp3-empty': 'layout' }, c3ic('moon', 'sm'), 'This dashboard has no reports. Press Edit dashboard and add one from the library.'));
|
|
312
|
+
if (S.edit) {
|
|
313
|
+
const free = lib().filter((r) => !ids.includes(r.id) && fits(r, S.scope));
|
|
314
|
+
kids.push(el('section', { class: 'sec rp3-add', 'data-rp3-addlist': '' }, el('div', { class: 'sh' }, el('h3', null, 'Add from the report library'), el('span', { class: 'r' }, free.length + ' available')),
|
|
315
|
+
free.length ? el('ul', { class: 'list' }, free.map((r) => el('li', { class: 'rp3-addrow', 'data-rp3-free': r.id }, el('div', { class: 'nb' }, el('b', null, r.title), el('div', { class: 'dim' }, r.question || '')),
|
|
316
|
+
btn({ 'data-rp3-add': r.id, 'aria-label': 'Add ' + r.title, onclick: () => add(r.id) }, c3ic('plus', 'sm'), 'Add'))))
|
|
317
|
+
: el('div', { class: 'fold' }, c3ic('check', 'sm'), 'Every report that fits this scope is on the dashboard.')));
|
|
318
|
+
}
|
|
319
|
+
P.replaceChildren(...kids);
|
|
320
|
+
if (!model && reps.length) {
|
|
321
|
+
const want = keyOf(S.scope, id);
|
|
322
|
+
readData(get, C.ws, S.scope, id).then((m) => {
|
|
323
|
+
if (!S.alive || keyOf(S.scope, curId()) !== want) return;
|
|
324
|
+
if (m.ok) drawDash();
|
|
325
|
+
else grid.querySelectorAll('[data-rp3-loading]').forEach((n) => n.replaceWith(el('p', { class: 'dim mono rp3-status', role: 'status', 'data-rp3-error': n.getAttribute('data-rp3-loading') }, 'This report could not be read: ' + m.error)));
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* ── layout changes: the page keeps the change, then POSTs it; a failed POST is said, not undone ── */
|
|
331
|
+
async function save(scope, id, reports) {
|
|
332
|
+
const key = keyOf(scope, id);
|
|
333
|
+
if (reports === null) delete S.layouts[key]; else S.layouts[key] = reports.slice();
|
|
334
|
+
drawDash(); if (S.tab === 'lib') drawLib();
|
|
335
|
+
const r = await pmPost('/api/report-dashboards/layout', { key, reports });
|
|
336
|
+
if (!r || !r.ok) toast({ text: 'The layout was not saved: ' + ((r && (r.error || r.reason)) || 'no answer') + '. This page keeps it until you reload.', tone: 'error', timeout: 6000 });
|
|
337
|
+
return r;
|
|
338
|
+
}
|
|
339
|
+
function move(rid, d) {
|
|
340
|
+
const id = curId(); const ids = layoutOf(S.scope, id); const i = ids.indexOf(rid); const j = i + d;
|
|
341
|
+
if (i < 0 || j < 0 || j >= ids.length) return;
|
|
342
|
+
[ids[i], ids[j]] = [ids[j], ids[i]];
|
|
343
|
+
save(S.scope, id, ids).then(() => { const b = panes.dash.querySelector(d < 0 ? '[data-rp3-up="' + rid + '"]' : '[data-rp3-down="' + rid + '"]'); if (b && !b.disabled) b.focus(); });
|
|
344
|
+
}
|
|
345
|
+
function remove(rid) {
|
|
346
|
+
const scope = S.scope; const id = curId(); const ids = layoutOf(scope, id); const i = ids.indexOf(rid); if (i < 0) return;
|
|
347
|
+
const rep = repOf(rid); ids.splice(i, 1);
|
|
348
|
+
save(scope, id, ids);
|
|
349
|
+
toast({ text: 'Removed ' + (rep ? rep.title : rid) + ' from this dashboard. It stays in the library.', actionLabel: 'Undo', timeout: 6000,
|
|
350
|
+
onAction: () => { const now = layoutOf(scope, id); if (!now.includes(rid)) { now.splice(Math.min(i, now.length), 0, rid); save(scope, id, now); } } });
|
|
351
|
+
}
|
|
352
|
+
function add(rid) {
|
|
353
|
+
const id = curId(); const ids = layoutOf(S.scope, id); if (ids.includes(rid)) return;
|
|
354
|
+
ids.push(rid); const rep = repOf(rid);
|
|
355
|
+
save(S.scope, id, ids); toast('Added ' + (rep ? rep.title : rid) + ' to this dashboard.');
|
|
356
|
+
}
|
|
357
|
+
function resetLayout() { save(S.scope, curId(), null); toast('This dashboard is back to its default layout.'); }
|
|
358
|
+
|
|
359
|
+
/* ── Share: the report frame for an audience, one draft in the outbox, a schedule that is not available ── */
|
|
360
|
+
function share(ids) {
|
|
361
|
+
const scope = S.scope; const id = curId(); const title = dashTitle(); const reps = ids.map(repOf).filter(Boolean);
|
|
362
|
+
const st = { aud: 'Leadership', saved: {}, busy: false, msg: '' };
|
|
363
|
+
const body = el('div', { class: 'rp3-share', 'data-rp3-sheet': '' });
|
|
364
|
+
const paint = (raw) => {
|
|
365
|
+
const today = (raw && raw.today) || todayStr(Date.now());
|
|
366
|
+
const F = frameOf({ title, reports: reps, raw: raw && raw.ok ? raw : {}, audience: st.aud, today });
|
|
367
|
+
const sec = (name, ...kids) => el('section', { 'data-rp3-frame': name }, el('h4', null, name), ...kids);
|
|
368
|
+
const frame = el('div', { class: 'rp3-frame', 'data-rp3-aud': st.aud },
|
|
369
|
+
el('div', { class: 'chips2' }, el('span', { class: 'tag' }, 'Draft'), el('b', null, title), el('span', { class: 'dim' }, 'For ' + st.aud + ', ' + today)),
|
|
370
|
+
sec('Headline', el('p', { class: 'rp3-hl' }, F.headline)),
|
|
371
|
+
sec('Sections', ...(F.sections.length ? F.sections.flatMap((s) => [el('h5', null, s.title), el('p', null, s.line), s.nums.length ? el('ul', { class: 'rp3-nums', 'data-rp3-nums': s.id }, s.nums.map((t) => el('li', null, t))) : null]) : [el('p', { class: 'dim' }, 'Nothing to show.')])),
|
|
372
|
+
sec('Needs your call', ...(F.needs.length ? F.needs.map((t) => el('p', null, t)) : [el('p', { class: 'dim' }, 'Nothing needs your call in this scope.')])),
|
|
373
|
+
sec('Sources', el('p', { class: 'dim' }, (F.sources.length ? F.sources.join('; ') + '. ' : '') + 'Read from the repository on ' + today + '.')));
|
|
374
|
+
const saved = st.saved[st.aud];
|
|
375
|
+
const saveBtn = btn({ class: 'rp3-btn rp3-pri', 'data-rp3-draft': '', disabled: st.busy || !!saved || !raw || !raw.ok,
|
|
376
|
+
onclick: async () => {
|
|
377
|
+
st.busy = true; paint(raw);
|
|
378
|
+
const r = await pmPost('/api/outbox/draft', { channel: 'customer-note', recipient: st.aud, subject: title + ' for ' + st.aud, body: draftBody(F) });
|
|
379
|
+
st.busy = false;
|
|
380
|
+
if (r && r.ok) {
|
|
381
|
+
st.saved[st.aud] = true;
|
|
382
|
+
st.msg = r.entry && r.entry.state === 'blocked' ? 'Saved in the outbox, but the claims check blocks it: ' + ((r.entry.claims && r.entry.claims.refused || []).join(' ') || 'a claim has no label') + '. Nothing was sent.'
|
|
383
|
+
: 'Saved as one draft in the outbox. Nothing was sent: no channel is connected.';
|
|
384
|
+
} else st.msg = 'The draft was not saved: ' + ((r && (r.error || r.reason)) || 'no answer') + '. Nothing was sent.';
|
|
385
|
+
toast(st.msg); paint(raw);
|
|
386
|
+
} }, c3ic('doc', 'sm'), saved ? 'Saved as draft' : st.busy ? 'Saving' : 'Save as draft');
|
|
387
|
+
body.replaceChildren(
|
|
388
|
+
el('div', { class: 'sec' }, el('div', { class: 'dim' }, 'Audience'),
|
|
389
|
+
el('div', { class: 'seg', role: 'group', 'aria-label': 'Audience' }, ['Leadership', 'Team'].map((a) => pill({ 'aria-pressed': String(st.aud === a), 'data-rp3-audbtn': a, onclick: () => { st.aud = a; st.msg = ''; paint(raw); } }, a))),
|
|
390
|
+
el('p', { class: 'dim' }, st.aud === 'Leadership' ? 'Short: one sentence per report, and what needs your call.' : 'Fuller: adds the numbers behind each sentence.')),
|
|
391
|
+
raw ? (raw.ok ? frame : el('div', { class: 'fold', role: 'status' }, c3ic('alert', 'sm'), 'The reports could not be read: ' + raw.error)) : el('p', { class: 'dim mono', role: 'status' }, 'Reading the repository'),
|
|
392
|
+
el('div', { class: 'rp3-acts rp3-wrap' }, saveBtn),
|
|
393
|
+
el('p', { class: 'dim', role: 'status', 'data-rp3-share-msg': '' }, st.msg || 'Save as draft puts one draft in the outbox. Nothing is sent from this page.'),
|
|
394
|
+
el('section', { class: 'sec', 'data-rp3-schedule': '' }, el('div', { class: 'sh' }, el('h3', null, 'Schedule')),
|
|
395
|
+
el('label', { class: 'rp3-pick' }, el('span', { class: 'dim' }, 'Send'), el('select', { class: 'rp3-select', disabled: true, 'aria-describedby': 'rp3-sched-why', 'data-rp3-sched': '' }, el('option', null, 'Not scheduled'))),
|
|
396
|
+
el('p', { class: 'rp3-why', id: 'rp3-sched-why' }, 'Scheduled sending is not available yet, because nothing is sent outward from this page. Nothing is stored or run for a schedule.')));
|
|
397
|
+
};
|
|
398
|
+
const cached = cachedData(scope, id);
|
|
399
|
+
openSheet({ title: 'Share: ' + title, icon: 'out', wide: true, body });
|
|
400
|
+
paint(cached);
|
|
401
|
+
if (!cached) readData(get, C.ws, scope, id).then((m) => { if (body.isConnected) paint(m); });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* ── Library: the home of every report ── */
|
|
405
|
+
function usedOn(rid) {
|
|
406
|
+
if (!S.meta) return 'nowhere yet';
|
|
407
|
+
const on = [];
|
|
408
|
+
if (layoutOf('portfolio', '').includes(rid)) on.push('Portfolio');
|
|
409
|
+
for (const [scope, label] of [['board', 'Board'], ['initiative', 'Initiative']]) {
|
|
410
|
+
if (defaultOf(scope).includes(rid)) on.push(label + ' dashboards by default');
|
|
411
|
+
for (const k of Object.keys(S.layouts)) if (k.startsWith(scope + ':')) { const id = k.slice(scope.length + 1); if (known(S.layouts[k]).includes(rid)) on.push(label + ': ' + nameOf(scope, id)); }
|
|
412
|
+
}
|
|
413
|
+
return on.length ? on.join(', ') : 'no dashboard';
|
|
414
|
+
}
|
|
415
|
+
function drawLib() {
|
|
416
|
+
const P = panes.lib;
|
|
417
|
+
if (S.metaErr) { P.replaceChildren(el('div', { class: 'fold', role: 'status' }, c3ic('alert', 'sm'), 'The library could not be read: ' + S.metaErr)); return; }
|
|
418
|
+
if (!S.meta) { P.replaceChildren(el('p', { class: 'dim mono rp3-status', role: 'status' }, 'Reading the repository')); return; }
|
|
419
|
+
const rows = lib().map((r) => {
|
|
420
|
+
const card = el('div', { class: 'rp3-libcard', 'data-rp3-libcard': r.id });
|
|
421
|
+
const xp = makeXp(card);
|
|
422
|
+
const row = el('li', { class: 'nrow rp3-librow', 'data-home': r.id },
|
|
423
|
+
el('span', { class: 'ki hot' }, c3ic(ICON[builderOf(r)] || 'report')),
|
|
424
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, r.title), el('div', { class: 'dim' }, r.question || ''),
|
|
425
|
+
el('div', { class: 'chips' }, el('span', { class: 'tag' }, r.source || 'no source named'), el('span', { class: 'dim', 'data-rp3-used': r.id }, 'Used on: ' + usedOn(r.id))), xp));
|
|
426
|
+
const fill = () => {
|
|
427
|
+
const m = cachedData('portfolio', '');
|
|
428
|
+
if (m) { card.replaceChildren(m.ok ? drawReport(r, m, { goInit }) : el('p', { class: 'dim mono' }, 'This report could not be read: ' + m.error)); return; }
|
|
429
|
+
card.replaceChildren(el('p', { class: 'dim mono rp3-status', role: 'status' }, 'Reading the repository'));
|
|
430
|
+
readData(get, C.ws, 'portfolio', '').then(() => { if (card.isConnected) fill(); });
|
|
431
|
+
};
|
|
432
|
+
const api = attachExpand(row, xp, { key: 'rp3l-' + r.id, onToggle: (open) => { if (open) { S.libOpen.add(r.id); fill(); } else S.libOpen.delete(r.id); } });
|
|
433
|
+
if (S.libOpen.has(r.id)) api.open();
|
|
434
|
+
return row;
|
|
435
|
+
});
|
|
436
|
+
P.replaceChildren(el('p', { class: 'dim rp3-note' }, 'Every report the workspace can draw lives here. A dashboard points at these. Removing a report from a dashboard keeps it here. Open a row to see the report across the portfolio.'),
|
|
437
|
+
el('ul', { class: 'list', 'data-rp3-lib': '' }, rows));
|
|
438
|
+
}
|
|
439
|
+
function openInLibrary(rid) {
|
|
440
|
+
S.libOpen.add(rid); showTab('lib');
|
|
441
|
+
const row = panes.lib.querySelector('[data-home="' + rid + '"]');
|
|
442
|
+
if (row) { try { row.focus({ preventScroll: true }); row.scrollIntoView({ block: 'nearest' }); } catch { /* not focusable */ } }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* boot */
|
|
446
|
+
drawTabs(); drawDash();
|
|
447
|
+
Promise.resolve(get('/api/report-dashboards' + (C.ws ? '?ws=' + encodeURIComponent(C.ws) : ''))).then((r) => {
|
|
448
|
+
if (!S.alive) return;
|
|
449
|
+
if (r && r.ok) { S.meta = r; S.layouts = { ...(r.layouts || {}) }; } else S.metaErr = (r && (r.error || r.reason)) || 'not available';
|
|
450
|
+
drawTabs(); drawDash(); if (S.tab === 'lib') drawLib();
|
|
451
|
+
}, (e) => { S.metaErr = String((e && e.message) || e); drawDash(); });
|
|
452
|
+
|
|
453
|
+
return { dispose() { S.alive = false; stopPhone(); root.remove(); }, root };
|
|
454
|
+
}
|
|
@@ -21,6 +21,7 @@ import { mountLifecycle } from './lifecycle-lens.js';
|
|
|
21
21
|
import { mountForecast } from './timeline-forecast-view.js';
|
|
22
22
|
import { mountReleases } from './releases-view.js';
|
|
23
23
|
import { mountReportFamilies } from './report-families-view.js';
|
|
24
|
+
import { mountReports } from './c3-reports.js';
|
|
24
25
|
import { mountOutbox } from './outbox-panel.js';
|
|
25
26
|
import { createDock } from './c3-dock.js';
|
|
26
27
|
import { belowLine, reviewSection, handledRow, trustCard, footLine, needRow, rowApi, BX, toast, setNeedsTitle, caseFile, hooks as briefHooks } from './c3-brief.js';
|
|
@@ -597,7 +598,8 @@ function showInit(id) {
|
|
|
597
598
|
}
|
|
598
599
|
}
|
|
599
600
|
}
|
|
600
|
-
function repTab(pane) {
|
|
601
|
+
function repTab(pane) { mountReports(pane, { ws: S.ws, pmGet, drafts: draftsTab }); } // dashboards and the library; the old Reports content is the Drafts tab
|
|
602
|
+
function draftsTab(pane) {
|
|
601
603
|
const fam = el('div', { 'data-c3pg': 'families-host' }); const out = el('div', { class: 'brief', 'data-c3pg': 'outbox-host', style: 'margin-top:var(--sp-4)' });
|
|
602
604
|
pane.append(fam, out);
|
|
603
605
|
pmGet('/api/report-families/facts?ws=' + encodeURIComponent(S.ws)).then((j) => {
|