PageSourceSearch

https://quasar-safiery-967.netlify.app/

html quasar-safiery-967.netlify.app collected 2026-10-03 11:18:20 UTC 615,650 bytes, 11,354 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8" />
5<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48">
7<link rel="apple-touch-icon" href="/icon-180.png">
8<!-- HQ-1091: a SECOND, distinct installable app for staff who need the rest of HQ's
9     functionality on their phone - own manifest, own start_url, no relation to
10     people.html's manifest.webmanifest (the field/production employee app frame,
11     HQ-312/HQ-970). Two separate "Add to Home Screen" targets by design. -->
12<link rel="manifest" href="/manifest-staff.webmanifest">
13<title>QUASAR HQ — Your Territory</title>
14
15<style>
16/* ─────────────────────────────────────────────────────────────
17   QUASAR HQ — Safiery territory map
18   Single-file app. Data lives in Supabase. Hosted on Netlify.
19   ───────────────────────────────────────────────────────────── */
20
21:root {
22  /* ── Dark theme (default) ──────────────────────── */
23  --bg:           #07090d;
24  --bg-panel:    rgba(13, 18, 26, 0.78);
25  --bg-panel-solid: #0d121a;
26  --bg-elev:      #121823;
27  --line:         rgba(255, 255, 255, 0.08);
28  --line-strong:  rgba(255, 255, 255, 0.16);
29  --hover-bg:     rgba(255, 255, 255, 0.025);
30  --hover-bg-strong: rgba(255, 255, 255, 0.05);
31  --ink:          #e8e4d8;
32  --ink-dim:      #a0a0a8;
33  --ink-faint:    #666670;
34  --brass:        #3a7fc2;
35  --brass-bright: #5d9bd8;
36  --brass-on-bg:  #ffffff;   /* text colour for buttons filled with brass */
37  --ocean:        #3a7bd5;
38  --rust:         #d87252;
39  --good:         #4ade80;
40  --modal-overlay: rgba(5, 6, 8, 0.78);
41  --scrim:        rgba(0, 0, 0, 0.5);
42
43  --status-lead:       #7a7a84;
44  --status-contacted:  #4a9eff;
45  --status-discussion: #e4bc6c;
46  --status-nda:        #b084d9;
47  --status-proposal:   #ff9f40;
48  --status-negotiating:#ff6fa8;
49  --status-customer:   #4ade80;
50  --status-paused:     #525260;
51  --status-lost:       #ef4444;
52
53  --font-ui:      'Manrope', system-ui, sans-serif;
54  --font-display: 'Instrument Serif', serif;
55  --font-mono:    'JetBrains Mono', ui-monospace, monospace;
56}
57
58/* ── Light theme ─────────────────────────────────── */
59:root[data-theme="light"] {
60  --bg:           #f3f1ea;
61  --bg-panel:     rgba(255, 255, 255, 0.86);
62  --bg-panel-solid: #fbfaf6;
63  --bg-elev:      #fff;
64  --line:         rgba(0, 0, 0, 0.1);
65  --line-strong:  rgba(0, 0, 0, 0.18);
66  --hover-bg:     rgba(0, 0, 0, 0.025);
67  --hover-bg-strong: rgba(0, 0, 0, 0.05);
68  --ink:          #1d2330;
69  --ink-dim:      #5a6170;
70  --ink-faint:    #9098a6;
71  --brass:        #2a66ab;
72  --brass-bright: #1f568f;
73  --brass-on-bg:  #fff;
74  --modal-overlay: rgba(20, 22, 28, 0.5);
75  --scrim:        rgba(0, 0, 0, 0.18);
76}
77
78/* ── Terminal theme (phosphor CRT / radar) ──────── */
79:root[data-theme="terminal"] {
80  --bg:           #030a07;
81  --bg-panel:     rgba(7, 22, 16, 0.88);
82  --bg-panel-solid: #061510;
83  --bg-elev:      #08201a;
84  --line:         rgba(138, 240, 138, 0.22);
85  --line-strong:  rgba(138, 240, 138, 0.48);
86  --hover-bg:     rgba(138, 240, 138, 0.06);
87  --hover-bg-strong: rgba(138, 240, 138, 0.12);
88  --ink:          #bfe8bf;
89  --ink-dim:      #86d886;
90  --ink-faint:    #447e44;
91  --brass:        #7cec7c;
92  --brass-bright: #c8ffc8;
93  --brass-on-bg:  #030a07;
94  --modal-overlay: rgba(3, 10, 7, 0.88);
95  --scrim:        rgba(0, 20, 6, 0.55);
96  --status-lead:       #6fa06f;
97  --status-contacted:  #8ad6ff;
98  --status-discussion: #e4ff6c;
99  --status-nda:        #d084ff;
100  --status-proposal:   #ffcf6c;
101  --status-negotiating:#ff8ad6;
102  --status-customer:   #9aff8a;
103  --status-paused:     #527052;
104  --status-lost:       #ff6b6b;
105}
106
107/* All type collapses to mono in Terminal mode — no serif display, no
108   humanist sans. Everything reads like a 1984 shipboard console. */
109:root[data-theme="terminal"] body,
110:root[data-theme="terminal"] input,
111:root[data-theme="terminal"] button,
112:root[data-theme="terminal"] select,
113:root[data-theme="terminal"] textarea,
114:root[data-theme="terminal"] .name-input,
115:root[data-theme="terminal"] .contact-item .name,
116:root[data-theme="terminal"] .contact-item .company,
117:root[data-theme="terminal"] .detail-header .name-input,
118:root[data-theme="terminal"] .modal h2,
119:root[data-theme="terminal"] .brand .mark,
120:root[data-theme="terminal"] .empty-mini .glyph,
121:root[data-theme="terminal"] .loading-globe .spinner,
122:root[data-theme="terminal"] h1, :root[data-theme="terminal"] h2 {
123  font-family: var(--font-mono) !important;
124  font-style: normal !important;
125  letter-spacing: 0.04em;
126}
127:root[data-theme="terminal"] .brand .mark {
128  text-transform: uppercase;
129  letter-spacing: 0.28em;
130  font-weight: 600;
131  color: var(--brass-bright);
132  text-shadow: 0 0 8px rgba(138, 240, 138, 0.55);
133}
134:root[data-theme="terminal"] .brand .tag::before { content: "// "; }
135:root[data-theme="terminal"] .modal h2 {
136  text-transform: uppercase;
137  letter-spacing: 0.15em;
138  font-size: 18px;
139  font-weight: 600;
140}
141/* No rounded chrome — sharp angles only */
142:root[data-theme="terminal"] button,
143:root[data-theme="terminal"] input,
144:root[data-theme="terminal"] select,
145:root[data-theme="terminal"] textarea,
146:root[data-theme="terminal"] .tag-pill,
147:root[data-theme="terminal"] .gmail-pill,
148:root[data-theme="terminal"] .save-indicator,
149:root[data-theme="terminal"] .unread-badge,
150:root[data-theme="terminal"] .status-badge .pip,
151:root[data-theme="terminal"] .mdir,
152:root[data-theme="terminal"] .pstatus {
153  border-radius: 0 !important;
154}
155/* Squared pips everywhere for radar-style readouts */
156:root[data-theme="terminal"] .pip,
157:root[data-theme="terminal"] .save-indicator .dot,
158:root[data-theme="terminal"] .gmail-pill .dot {
159  border-radius: 0 !important;
160  box-shadow: 0 0 6px currentColor;
161}
162/* Strong glow on accented strings and hover */
163:root[data-theme="terminal"] .new-btn {
164  background: transparent;
165  color: var(--brass-bright);
166  border: 1px solid var(--brass);
167  text-shadow: 0 0 6px rgba(138, 240, 138, 0.6);
168}
169:root[data-theme="terminal"] .new-btn:hover {
170  background: rgba(138, 240, 138, 0.1);
171  box-shadow: inset 0 0 0 1px var(--brass-bright);
172}
173:root[data-theme="terminal"] .send-btn,
174:root[data-theme="terminal"] .btn-primary,
175:root[data-theme="terminal"] .email-disconnected .connect-btn {
176  background: transparent !important;
177  color: var(--brass-bright) !important;
178  border: 1px solid var(--brass) !important;
179  text-shadow: 0 0 6px rgba(138, 240, 138, 0.6);
180}
181:root[data-theme="terminal"] .send-btn:hover,
182:root[data-theme="terminal"] .btn-primary:hover,
183:root[data-theme="terminal"] .email-disconnected .connect-btn:hover {
184  background: rgba(138, 240, 138, 0.12) !important;
185}
186:root[data-theme="terminal"] .tab-bar button.active {
187  text-shadow: 0 0 6px rgba(138, 240, 138, 0.7);
188}
189:root[data-theme="terminal"] .tag-pill {
190  background: rgba(138, 240, 138, 0.08);
191  border-color: rgba(138, 240, 138, 0.35);
192  color: var(--brass-bright);
193}
194:root[data-theme="terminal"] .unread-badge {
195  box-shadow: 0 0 8px rgba(200, 255, 200, 0.7);
196}
197
198/* Faint CRT scanlines over the whole app (pointer-events off so nothing
199   blocks clicks). Plus a very subtle vignette. */
200:root[data-theme="terminal"] body::before {
201  content: "";
202  position: fixed; inset: 0; pointer-events: none; z-index: 9998;
203  background:
204    radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,20,6,0.38) 100%),
205    repeating-linear-gradient(
206      to bottom,
207      rgba(138, 240, 138, 0.0) 0px,
208      rgba(138, 240, 138, 0.0) 2px,
209      rgba(138, 240, 138, 0.05) 2px,
210      rgba(138, 240, 138, 0.05) 3px
211    );
212  mix-blend-mode: screen;
213}
214/* Ambient phosphor flicker — very subtle, just enough to feel alive */
215@keyframes crt-flicker {
216  0%, 100%   { opacity: 1; }
217  47%, 51%   { opacity: 0.97; }
218  49%        { opacity: 0.94; }
219}
220:root[data-theme="terminal"] #app { animation: crt-flicker 6s infinite; }
221
222/* Quasar HQ wordmark gets a blinking caret in Terminal mode */
223:root[data-theme="terminal"] .brand .mark::after {
224  content: "█";
225  display: inline-block;
226  margin-left: 4px;
227  color: var(--brass-bright);
228  animation: caret 1.05s steps(1) infinite;
229  text-shadow: 0 0 8px rgba(138, 240, 138, 0.8);
230}
231@keyframes caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
232
233/* Save indicator reads like a shipboard status line */
234:root[data-theme="terminal"] .save-indicator::before { content: "[ "; color: var(--ink-faint); }
235:root[data-theme="terminal"] .save-indicator::after  { content: " ]"; color: var(--ink-faint); }
236
237/* Terminal tweaks for specific components */
238:root[data-theme="terminal"] .globe-hud,
239:root[data-theme="terminal"] .legend {
240  background: rgba(7, 22, 16, 0.75);
241  border: 1px solid var(--line);
242  box-shadow: 0 0 0 1px rgba(138, 240, 138, 0.08), 0 0 22px rgba(138, 240, 138, 0.12);
243}
244:root[data-theme="terminal"] .legend .label {
245  color: var(--brass-bright);
246  text-shadow: 0 0 6px rgba(138, 240, 138, 0.6);
247}
248
249* { box-sizing: border-box; margin: 0; padding: 0; }
250
251html, body {
252  height: 100%;
253  overflow: hidden;
254  background: var(--bg);
255  color: var(--ink);
256  font-family: var(--font-ui);
257  font-size: 14px;
258  -webkit-font-smoothing: antialiased;
259  text-rendering: optimizeLegibility;
260}
261
262button, input, textarea, select {
263  font-family: inherit;
264  font-size: inherit;
265  color: inherit;
266  background: transparent;
267  border: none;
268  outline: none;
269}
270button { cursor: pointer; }
271input, textarea, select { -webkit-appearance: none; appearance: none; }
272textarea { resize: vertical; font-family: var(--font-ui); }
273
274::-webkit-scrollbar { width: 8px; height: 8px; }
275::-webkit-scrollbar-track { background: transparent; }
276::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; }
277::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
278
279/* ── Config gate (first-run) ─────────────────────────────── */
280#config-gate, #auth-gate {
281  position: fixed; inset: 0; z-index: 1000;
282  display: grid; place-items: center;
283  background: radial-gradient(ellipse at center, #0d1826 0%, #050608 70%);
284}
285#config-gate .card, #auth-gate .card {
286  width: min(92vw, 560px);
287  padding: 48px 44px;
288  border: 1px solid var(--line-strong);
289  background: var(--bg-panel-solid);
290  border-radius: 2px;
291}
292#config-gate h1, #auth-gate h1 {
293  font-family: var(--font-display);
294  font-size: 52px;
295  letter-spacing: -0.02em;
296  line-height: 1;
297  margin-bottom: 6px;
298  color: var(--brass-bright);
299}
300#config-gate .sub, #auth-gate .sub {
301  font-family: var(--font-mono);
302  font-size: 11px;
303  letter-spacing: 0.18em;
304  text-transform: uppercase;
305  color: var(--ink-faint);
306  margin-bottom: 32px;
307}
308#config-gate p, #auth-gate p {
309  color: var(--ink-dim);
310  margin-bottom: 24px;
311  line-height: 1.55;
312}
313#config-gate p a, #auth-gate p a { color: var(--brass); text-decoration: underline; text-underline-offset: 3px; }
314#config-gate label, #auth-gate label {
315  display: block;
316  font-family: var(--font-mono);
317  font-size: 10px;
318  letter-spacing: 0.2em;
319  text-transform: uppercase;
320  color: var(--ink-faint);
321  margin-bottom: 8px;
322  margin-top: 18px;
323}
324#config-gate input, #auth-gate input {
325  width: 100%;
326  padding: 12px 14px;
327  background: var(--bg);
328  border: 1px solid var(--line-strong);
329  border-radius: 2px;
330  font-family: var(--font-mono);
331  font-size: 12px;
332  color: var(--ink);
333  transition: border-color 0.15s;
334}
335#config-gate input:focus, #auth-gate input:focus { border-color: var(--brass); }
336
337/* ── App shell ────────────────────────────────────────────── */
338#app {
339  display: grid;
340  grid-template-columns: 256px 1fr 300px;
341  grid-template-rows: auto 1fr;
342  grid-template-areas:
343    "header header header"
344    "sidebar main detail";
345  height: 100vh;
346  width: 100vw;
347}
348
349/* ── Page header - Manufacturing-pattern content band inside the capped
350   .dash-wrap: bold title + dim subtitle stacked left, ghost controls right.
351   The shared qtopbar above keeps its standard layout (search left, tools
352   right), exactly like the Manufacturing dashboard. ────────────────────── */
353.page-head {
354  display: flex;
355  align-items: flex-start;
356  justify-content: space-between;
357  gap: 16px;
358  margin-bottom: 14px;
359}
360.page-head .ph-titles { min-width: 0; }
361.page-head .ph-titles h1 {
362  font-family: var(--font-ui);
363  font-size: 23px;
364  font-weight: 800;
365  margin: 0;
366  line-height: 1.12;
367  letter-spacing: -0.02em;
368  color: var(--ink);
369}
370.page-head .ph-titles .sub {
371  display: block;
372  margin-top: 3px;
373  font-size: 12.5px;
374  line-height: 1.3;
375  color: var(--ink-dim);
376}
377/* Live stat mirrors (Contacts / Customers / Pipeline) stay in the DOM so the
378   app's counters keep flowing, but the header band shows controls only,
379   matching the dashboard mockup and the Manufacturing header. */
380.page-head .ph-stat { display: none; }
381.page-head .ph-stats-wrap { display: flex; align-items: center; gap: 8px; flex: none; }
382
383/* Header */
384header.topbar {
385  grid-area: header;
386  display: flex; align-items: center; justify-content: space-between;
387  padding: 0 22px;
388  background: var(--bg-panel);
389  -webkit-backdrop-filter: blur(12px);
390  backdrop-filter: blur(12px);
391  border-bottom: 1px solid var(--line);
392  z-index: 20;
393  position: relative;
394}
395a.brand {
396  display: flex; align-items: baseline; gap: 14px;
397  text-decoration: none;
398}
399.brand {
400  display: flex; align-items: baseline; gap: 14px;
401}
402.brand .mark {
403  font-family: var(--font-display);
404  font-size: 26px;
405  font-style: italic;
406  color: var(--brass-bright);
407  letter-spacing: -0.01em;
408  line-height: 1;
409}
410.brand .tag {
411  font-family: var(--font-mono);
412  font-size: 9px;
413  letter-spacing: 0.3em;
414  color: var(--ink-faint);
415  text-transform: uppercase;
416}
417.topbar .links { display: flex; gap: 4px; margin-left: 8px; }
418.topbar .links a { font-size: 12px; letter-spacing: 0.04em; color: var(--ink-dim); padding: 5px 10px; border-radius: 6px; text-decoration: none; }
419.topbar .links a:hover { color: var(--ink); background: var(--hover-bg); }
420.topbar .links a.active { color: var(--brass); background: var(--hover-bg-strong); }
421.topbar-center {
422  display: flex; gap: 28px;
423  font-family: var(--font-mono);
424  font-size: 10px;
425  letter-spacing: 0.18em;
426  text-transform: uppercase;
427  color: var(--ink-faint);
428}
429.topbar-center .stat strong {
430  color: var(--ink);
431  font-weight: 500;
432  font-size: 13px;
433  letter-spacing: 0;
434  display: block;
435  margin-top: 1px;
436}
437.topbar-right { display: flex; align-items: center; gap: 18px; }
438.save-indicator {
439  display: flex; align-items: center; gap: 8px;
440  font-family: var(--font-mono);
441  font-size: 10px;
442  letter-spacing: 0.18em;
443  text-transform: uppercase;
444  color: var(--ink-faint);
445  transition: color 0.2s;
446}
447.save-indicator .dot {
448  width: 6px; height: 6px; border-radius: 50%;
449  background: var(--status-customer);
450  transition: background 0.2s;
451}
452.save-indicator[data-state="saving"] { color: var(--brass); }
453.save-indicator[data-state="saving"] .dot { background: var(--brass); animation: pulse 0.8s infinite; }
454.save-indicator[data-state="error"] { color: var(--status-lost); }
455.save-indicator[data-state="error"] .dot { background: var(--status-lost); }
456@keyframes pulse { 50% { opacity: 0.3; } }
457
458.icon-btn {
459  width: 32px; height: 32px;
460  display: grid; place-items: center;
461  border: 1px solid var(--line);
462  border-radius: 2px;
463  color: var(--ink-dim);
464  transition: all 0.15s;
465}
466.icon-btn:hover { color: var(--brass); border-color: var(--brass); }
467#theme-toggle svg { width: 16px; height: 16px; }
468/* Icon shows the CURRENT theme: dark = moon, light = sun. */
469#theme-toggle .ic-sun, #theme-toggle .ic-moon, #theme-toggle .ic-terminal { display: none; }
470:root[data-theme="dark"]  #theme-toggle .ic-moon { display: inline; }
471:root:not([data-theme])   #theme-toggle .ic-moon { display: inline; }
472:root[data-theme="light"] #theme-toggle .ic-sun  { display: inline; }
473.icon-btn svg { width: 14px; height: 14px; }
474
475/* ── Sidebar ──────────────────────────────────────────────── */
476aside.sidebar {
477  grid-area: sidebar;
478  background: var(--bg-panel);
479  -webkit-backdrop-filter: blur(12px);
480  backdrop-filter: blur(12px);
481  border-right: 1px solid var(--line);
482  display: flex; flex-direction: column;
483  overflow: hidden;
484}
485.sidebar-head {
486  padding: 20px 20px 14px;
487  border-bottom: 1px solid var(--line);
488}
489.new-btn {
490  width: 100%;
491  padding: 12px 14px;
492  background: var(--brass);
493  color: var(--brass-on-bg);
494  font-family: var(--font-mono);
495  font-weight: 600;
496  font-size: 10px;
497  letter-spacing: 0.25em;
498  text-transform: uppercase;
499  border-radius: 2px;
500  transition: background 0.15s;
501  display: flex; align-items: center; justify-content: center; gap: 8px;
502}
503.new-btn:hover { background: var(--brass-bright); }
504
505.search {
506  position: relative;
507  margin-top: 14px;
508}
509.search input {
510  width: 100%;
511  padding: 9px 14px 9px 34px;
512  background: var(--bg);
513  border: 1px solid var(--line);
514  border-radius: 2px;
515  font-size: 13px;
516  transition: border-color 0.15s;
517}
518.search input:focus { border-color: var(--line-strong); }
519.search input::placeholder { color: var(--ink-faint); }
520.search svg {
521  position: absolute;
522  left: 11px; top: 50%;
523  transform: translateY(-50%);
524  width: 14px; height: 14px;
525  color: var(--ink-faint);
526}
527
528.filter-row {
529  display: flex; gap: 6px;
530  padding: 12px 20px 16px;
531  border-bottom: 1px solid var(--line);
532  flex-wrap: wrap;
533}
534.filter-chip {
535  padding: 4px 10px;
536  font-family: var(--font-mono);
537  font-size: 9px;
538  letter-spacing: 0.15em;
539  text-transform: uppercase;
540  color: var(--ink-dim);
541  border: 1px solid var(--line);
542  border-radius: 99px;
543  transition: all 0.15s;
544}
545.filter-chip:hover { color: var(--ink); border-color: var(--line-strong); }
546.filter-chip.active {
547  background: var(--ink);
548  color: var(--bg);
549  border-color: var(--ink);
550}
551
552.contact-list {
553  flex: 1;
554  overflow-y: auto;
555  padding: 6px 0;
556}
557.contact-item {
558  padding: 14px 20px;
559  border-bottom: 1px solid var(--line);
560  cursor: pointer;
561  transition: background 0.12s;
562  position: relative;
563}
564.contact-item:hover { background: var(--hover-bg); }
565.contact-item.selected {
566  background: rgba(198, 163, 93, 0.06);
567}
568.contact-item.selected::before {
569  content: "";
570  position: absolute;
571  left: 0; top: 0; bottom: 0;
572  width: 2px;
573  background: var(--brass);
574}
575.contact-item .name {
576  font-size: 14px;
577  font-weight: 500;
578  color: var(--ink);
579  margin-bottom: 3px;
580  display: flex; align-items: center; gap: 8px;
581}
582.contact-item .name .type-glyph {
583  font-size: 11px;
584  color: var(--ink-faint);
585  width: 14px;
586  text-align: center;
587}
588.contact-item .company {
589  font-size: 12px;
590  color: var(--ink-dim);
591  margin-bottom: 6px;
592  font-family: var(--font-mono);
593}
594.contact-item .meta {
595  display: flex; justify-content: space-between; align-items: center;
596  font-family: var(--font-mono);
597  font-size: 9px;
598  letter-spacing: 0.12em;
599  text-transform: uppercase;
600  color: var(--ink-faint);
601}
602.contact-item .status-badge {
603  display: inline-flex; align-items: center; gap: 6px;
604}
605.contact-item .status-badge .pip {
606  width: 6px; height: 6px; border-radius: 50%;
607}
608.contact-item .location {
609  color: var(--ink-faint);
610}
611.contact-item .unread-badge {
612  display: inline-flex; align-items: center; justify-content: center;
613  min-width: 18px; height: 18px;
614  padding: 0 6px;
615  margin-left: 8px;
616  background: var(--brass);
617  color: var(--brass-on-bg);
618  font-family: var(--font-mono);
619  font-size: 10px;
620  font-weight: 700;
621  letter-spacing: 0;
622  border-radius: 99px;
623  vertical-align: middle;
624}
625.contact-item.has-unread .name { color: var(--brass-bright); }
626.contact-item .unread-badge { cursor: pointer; }
627.contact-item .unread-badge:hover { background: var(--brass-bright); }
628
629/* ── Unread review popover (click a sidebar badge) ─────────── */
630#unread-pop {
631  position: fixed;
632  z-index: 950;
633  width: 400px;
634  max-height: 70vh;
635  display: none;
636  flex-direction: column;
637  background: var(--bg-panel);
638  backdrop-filter: blur(14px);
639  -webkit-backdrop-filter: blur(14px);
640  border: 1px solid var(--line-strong);
641  border-radius: 12px;
642  box-shadow: 0 18px 50px rgba(0,0,0,0.55);
643  overflow: hidden;
644}
645#unread-pop.show { display: flex; }
646#unread-pop .up-head {
647  display: flex; align-items: center; gap: 10px;
648  padding: 12px 14px;
649  border-bottom: 1px solid var(--line);
650  flex-shrink: 0;
651}
652#unread-pop .up-title {
653  font-size: 13px; font-weight: 600; color: var(--ink);
654  flex: 1; min-width: 0;
655  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
656}
657#unread-pop .up-count {
658  font-family: var(--font-mono); font-size: 10px;
659  color: var(--brass-bright);
660}
661#unread-pop .up-head button {
662  background: none; border: 1px solid var(--line-strong);
663  color: var(--ink-dim); border-radius: 8px;
664  font-family: var(--font-mono); font-size: 10px;
665  padding: 4px 9px; cursor: pointer; white-space: nowrap;
666}
667#unread-pop .up-head button:hover { color: var(--ink); border-color: var(--brass); }
668#unread-pop .up-head .up-close { border: none; font-size: 14px; padding: 2px 6px; }
669#unread-pop .up-list { overflow-y: auto; padding: 6px; }
670#unread-pop .up-item {
671  padding: 10px 10px 8px;
672  border-radius: 8px;
673  cursor: pointer;
674  border: 1px solid transparent;
675}
676#unread-pop .up-item:hover { background: var(--hover-bg-strong); }
677#unread-pop .up-item + .up-item { margin-top: 2px; }
678#unread-pop .up-row { display: flex; align-items: baseline; gap: 8px; }
679#unread-pop .up-subj {
680  flex: 1; min-width: 0;
681  font-size: 12.5px; font-weight: 600; color: var(--brass-bright);
682  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
683}
684#unread-pop .up-date { font-family: var(--font-mono); font-size: 9px; color: var(--ink-faint); white-space: nowrap; }
685#unread-pop .up-from {
686  font-family: var(--font-mono); font-size: 10px; color: var(--ink-dim);
687  margin-top: 2px;
688  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
689}
690#unread-pop .up-snip { font-size: 11.5px; color: var(--ink-dim); margin-top: 4px; line-height: 1.45; }
691#unread-pop .up-item:not(.expanded) .up-snip {
692  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
693}
694#unread-pop .up-item.expanded .up-snip { white-space: pre-wrap; }
695#unread-pop .up-actions { display: flex; justify-content: flex-end; margin-top: 6px; }
696#unread-pop .up-actions button {
697  background: none; border: 1px solid var(--line-strong);
698  color: var(--ink-dim); border-radius: 8px;
699  font-family: var(--font-mono); font-size: 10px;
700  padding: 3px 9px; cursor: pointer;
701}
702#unread-pop .up-actions button:hover { color: var(--brass-on-bg); background: var(--brass); border-color: var(--brass); }
703#unread-pop .up-empty {
704  padding: 26px 16px; text-align: center;
705  font-size: 12px; color: var(--ink-faint);
706}
707#unread-pop .up-foot {
708  padding: 9px 14px;
709  border-top: 1px solid var(--line);
710  flex-shrink: 0;
711}
712#unread-pop .up-foot a {
713  font-family: var(--font-mono); font-size: 10px;
714  color: var(--brass-bright); text-decoration: none; cursor: pointer;
715}
716#unread-pop .up-foot a:hover { text-decoration: underline; }
717
718.empty-state {
719  padding: 60px 20px;
720  text-align: center;
721  color: var(--ink-faint);
722}
723.empty-state .big {
724  font-family: var(--font-display);
725  font-size: 42px;
726  font-style: italic;
727  color: var(--brass);
728  line-height: 1;
729  margin-bottom: 12px;
730}
731.empty-state p { font-size: 12px; line-height: 1.6; }
732
733/* ── Main / globe ─────────────────────────────────────────── */
734main.globe-container {
735  grid-area: main;
736  position: relative;
737  background: #000;
738  overflow: hidden;
739  min-width: 0;    /* critical: lets grid cell shrink properly */
740  min-height: 0;
741}
742#globe {
743  position: absolute;
744  inset: 0;
745  width: 100%;
746  height: 100%;
747}
748#globe canvas { display: block; margin: 0 auto; }
749
750.globe-hud {
751  position: absolute;
752  pointer-events: none;
753  font-family: var(--font-mono);
754  font-size: 10px;
755  letter-spacing: 0.15em;
756  text-transform: uppercase;
757  color: var(--ink-faint);
758}
759.hud-bl {
760  bottom: 20px; left: 22px;
761  line-height: 1.7;
762}
763.hud-bl .readout {
764  color: var(--brass);
765  font-size: 11px;
766}
767.hud-br {
768  bottom: 20px; right: 22px;
769  text-align: right;
770  line-height: 1.7;
771}
772.hud-tl {
773  top: 16px; left: 22px;
774}
775
776.legend {
777  position: absolute;
778  top: 16px; right: 22px;
779  background: var(--bg-panel);
780  backdrop-filter: blur(10px);
781  border: 1px solid var(--line);
782  padding: 12px 14px;
783  border-radius: 2px;
784  font-family: var(--font-mono);
785  font-size: 10px;
786  pointer-events: auto;
787}
788.legend .label {
789  color: var(--ink-faint);
790  letter-spacing: 0.18em;
791  text-transform: uppercase;
792  font-size: 9px;
793  margin-bottom: 8px;
794}
795.legend .row {
796  display: flex; align-items: center; gap: 8px;
797  margin-bottom: 4px;
798  color: var(--ink-dim);
799}
800.legend .row:last-child { margin-bottom: 0; }
801.legend .pip {
802  width: 8px; height: 8px; border-radius: 50%;
803}
804
805/* Globe tooltip
806   globe.gl wraps the `pointLabel` HTML in a `.scene-tooltip` container that
807   ships with its own hard-coded styles (white-space: nowrap, centered, etc.)
808   that collapse multi-line content onto a single cropped line — which made
809   the tooltip show only the status and clip the name/company. Override
810   aggressively with !important so our layout wins regardless of which
811   globe.gl version the CDN serves. */
812.scene-tooltip { white-space: normal !important; overflow: visible !important; text-align: left !important; }
813.scene-tooltip > * { white-space: normal !important; }
814/* Tooltip colours are hard-coded (not theme-variable driven) because the
815   tooltip sits over the globe regardless of theme, and a light-theme
816   --ink (dark) on our dark panel made the name invisible. Keep it a
817   consistent dark chip in both themes. */
818.globe-tooltip {
819  background: rgba(13, 18, 26, 0.95);
820  backdrop-filter: blur(10px);
821  border: 1px solid rgba(255, 255, 255, 0.16);
822  padding: 10px 14px;
823  border-radius: 2px;
824  font-family: var(--font-ui);
825  font-size: 13px;
826  color: #e8e4d8;
827  box-shadow: 0 8px 24px rgba(0,0,0,0.5);
828  pointer-events: none;
829  text-align: left;
830  min-width: 140px; max-width: 280px;
831  white-space: normal;
832  line-height: 1.35;
833}
834.globe-tooltip > div { display: block; white-space: normal;
834 color: inherit; }
835.globe-tooltip .name {
836  font-size: 14px;
837  font-weight: 600;
838  color: #e8e4d8;
839  margin-bottom: 2px;
840}
841.globe-tooltip .company {
842  font-family: var(--font-mono);
843  font-size: 11px;
844  color: #a0a0a8;
845  margin-top: 2px;
846}
847.globe-tooltip .status {
848  font-family: var(--font-mono);
849  font-size: 9px;
850  letter-spacing: 0.15em;
851  text-transform: uppercase;
852  margin-top: 6px;
853}
854
855/* ── Detail panel ─────────────────────────────────────────── */
856aside.detail {
857  grid-area: detail;
858  background: var(--bg-panel);
859  -webkit-backdrop-filter: blur(12px);
860  backdrop-filter: blur(12px);
861  border-left: 1px solid var(--line);
862  overflow-y: auto;
863  overflow-x: hidden;
864  display: flex; flex-direction: column;
865}
866.detail-empty {
867  flex: 1;
868  display: grid; place-items: center;
869  padding: 40px;
870  text-align: center;
871  color: var(--ink-faint);
872}
873.detail-empty .big {
874  font-family: var(--font-display);
875  font-style: italic;
876  font-size: 56px;
877  color: var(--brass);
878  line-height: 1;
879  margin-bottom: 14px;
880}
881.detail-empty p { max-width: 240px; line-height: 1.55; font-size: 13px; }
882
883.detail-form {
884  padding: 24px 24px 40px;
885  display: none;
886}
887.detail-form.show { display: block; }
888
889.detail-header {
890  padding-bottom: 18px;
891  border-bottom: 1px solid var(--line);
892  margin-bottom: 22px;
893}
894.detail-header .coords {
895  font-family: var(--font-mono);
896  font-size: 10px;
897  letter-spacing: 0.15em;
898  color: var(--brass);
899  margin-bottom: 8px;
900}
901.detail-header .id {
902  font-family: var(--font-mono);
903  font-size: 9px;
904  color: var(--ink-faint);
905  letter-spacing: 0.1em;
906  margin-top: 10px;
907}
908.detail-header .actions {
909  display: flex; gap: 8px;
910  margin-top: 14px;
911}
912.detail-header .actions button {
913  padding: 7px 12px;
914  font-family: var(--font-mono);
915  font-size: 9px;
916  letter-spacing: 0.18em;
917  text-transform: uppercase;
918  border: 1px solid var(--line);
919  border-radius: 2px;
920  color: var(--ink-dim);
921  transition: all 0.15s;
922}
923.detail-header .actions button:hover { border-color: var(--line-strong); color: var(--ink); }
924.detail-header .actions button.danger:hover { border-color: var(--status-lost); color: var(--status-lost); }
925/* Drawer back/close: only meaningful when the panel is the off-canvas mobile
926   drawer (<=900px). On desktop the detail panel is a permanent column, so hide. */
927.detail-header .backbtn { display: none; }
928
929.field {
930  margin-bottom: 18px;
931}
932.field label {
933  display: block;
934  font-family: var(--font-mono);
935  font-size: 9px;
936  letter-spacing: 0.2em;
937  text-transform: uppercase;
938  color: var(--ink-faint);
939  margin-bottom: 6px;
940}
941.field input[type="text"],
942.field input[type="email"],
943.field input[type="url"],
944.field input[type="tel"],
945.field input[type="number"],
946.field textarea,
947.field select {
948  width: 100%;
949  padding: 9px 12px;
950  background: var(--bg);
951  border: 1px solid var(--line);
952  border-radius: 2px;
953  color: var(--ink);
954  font-size: 13px;
955  transition: border-color 0.15s;
956}
957.field input:focus, .field textarea:focus, .field select:focus {
958  border-color: var(--brass);
959}
960.field textarea {
961  min-height: 120px;
962  line-height: 1.55;
963  font-size: 13px;
964}
965.field select {
966  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23a0a0a8' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
967  background-repeat: no-repeat;
968  background-position: right 12px center;
969  padding-right: 32px;
970}
971
972.row-two {
973  display: grid;
974  grid-template-columns: 1fr 1fr;
975  gap: 12px;
976}
977
978.name-input {
979  font-family: var(--font-display) !important;
980  font-size: 24px !important;
981  padding: 6px 0 !important;
982  background: transparent !important;
983  border: none !important;
984  border-bottom: 1px solid var(--line) !important;
985  border-radius: 0 !important;
986  color: var(--ink) !important;
987  margin-bottom: 10px;
988}
989.name-input:focus { border-bottom-color: var(--brass) !important; }
990
991.geocode-row {
992  display: flex; gap: 8px; align-items: flex-end;
993}
994.geocode-row .field { flex: 1; margin-bottom: 0; }
995.geocode-btn {
996  padding: 9px 12px;
997  background: var(--bg);
998  border: 1px solid var(--line);
999  border-radius: 2px;
1000  color: var(--ink-dim);
1001  font-family: var(--font-mono);
1002  font-size: 10px;
1003  letter-spacing: 0.15em;
1004  text-transform: uppercase;
1005  transition: all 0.15s;
1006  white-space: nowrap;
1007}
1008.geocode-btn:hover { color: var(--brass); border-color: var(--brass); }
1009.geocode-btn:disabled { opacity: 0.4; cursor: wait; }
1010
1011/* ── Company view toggle ─────────────────────────────────────── */
1012.company-pill { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border:1px solid var(--line,#2a2a33); border-radius:999px; background:transparent; color:var(--ink-dim); font:inherit; font-size:12px; cursor:pointer; transition:all .15s; }
1013.company-pill svg { width:14px; height:14px; }
1014.company-pill:hover { color:var(--brass,#3a7fc2); border-color:var(--brass,#3a7fc2); }
1015.company-pill[data-on="true"] { color:var(--brass-on-bg); background:var(--brass,#3a7fc2); border-color:var(--brass,#3a7fc2); }
1016
1017/* ── Map location search overlay ─────────────────────────────── */
1018.map-search { position:absolute; top:18px; left:50%; transform:translateX(-50%); z-index:20; width:min(440px,72vw); display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none; }
1019.map-search > * { pointer-events:auto; }
1020.map-search-bar { display:flex; align-items:center; gap:8px; width:100%; padding:8px 14px; background:var(--bg-panel); border:1px solid var(--line,#2a2a33); border-radius:999px; backdrop-filter:blur(8px); }
1021.map-search-bar svg { width:16px; height:16px; color:var(--ink-dim); flex:none; }
1022.map-search-bar input { flex:1; background:transparent; border:none; outline:none;
1022 color:var(--ink,#e8e4d8); font:inherit; font-size:13px; }
1023.map-search-bar button { flex:none; padding:6px 16px; border:1px solid var(--brass,#3a7fc2); background:var(--brass,#3a7fc2); color:var(--brass-on-bg); border-radius:999px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; }
1024.map-search-bar button:hover { filter:brightness(1.1); }
1025.map-filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; justify-content:center; }
1026.map-filters select { padding:7px 10px; background:var(--bg-panel); border:1px solid var(--line,#2a2a33); border-radius:9px; color:var(--ink,#e8e4d8); font:inherit; font-size:12px; cursor:pointer; backdrop-filter:blur(8px); }
1027.map-filters select:focus { outline:none; border-color:var(--brass,#3a7fc2); }
1028.map-product-chips { display:flex; gap:6px; }
1029.map-product-chip { width:38px; height:38px; padding:3px; border:1px solid var(--line,#2a2a33); border-radius:9px; background:var(--bg-panel); cursor:pointer; transition:all .15s; backdrop-filter:blur(8px); }
1030.map-product-chip img { width:100%; height:100%; object-fit:contain; opacity:.5; }
1031.map-product-chip.on { border-color:var(--brass,#3a7fc2); background:rgba(58,127,194,.2); }
1032.map-product-chip.on img { opacity:1; }
1033.map-product-chip:hover img { opacity:.9; }
1034.map-search-result { font-size:12px; color:var(--ink,#e8e4d8); background:var(--bg-panel); padding:4px 10px; border-radius:7px; backdrop-filter:blur(8px); }
1035.map-search-result:empty { display:none; }
1036
1037/* ── Account modal ───────────────────────────────────────────── */
1038.acc-msg { font-size:12px; margin:2px 0 10px; }
1039.acc-msg:empty { display:none; }
1040.acc-msg.error { color:var(--status-lost); }
1041.acc-msg.ok { color:var(--brass,#3a7fc2); }
1042.acc-divider { border:none; border-top:1px solid var(--line,#2a2a33); margin:20px 0 14px; }
1043.acc-admin-h { font-size:13px; margin:0 0 4px; color:var(--ink,#e8e4d8); }
1044
1045/* ── Product toggles on the contact form ─────────────────────── */
1046.product-toggles { display:flex; flex-wrap:wrap; gap:8px; }
1047.product-toggles.locked { opacity:.6; pointer-events:none; }
1048.product-btn { display:flex; flex-direction:column; align-items:center; gap:4px; width:66px; padding:6px 4px; border:1px solid var(--line,#2a2a33); border-radius:9px; background:transparent; color:var(--ink-dim); cursor:pointer; transition:all .15s; }
1049.product-btn img { width:38px; height:38px; object-fit:contain; opacity:.45; filter:grayscale(1); transition:all .15s; }
1050.product-btn span { font-size:9px; line-height:1.15; text-align:center; }
1051.product-btn.on { border-color:var(--brass,#3a7fc2); background:rgba(58,127,194,.16); color:var(--ink,#e8e4d8); }
1052.product-btn.on img { opacity:1; filter:none; }
1053.product-btn:hover img { opacity:.85; }
1054
1055.readonly-banner { margin:8px 16px; padding:7px 12px; font-size:12px; color:var(--brass,#3a7fc2); background:rgba(58,127,194,.14); border:1px solid rgba(58,127,194,.35); border-radius:8px; }
1056
1057/* ── Pin hover: product icons + team badge ───────────────────── */
1058.tt-products { display:flex; gap:4px; margin-top:6px; }
1059.tt-products img { width:22px; height:22px; object-fit:contain; background:rgba(255,255,255,.06); border-radius:4px; padding:2px; }
1060.tt-team { margin-top:5px; font-size:10px; color:var(--ink-dim); opacity:.85; }
1061
1062.coord-display {
1063  font-family: var(--font-mono);
1064  font-size: 11px;
1065  color: var(--ink-dim);
1066  padding: 8px 12px;
1067  background: var(--bg);
1068  border: 1px solid var(--line);
1069  border-radius: 2px;
1070  margin-top: 6px;
1071}
1072
1073.tags-input {
1074  display: flex; flex-wrap: wrap; gap: 6px;
1075  padding: 8px;
1076  background: var(--bg);
1077  border: 1px solid var(--line);
1078  border-radius: 2px;
1079  min-height: 38px;
1080}
1081.tags-input input {
1082  flex: 1;
1083  min-width: 100px;
1084  background: transparent;
1085  border: none;
1086  padding: 2px 4px;
1087  font-size: 12px;
1088}
1089.tag-pill {
1090  display: inline-flex; align-items: center; gap: 6px;
1091  padding: 3px 4px 3px 10px;
1092  background: rgba(198, 163, 93, 0.12);
1093  border: 1px solid rgba(198, 163, 93, 0.3);
1094  border-radius: 99px;
1095  font-family: var(--font-mono);
1096  font-size: 10px;
1097  color: var(--brass-bright);
1098}
1099.tag-pill button {
1100  width: 16px; height: 16px;
1101  display: grid; place-items: center;
1102  color: var(--brass);
1103  opacity: 0.6;
1104  border-radius: 50%;
1105  transition: all 0.15s;
1106}
1107.tag-pill button:hover { opacity: 1; background: rgba(198, 163, 93, 0.2); }
1108.tag-pill.primary { background: rgba(198, 163, 93, 0.22); border-color: rgba(198, 163, 93, 0.55); }
1109.tag-pill.primary::before { content: "★"; font-size: 10px; margin-right: 2px; color: var(--brass-bright); }
1110.hint-label { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.08em; color: var(--ink-faint); text-transform: none; }
1111.tag-pill .addr { cursor: pointer; }
1112.tag-pill.primary .addr { cursor: default; }
1113.tags-input input.invalid { color: var(--status-lost); }
1114.tags-input input.invalid::placeholder { color: var(--status-lost); }
1115
1116.priority-stars {
1117  display: flex; gap: 2px;
1118}
1119.priority-stars button {
1120  width: 28px; height: 28px;
1121  display: grid; place-items: center;
1122  color: var(--ink-faint);
1123  transition: color 0.15s;
1124}
1125.priority-stars button.on { color: var(--brass-bright); }
1126.priority-stars button:hover { color: var(--brass); }
1127
1128/* ── Detail panel tabs ────────────────────────────────────── */
1129.tab-bar {
1130  display: flex;
1131  border-bottom: 1px solid var(--line);
1132  margin-bottom: 20px;
1133  margin-top: -2px;
1134  gap: 0;
1135}
1136.tab-bar button {
1137  flex: 1;
1138  padding: 10px 8px;
1139  font-family: var(--font-mono);
1140  font-size: 9px;
1141  letter-spacing: 0.2em;
1142  text-transform: uppercase;
1143  color: var(--ink-faint);
1144  border: none;
1145  border-bottom: 2px solid transparent;
1146  background: transparent;
1147  transition: all 0.15s;
1148  display: flex; align-items: center; justify-content: center; gap: 6px;
1149}
1150.tab-bar button:hover { color: var(--ink-dim); }
1151.tab-bar button.active {
1152  color: var(--brass-bright);
1153  border-bottom-color: var(--brass);
1154}
1155.tab-bar button .count {
1156  display: inline-block;
1157  padding: 1px 6px;
1158  background: rgba(198, 163, 93, 0.12);
1159  border-radius: 99px;
1160  color: var(--brass);
1161  font-size: 9px;
1162  letter-spacing: 0;
1163}
1164.tab-panel { display: none; }
1165.tab-panel.active { display: block; }
1166
1167/* Projects list */
1168.project-list { display: flex; flex-direction: column; gap: 10px; }
1169.project-item {
1170  padding: 12px 14px;
1171  border: 1px solid var(--line);
1172  border-radius: 2px;
1173  background: var(--bg);
1174  transition: border-color 0.15s;
1175  cursor: pointer;
1176}
1177.project-item:hover { border-color: var(--line-strong); }
1178.project-item.expanded { border-color: var(--brass); }
1179.project-item .phead {
1180  display: flex; justify-content: space-between; align-items: center;
1181  gap: 10px;
1182}
1183.project-item .pname {
1184  font-size: 13px;
1185  color: var(--ink);
1186  font-weight: 500;
1187  flex: 1;
1188  min-width: 0;
1189  overflow: hidden;
1190  text-overflow: ellipsis;
1191  white-space: nowrap;
1192}
1193.project-item .pstatus {
1194  font-family: var(--font-mono);
1195  font-size: 9px;
1196  letter-spacing: 0.15em;
1197  text-transform: uppercase;
1198  padding: 2px 7px;
1199  border-radius: 99px;
1200  white-space: nowrap;
1201}
1202.project-item .pstatus.active    { background: rgba(74, 158, 255, 0.15); color: #7ab5ff; }
1203.project-item .pstatus.won       { background: rgba(74, 222, 128, 0.15); color: #6ee7a5; }
1204.project-item .pstatus.lost      { background: rgba(239, 68, 68, 0.15);  color: #fca5a5; }
1205.project-item .pstatus.paused    { background: rgba(82, 82, 96, 0.15);   color: #9a9aa8; }
1206.project-item .pstatus.shipped   { background: rgba(228, 188, 108, 0.15);color: var(--brass-bright); }
1207.project-item .pmeta {
1208  display: flex; gap: 12px;
1209  margin-top: 6px;
1210  font-family: var(--font-mono);
1211  font-size: 10px;
1212  color: var(--ink-faint);
1213  letter-spacing: 0.08em;
1214}
1215.project-item .pmeta > span { display: inline-flex; align-items: center; gap: 4px; }
1216.project-body {
1217  margin-top: 12px;
1218  padding-top: 12px;
1219  border-top: 1px solid var(--line);
1220  display: none;
1221}
1222.project-item.expanded .project-body { display: block; }
1223.project-body .field { margin-bottom: 12px; }
1224.project-body .prow { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
1225.project-body .pactions {
1226  display: flex; justify-content: flex-end;
1227  margin-top: 6px;
1228}
1229.project-body .pactions button {
1230  padding: 5px 10px;
1231  font-family: var(--font-mono);
1232  font-size: 9px;
1233  letter-spacing: 0.18em;
1234  text-transform: uppercase;
1235  color: var(--ink-faint);
1236  border: 1px solid var(--line);
1237  border-radius: 2px;
1238  background: transparent;
1239  transition: all 0.15s;
1240}
1241.project-body .pactions button:hover { color: var(--status-lost); border-color: var(--status-lost); }
1242
1243.add-project-btn {
1244  width: 100%;
1245  padding: 10px;
1246  margin-top: 10px;
1247  background: transparent;
1248  border: 1px dashed var(--line-strong);
1249  border-radius: 2px;
1250  color: var(--ink-faint);
1251  font-family: var(--font-mono);
1252  font-size: 10px;
1253  letter-spacing: 0.2em;
1254  text-transform: uppercase;
1255  transition: all 0.15s;
1256}
1257.add-project-btn:hover {
1258  color: var(--brass);
1259  border-color: var(--brass);
1260  border-style: solid;
1261}
1262
1263.empty-mini {
1264  text-align: center;
1265  padding: 30px 10px;
1266  color: var(--ink-faint);
1267  font-size: 12px;
1268  line-height: 1.5;
1269}
1270.empty-mini .glyph {
1271  font-family: var(--font-display);
1272  font-size: 32px;
1273  color: var(--brass);
1274  line-height: 1;
1275  margin-bottom: 10px;
1276  font-style: italic;
1277}
1278
1279/* ── Gmail status pill (header) ────────────────────────────── */
1280.ws-link {
1281  display: inline-flex; align-items: center; gap: 7px;
1282  padding: 6px 12px;
1283  border: 1px solid var(--line);
1284  border-radius: 2px;
1285  font-family: var(--font-mono);
1286  font-size: 10px;
1287  letter-spacing: 0.22em;
1288  text-transform: uppercase;
1289  color: var(--ink-dim);
1290  text-decoration: none;
1291  transition: all 0.15s;
1292}
1293.ws-link:hover { color: var(--brass); border-color: var(--brass); }
1294.ws-link svg { width: 12px; height: 12px; }
1295.ws-link.active { color: var(--brass-bright); border-color: var(--brass); background: var(--bg-elev); }
1296
1297/* Mini stages bar — read-only summary on contact-level project rows */
1298.mini-bar {
1299  display: flex; gap: 2px;
1300  margin: 8px 0 0;
1301}
1302.mini-bar .seg {
1303  flex: 1; height: 4px; min-width: 6px;
1304  background: var(--bg-elev);
1305  border-radius: 1px;
1306  transition: background 0.15s;
1307}
1308.mini-bar .seg.complete { background: var(--brass); }
1309.mini-bar .seg.active   { background: var(--brass-bright); box-shadow: 0 0 4px rgba(228, 188, 108, 0.45); }
1310/* The bar is editable in place — click a segment to move the project there
1311   (same semantics as the big stages bar in projects.html). */
1312.mini-bar .seg { cursor: pointer; }
1313.mini-bar .seg:hover { transform: scaleY(1.6); }
1314.mini-bar-label {
1315  font-family: var(--font-mono);
1316  font-size: 9px;
1317  letter-spacing: 0.2em;
1318  text-transform: uppercase;
1319  color: var(--ink-faint);
1320  margin-top: 4px;
1321  display: flex; gap: 6px; align-items: center;
1322}
1323.mini-bar-label .stage-name { color: var(--brass); }
1324.mini-bar-label .stage-when { opacity: 0.7; }
1325
1326/* "Open in Projects" link inside each expanded project body */
1327.po-link {
1328  display: inline-flex; align-items: center; gap: 6px;
1329  padding: 7px 12px;
1330  border: 1px solid var(--line);
1331  border-radius: 2px;
1332  font-family: var(--font-mono);
1333  font-size: 9px;
1334  letter-spacing: 0.22em;
1335  text-transform: uppercase;
1336  color: var(--ink-dim);
1337  text-decoration: none;
1338  transition: all 0.15s;
1339  margin-right: auto;
1340}
1341.po-link:hover { color: var(--brass); border-color: var(--brass); }
1342.po-link svg { width: 11px; height: 11px; }
1343
1344.gmail-pill {
1345  display: inline-flex; align-items: center; gap: 6px;
1346  height: 28px;
1347  padding: 0 10px;
1348  border: 1px solid var(--line);
1349  border-radius: 99px;
1350  font-family: var(--font-mono);
1351  font-size: 10px;
1352  letter-spacing: 0.12em;
1353  text-transform: uppercase;
1354  color: var(--ink-dim);
1355  background: transparent;
1356  cursor: pointer;
1357  transition: all 0.15s;
1358  white-space: nowrap;
1359  max-width: 240px;
1360  overflow: hidden;
1361  text-overflow: ellipsis;
1362}
1363.gmail-pill:hover { color: var(--ink); border-color: var(--line-strong); }
1364.gmail-pill .dot {
1365  width: 6px; height: 6px; border-radius: 50%;
1366  background: var(--ink-faint);
1367  flex-shrink: 0;
1368}
1369.gmail-pill[data-state="connected"] { color: var(--brass-bright); border-color: rgba(58,127,194,0.4); }
1370.gmail-pill[data-state="connected"] .dot { background: var(--good); }
1371.gmail-pill[data-state="connecting"] .dot { background: var(--brass); animation: pulse 0.8s infinite; }
1372.gmail-pill[data-state="error"] { color: #fca5a5; border-color: rgba(239,68,68,0.4); }
1373.gmail-pill[data-state="error"] .dot { background: var(--status-lost); }
1374
1375/* ── Email tab ─────────────────────────────────────────────── */
1376.email-tab {
1377  display: flex; flex-direction: column; gap: 18px;
1378}
1379.email-head {
1380  display: flex; justify-content: space-between; align-items: center;
1381  gap: 10px;
1382  padding-bottom: 10px;
1383  border-bottom: 1px solid var(--line);
1384}
1385.email-head .left {
1386  font-family: var(--font-mono);
1387  font-size: 10px;
1388  letter-spacing: 0.18em;
1389  text-transform: uppercase;
1390  color: var(--ink-faint);
1391}
1392.email-head .left strong { color: var(--ink); font-weight: 500; letter-spacing: 0.02em; text-transform: none; }
1393.email-head .right { display: flex; gap: 6px; }
1394.email-head button {
1395  padding: 5px 10px;
1396  font-family: var(--font-mono);
1397  font-size: 9px;
1398  letter-spacing: 0.2em;
1399  text-transform: uppercase;
1400  color: var(--ink-dim);
1401  border: 1px solid var(--line);
1402  border-radius: 2px;
1403  background: transparent;
1404  transition: all 0.15s;
1405}
1406.email-head button:hover { color: var(--brass); border-color: var(--brass); }
1407.email-head button:disabled { opacity: 0.4;
1407 cursor: not-allowed; }
1408
1409.compose-box {
1410  border: 1px solid var(--line);
1411  border-radius: 2px;
1412  padding: 12px;
1413  background: var(--bg);
1414  display: flex; flex-direction: column; gap: 10px;
1415}
1416.compose-box .compose-row {
1417  display: grid;
1418  grid-template-columns: 70px 1fr;
1419  align-items: center;
1420  gap: 10px;
1421}
1422.compose-box .compose-row label {
1423  font-family: var(--font-mono);
1424  font-size: 10px;
1425  letter-spacing: 0.18em;
1426  text-transform: uppercase;
1427  color: var(--ink-faint);
1428  margin: 0;
1429}
1430.compose-box input, .compose-box select, .compose-box textarea {
1431  background: var(--bg-elev);
1432  border: 1px solid var(--line);
1433  border-radius: 2px;
1434  padding: 8px 10px;
1435  color: var(--ink);
1436  font-family: var(--font-ui);
1437  font-size: 13px;
1438  width: 100%;
1439}
1440.compose-box textarea {
1441  min-height: 140px;
1442  resize: vertical;
1443  font-family: var(--font-ui);
1444  line-height: 1.5;
1445}
1446.compose-box input:focus, .compose-box select:focus, .compose-box textarea:focus {
1447  outline: none;
1448  border-color: var(--brass);
1449}
1450.compose-box .tags-input { padding: 6px; }
1451.compose-box .tags-input input { font-size: 12px; }
1452.suggest-row { display: flex; flex-wrap: wrap; gap: 6px; }
1453.suggest-row button {
1454  padding: 4px 10px;
1455  border: 1px dashed var(--line-strong);
1456  border-radius: 99px;
1457  background: transparent;
1458  color: var(--ink-dim);
1459  font-family: var(--font-mono);
1460  font-size: 10px;
1461  cursor: pointer;
1462}
1463.suggest-row button:hover { color: var(--brass); border-color: var(--brass); border-style: solid; }
1464.compose-box .compose-actions {
1465  display: flex; justify-content: space-between; align-items: center;
1466  margin-top: 4px;
1467}
1468.compose-box .hint {
1469  font-family: var(--font-mono);
1470  font-size: 9px;
1471  letter-spacing: 0.12em;
1472  color: var(--ink-faint);
1473}
1474.compose-box .send-btn {
1475  padding: 9px 20px;
1476  background: var(--brass);
1477  color: var(--brass-on-bg);
1478  font-family: var(--font-mono);
1479  font-size: 10px;
1480  letter-spacing: 0.22em;
1481  text-transform: uppercase;
1482  font-weight: 600;
1483  border: none;
1484  border-radius: 2px;
1485  transition: all 0.15s;
1486}
1487.compose-box .send-btn:hover { background: var(--brass-bright); }
1488.compose-box .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
1489
1490.thread-list { display: flex; flex-direction: column; gap: 8px; }
1491.thread-item {
1492  border: 1px solid var(--line);
1493  border-radius: 2px;
1494  background: var(--bg);
1495  transition: border-color 0.15s;
1496  overflow: hidden;
1497}
1498.thread-item:hover { border-color: var(--line-strong); }
1499.thread-item.expanded { border-color: var(--brass); }
1500.thread-item.unread { border-left: 3px solid var(--brass-bright); }
1501.thread-item.unread .tsubj { color: var(--brass-bright); font-weight: 600; }
1502.thread-item .thead {
1503  display: flex; justify-content: space-between; align-items: flex-start;
1504  gap: 10px;
1505  padding: 10px 12px;
1506  cursor: pointer;
1507}
1508.thread-item .tsubj {
1509  font-size: 13px;
1510  color: var(--ink);
1511  font-weight: 500;
1512  flex: 1;
1513  min-width: 0;
1514  overflow: hidden;
1515  text-overflow: ellipsis;
1516  white-space: nowrap;
1517}
1518.thread-item .tdate {
1519  font-family: var(--font-mono);
1520  font-size: 10px;
1521  color: var(--ink-faint);
1522  white-space: nowrap;
1523  flex-shrink: 0;
1524}
1525.thread-item .tsnip {
1526  padding: 0 12px 10px;
1527  font-size: 12px;
1528  color: var(--ink-dim);
1529  line-height: 1.4;
1530  display: -webkit-box;
1531  -webkit-line-clamp: 2;
1532  -webkit-box-orient: vertical;
1533  overflow: hidden;
1534}
1535.thread-item.expanded .tsnip { display: none; }
1536.thread-body { display: none; border-top: 1px solid var(--line); padding: 4px 0; }
1537.thread-item.expanded .thread-body { display: block; }
1538.msg-item {
1539  padding: 10px 12px;
1540  border-bottom: 1px dotted var(--line);
1541}
1542.msg-item:last-child { border-bottom: none; }
1543.msg-item .mhead {
1544  display: flex; justify-content: space-between;
1545  font-family: var(--font-mono);
1546  font-size: 10px;
1547  letter-spacing: 0.1em;
1548  color: var(--ink-faint);
1549  margin-bottom: 4px;
1550}
1551.msg-item .mdir {
1552  padding: 1px 6px;
1553  border-radius: 99px;
1554  font-size: 9px;
1555  letter-spacing: 0.2em;
1556  text-transform: uppercase;
1557}
1558.msg-item .mdir.in  { background: rgba(74,158,255,0.15); color: #7ab5ff; }
1559.msg-item .mdir.out { background: rgba(228,188,108,0.15); color: var(--brass-bright); }
1560.msg-item .mfrom { color: var(--ink-dim); }
1561.msg-item .mbody {
1562  font-size: 13px;
1563  color: var(--ink);
1564  line-height: 1.5;
1565  white-space: pre-wrap;
1566  word-break: break-word;
1567  margin-top: 4px;
1568}
1569
1570.email-disconnected {
1571  border: 1px dashed var(--line-strong);
1572  border-radius: 2px;
1573  padding: 24px 16px;
1574  text-align: center;
1575}
1576.email-disconnected p {
1577  color: var(--ink-faint);
1578  font-size: 12px;
1579  line-height: 1.5;
1580  margin-bottom: 14px;
1581}
1582.email-disconnected .connect-btn {
1583  padding: 10px 22px;
1584  background: var(--brass);
1585  color: var(--brass-on-bg);
1586  font-family: var(--font-mono);
1587  font-size: 10px;
1588  letter-spacing: 0.22em;
1589  text-transform: uppercase;
1590  font-weight: 600;
1591  border: none;
1592  border-radius: 2px;
1593}
1594.email-disconnected .connect-btn:hover { background: var(--brass-bright); }
1595
1596/* ── Templates modal ───────────────────────────────────────── */
1597.tpl-list { display: flex; flex-direction: column; gap: 8px; max-height: 50vh; overflow-y: auto; }
1598.tpl-item {
1599  border: 1px solid var(--line);
1600  border-radius: 2px;
1601  padding: 10px 12px;
1602  background: var(--bg);
1603  cursor: pointer;
1604  transition: border-color 0.15s;
1605}
1606.tpl-item:hover { border-color: var(--line-strong); }
1607.tpl-item.expanded { border-color: var(--brass); }
1608.tpl-item .tph {
1609  display: flex; justify-content: space-between; align-items: center;
1610  gap: 10px;
1611}
1612.tpl-item .tplabel { color: var(--ink); font-size: 13px; font-weight: 500; }
1613.tpl-item .tpkey {
1614  font-family: var(--font-mono);
1615  font-size: 10px;
1616  color: var(--ink-faint);
1617  letter-spacing: 0.08em;
1618}
1619.tpl-body {
1620  display: none;
1621  margin-top: 10px;
1622  padding-top: 10px;
1623  border-top: 1px solid var(--line);
1624  gap: 10px;
1625  flex-direction: column;
1626}
1627.tpl-item.expanded .tpl-body { display: flex; }
1628.tpl-body label {
1629  font-family: var(--font-mono);
1630  font-size: 10px;
1631  letter-spacing: 0.18em;
1632  text-transform: uppercase;
1633  color: var(--ink-faint);
1634  display: block;
1635  margin-bottom: 4px;
1636}
1637.tpl-body input, .tpl-body textarea {
1638  background: var(--bg-elev);
1639  border: 1px solid var(--line);
1640  border-radius: 2px;
1641  padding: 8px 10px;
1642  color: var(--ink);
1643  font-family: var(--font-ui);
1644  font-size: 13px;
1645  width: 100%;
1646}
1647.tpl-body textarea { min-height: 120px; resize: vertical; }
1648.tpl-body input:focus, .tpl-body textarea:focus { outline: none; border-color: var(--brass); }
1649.tpl-body .tpactions {
1650  display: flex; justify-content: space-between; align-items: center;
1651  margin-top: 4px;
1652}
1653.tpl-body .tpactions .note {
1654  font-family: var(--font-mono);
1655  font-size: 9px;
1656  color: var(--ink-faint);
1657}
1658.tpl-body .tpactions button {
1659  padding: 5px 10px;
1660  font-family: var(--font-mono);
1661  font-size: 9px;
1662  letter-spacing: 0.18em;
1663  text-transform: uppercase;
1664  color: var(--ink-faint);
1665  border: 1px solid var(--line);
1666  border-radius: 2px;
1667  background: transparent;
1668}
1669.tpl-body .tpactions button:hover { color: var(--status-lost); border-color: var(--status-lost); }
1670
1671/* ── Modal (Add contact) ──────────────────────────────────── */
1672.modal-overlay {
1673  position: fixed; inset: 0; z-index: 500;
1674  background: var(--modal-overlay);
1675  backdrop-filter: blur(6px);
1676  display: none;
1677  align-items: center; justify-content: center;
1678  padding: 20px;
1679}
1680.modal-overlay.show { display: flex; }
1681.modal {
1682  width: min(92vw, 520px);
1683  background: var(--bg-panel-solid);
1684  border: 1px solid var(--line-strong);
1685  padding: 32px;
1686  border-radius: 2px;
1687  box-shadow: 0 20px 60px rgba(0,0,0,0.5);
1688}
1689.modal h2 {
1690  font-family: var(--font-display);
1691  font-size: 36px;
1692  color: var(--brass-bright);
1693  margin-bottom: 4px;
1694  line-height: 1;
1695}
1696.modal .sub {
1697  font-family: var(--font-mono);
1698  font-size: 10px;
1699  letter-spacing: 0.2em;
1700  text-transform: uppercase;
1701  color: var(--ink-faint);
1702  margin-bottom: 24px;
1703}
1704.modal .search-results {
1705  max-height: 200px;
1706  overflow-y: auto;
1707  margin-top: 10px;
1708  border: 1px solid var(--line);
1709  border-radius: 2px;
1710  display: none;
1711}
1712.modal .search-results.show { display: block; }
1713.search-result {
1714  padding: 10px 14px;
1715  border-bottom: 1px solid var(--line);
1716  cursor: pointer;
1717  font-size: 13px;
1718  transition: background 0.12s;
1719}
1720.search-result:hover { background: var(--hover-bg-strong); }
1721.search-result:last-child { border-bottom: none; }
1722.search-result .primary { color: var(--ink); }
1723.search-result .coords {
1724  font-family: var(--font-mono); font-size: 10px;
1725  color: var(--ink-faint); margin-top: 2px;
1726}
1727.modal .actions {
1728  display: flex; gap: 10px; margin-top: 22px;
1729}
1730.modal .actions button {
1731  flex: 1;
1732  padding: 12px;
1733  font-family: var(--font-mono);
1734  font-size: 10px;
1735  letter-spacing: 0.2em;
1736  text-transform: uppercase;
1737  font-weight: 600;
1738  border-radius: 2px;
1739  transition: all 0.15s;
1740}
1741.btn-primary {
1742  background: var(--brass);
1743  color: var(--brass-on-bg);
1744}
1745.btn-primary:hover { background: var(--brass-bright); }
1746.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
1747.btn-secondary {
1748  background: transparent;
1749  color: var(--ink-dim);
1750  border: 1px solid var(--line);
1751}
1752.btn-secondary:hover { color: var(--ink); border-color: var(--line-strong); }
1753
1754/* ── Utility ──────────────────────────────────────────────── */
1755.hidden { display: none !important; }
1756
1757.loading-globe {
1758  position: absolute; inset: 0;
1759  display: grid; place-items: center;
1760  background: #000;
1761  z-index: 5;
1762  transition: opacity 0.4s;
1763}
1764.loading-globe.hide { opacity: 0; pointer-events: none; }
1765.loading-globe .spinner {
1766  font-family: var(--font-display);
1767  font-style: italic;
1768  font-size: 42px;
1769  color: var(--brass);
1770  animation: breathe 2s ease-in-out infinite;
1771}
1772@keyframes breathe { 50% { opacity: 0.4; } }
1773
1774@media (max-width: 1100px) {
1775  #app { grid-template-columns: 256px 1fr 300px; }
1776}
1777@media (max-width: 900px) {
1778  #app {
1779    grid-template-columns: 1fr;
1780    grid-template-rows: auto 40vh 1fr;
1781    grid-template-areas: "header" "main" "sidebar";
1782  }
1783  aside.detail {
1784    position: fixed;
1785    top: 54px; right: 0; bottom: 0;
1786    width: min(92vw, 300px);
1787    transform: translateX(100%);
1788    transition: transform 0.25s;
1789    z-index: 50;
1790  }
1791  aside.detail.open { transform: translateX(0); }
1792  .detail-header .backbtn { display: inline-flex; }
1793}
1794
1795/* ════════════════════════════════════════════════════════════════
1796   HOME DASHBOARD (Business Overview) — the default home view.
1797   Lives ALONGSIDE the CRM globe shell: a full-bleed surface that
1798   covers #app's content area (below the shared nav top-bar). #dashboard
1799   always keeps .show — the globe shell stays covered beneath, and section
1800   switches swap views within it. Built from the theme tokens to
1801   match the dark dashboard mockup, mirroring the Manufacturing density.
1802   ════════════════════════════════════════════════════════════════ */
1803
1804/* Hidden in-page section strip — the left sidebar (quasar-nav) reads its
1805   [data-sec] buttons and clicks them to drive section switching; the
1806   ?__sec=NAME render helper clicks them too. Kept in the DOM, never shown. */
1807.subtabs { display: none !important; }
1808
1809#dashboard {
1810  position: fixed;
1811  inset: var(--qtop-h, 54px) 0 0 var(--qside-off, 264px);
1812  z-index: 410;
1813  overflow-y: auto; overflow-x: hidden;
1814  background: var(--bg);
1815  padding: 12px 22px 14px;   /* same frame padding as the Manufacturing .mf-wrap */
1816  display: none;
1817}
1818html.qnav-rail #dashboard { left: var(--qside-off-rail, 88px); }
1819#dashboard.show { display: block; }
1820.dash-wrap { max-width: 1920px; margin: 0 auto; }
1821
1822/* KPI tiles - same idiom and scale as the Manufacturing dashboard tiles */
1823#dashboard .kpis { display:grid; grid-template-columns:repeat(5,1fr); gap:var(--qds-sp-3); margin-bottom:var(--qds-sp-4); }
1824@media(max-width:1180px){ #dashboard .kpis{ grid-template-columns:repeat(2,1fr); } }
1825#dashboard .kpi { background:var(--bg-panel-solid); border:1px solid var(--line); border-radius:14px; padding:8px 13px 6px; min-width:0; }
1826#dashboard a.kpi { display:block; text-decoration:none; color:inherit; cursor:pointer; }
1827#dashboard a.kpi:hover { border-color:var(--line-strong); background:var(--hover-bg); }
1828#dashboard .kpi .khead { display:flex; align-items:center; gap:12px; margin-bottom:4px; }
1829#dashboard .kpi .ktext { min-width:0; }
1830#dashboard .kpi .lbl { font-size:11.5px; font-weight:600; color:var(--ink-dim); line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1831#dashboard .kpi .val { font-size:18px; font-weight:700; color:var(--ink); line-height:1; letter-spacing:-.02em; margin-top:4px; }
1832#dashboard .kpi svg.spark { display:block; width:100%; height:20px; margin:3px 0 4px; }
1833#dashboard .kpi .delta { display:inline-flex; align-items:center; gap:4px; font-size:11.5px; font-weight:700; color:var(--ink-faint); }
1834#dashboard .kpi .delta.up { color:var(--status-green); } #dashboard .kpi .delta.down { color:var(--status-red); }
1835#dashboard .kpi .delta .sub { color:inherit; font-weight:700; }
1836
1837/* cards / panels - Manufacturing chrome: same radii, head padding, gaps */
1838#dashboard .card { background:var(--bg-panel-solid); border:1px solid var(--line); border-radius:var(--qds-r-md); overflow:hidden; display:flex; flex-direction:column; }
1839#dashboard .card-head { display:flex; align-items:center; gap:var(--qds-sp-2); padding:var(--qds-sp-3) var(--qds-sp-4); border-bottom:1px solid var(--line); }
1840#dashboard .card-head h3 { margin:0; font-size:14px; font-weight:700; color:var(--ink); }
1841#dashboard .card-head .spacer { flex:1; }
1842#dashboard .card-head .lnk { font-size:12px; font-weight:600; color:var(--brass-bright); cursor:pointer; text-decoration:none; }
1843#dashboard .card-head .lnk:hover { text-decoration:underline; }
1844#dashboard .card-head .mini-sel { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:4px 8px; font-size:11.5px; color:var(--ink-dim); }
1845#dashboard .card-body { padding:var(--qds-sp-4); }
1846
1847/* grids */
1848#dashboard .row-snap { display:grid; grid-template-columns:1.95fr 1fr; gap:11px; margin-bottom:11px; align-items:stretch; }
1849#dashboard .row-4 { display:grid; grid-template-columns:repeat(4,1fr); gap:11px; margin-bottom:11px; align-items:stretch; }
1850#dashboard .row-4 .card-body { max-height:184px; overflow-y:auto; }
1851#dashboard .row-2 { display:grid; grid-template-columns:1fr 1fr; gap:11px; margin-bottom:11px; align-items:stretch; }
1852#dashboard .row-2 .card-body { max-height:172px; overflow-y:auto; }
1853@media(max-width:1180px){ #dashboard .row-snap,#dashboard .row-4,#dashboard .row-2{ grid-template-columns:1fr; } }
1854/* visible scroll affordance on any card body that overflows - nothing cut
1855   off silently (Adam's People rule applied here too) */
1856#dashboard .card-body { scrollbar-width:thin; scrollbar-color:var(--line-strong) transparent; }
1857#dashboard .card-body::-webkit-scrollbar { width:9px; }
1858#dashboard .card-body::-webkit-scrollbar-thumb { background:var(--line-strong); border-radius:8px; border:2px solid var(--bg-panel-solid); }
1859#dashboard .card-body::-webkit-scrollbar-track { background:transparent; }
1860
1861/* My Dashboard focus view - hidden by default; #dashboard.sec-mydash hides the
1862   company Overview rows and reveals it. Overview restores the full grid. */
1863#dashboard .dash-focus { display:none; }
1864#dashboard.sec-mydash .kpis, #dashboard.sec-mydash .row-snap, #dashboard.sec-mydash .row-4, #dashboard.sec-mydash .row-2 { display:none; }
1865#dashboard.sec-mydash #dash-focus-mydash { display:block; }
1866#dashboard.sec-mydash #dash-focus-mydash .kpis { display:grid; }
1867#dashboard .dash-focus .card-body { max-height:none; padding-bottom:12px; }
1868/* My Dashboard chrome: welcome bar (two-initial avatar, greeting, role pill,
1869   Viewing menu). The Viewing button replaces the old raw "Preview as" select:
1870   signed in it filters to yourself + the staff who report up to you; the demo
1871   session additionally exposes everyone. */
1872#dashboard .signed-as { display:flex; align-items:center; gap:13px; background:var(--bg-panel-solid); border:1px solid var(--line); border-radius:var(--qds-r-md); padding:var(--qds-sp-4); margin-bottom:var(--qds-sp-4); position:relative; }
1873#dashboard .signed-as .sa-tt { min-width:0; flex:1; }
1874#dashboard .signed-as .sa-name { display:block; font-size:15.5px; font-weight:800; color:var(--ink); }
1875#dashboard .signed-as .sa-sub { display:block; font-size:12px; color:var(--ink-dim); margin-top:3px; }
1876#dashboard .signed-as .sa-sub a { color:var(--brass-bright, var(--status-blue)); font-weight:700; text-decoration:none; cursor:pointer; }
1877#dashboard .signed-as .sa-sub a:hover { text-decoration:underline; }
1878#dashboard .signed-as .sa-demo { padding:3px 9px; border-radius:999px; border:1px dashed var(--line-strong); color:var(--ink-faint); font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; flex:none; }
1879#dashboard .sa-view { display:flex; align-items:center; gap:8px; padding:8px 13px; border-radius:var(--r-control); border:1px solid var(--line-strong); background:var(--bg-elev); color:var(--ink); font-size:12.5px; font-weight:700; cursor:pointer; font-family:inherit; flex:none; }
1880#dashboard .sa-view:hover { border-color:var(--brass); color:var(--brass-bright); }
1881#dashboard .sa-view svg { width:15px; height:15px;
1881 color:var(--ink-dim); }
1882#dashboard .sa-view .car { width:13px; height:13px; color:var(--ink-faint); transition:transform .15s; }
1883#dashboard .sa-view.open .car { transform:rotate(180deg); }
1884#dashboard .sa-pop { position:absolute; top:calc(100% + 7px); right:12px; z-index:60; min-width:290px; max-width:340px; max-height:430px; overflow:auto; background:var(--bg-panel-solid); border:1px solid var(--line-strong); border-radius:13px; box-shadow:0 18px 45px rgba(0,0,0,.35); padding:7px; display:none; }
1885#dashboard .sa-pop.open { display:block; }
1886#dashboard .sa-pop .sp-h { font-size:10.5px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-faint); padding:8px 10px 4px; }
1887#dashboard .sa-pop .sp-it { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:9px; cursor:pointer; }
1888#dashboard .sa-pop .sp-it:hover { background:var(--hover-bg-strong); }
1889#dashboard .sa-pop .sp-tt { min-width:0; flex:1; }
1890#dashboard .sa-pop .sp-n { display:block; font-size:12.5px; font-weight:700; color:var(--ink); line-height:1.25; }
1891#dashboard .sa-pop .sp-r { display:block; font-size:11px; color:var(--ink-faint); line-height:1.25; margin-top:1px; }
1892#dashboard .sa-pop .sp-ck { width:15px; height:15px; color:var(--good); flex:none; }
1893#dashboard .sa-pop .sp-note { font-size:10.5px; color:var(--ink-faint); padding:8px 10px 4px; border-top:1px solid var(--line); margin-top:6px; line-height:1.45; }
1894/* "Log in as" affordance on a live-roster row (admin-only, HQ impersonation). */
1895#dashboard .sa-pop .sp-loginas { flex:none; font:inherit; font-size:11px; font-weight:700; color:var(--brass-bright); background:transparent; border:1px solid var(--line-strong); border-radius:var(--r-pill); padding:4px 10px; cursor:pointer; white-space:nowrap; }
1896#dashboard .sa-pop .sp-loginas:hover { border-color:var(--brass); color:var(--brass); background:var(--hover-bg-strong); }
1897#dashboard .sa-pop .sp-loginas:disabled { opacity:.6; cursor:default; }
1898/* My Dashboard: Today card (clock, live work, quick actions) + leave strip. */
1899#dashboard .mc-live { display:flex; align-items:center; gap:8px; font-size:12px; font-weight:700; color:var(--brass-bright); border:1px solid var(--line); border-radius:var(--r-control); background:var(--hover-bg); padding:7px 10px; margin-bottom:10px; text-decoration:none; }
1900#dashboard .mc-live svg { width:15px; height:15px; }
1901#dashboard .mc-state { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:700; color:var(--ink); padding:2px 0; }
1902#dashboard .mc-state .fdot-lg { width:10px; height:10px; border-radius:999px; background:var(--good); flex:none; }
1903#dashboard .mc-state.out .fdot-lg { background:var(--ink-faint); }
1904#dashboard .mc-timer { font-family:var(--font-mono); font-size:26px; font-weight:700; color:var(--ink); letter-spacing:-.02em; margin:4px 0 10px; font-variant-numeric:tabular-nums; }
1905#dashboard .mc-btns { display:flex; gap:8px; flex-wrap:wrap; margin:12px 0 10px; }
1906#dashboard .mc-btn { padding:8px 14px; border-radius:var(--r-control); border:1px solid var(--line-strong); background:var(--bg-elev); color:var(--ink); font-size:12.5px; font-weight:700; cursor:pointer; font-family:inherit; }
1907#dashboard .mc-btn:hover { border-color:var(--brass); color:var(--brass-bright); }
1908/* .mc-btn.primary removed with HQ-523 round 3 - Apply for leave moved to the
1909   accent-outline variant (.mc-btn.outline, quasar-ui.css) and nothing else used it. */
1910/* Prominent clock action - the single most-used control on My Dashboard, so it
1911   reads as a filled state chip: green to clock in, amber to clock out. */
1912#dashboard .mc-btn.big { flex:1 1 auto; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:11px 18px; font-size:13px; font-weight:800; border-radius:11px; letter-spacing:.01em; }
1913#dashboard .mc-btn.big svg { width:16px; height:16px; }
1914#dashboard .mc-btn.big.clock-in { background:color-mix(in srgb, var(--good) 16%, transparent); border-color:color-mix(in srgb, var(--good) 50%, transparent); color:var(--good); }
1915#dashboard .mc-btn.big.clock-in:hover { background:color-mix(in srgb, var(--good) 24%, transparent); border-color:var(--good); color:var(--good); }
1916#dashboard .mc-btn.big.clock-out { background:color-mix(in srgb, var(--status-amber) 15%, transparent); border-color:color-mix(in srgb, var(--status-amber) 50%, transparent); color:var(--status-amber); }
1917#dashboard .mc-btn.big.clock-out:hover { background:color-mix(in srgb, var(--status-amber) 23%, transparent); border-color:var(--status-amber); color:var(--status-amber); }
1918#dashboard .mc-note { font-size:11.5px; color:var(--ink-faint); }
1919/* My Dashboard rows are read-only glance rows - neutralise the shared
1920   "every list row click-opens something" affordance (cursor + hover) that
1921   #dashboard .fs-row rules apply globally. Two ids out-rank the one-id rules. */
1922#dashboard #dash-focus-mydash .fs-row { cursor:default; }
1923#dashboard #dash-focus-mydash .fs-row:hover { background:transparent; }
1924#dashboard #dash-focus-mydash .fs-row:hover .fname { color:var(--ink-dim); }
1925/* Empty-state rows (no bookings / all clear / no requests) - replaces the
1926   previously spec'd inline style, per the one-look colour rule. */
1927#dashboard #dash-focus-mydash .fs-row.empty .fname,
1928#dashboard #dash-focus-mydash .fs-row.empty:hover .fname { color:var(--ink-faint); }
1929/* Two-column focus layout: needs-attention beside the module summary/funnel. */
1930#dashboard .focus-grid { display:grid; grid-template-columns:1fr 1.35fr; gap:var(--qds-sp-3); align-items:stretch; margin-bottom:var(--qds-sp-4); }
1931#dashboard .focus-grid.even { grid-template-columns:1fr 1fr; }
1932@media(max-width:1180px){ #dashboard .focus-grid, #dashboard .focus-grid.even{ grid-template-columns:1fr; } }
1933/* Deep-link tiles */
1934#dashboard .deeprow { display:grid; grid-template-columns:repeat(3,1fr); gap:var(--qds-sp-3); }
1935@media(max-width:1180px){ #dashboard .deeprow{ grid-template-columns:1fr; } }
1936#dashboard a.deeptile { display:flex; align-items:center; gap:13px; background:var(--bg-panel-solid); border:1px solid var(--line); border-radius:var(--qds-r-md); padding:var(--qds-sp-4); text-decoration:none; color:inherit; cursor:pointer; transition:border-color .15s, background .15s, transform .15s; }
1937#dashboard a.deeptile:hover { border-color:var(--line-strong); background:var(--hover-bg); transform:translateY(-1px); }
1938#dashboard .deeptile .dti svg { width:20px; height:20px; }
1939/* Tile accents cycle by position so the shortcut row reads as three distinct
1940   destinations rather than three grey boxes. */
1941#dashboard .deeptile.t2 .dti { background:color-mix(in srgb, var(--status-green) 14%, transparent); color:var(--status-green); }
1942#dashboard .deeptile.t3 .dti { background:color-mix(in srgb, var(--status-violet) 14%, transparent); color:var(--status-violet); }
1943#dashboard .deeptile .dtt { min-width:0; }
1944#dashboard .deeptile .dt-name { display:block; font-size:13.5px; font-weight:700; color:var(--ink); }
1945#dashboard .deeptile .dt-sub { display:block; font-size:11.5px; color:var(--ink-faint); margin-top:2px; }
1946#dashboard .deeptile .dtx { margin-left:auto; color:var(--ink-faint); flex:none; transition:transform .15s, color .15s; }
1947#dashboard .deeptile .dtx svg { width:16px; height:16px; }
1948#dashboard a.deeptile:hover .dtx { color:var(--brass-bright); transform:translateX(2px); }
1949/* Approvals (My Queue until HQ-523): mini-card rows - severity-tinted icon chip + count-forward layout
1950   (the count is the decision datum, so it leads). HQ-1213: the .aq-row family
1951   itself (aq-row/aq-ic/aq-n/aq-t/urg/chev) moved to web/quasar-ui.css so a
1952   second page (Sales Overview's Needs Me card) can reuse it - see quasar-ui.css
1953   for the rules; renderDeptAttn() below is unchanged and still builds this
1954   exact markup. */
1955
1956/* Line chart family (was Business Snapshot; Total Revenue draws with it) */
1957#dashboard .snapchart { width:100%; height:auto; display:block; }
1958#dashboard .snap-area { stroke:none; }  /* fill is a per-series gradient (item 13 dot+fade) */
1959#dashboard .snap-line { fill:none; stroke-width:2.2; stroke-linejoin:round; stroke-linecap:round; }
1960#dashboard .snap-dot { stroke-width:2; }
1961#dashboard .snap-grid { stroke:var(--line); stroke-width:1; }
1962#dashboard .snap-axis { fill:var(--ink-faint); font-size:10px; }
1963#dashboard .snap-xlbl { fill:var(--ink-faint); font-size:10.5px; text-anchor:middle; }
1964#dashboard .snap-legend { display:flex; gap:18px; align-items:center; }
1965#dashboard .snap-legend span { display:inline-flex; align-items:center; gap:7px; font-size:11.5px; color:var(--ink-dim); }
1966#dashboard .snap-legend i { width:9px; height:9px; border-radius:999px; display:inline-block; }
1967
1968/* HQ-1213 round 13: the Action Required row rules that lived here (.actrow and
1969   its .aic/.an/.chev children) are GONE - both real and demo Action Required
1970   rows now build .aq-row markup (web/quasar-ui.css), the same component the
1971   comment above already describes for Approvals. .urg (the pill both idioms
1972   shared) already moved to quasar-ui.css in the round that extracted .aq-row. */
1973
1974/* Funnel base fill - demo fallback (real mode layers a .fseg-* modifier,
1975   categorical). HQ-1213: was --status-blue; now --qds-accent, the locked
1976   design doc's gold (see .qattain-track's note - not --brass, stale). */
1977#dashboard .funnel { display:flex; flex-direction:column; align-items:center; gap:3px; padding:4px 0; }
1978#dashboard a.funnel { text-decoration:none; cursor:pointer; }
1979#dashboard .funnel .seg { height:15px; border-radius:3px; background:linear-gradient(180deg,var(--qds-accent),color-mix(in srgb, var(--qds-accent) 70%, black)); }
1980#dashboard .pipe-rows { display:flex; flex-direction:column; }
1981#dashboard .pipe-row { display:flex; justify-content:space-between; align-items:center; padding:3px 0; border-bottom:1px solid var(--line); font-size:12.5px; color:var(--ink-dim); }
1982#dashboard .pipe-row:last-child { border-bottom:none; }
1983#dashboard .pipe-row b { color:var(--ink); font-variant-numeric:tabular-nums; }
1984#dashboard .pipe-row.won { color:var(--status-green); } #dashboard .pipe-row.won b { color:var(--status-green); }
1985#dashboard .pipe-foot { display:flex; justify-content:space-between; align-items:center; padding-top:6px; margin-top:3px; border-top:1px solid var(--line); font-size:12px; color:var(--ink-dim); }
1986#dashboard .pipe-foot b { color:var(--qds-accent); font-size:14px; }
1987
1988/* Production Overview rows */
1989#dashboard .prod-row { display:grid; grid-template-columns:48px 1fr 38px; align-items:center; gap:9px; padding:4px 0; }
1990#dashboard .prod-row .pn { font-size:12px; color:var(--ink-dim); }
1991#dashboard .prod-row .pbar { height:7px; border-radius:999px; background:var(--line); overflow:hidden; }
1992#dashboard .prod-row .pbar > i { display:block; height:100%; background:var(--status-green); }
1993#dashboard .prod-row.idle .pbar > i { background:var(--ink-faint); }
1994#dashboard .prod-row .pst { font-size:10.5px; font-weight:700; color:var(--status-green); }
1995#dashboard .prod-row.idle .pst { color:var(--ink-faint); }
1996#dashboard .prod-row .ppc { font-size:12px; font-weight:700; color:var(--ink-dim); text-align:right; font-variant-numeric:tabular-nums; }
1997#dashboard .prod-foot { display:flex; justify-content:space-between; align-items:center; padding-top:6px; margin-top:3px; border-top:1px solid var(--line); font-size:12px; color:var(--ink-dim); }
1998
1999/* Field Service Schedule */
2000#dashboard .fs-row { display:flex; align-items:center; gap:9px; padding:4px 0; border-bottom:1px solid var(--line); font-size:12.5px; }
2001#dashboard .fs-row:last-child { border-bottom:none; }
2002#dashboard .fs-row .fic { color:var(--ink-faint); flex:none; display:flex; }
2003#dashboard .fs-row .fname { color:var(--ink-dim); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
2004#dashboard .fs-row .ftime { color:var(--ink-dim); font-variant-numeric:tabular-nums; flex:none; }
2005#dashboard .fs-row .fdot { width:8px; height:8px; border-radius:999px; flex:none; }
2006#dashboard .fs-foot { display:flex; justify-content:space-between; align-items:center; padding-top:6px; margin-top:3px; border-top:1px solid var(--line); font-size:12px; color:var(--ink-dim); }
2007
2008/* Recent Activity feed */
2009#dashboard .act-feed .af-row { display:flex; align-items:flex-start; gap:10px; padding:5px 0; border-bottom:1px solid var(--line); font-size:12.5px; }
2010#dashboard .act-feed .af-row:last-child { border-bottom:none; }
2011#dashboard .act-feed .afic { width:22px; height:22px; flex:none; display:grid; place-items:center; border-radius:7px; background:var(--bg); }
2012#dashboard .act-feed .afic svg { width:13px; height:13px; }
2013#dashboard .act-feed .aft { color:var(--ink-dim); flex:1; line-height:1.4; }
2014#dashboard .act-feed .aft b { color:var(--ink); font-weight:700; }
2015#dashboard .act-feed .afw { color:var(--ink-faint); font-size:11px; white-space:nowrap; flex:none; }
2016
2017/* Team Availability */
2018#dashboard .team-cols { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
2019#dashboard .team-h { display:flex; align-items:center; gap:7px; font-size:12.5px; font-weight:700; color:var(--ink); margin-bottom:7px; }
2020#dashboard .team-h .td { width:9px; height:9px; border-radius:999px; flex:none; }
2021#dashboard .tm-row { display:flex; align-items:center; gap:9px; padding:3px 0; font-size:12px; }
2022#dashboard .tm-av { width:24px; height:24px; flex:none; border-radius:999px; display:grid; place-items:center; font-size:9.5px; font-weight:800; color:#fff; }
2023#dashboard .tm-name { color:var(--ink); font-weight:600; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
2024#dashboard .tm-dept { color:var(--ink-faint); font-size:11px; flex:none; }
2025#dashboard .tm-more { font-size:11.5px; color:var(--ink-faint); padding-top:5px; }
2026#dashboard a.team-h { text-decoration:none; color:var(--ink); }
2027#dashboard a.team-h:hover { color:var(--brass-bright); }
2028#dashboard a.tm-more { display:block; text-decoration:none; }
2029#dashboard a.tm-more:hover { color:var(--brass-bright); }
2030/* every list row click-opens something - show the affordance */
2031#dashboard .pipe-row, #dashboard .prod-row, #dashboard .fs-row,
2032#dashboard .act-feed .af-row, #dashboard .tm-row { cursor:pointer; }
2033#dashboard .pipe-row:hover span,
2034#dashboard .fs-row:hover .fname, #dashboard .act-feed .af-row:hover .aft { color:var(--ink); }
2035
2036/* quick-action row - Manufacturing quick-card scale */
2037/* Item 13 declutter: quick-create actions now live in the page-head "+ New"
2038   popover (#dash-popmenu .qpop-item) rather than a 5-card overview strip. */
2039#dash-popmenu .qpop-item { display:flex; align-items:center; gap:11px; padding:8px 10px; border-radius:9px; cursor:pointer; }
2040#dash-popmenu .qpop-item:hover { background:var(--hover-bg-strong); }
2041#dash-popmenu .qpop-ic svg { width:16px; height:16px; }
2042#dash-popmenu .qpop-tt { min-width:0; }
2043#dash-popmenu .qpop-t { display:block; font-weight:700; font-size:12.5px; color:var(--ink); line-height:1.2; }
2044#dash-popmenu .qpop-s { display:block; font-size:11px; color:var(--ink-dim); line-height:1.2; margin-top:1px; }
2045
2046/* ── Interactive affordances (demo wiring) ─────────────────────── */
2047#dashboard .kpi { cursor:pointer; transition:border-color .12s, transform .08s; }
2048#dashboard .kpi:hover { border-color:var(--brass); transform:translateY(-1px); }
2049#dashboard .fs-row, #dashboard .pipe-row, #dashboard .prod-row, #dashboard .af-row, #dashboard .tm-row { cursor:pointer; border-radius:6px; }
2050#dashboard .fs-row:hover, #dashboard .pipe-row:hover, #dashboard .prod-row:hover, #dashboard .af-row:hover, #dashboard .tm-row:hover { background:var(--hover-bg-strong); }
2051#dashboard .card-head .lnk, #dashboard .card-head .mini-sel { cursor:pointer; }
2052#dashboard .card-head .mini-sel:hover { border-color:var(--brass); color:var(--ink); }
2053
2054/* Header controls (This Week / Filters) - Manufacturing ghost-button chrome */
2055.page-head .ph-ctl { display:inline-flex; align-items:center; justify-content:center; gap:6px; padding:8px 15px; background:transparent; border:1px solid var(--line); border-radius:8px; font-size:12.5px; font-weight:700; line-height:1.1; color:var(--ink-dim); white-space:nowrap; cursor:pointer; font-family:inherit; transition:border-color .12s, color .12s; }
2056.page-head .ph-ctl:hover { border-color:var(--line-strong, var(--line)); color:var(--ink); }
2057.page-head .ph-ctl svg { width:14px; height:14px; flex:none; }
2058.page-head .ph-ctl svg.caret { width:11px; height:11px; opacity:.7; margin-left:2px; }
2059.page-head .ph-ctls { display:inline-flex; align-items:center; gap:8px; }
2060/* Item 13 declutter: the 5-card quick-action strip folds into this primary
2061   "+ New" popover so the overview is less packed. Filled accent = primary
2062   (the fill itself lives in quasar-ui.css .page-head .ph-ctl.ph-new). */
2063
2064/* ── Toast ─────────────────────────────────────────────────────── */
2065#dash-toasts { position:fixed;
2065 right:20px; bottom:20px; z-index:4000; display:flex; flex-direction:column; gap:9px; align-items:flex-end; pointer-events:none; }
2066#dash-toasts .toast { pointer-events:auto; display:flex; align-items:center; gap:10px; min-width:220px; max-width:360px; padding:11px 14px; background:var(--bg-panel-solid); border:1px solid var(--line-strong); border-left:3px solid var(--brass); border-radius:10px; color:var(--ink); font-size:13px; font-weight:600; box-shadow:0 10px 30px rgba(0,0,0,.45); animation:toastIn .22s ease; }
2067#dash-toasts .toast.good { border-left-color:var(--status-green); }
2068#dash-toasts .toast.warn { border-left-color:var(--status-amber); }
2069#dash-toasts .toast .ti { flex:none; display:grid; place-items:center; color:var(--brass); }
2070#dash-toasts .toast.good .ti { color:var(--status-green); }
2071#dash-toasts .toast.warn .ti { color:var(--status-amber); }
2072#dash-toasts .toast .ti svg { width:17px; height:17px; }
2073@keyframes toastIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
2074
2075/* ── Demo modal (New / Edit / detail) ──────────────────────────── */
2076#dm-overlay { position:fixed; inset:0; z-index:3800; display:none; align-items:center; justify-content:center; background:var(--modal-overlay); padding:24px; }
2077#dm-overlay.show { display:flex; }
2078#dm-overlay .dm { width:min(94vw,520px); max-height:88vh; overflow:auto; background:var(--bg-panel-solid); border:1px solid var(--line-strong); border-radius:16px; box-shadow:0 24px 70px rgba(0,0,0,.55); }
2079#dm-overlay .dm-head { display:flex; align-items:flex-start; gap:12px; padding:18px 20px 12px; border-bottom:1px solid var(--line); }
2080#dm-overlay .dm-head .dm-ic svg { width:20px; height:20px; }
2081#dm-overlay .dm-head h2 { margin:0; font-size:17px; font-weight:800; color:var(--ink); line-height:1.15; }
2082#dm-overlay .dm-head .dm-sub { display:block; margin-top:3px; font-size:12px; color:var(--ink-dim); line-height:1.3; }
2083#dm-overlay .dm-head .dm-x { margin-left:auto; flex:none; width:30px; height:30px; display:grid; place-items:center; border-radius:8px; color:var(--ink-faint); background:transparent; }
2084#dm-overlay .dm-head .dm-x:hover { background:var(--hover-bg-strong); color:var(--ink); }
2085#dm-overlay .dm-body { padding:16px 20px; }
2086#dm-overlay .dm-field { margin-bottom:13px; }
2087#dm-overlay .dm-field:last-child { margin-bottom:0; }
2088#dm-overlay .dm-field label { display:block; font-size:11px; font-weight:700; letter-spacing:.02em; color:var(--ink-dim); margin-bottom:5px; }
2089#dm-overlay .dm-field label .req { color:var(--status-red); }
2090#dm-overlay .dm-field input, #dm-overlay .dm-field select, #dm-overlay .dm-field textarea { width:100%; padding:9px 11px; background:var(--bg); border:1px solid var(--line-strong); border-radius:9px; font-size:13px; color:var(--ink); transition:border-color .12s; }
2091#dm-overlay .dm-field input:focus, #dm-overlay .dm-field select:focus, #dm-overlay .dm-field textarea:focus { border-color:var(--brass); }
2092#dm-overlay .dm-field textarea { min-height:70px; resize:vertical; }
2093#dm-overlay .dm-field.err input, #dm-overlay .dm-field.err select, #dm-overlay .dm-field.err textarea { border-color:var(--status-red); }
2094#dm-overlay .dm-field .dm-hint { margin-top:5px; font-size:11px; color:var(--status-red); display:none; }
2095#dm-overlay .dm-field.err .dm-hint { display:block; }
2096#dm-overlay .dm-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
2097#dm-overlay .dm-info { font-size:13px; color:var(--ink-dim); line-height:1.5; }
2098#dm-overlay .dm-info .dm-kv { display:flex; justify-content:space-between; gap:12px; padding:6px 0; border-bottom:1px solid var(--line); }
2099#dm-overlay .dm-info .dm-kv:last-child { border-bottom:none; }
2100#dm-overlay .dm-info .dm-kv b { color:var(--ink); font-weight:700; }
2101#dm-overlay .dm-foot { display:flex; justify-content:flex-end; gap:10px; padding:14px 20px 18px; border-top:1px solid var(--line); }
2102#dm-overlay .dm-foot button, #dm-overlay .dm-foot a.dm-save { padding:9px 18px; border-radius:9px; font-size:13px; font-weight:700; text-decoration:none; display:inline-flex; align-items:center; cursor:pointer; }
2103#dm-overlay .dm-foot .dm-cancel { background:transparent; border:1px solid var(--line-strong); color:var(--ink-dim); }
2104#dm-overlay .dm-foot .dm-cancel:hover { border-color:var(--line-strong); color:var(--ink); background:var(--hover-bg); }
2105#dm-overlay .dm-foot .dm-save { background:var(--brass); color:var(--brass-on-bg); border:1px solid var(--brass); }
2106#dm-overlay .dm-foot .dm-save:hover { background:var(--brass-bright); }
2107
2108/* ── Round-21 mobile pass (HQ-342): phone-width fixes, desktop untouched ── */
2109@media (max-width: 700px) {
2110  #unread-pop { width: min(92vw, 400px); }
2111}
2112@media (max-width: 480px) {
2113  .project-body .prow { grid-template-columns: 1fr; }
2114}
2115
2116/* HQ-1213 round 8: Company Overview's Popular Products ranked-bar panel (Adam,
2117   over reference screenshots of a "Popular Product" panel: "yes i want those
2118   graphs"). Same track/fill construction as the existing .bar pattern
2119   (operations.html/sales.html each have their own copy of this shape) - bar
2120   length is proportional to the top item in the list, not a 0-100% scale.
2121   HQ-1213: was --status-blue, now --qds-accent (locked design doc's gold,
2122   not --brass, stale). .dash-pop-name locked to flex:0 0 40% too - same
2123   shape that misaligned Sales Targets' .qrank-name. */
2124#dash-popular .dash-pop-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--line); }
2125#dash-popular .dash-pop-row:last-child { border-bottom:none; }
2126#dash-popular .dash-pop-name { flex:0 0 40%; min-width:0; font-size:12.5px; font-weight:600; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
2127#dash-popular .dash-pop-track { flex:1 1 auto; height:6px; border-radius:999px; background:var(--line); overflow:hidden; }
2128#dash-popular .dash-pop-track > i { display:block; height:100%; border-radius:999px; background:var(--qds-accent); }
2129#dash-popular .dash-pop-val { flex:none; font-size:12px; font-weight:700; color:var(--ink); font-variant-numeric:tabular-nums; min-width:56px; text-align:right; }
2130</style>
2131<link rel="preconnect" href="https://fonts.googleapis.com">
2132<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
2133<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500;700&display=swap">
2134<link rel="stylesheet" href="/quasar-ui.css?v=hq1595design6" />
2134<script src="/quasar-nav.js?v=hq1595agents1"></script>
2134<script src="/quasar-workspace.js?v=hq1213r4" defer></script>
2134
2135</head><body><!-- ══════════════════════════════════════════════════════════     CONFIG GATE (first run — set Supabase creds)     ══════════════════════════════════════════════════════════ --><!-- Hidden until boot decides it's needed — it used to default-show, which
2136     flashed the setup/login screen on every load for signed-in users. -->
2137<div id="config-gate" class="q-signin-gate" style="display:none;">
2138  <div class="card">
2139    <h1>Quasar HQ</h1>
2140    <div class="sub">Territory Command — First Run</div>
2141    <p>Paste a setup token, or enter your Supabase URL + key manually. Credentials live in your browser only; your data lives in your Supabase project, independent of this site. Need a project? <a href="https://supabase.com" target="_blank" rel="noopener">supabase.com</a> — free tier is plenty. See <code>README.md</code> for the SQL schema.</p>
2142
2143    <label>Setup Token <span style="color:var(--ink-faint);text-transform:none;letter-spacing:0">(fastest — one paste)</span></label>
2144    <input type="text" id="cfg-token" placeholder="quasar:v1:..." autocomplete="off" spellcheck="false" />
2145
2146    <div style="display:flex;align-items:center;gap:12px;margin:20px 0 -4px;font-family:var(--font-mono);font-size:9px;letter-spacing:0.2em;color:var(--ink-faint);text-transform:uppercase;">
2147      <span style="flex:1;height:1px;background:var(--line)"></span>
2148      <span>OR MANUAL</span>
2149      <span style="flex:1;height:1px;background:var(--line)"></span>
2150    </div>
2151
2152    <label>Supabase URL</label>
2153    <input type="text" id="cfg-url" placeholder="https://xxxxxxxxxxxx.supabase.co" autocomplete="off" spellcheck="false" />
2154
2155    <label>Anon Key (public)</label>
2156    <input type="text" id="cfg-key" placeholder="eyJhbGciOiJI..." autocomplete="off" spellcheck="false" />
2157
2158    <div class="error" id="cfg-error"></div>
2159
2160    <button id="cfg-save">Connect</button>
2161  </div>
2162</div>
2163
2164<!-- ══════════════════════════════════════════════════════════
2165     AUTH GATE (sign in / sign up — shown after config, before app)
2166     ══════════════════════════════════════════════════════════ -->
2167<div id="auth-gate" class="q-signin-gate" style="display:none;">
2168  <div class="card">
2169    <h1>Quasar HQ</h1>
2170    <div class="sub" id="auth-sub">Sign in</div>
2171    <p id="auth-intro">Quasar HQ is private — sign in to access your territory. New here? Switch to sign-up.</p>
2172
2173    <div id="auth-credentials">
2174    <label for="auth-email">Email</label>
2175    <input type="email" id="auth-email" placeholder="[email protected]" autocomplete="email" />
2176
2177    <label for="auth-password">Password</label>
2178    <!-- HQ-391: show/hide eye INSIDE the field, far right (Adam). A span, not a
2179         button: the auth-gate glass rules restyle every <button> into a full-width
2180         primary bar (!important), which is exactly the eyesore this replaces.
2181         role=button + tabindex + keydown keep it keyboard/AT accessible. -->
2182    <div style="position:relative;">
2183      <input type="password" id="auth-password" placeholder="••••••••" autocomplete="current-password" style="padding-right:44px;width:100%;box-sizing:border-box;" />
2184      <span id="auth-showpw" role="button" tabindex="0" aria-pressed="false" title="Show password"
2185        style="position:absolute;right:12px;top:50%;transform:translateY(-50%);cursor:pointer;color:var(--ink-faint);display:inline-flex;align-items:center;">
2186        <svg id="auth-showpw-eye" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
2187        <svg id="auth-showpw-eyeoff" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
2188      </span>
2189    </div>
2190
2191    </div>
2192
2193    <!-- HQ-12: revealed only when the account has 2FA enabled (server replies totp_required) -->
2194    <div id="auth-2fa-row" style="display:none;">
2195      <label for="auth-code">Authenticator code</label>
2196      <input type="text" id="auth-code" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code" />
2197    </div>
2198
2199    <!-- HQ-457: revealed only when the "Require 2FA for all staff" policy is ON and this
2200         account has no authenticator yet (server replies enrol_required with a short-lived,
2201         enrolment-only credential - no session). Enrolment has to be reachable from HERE:
2202         it needs an authenticated caller, so refusing at the gate would lock every
2203         unenrolled member of staff out with no way back. Uses the `hidden` attribute, not
2204         an inline style, so it adds no UI drift (CLAUDE.md "one look, one file"). -->
2205    <div id="auth-enrol-row" hidden>
2206      <p id="auth-enrol-help">Use Apple Passwords, Google Authenticator, Microsoft Authenticator, Authy or 1Password.</p>
2207      <div id="auth-enrol-add">
2208        <h2 class="q-2fa-step" tabindex="-1" id="auth-enrol-heading">1. Add Quasar to your authenticator</h2>
2209        <div class="q-2fa-qr" id="auth-enrol-qr"></div>
2210        <p class="q-2fa-help">Scan the QR code in your app, or enter the setup key below. Keep this key private.</p>
2211        <label for="auth-enrol-secret">Manual setup key</label>
2212        <input type="text" id="auth-enrol-secret" readonly spellcheck="false" autocomplete="off" />
2213        <a id="auth-enrol-uri" href="#" hidden>Open in your authenticator app</a>
2214        <p class="q-2fa-help">In Apple Passwords, add a verification code to your Quasar password entry. Face ID or Touch ID unlocks Passwords; Quasar still asks for the 6-digit code.</p>
2215        <button type="button" id="auth-enrol-next">I have added it - continue</button>
2216      </div>
2217      <div id="auth-enrol-verify" hidden>
2218        <h2 class="q-2fa-step">2. Check your verification code</h2>
2219        <p class="q-2fa-help">Enter the current code for Quasar from your app to finish setup.</p>
2220        <label for="auth-enrol-code">6-digit verification code</label>
2221        <input type="text" id="auth-enrol-code" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code" />
2222        <button type="button" id="auth-enrol-confirm">Confirm code and finish setup</button>
2223        <a href="#" id="auth-enrol-back">Show QR code and setup key again</a>
2224      </div>
2225      <a href="#" id="auth-enrol-cancel">Back to sign in</a>
2226    </div>
2227
2228    <!-- HQ-391: opt-in longer session (30 days vs the 7-day default; auth-login mints
2229         token + cookie together). Sign-in only - hidden in sign-up mode by the same
2230         toggle that swaps the intro copy. -->
2231    <label id="auth-remember-row" class="qf-chk">
2232      <input type="checkbox" id="auth-remember" />
2233      <span>Keep me signed in on this browser (30 days)</span>
2234    </label>
2235
2236    <div class="error" id="auth-error" role="alert"></div>
2237
2238    <button id="auth-submit" style="margin-top:22px;">Sign In</button>
2239
2240    <div style="margin-top:16px;text-align:center;font-family:var(--font-mono);font-size:10px;letter-spacing:0.15em;color:var(--ink-faint);">
2241      <a href="#" id="auth-toggle" style="color:var(--brass);text-decoration:none;">Need an account? Sign up →</a>
2242    </div>
2243    <div style="margin-top:10px;text-align:center;font-family:var(--font-mono);font-size:9px;letter-spacing:0.15em;color:var(--ink-faint);">
2244      <a href="#" id="auth-forgot" style="color:var(--ink-faint);text-decoration:none;">Forgot password?</a>
2245    </div>
2246  </div>
2247</div>
2248<div id="app" class="hidden">
2249
2250  <header class="topbar">
2251    <a class='brand' href='/'>Quasar HQ <span class="slash">/</span><span class="what">Main</span></a>
2252    <div class="links">
2253      <a class='active' href='/'>Main</a>
2254      <a href='/erp'>CRM</a>
2255      <a href='/projects'>Projects</a>
2256      <a href='/proposal'>Proposals</a>
2257      <a href='/wiring'>Wiring</a>
2258      <a href='/quote'>Orders</a>
2259      <a href='/shipping'>Shipping</a>
2260      <a data-nav='inventory' href='/inventory'>Inventory</a>
2261      <a data-nav='incoming' href='/incoming'>Incoming</a>
2262      <a data-nav='manufacturing' href='/manufacturing'>Manufacturing</a>
2263      <a data-nav='warehouse' href='/warehouse'>Warehouse</a>
2264      <a data-nav='fieldservice' href='/field-service'>Field Service</a>
2265      <a data-nav='support' href='/support'>RMA/Warranty</a>
2266      <a data-nav='goldsupport' href='/gold-support'>Gold Support</a>
2267      <a data-nav='operations' href='/operations'>Operations</a>
2268      <a data-nav='outbox' href='/outbox'>Outbox</a>
2269      <a data-nav='inbox' href='/inbox'>Inbox</a>
2270      <a data-nav='contacts' href='/quote?view=contacts'>Contacts</a>
2271      <span class="navdrop"><a data-nav="advanced">Advanced ▾</a><span class="navdd"><a data-nav='reports' href='/reports'>Reports</a><a data-nav='b2b' href='/quote?view=b2b'>B2B pricing</a><a href='/admin'>Users &amp; access</a><a data-nav='website' href='/website'>Manage Website</a><a data-nav='refdocs' href='/reference-docs'>Reference Docs</a></span></span>
2272    </div>
2273    <div class="topbar-center">
2274      <div class="stat">Contacts <strong id="stat-total">—</strong></div>
2275      <div class="stat">Customers <strong id="stat-customer">—</strong></div>
2276      <div class="stat">Active Pipeline <strong id="stat-pipeline">—</strong></div>
2277    </div>
2278    <div class="topbar-right">
2279      <div class="save-indicator" data-state="idle">
2280        <span class="dot"></span>
2281        <span id="save-text">Synced</span>
2282      </div>
2283      <button class="gmail-pill" id="gmail-pill" data-state="unknown" title="Gmail integration">
2284        <span class="dot"></span>
2285        <span id="gmail-pill-text">Gmail</span>
2286      </button>
2287      <span id="who"></span>
2288      <button class="icon-btn" id="theme-toggle" title="Toggle theme (dark ↔ light)">
2289        <svg class="ic-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" stroke-linecap="round"/></svg>
2290        <svg class="ic-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M7 9l3 3-3 3M12 15h5" stroke-linecap="round" stroke-linejoin="round"/></svg>
2291        <svg class="ic-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke-linejoin="round"/></svg>
2292      </button>
2293      <button class="icon-btn" id="btn-account" title="Account &amp; password">
2294        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>
2295      </button>
2296      <button class="icon-btn" id="btn-signout" title="Sign out">
2297        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
2298      </button>
2299    </div>
2300  </header>
2301
2302  <!-- Hidden section strip — quasar-nav reads these [data-sec] buttons and clicks
2303       them from the sidebar sub-items (Overview / Pipeline / Activity), and the
2304       ?__sec=NAME render helper clicks them too. Overview is the management view;
2305       My Dashboard is the per-user view (the Overview button is removed at
2306       runtime for non-management). #dashboard always keeps .show - nothing
2307       reveals the globe shell any more. Never displayed (see .subtabs CSS). -->
2308  <div class="subtabs" id="subtabs">
2309    <button data-sec="overview" class="on">Company Overview</button>
2310    <button data-sec="mydash">My Dashboard</button>
2311  </div>
2312
2313  <!-- ══════════════════════════════════════════════════════════
2314       HOME DASHBOARD (Business Overview) — default home view.
2315       Full-bleed surface over the content area; the globe shell stays
2316       mounted beneath and is always covered (no territory section any more).
2317       Populated by renderDashboard() below. ══════════════════════════ -->
2318  <section id="dashboard" class="show" aria-label="Business overview">
2319    <div class="dash-wrap">
2320      <!-- Page header band - same stacked pattern as the Manufacturing
2321           dashboard: bold title + dim subtitle left, ghost controls right.
2322           Lives inside the capped .dash-wrap so it centres with the content.
2323           (The hidden ph-stat mirrors keep the live counters wired.) -->
2324      <div class="page-head">
2325        <div class="ph-titles">
2326          <h1 id="page-title">Company Overview</h1>
2327          <span class="sub" id="page-sub">Company-wide overview. Sales values exclude GST.</span>
2328        </div>
2329        <div class="ph-stats-wrap">
2330          <span class="ph-stat"><span class="ph-stat-k">Contacts</span><strong id="ph-total">—</strong></span>
2331          <span class="ph-stat"><span class="ph-stat-k">Customers</span><strong id="ph-customer">—</strong></span>
2332          <span class="ph-stat"><span class="ph-stat-k">Pipeline</span><strong id="ph-pipeline">—</strong></span>
2333          <span class="ph-ctls q-page-actions">
2334            <!-- HQ-520 round 3: the HQ-417 "Set as my home" button moved to the
2335                 account (avatar) menu in the shared shell (quasar-nav.js) -
2336                 Adam: it sat "randomly" in the page chrome here. -->
2337            <button class="ph-ctl" id="ph-filters" type="button">
2338              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7h.01"/></svg>
2339              <span id="ph-filters-label">About this view</span>
2340            </button>
2341            <button class="ph-ctl" id="ph-range" type="button">
2342              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
2343              <span id="ph-range-lbl">This Month</span>
2344              <svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
2345            </button>
2346            <button class="ph-ctl ph-new" id="ph-new" type="button" title="Quick create">
2347              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
2348              New
2349            </button>
2350          </span>
2351        </div>
2352      </div>
2353      <div class="kpis" id="dash-kpis"></div>
2354      <!-- HQ-1213 round 10 (Adam, live review): "on the main company dashboard we want
2355           that nice big total revenue in there could put a could extra lines in there
2356           for quote vs invoice which removes that other table".
2357           Total Revenue takes the wide slot Business Snapshot held, and Business
2358           Snapshot is GONE. It plotted quote / order / paid COUNTS per bucket; this
2359           card answers the same question in dollars (paid $ against quoted $), which
2360           is what Adam asked the counts table to be replaced BY. Nothing else read its
2361           data - the .snap-* chart classes it shared with this card stay, because this
2362           card draws with them.
2363           Action Required keeps the narrow slot: it is a live "right now" list, not a
2364           period chart, so it does not belong beside the period-driven cards below. -->
2365      <div class="row-snap qflow-company">
2366        <div class="card">
2367          <div class="card-head">
2368            <h3>Cash received &amp; credit sales</h3>
2369            <span class="spacer"></span>
2370            <div class="snap-legend dash-rev-legend">
2371              <span><i class="dash-rev-sw-paid"></i>Cash received</span>
2372              <span><i class="dash-rev-sw-quotes"></i>Unpaid credit sales</span>
2373            </div>
2374            <button type="button" class="qtoggle active" id="dash-rev-compare-btn" aria-pressed="true">vs Last Period</button>
2375          </div>
2376          <div class="card-body">
2377            <div class="dash-cash-figures" id="dash-cash-summary" aria-live="polite">
2378              <div>
2379                <div class="mc-note">Cash received</div>
2380                <div class="dash-rev-head">
2381                  <div class="dash-rev-total" id="dash-revenue-total">-</div>
2382                  <div class="delta" id="dash-revenue-delta" hidden></div>
2383                </div>
2384              </div>
2385              <div>
2386                <div class="mc-note">Unpaid credit sales</div>
2387                <div class="dash-rev-total" id="dash-credit-total">-</div>
2388              </div>
2389              <div>
2390                <div class="mc-note">Cash + unpaid credit</div>
2391                <div class="dash-rev-total" id="dash-combined-total">-</div>
2392              </div>
2393            </div>
2394            <div class="mc-note" id="dash-cash-period"></div>
2395            <div id="dash-revenue"></div>
2396            <div class="mc-note">HQ-linked sales, AUD excluding GST. Cash uses payment dates and includes verified website card receipts before bank reconciliation. Monthly credit includes already-issued invoices dated throughout the month, using today's remaining balance. Cash stops at the selected receipt date. Quotes and credit notes are not cash.</div>
2397          </div>
2398        </div>
2399        <div class="card">
2400          <div class="card-head"><h3>Action Required</h3></div>
2401          <div class="card-body" id="dash-action"></div>
2402        </div>
2403      </div>
2404      <!-- HQ-1213 round 11 (Adam, live review 2026-09-16): Average Sales, Inventory
2405           Alerts and New Leads are gone. Average Sales duplicated Total Revenue
2406           without adding a number Adam recognised ("im not sure what that is yet");
2407           Inventory Alerts only ever said "No reorder levels set" until Inventory
2408           has reorder levels configured; New Leads repeated the exact open-lead
2409           population Leads by Owner already counts a second way - both still read
2410           the shared dashOpenLeads() memo (see that function's comment), so a
2411           future round cannot silently reintroduce New Leads as a diverging query.
2412           Average Order Value, Leads by Owner and Popular Products now share ONE
2413           three-up row rather than each sitting alone in a now half-empty one:
2414           Popular Products drops from the full-width slot it held down to the
2415           same narrower box as its two neighbours (Adam: "Popular products...
2416           take up the whole screen" was a complaint, not a request), and Leads by
2417           Owner picks up the "View all" link New Leads used to carry. -->
2418      <div class="qflow-company qflow-dashboard-work">
2419        <div class="card">
2420          <div class="card-head"><h3>Average Order Value</h3><span class="spacer"></span><span class="sub" id="dash-aov-sub"></span></div>
2421          <div class="card-body" id="dash-aov"></div>
2422        </div>
2423        <div class="card">
2424          <div class="card-head"><h3>Leads by Owner</h3><span class="spacer"></span><span class="sub" id="dash-leadowners-sub"></span><a class='lnk' href='/sales?__sec=leads'>View all</a></div>
2425          <div class="card-body" id="dash-leadowners"></div>
2426        </div>
2427        <div class="card">
2428          <div class="card-head"><h3>Popular Products</h3><span class="spacer"></span><span class="sub" id="dash-popular-sub"></span></div>
2429          <div class="card-body">
2430            <div class="dash-rev-head">
2431              <div class="dash-rev-total" id="dash-popular-top">-</div>
2432              <div class="dash-rev-sub" id="dash-popular-topname"></div>
2433            </div>
2434            <div id="dash-popular"></div>
2435          </div>
2436        </div>
2437      </div>
2438      <!-- HQ-1213 (Adam, live review 2026-09-18): Sales Pipeline, Production Overview
2439           and Order Activity now sit next to each other in one three-up row, instead
2440           of Order Activity sitting alone at the end of the card list. Same
2441           .qflow-dashboard-secondary row and the same :has(> :nth-child(3)) breakpoint
2442           technique .qflow-dashboard-work already uses for its own three-up row
2443           (Average Order Value / Leads by Owner / Popular Products above) - extended
2444           to this row rather than a new grid system (quasar-ui.css). Order Activity
2445           reuses the EXACT SAME daily series dashOrdersFor(DASH_RANGE) already fetches
2446           for Total Revenue's own quote line and the KPI strip - no second network
2447           call. -->
2448      <div class="qflow-company qflow-dashboard-secondary">
2449        <div class="card">
2450          <div class="card-head"><h3>Sales Pipeline</h3><span class="spacer"></span><span class="mini-sel" id="pipe-range-sel">This Month ▾</span><a class='lnk' href='/sales?__sec=pipeline'>View all</a></div>
2451          <div class="card-body" id="dash-pipeline"></div>
2452        </div>
2453        <div class="qflow-operational-status">
2454        <div class="card qflow-status-card" id="qflow-production-card">
2455          <div class="card-head"><h3>Production Overview</h3><span class="spacer"></span><a class='lnk' href='/manufacturing?__sec=overview'>View all</a></div>
2456          <div class="card-body" id="dash-production"></div>
2457        </div>
2458        </div>
2459        <div class="card">
2460          <div class="card-head"><h3>Order Activity</h3><span class="spacer"></span><span class="sub" id="dash-heat-sub"></span></div>
2461          <div class="card-body" id="dash-heat"></div>
2462        </div>
2463      </div>
2464      <p class="qflow-company qflow-context">Company performance follows the selected period. Production, stock and support counts show the current position. <a href='/people?__sec=clock'>Open team attendance</a></p>
2465      <!-- Item 13 declutter: the 5-card quick-action row moved into the
2466           page-head "+ New" popover (wireQuickPop). The overview reads cleaner
2467           and the actions are one click from the header. -->
2468
2469      <!-- ══ My Dashboard (item 14 resolved: Option B promoted, A and C deleted) ══
2470           The per-user home for staff: clock, leave and this week's bookings
2471           across production, field service, support and projects. Login-aware
2472           via the shim session; the Viewing menu filters to yourself + the
2473           staff who report up to you (everyone when the session is the demo
2474           login). Demo-grade - static figures + localStorage. -->
2475      <div class="dash-focus" id="dash-focus-mydash">
2476        <div class="signed-as" id="mydash-hdr"></div>
2477        <div class="kpis" id="mb-kpis"></div>
2478        <section class="card qflow-attendance" id="mb-attendance" aria-label="My attendance" hidden></section>
2479        <!-- HQ-523 round 2 (Adam's confirmed layout): the My Leave card is GONE -
2480             balances and request history live in People > Leave (one master per
2481             surface); Apply-for-leave and the person's own withdrawable pending
2482             requests ride the Today card, because People carries no withdraw
2483             path yet and the dashboard rows were the only reach
2483able cancel gate.
2484             My Bookings is renamed Assigned Work (a pure list of what you are
2485             assigned); My Queue is renamed Approvals (what it actually holds:
2486             leave approvals). HQ-1213: My Mail's full-width row is gone.
2487             HQ-1213 live-review round (Adam: "put the same action required there
2488             for the user on the right so they see that straight away on the right
2489             i believe thats the best way to do this"): Needs me (the categorised
2490             action-required list - not the "Needs Me" KPI notification count tile
2491             above) moves up to sit beside Today instead of down with Approvals, so
2492             it is visible without scrolling. Today drops from its own full-width
2493             row to an even half to share the row - its render (renderMbToday) is
2494             unchanged; it was already a compact big-number/caption stack (the
2495             conic-gradient ring was dropped for one earlier), so a narrower column
2496             reflows the day-hours chart and rows without losing any of it.
2497
2498             HQ-1213 (this round, Adam): "use the same actions required for staff where
2499             the needs me box instead ... the my leads should look like how we have for
2500             the company overview but just for them which could go into the actions
2501             required for the my dashboard. same with assigned work could follow the
2502             same theme." Needs me, My Leads and Assigned Work below are rebuilt onto the
2503             shared .aq-row component (icon chip + number/date + title + coloured
2504             urgency pill + chevron - quasar-ui.css, the same component Company
2505             Overview's Action Required and Sales Overview's Needs Me already use) in
2506             place of the old .fs-row list rows - three separate cards still, same
2507             visual language, not merged into one (a physical merge would be a bigger
2508             structural change than "use the same look"). Needs me and My Leads keep
2509             their real per-item rows (a notification, a lead) rather than rolling them
2510             up into counted categories, because a rollup would need an invented
2511             grouping neither card's data actually has. Also this round: the Today
2512             card's clock/hours ("the daily hours and clock for today doesnt look that
2513             nice") - the cramped half-width bar chart is gone (the always-open Daily
2514             hours list beside it already carried the same numbers), the ring/hub and
2515             its lead figure now share one bordered .clockcard box instead of floating
2516             loose in the card body, and the status line moved off .qs-empty (an
2517             EMPTY-state class, wrong for a populated status readout) onto a plain
2518             .mc-status line. renderMbToday's live-timer id and clock semantics are
2519             unchanged - this is a layout tidy-up, not a behaviour change. -->
2520        <div class="focus-grid even">
2521          <div class="card">
2522            <div class="card-head"><h3>Today</h3><span class="spacer"></span><a class="lnk mb-viewlnk" id="mb-view-today" href="#view" data-view="today">View</a><a class='lnk' href='/people?__sec=clock'>Timesheets</a><a class='lnk' href='/people?__sec=leave'>Leave</a></div>
2523            <div class="card-body" id="mb-today"></div>
2524          </div>
2525          <div class="card">
2526            <div class="card-head"><h3>Needs me</h3><span class="spacer"></span><a class="lnk mb-viewlnk" id="mb-view-needs" href="#view" data-view="needs">View</a><a class="lnk" id="mb-needs-open" href="#notif">Open</a></div>
2527            <div class="card-body" id="mb-needs"><div class="qs-empty">Nothing needs you right now.</div></div>
2528          </div>
2529        </div>
2530        <!-- HQ-1213 (Adam: "the my leads could come up higher, make the assigned work
2531             and leads together... small boxes"): My Leads sits beside Assigned Work as
2532             an even, compact pair - kept together here rather than breaking either of
2533             them apart when Needs me moved up above (see the comment there). -->
2534        <div class="focus-grid even">
2535          <div class="card">
2536            <div class="card-head"><h3 id="mb-book-h">Assigned Work</h3><span class="spacer"></span><a class="lnk mb-viewlnk" id="mb-view-book" href="#view" data-view="book">View</a><a class='lnk' href='/operations?__sec=calendar'>Ops calendar</a></div>
2537            <div class="card-body" id="mb-book"></div>
2538          </div>
2539          <div class="card">
2540            <div class="card-head"><h3>My Leads</h3><span class="spacer"></span><a class='lnk' href='/sales?__sec=leads'>Open Leads</a></div>
2541            <div class="card-body" id="mb-leads"><div class="qs-empty">No leads assigned to you.</div></div>
2542          </div>
2543        </div>
2544        <!-- HQ-1213 live-review round: Approvals loses its old Needs-me pairing (moved
2545             above, beside Today) and takes its own full-width row - it did not pair
2546             cleanly with Assigned Work/My Leads without breaking that established pair
2547             into an uneven three-up, and it is often the emptiest card on this page
2548             (most staff approve nothing), so a lone row costs little. -->
2549        <div class="focus-grid one">
2550          <div class="card">
2551            <div class="card-head"><h3>Approvals</h3><span class="spacer"></span><a class="lnk mb-viewlnk" id="mb-view-queue" href="#view" data-view="queue">View</a></div>
2552            <div class="card-body" id="mb-attn"></div>
2553          </div>
2554        </div>
2555        <!-- HQ-1213 (Adam: "remove the mail" - the dashboard card, separate from the
2556             topbar mail icon removed the same round): the My Mail card is gone.
2557             renderMyDashMail() null-guards on #mb-mail and is left in place, inert. -->
2558        <details class="qflow-disclosure qflow-recent"><summary>Recent activity</summary>
2559        <div class="card">
2560          <div class="card-head"><h3>Recent activity</h3><span class="spacer"></span></div>
2561          <div class="card-body act-feed" id="dash-recent"></div>
2562        </div>
2563        </details>
2564        <div class="deeprow" id="mb-links"></div>
2565      </div>
2566    </div>
2567  </section>
2568
2569  <!-- Demo toast + modal host for the dashboard (presentation only, no persistence) -->
2570  <div id="dash-toasts" aria-live="polite"></div>
2571  <div id="dm-overlay" role="dialog" aria-modal="true">
2572    <div class="dm" id="dm-panel">
2573      <div class="dm-head">
2574        <div class="dm-ic" id="dm-ic"></div>
2575        <div style="min-width:0;flex:1"><h2 id="dm-title">Title</h2><span class="dm-sub" id="dm-sub"></span></div>
2576        <button class="dm-x" id="dm-x" type="button" title="Close">
2577          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
2578        </button>
2579      </div>
2580      <div class="dm-body" id="dm-body"></div>
2581      <div class="dm-foot" id="dm-foot"></div>
2582    </div>
2583  </div>
2584
2585  <aside class="sidebar">
2586    <div class="sidebar-head">
2587      <button class="new-btn" id="btn-new" data-acl="contact.create">
2588        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
2589        New Contact
2590      </button>
2591      <div class="search">
2592        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
2593        <input type="text" id="sidebar-search" placeholder="Search name, company, country…" />
2594      </div>
2595    </div>
2596    <div class="filter-row" id="filter-row"></div>
2597    <div class="contact-list" id="contact-list"></div>
2598  </aside>
2599
2600  <!-- Unread-mail review popover — opens beside a contact's unread badge -->
2601  <div id="unread-pop">
2602    <div class="up-head">
2603      <div class="up-title" id="up-title">Unread</div>
2604      <span class="up-count" id="up-count"></span>
2605      <button id="up-mark-all">✓ Mark all read</button>
2606      <button class="up-close" id="up-close" title="Close">✕</button>
2607    </div>
2608    <div class="up-list" id="up-list"></div>
2609    <div class="up-foot"><a id="up-open-email">Open email tab →</a></div>
2610  </div>
2611
2612  <main class="globe-container">
2613    <div id="globe"></div>
2614    <div class="loading-globe" id="loading-globe"><div class="spinner">loading the world…</div></div>
2615
2616    <!-- Cost of Billing Drift - named-viewer needs-attention box (product costing).
2617         Hidden until the costing function (server-gated) says something needs a look. -->
2618    <a hidden href='/inventory#costing' id='costing-drift-box' title='Protected product costing. Open Inventory &gt; Costing.'>
2619      <span class="cdb-eyebrow">Needs attention</span>
2620      <span class="cdb-title">Cost of Billing Drift</span>
2621      <span class="cdb-count" id="costing-drift-count">0</span>
2622      <span class="cdb-sub" id="costing-drift-sub"></span>
2623    </a>
2624
2625    <div class="map-search" id="map-search">
2626      <div class="map-search-bar">
2627        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
2628        <input type="text" id="map-search-input" placeholder="Fly to a city or country…" />
2629        <button id="map-search-go">Go</button>
2630      </div>
2631      <div class="map-filters">
2632        <select id="map-type-filter" title="Filter the nearest-contact result by contact type">
2633          <option value="all">All types</option>
2634          <option value="boat_builder">Boat Builder</option>
2635          <option value="distributor">Distributor</option>
2636          <option value="technician">Install Tech</option>
2637          <option value="oem">OEM Partner</option>
2638          <option value="customer">Customer / Owner</option>
2639          <option value="other">Other</option>
2640        </select>
2641        <div class="map-product-chips" id="map-product-chips" title="Filter the nearest-contact result by product"></div>
2642      </div>
2643      <div class="map-search-result" id="map-search-result"></div>
2644    </div>
2645
2646    <div class="hud-bl globe-hud">
2647      <div>◉ Quasar HQ</div>
2648      <div class="readout">28°00'S 153°25'E · Gold Coast</div>
2649    </div>
2650    <div class="hud-br globe-hud" id="hud-cursor">
2651      <div>Cursor</div>
2652      <div class="readout" id="cursor-coords">—</div>
2653    </div>
2654
2655    <div class="legend">
2656      <div class="label">Status</div>
2657      <div class="row"><span class="pip" style="background:var(--status-lead)"></span>Lead</div>
2658      <div class="row"><span class="pip" style="background:var(--status-contacted)"></span>Contacted</div>
2659      <div class="row"><span class="pip" style="background:var(--status-discussion)"></span>In Discussion</div>
2660      <div class="row"><span class="pip" style="background:var(--status-nda)"></span>NDA Signed</div>
2661      <div class="row"><span class="pip" style="background:var(--status-proposal)"></span>Proposal Sent</div>
2662      <div class="row"><span class="pip" style="background:var(--status-negotiating)"></span>Negotiating</div>
2663      <div class="row"><span class="pip" style="background:var(--status-customer)"></span>Customer</div>
2664      <div class="row"><span class="pip" style="background:var(--status-paused)"></span>Paused</div>
2665      <div class="row"><span class="pip" style="background:var(--status-lost)"></span>Lost</div>
2666    </div>
2667  </main>
2668
2669  <aside class="detail" id="detail-panel">
2670    <div class="detail-empty" id="detail-empty">
2671      <div>
2672        <div class="big">Quasar HQ</div>
2673        <p>Select a contact from the sidebar or the globe, or add a new one to begin.</p>
2674      </div>
2675    </div>
2676    <div class="detail-form" id="detail-form">
2677      <div class="detail-header">
2678        <button type="button" class="backbtn" id="df-close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>Back to contacts</button>
2679        <div class="coords" id="df-coords">—</div>
2680        <input type="text" class="name-input" id="df-name" placeholder="Name" />
2681        <div class="id" id="df-id">—</div>
2682        <div class="actions">
2683          <button id="df-flyto">◎ Fly To</button>
2684          <button id="df-duplicate">⧉ Duplicate</button>
2685          <button class="danger" id="df-delete" data-acl="contact.delete">✕ Delete</button>
2686        </div>
2687      </div>
2688
2689      <div class="tab-bar">
2690        <button class="tab-btn active" data-tab="overview">Overview</button>
2691        <button class="tab-btn" data-tab="projects">Projects <span class="count" id="tab-count-projects">0</span></button>
2692        <button class="tab-btn" data-tab="email">Email <span class="count" id="tab-count-email">0</span></button>
2693      </div>
2694
2695      <!-- AI "may be the same" link suggestions for the open contact -->
2696      <div id="link-suggest-host"></div>
2697
2698      <div class="tab-panel active" data-panel="overview">
2699
2700      <div class="field">
2701        <label>Company</label>
2702        <input type="text" id="df-company" placeholder="Company / Yard / Studio" />
2703      </div>
2704
2705      <div class="row-two">
2706        <div class="field">
2707          <label>Type</label>
2708          <select id="df-type">
2709            <option value="boat_builder">
2709Boat Builder</option>
2710            <option value="distributor">Distributor</option>
2711            <option value="technician">Install Technician</option>
2712            <option value="oem">OEM Partner</option>
2713            <option value="customer">Customer / Yacht Owner</option>
2714            <option value="other">Other</option>
2715          </select>
2716        </div>
2717        <div class="field">
2718          <label>Status</label>
2719          <select id="df-status">
2720            <option value="lead">Lead</option>
2721            <option value="contacted">Contacted</option>
2722            <option value="discussion">In Discussion</option>
2723            <option value="nda">NDA Signed</option>
2724            <option value="proposal">Proposal Sent</option>
2725            <option value="negotiating">Negotiating</option>
2726            <option value="customer">Customer</option>
2727            <option value="paused">Paused</option>
2728            <option value="lost">Lost</option>
2729          </select>
2730        </div>
2731      </div>
2732
2733      <div class="field">
2734        <label>Account rep <span class="hint-label">(salesperson — tagged on this account's Xero invoices)</span></label>
2735        <select id="df-rep"><option value="">— Unassigned —</option></select>
2736      </div>
2737
2738      <div class="field">
2739        <label>Products stocked / sold</label>
2740        <div class="product-toggles" id="df-products"></div>
2741      </div>
2742
2743      <div class="field">
2744        <label>Priority</label>
2745        <div class="priority-stars" id="df-priority"></div>
2746      </div>
2747
2748      <div class="field">
2749        <label>Emails <span class="hint-label">(first is primary · press Enter to add)</span></label>
2750        <div class="tags-input" id="df-emails">
2751          <input type="email" id="df-email-input" placeholder="[email protected]" />
2752        </div>
2753      </div>
2754
2755      <div class="field">
2756        <label>Invoice email <span class="hint-label">(accounts — invoices/receipts go here)</span></label>
2757        <input type="email" id="df-invoice-email" placeholder="[email protected]" />
2758      </div>
2759
2760      <div class="field">
2761        <label>Phone</label>
2762        <input type="tel" id="df-phone" placeholder="+61..." />
2763      </div>
2764
2765      <div class="field">
2766        <label>Website</label>
2767        <input type="url" id="df-website" placeholder="https://..." />
2768      </div>
2769
2770      <div class="geocode-row">
2771        <div class="field">
2772          <label>Address / Location</label>
2773          <input type="text" id="df-address" placeholder="City, Country — or full address" />
2774        </div>
2775        <button class="geocode-btn" id="df-geocode">◎ Locate</button>
2776      </div>
2777      <div class="coord-display" id="df-coord-display">—</div>
2778
2779      <div class="addr-section" style="margin-top:18px;border-top:1px solid var(--line,#2a2a2a);padding-top:14px">
2780        <details id="df-billing-block" data-acl="contact.edit_address">
2781          <summary style="cursor:pointer;font-weight:600;font-size:13px;letter-spacing:.02em;opacity:.85">Billing address</summary>
2782          <div class="field"><label>Address line 1</label><input type="text" id="df-billing-address1" placeholder="Street address" /></div>
2783          <div class="field"><label>Address line 2</label><input type="text" id="df-billing-address2" placeholder="Suite, unit, etc." /></div>
2784          <div class="field"><label>City</label><input type="text" id="df-billing-city" /></div>
2785          <div class="field"><label>State / Region</label><input type="text" id="df-billing-state" /></div>
2786          <div class="field"><label>Postcode</label><input type="text" id="df-billing-postcode" /></div>
2787          <div class="field"><label>Country <span class="hint-label">(2-letter code or name)</span></label><input type="text" id="df-billing-country" /></div>
2788          <div class="field"><label>Billing phone</label><input type="tel" id="df-billing-phone" /></div>
2789        </details>
2790        <details id="df-shipping-block" data-acl="contact.edit_address" style="margin-top:8px">
2791          <summary style="cursor:pointer;font-weight:600;font-size:13px;letter-spacing:.02em;opacity:.85">Shipping address</summary>
2792          <div style="margin:8px 0"><button type="button" id="df-ship-copy" style="font-size:11px;padding:3px 9px;border:1px solid var(--line,#444);border-radius:6px;background:transparent;color:inherit;cursor:pointer">⇪ Copy from billing</button></div>
2793          <div class="field"><label>Address line 1</label><input type="text" id="df-shipping-address1" /></div>
2794          <div class="field"><label>Address line 2</label><input type="text" id="df-shipping-address2" /></div>
2795          <div class="field"><label>City</label><input type="text" id="df-shipping-city" /></div>
2796          <div class="field"><label>State / Region</label><input type="text" id="df-shipping-state" /></div>
2797          <div class="field"><label>Postcode</label><input type="text" id="df-shipping-postcode" /></div>
2798          <div class="field"><label>Country <span class="hint-label">(2-letter code or name)</span></label><input type="text" id="df-shipping-country" /></div>
2799        </details>
2800        <div class="hint-label" style="margin-top:6px;opacity:.6">Billing &amp; shipping sync to this customer in WooCommerce.</div>
2801      </div>
2802
2803      <div class="field" style="margin-top:18px;">
2804        <label>Tags</label>
2805        <div class="tags-input" id="df-tags">
2806          <input type="text" id="df-tag-input" placeholder="Add tag…" />
2807        </div>
2808      </div>
2809
2810      <div class="field">
2811        <label>Notes</label>
2812        <textarea id="df-notes" placeholder="Conversation history, technical notes, action items…"></textarea>
2813      </div>
2814
2815      <div class="field">
2816        <label>Last Contact</label>
2817        <input type="text" id="df-last-contact" placeholder="YYYY-MM-DD or free text" />
2818      </div>
2819
2820      </div><!-- /tab-panel overview -->
2821
2822      <div class="tab-panel" data-panel="projects">
2823        <div id="project-list-container" class="project-list"></div>
2824        <button class="add-project-btn" id="btn-add-project" data-acl="project.create">+ Add Project</button>
2825      </div>
2826
2827      <div class="tab-panel" data-panel="email">
2828        <div class="email-tab" id="email-tab">
2829          <!-- Rendered dynamically by renderEmailTab() -->
2830        </div>
2831      </div>
2832    </div>
2833  </aside>
2834</div>
2835
2836<!-- Add contact modal -->
2837<div class="modal-overlay" id="new-modal">
2838  <div class="modal">
2839    <h2>New Contact</h2>
2840    <div class="sub">Start with a name, or search a location</div>
2841
2842    <div class="field">
2843      <label>Contact or Company Name</label>
2844      <input type="text" id="new-name" placeholder="e.g. McConaghy Yachts" />
2845    </div>
2846
2847    <div class="field">
2848      <label>Location (optional — you can set later)</label>
2849      <input type="text" id="new-location" placeholder="Search city, address, or yard…" />
2850      <div class="search-results" id="new-location-results"></div>
2851    </div>
2852
2853    <div class="row-two">
2854      <div class="field">
2855        <label>Type</label>
2856        <select id="new-type">
2857          <option value="boat_builder">
2857Boat Builder</option>
2858          <option value="distributor">Distributor</option>
2859          <option value="technician">Install Technician</option>
2860          <option value="oem">OEM Partner</option>
2861          <option value="customer">Customer / Yacht Owner</option>
2862          <option value="other">Other</option>
2863        </select>
2864      </div>
2865      <div class="field">
2866        <label>Status</label>
2867        <select id="new-status">
2868          <option value="lead">Lead</option>
2869          <option value="contacted">Contacted</option>
2870          <option value="discussion">In Discussion</option>
2871          <option value="nda">NDA Signed</option>
2872          <option value="proposal">Proposal Sent</option>
2873          <option value="negotiating">Negotiating</option>
2874          <option value="customer">Customer</option>
2875          <option value="paused">Paused</option>
2876          <option value="lost">Lost</option>
2877        </select>
2878      </div>
2879    </div>
2880
2881    <div class="actions">
2882      <button class="btn-secondary" id="new-cancel">Cancel</button>
2883      <button class="btn-primary" id="new-create" data-acl="contact.create">Create</button>
2884    </div>
2885  </div>
2886</div>
2887
2888<!-- Email templates modal -->
2889<div class="modal-overlay" id="account-modal">
2890  <div class="modal">
2891    <h2>Account</h2>
2892    <div class="sub" id="account-sub">Change your password</div>
2893
2894    <div class="field">
2895      <label>Current password</label>
2896      <input type="password" id="acc-current" autocomplete="current-password" />
2897    </div>
2898    <div class="field">
2899      <label>New password <span class="hint-label">(min 8 characters)</span></label>
2900      <input type="password" id="acc-new" autocomplete="new-password" />
2901    </div>
2902    <div class="field">
2903      <label>Confirm new password</label>
2904      <input type="password" id="acc-new2" autocomplete="new-password" />
2905    </div>
2906    <div class="acc-msg" id="acc-msg"></div>
2907    <div class="modal-actions">
2908      <button class="btn-secondary" id="acc-close">Close</button>
2909      <button class="btn-primary" id="acc-change">Change password</button>
2910    </div>
2911
2912    <div id="acc-2fa">
2913      <hr class="acc-divider" />
2914      <h3 class="acc-admin-h">Two-factor authentication</h3>
2915      <div class="sub" id="acc-2fa-status">Checking…</div>
2916      <div id="acc-2fa-enrol" style="display:none;">
2917        <div class="sub">Add this key to your authenticator app (Google Authenticator, Authy, 1Password), then enter the 6-digit code it shows to turn 2FA on.
2917</div>
2918        <div class="field">
2919          <label>Setup key (manual entry)</label>
2920          <input type="text" id="acc-2fa-secret" readonly />
2921        </div>
2922        <div class="sub" style="margin:-4px 0 8px;"><a id="acc-2fa-uri" href="#" target="_blank" rel="noopener" style="color:var(--brass);text-decoration:none;display:none;">Open in your authenticator app →</a></div>
2923      </div>
2924      <div class="field" id="acc-2fa-code-row" style="display:none;">
2925        <label id="acc-2fa-code-label">Code from your app</label>
2926        <input type="text" id="acc-2fa-code" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code" />
2927      </div>
2928      <div class="acc-msg" id="acc-2fa-msg"></div>
2929      <div class="modal-actions">
2930        <button class="btn-secondary" id="acc-2fa-start"   style="display:none;">Set up 2FA</button>
2931        <button class="btn-primary"   id="acc-2fa-verify"  style="display:none;">Verify &amp; enable</button>
2932        <button class="btn-secondary" id="acc-2fa-disable" style="display:none;">Disable 2FA</button>
2933      </div>
2934    </div>
2935
2936    <div id="acc-admin" style="display:none;">
2937      <hr class="acc-divider" />
2938      <h3 class="acc-admin-h">Admin · create or reset an account</h3>
2939      <div class="sub">Create a new teammate's account, or reset a locked-out one. Set a temporary password and share it — they sign in and set their own under "Change password" above.</div>
2940      <div class="field">
2941        <label>User email</label>
2942        <input type="email" id="acc-admin-email" placeholder="[email protected]" />
2943      </div>
2944      <div class="field">
2945        <label>Temporary password <span class="hint-label">(min 8)</span></label>
2946        <input type="text" id="acc-admin-pw" placeholder="temporary password to share" />
2947      </div>
2948      <div class="acc-msg" id="acc-admin-msg"></div>
2949      <div class="modal-actions">
2950        <button class="btn-primary" id="acc-admin-reset">Create / reset account</button>
2951      </div>
2952    </div>
2953  </div>
2954</div>
2955
2956<div class="modal-overlay" id="tpl-modal">
2957  <div class="modal">
2958    <h2>Email Templates</h2>
2959    <div class="sub">Reusable boilerplate · placeholders</div>
2960    <p style="font-family:var(--font-mono);font-size:10px;letter-spacing:0.12em;color:var(--ink-faint);margin:0 0 14px;line-height:1.6">
2961      Placeholders: <code style="color:var(--brass)">{{name}}</code> · <code style="color:var(--brass)">{{first_name}}</code> · <code style="color:var(--brass)">{{company}}</code> · <code style="color:var(--brass)">{{project}}</code> · <code style="color:var(--brass)">{{sender_name}}</code>
2962    </p>
2963    <div class="tpl-list" id="tpl-list"></div>
2964    <div class="actions">
2965      <button class="btn-secondary" id="tpl-close">Close</button>
2966      <button class="btn-primary" id="tpl-add">+ New Template</button>
2967    </div>
2968  </div>
2969</div>
2970
2971<!-- ══════════════════════════════════════════════════════════
2972     DEPS (CDN)
2973     ══════════════════════════════════════════════════════════ -->
2974<!-- Unpinned jsDelivr URLs — jsDelivr auto-resolves to the correct dist file via package.json.
2975     globe.gl bundles three.js internally; we reach into it for Texture construction where needed. -->
2976<script src="/shim/quasar-web-shim.js?v=hq1523b"></script>
vendor: 1 bytes, line 2976
2976
2977<script src="/quasar-upload.js?v=hq1523"></script>
vendor: 1 bytes, line 2977
2977
2978<script src="/quasar-products.js?v=hq1104"></script>
vendor: 1 bytes, line 2978
2978
2979<script src="/quasar-demo-data.js?v=hq1"></script>
2979
2980<!-- Demo-render fix: the shared quasar-demo-data.js seeds module datasets but its
2981     `contacts` rows are support-shaped (no lat/lng/status the CRM globe needs). In
2982     demo mode (?demo=1, functions absent) an empty SELECT on `contacts` falls back
2983     to window.QUASAR_DEMO.contacts via the shim — so here we override that table
2984     with CRM-shaped contacts (status + coords + type) so the sidebar list, the
2985     stat pills and the globe pins all populate instead of showing "No contacts yet"
2986     and 0 / 0 / 0. Presentation-only seed data; real data always wins. -->
2987<script src="/quasar-leave-policy.js?v=hq1535"></script>
vendor: 1 bytes, line 2987
2987
2988<script>
2989(function () {
2990  function seed() {
2991    if (!window.QUASAR_DEMO) { setTimeout(seed, 30); return; }
2992    var now = Date.now();
2993    function iso(d) { return new Date(now - d * 864e5).toISOString(); }
2994    window.QUASAR_DEMO.contacts = [
2995      { id:'demo-ct-0001', name:'Ava Thompson', company:'Coastline Marine',       contact_type:'boat_builder', status:'customer',    lat:-27.47,  lng:153.02,  country:'Australia',     priority:3, tags:[], deleted_at:null, created_at:iso(120) },
2996      { id:'demo-ct-0002', name:'Marcus Lee',   company:'Outback Energy Co',      contact_type:'distributor',  status:'negotiating', lat:-31.95,  lng:115.86,  country:'Australia',     priority:2, tags:[], deleted_at:null, created_at:iso(95) },
2997      { id:'demo-ct-0003', name:'Priya Nair',   company:'Sunshine RV Group',      contact_type:'customer',     status:'proposal',    lat:-26.65,  lng:153.07,  country:'Australia',     priority:2, tags:[], deleted_at:null, created_at:iso(70) },
2998      { id:'demo-ct-0004', name:'Tom Walker',   company:'Walker 4x4 Fitouts',     contact_type:'technician',   status:'customer',    lat:-37.81,  lng:144.96,  country:'Australia',     priority:1, tags:[], deleted_at:null, created_at:iso(60) },
2999      { id:'demo-ct-0005', name:'Helen Cho',    company:'Reef Charters',          contact_type:'customer',     status:'discussion',  lat:-16.92,  lng:145.77,  country:'Australia',     priority:2, tags:[], deleted_at:null, created_at:iso(40) },
3000      { id:'demo-ct-0006', name:'Daniel Ross',  company:'Pilbara Mining Services',contact_type:'oem',          status:'customer',    lat:-20.31,  lng:118.61,  country:'Australia',     priority:3, tags:[], deleted_at:null, created_at:iso(30) },
3001      { id:'demo-ct-0007', name:'Sara Mills',   company:'Greenfield Solar',       contact_type:'distributor',  status:'contacted',   lat:-34.93,  lng:138.60,  country:'Australia',     priority:1, tags:[], deleted_at:null, created_at:iso(18) },
3002      { id:'demo-ct-0008', name:'Jack Doyle',   company:'Doyle Auto Electrics',   contact_type:'technician',   status:'lead',        lat:-33.87,  lng:151.21,  country:'Australia',     priority:1, tags:[], deleted_at:null, created_at:iso(9) },
3003      { id:'demo-ct-0009', name:'Nina Brandt',  company:'Pacific Yacht Systems',  contact_type:'boat_builder', status:'nda',         lat:49.28,   lng:-123.12, country:'Canada',        priority:2, tags:[], deleted_at:null, created_at:iso(52) },
3004      { id:'demo-ct-0010', name:'Leo Marchetti',company:'Adriatic Marine',        contact_type:'distributor',  status:'proposal',    lat:45.44,   lng:12.34,   country:'Italy',         priority:2, tags:[], deleted_at:null, created_at:iso(44) },
3005      { id:'demo-ct-0011', name:'Owen Pierce',  company:'Gulf Coast Power',       contact_type:'oem',          status:'discussion',  lat:29.76,   lng:-95.37,  country:'United States', priority:2, tags:[], deleted_at:null, created_at:iso(33) },
3006      { id:'demo-ct-0012', name:'Maya Okafor',  company:'Cape Renewables',        contact_type:'distributor',  status:'lead',        lat:-33.92,  lng:18.42,   country:'South Africa',  priority:1, tags:[], deleted_at:null, created_at:iso(12) }
3007    ];
3008  }
3009  seed();
3010})();
3011</script>
vendor: 1 bytes, line 3011
3011
3012<script src="/quasar-link.js?v=hq1"></script>
vendor: 1 bytes, line 3012
3012
3013<script src="/quasar-payweek.js?v=hq1"></script>
vendor: 1 bytes, line 3013
3013
3014<script src="/quasar-attendance.js?v=hq1449"></script>
vendor: 1 bytes, line 3014
3014
3015<script src="/quasar-attendance-location.js?v=hq1126r3"></script>
vendor: 1 bytes, line 3015
3015
3016<script src="/quasar-mobile-core.js?v=hq1455a"></script>
vendor: 1 bytes, line 3016
3016
3017<script src="/quasar-dashboard-attendance.js?v=hq140lv"></script>
vendor: 1 bytes, line 3017
3017
3018<script src="/quasar-search.js?v=hq857eng5" defer></script>
vendor: 1 bytes, line 3018
3018
3019<script src="/quasar-acl.js?v=hq1595agents1" defer></script>
vendor: 1 bytes, line 3019
3019
3020<script src="/quasar-qr.js?v=hq1280"></script>
vendor: 1 bytes, line 3020
3020
3021<script src="/quasar-2fa.js?v=hq955"></script>
vendor: 1 bytes, line 3021
3021
3022<script src="/quasar-address.js?v=hq1438" defer></script>
vendor: 1 bytes, line 3022
3022
3023<script src="https://cdn.jsdelivr.net/npm/globe.gl" defer></script>
3023
3024
3025<script>
3026/* ═════════════════════════════════════════════════════════════
3027   QUASAR HQ — application script
3028   ═════════════════════════════════════════════════════════════ */
3029
3030// ── Constants ─────────────────────────────────────────────────
3031const CONFIG_KEY = 'quasar.supabase.config.v1';
3032const THEME_KEY  = 'quasar.theme';
3033
3034// ── Theme (dark / light / terminal) ─────────────────────────
3035// Apply before paint to avoid FOUC.
3036const THEMES = ['dark', 'light'];
3037(function initTheme() {
3038  const saved = localStorage.getItem(THEME_KEY);
3039  document.documentElement.dataset.theme = THEMES.includes(saved) ? saved : 'dark';
3040})();
3041function toggleTheme() {
3042  const cur = document.documentElement.dataset.theme || 'dark';
3043  const idx = THEMES.indexOf(cur);
3044  const next = THEMES[(idx + 1) % THEMES.length];
3045  document.documentElement.dataset.theme = next;
3046  localStorage.setItem(THEME_KEY, next);
3047  if (typeof window.__applyGlobeTheme === 'function') window.__applyGlobeTheme();
3048}
3049const STATUS = {
3050  lead:        { label: 'Lead',          color: '#7a7a84' },
3051  contacted:   { label: 'Contacted',     color: '#4a9eff' },
3052  discussion:  { label: 'In Discussion', color: '#e4bc6c' },
3053  nda:         { label: 'NDA Signed',    color: '#b084d9' },
3054  proposal:    { label: 'Proposal Sent', color: '#ff9f40' },
3055  negotiating: { label: 'Negotiating',   color: '#ff6fa8' },
3056  customer:    { label: 'Customer',      color: '#4ade80' },
3057  paused:      { label: 'Paused',        color: '#525260' },
3058  lost:        { label: 'Lost',          color: '#ef4444' },
3059};
3060const TYPES = {
3061  boat_builder: { label: 'Boat Builder',     glyph: '▲' },
3062  distributor:  { label: 'Distributor',      glyph: '⬢' },
3063  technician:   { label: 'Install Tech',     glyph: '◆' },
3064  oem:          { label: 'OEM Partner',      glyph: '◉' },
3065  customer:     { label: 'Customer / Owner', glyph: '★' },
3066  other:        { label: 'Other',            glyph: '●' },
3067};
3068const SAFIERY_HQ = { lat: -28.0167, lng: 153.4000 };
3069
3070// ── Product catalogue ─────────────────────────────────────────
3071// Each contact can stock any of these. Icons live in web/img/products/.
3072const PRODUCTS = {
3073  digital_switching: { label: 'Star-Range Digital Switching', short: 'Digital Switching', img: 'img/products/thumb/star-light.png' },
3074  star_tank:         { label: 'Star-Tank',                    short: 'Star-Tank',         img: 'img/products/thumb/star-tank.png' },
3075  batteries:         { label: 'Batteries',                    short: 'Batteries',         img: 'img/products/thumb/solid-state.png' },
3076  bmg:               { label: "BMG's",                        short: "BMG's",             img: 'img/products/thumb/bmg.png' },
3077  scotty:            { label: 'Scotty',                        short: 'Scotty',            img: 'img/products/thumb/scotty.png' },
3078};
3079const PRODUCT_KEYS = Object.keys(PRODUCTS);
3080
3081// ── State ─────────────────────────────────────────────────────
3082let sb = null;
3083let globe = null;
3084let contacts = [];
3085let projects = [];            // projects for the currently-selected contact only
3086let selectedId = null;
3087let filter = { q: '', type: 'all', status: 'all' };
3088let companyView = true;                  // company-only map: always show the whole team's contacts
3089const mapProductFilter = new Set();      // product keys constraining the map search
3090let mapTypeFilter = 'all';               // contact_type constraining the map search
3091function myUserId() { try { return (window.quasarAuth.currentUser() || {}).id || null; } catch (e) { return null; } }
3092function contactIsMine(c) { return !c || !c.owner_id || c.owner_id === myUserId(); }
3093
3094// ── Save layer (hardened) ─────────────────────────────────────
3095// Per-{contactId, field} debounce timers — keyed by `${id}::${field}` so
3096// switching contact while a save is queued can't cross-contaminate. The
3097// previous global-by-field timer was the source of "edits saved to the
3098// wrong row" bugs.
3099let contactSaveTimers = new Map();   // key `${contactId}::${field}` → setTimeout id
3100let projectSaveTimers = new Map();   // key `${projectId}::${field}` → setTimeout id
3101
3102// Pending writes that haven't yet been persisted (timer queued OR in-flight
3103// OR last attempt failed and is awaiting retry). Each entry holds the
3104// captured id + field + value so the dispatcher always writes against the
3105// row the user was editing at schedule time.
3106//   contactDirty.get(`${id}::${field}`) = { id, field, value, baseVersion, attempts }
3107let contactDirty = new Map();
3108let projectDirty = new Map();
3109let inFlightContactSaves = 0;
3110let inFlightProjectSaves = 0;
3111let failedDirty = 0;                 // number of dirty entries whose last attempt errored
3112let isOffline = !navigator.onLine;
3113let pendingSaves = 0;                // legacy counter — preserved for the few spots that read it
3114
3115// Gmail state (Phase B)
3116let gmailStatus = { connected: false, email: null, last_sync_at: null };
3117let gmailThreads = [];      // thread-grouped messages for the current contact
3118let emailTemplates = [];    // all templates belonging to the user
3119let templateSaveTimers = new Map();
3120let unreadByContact = new Map();   // contact_id → count of unread inbound messages
3121let composeTo = [];                // current To recipients for the active Email tab
3122let autoSyncTimer = null;
3123let autoSyncInFlight = false;
3124const AUTO_SYNC_INTERVAL_MS = 30000;         // foreground: 30s for active contact
3125const AUTO_SYNC_MAX_THREADS = 5;
3126let bgSyncTimer = null;
3127let bgSyncRunning = false;
3128const BG_SYNC_INTERVAL_MS = 5 * 60 * 1000;   // background: 5 min across all contacts
3129const BG_SYNC_MAX_THREADS = 3;
3130const BG_SYNC_PACE_MS = 500;                 // delay between per-contact syncs in a sweep
3131
3132// ── Boot ──────────────────────────────────────────────────────
3133document.addEventListener('DOMContentLoaded', () => {
3134  // Early watchdog: if the CDN scripts didn't load, say so instead of hanging
3135  if (typeof supabase === 'undefined' || typeof Globe === 'undefined') {
3136    const missing = [];
3137    if (typeof supabase === 'undefined') missing.push('@supabase/supabase-js');
3138    if (typeof Globe === 'undefined')    missing.push('globe.gl');
3139    const gate = document.getElementById('config-gate');
3140    gate.style.display = 'grid';
3141    gate.querySelector('.card').innerHTML = `
3142      <h1 style="color:var(--status-lost)">Network block</h1>
3143      <div class="sub" style="color:#fca5a5">Libraries failed to load</div>
3144      <p>Your browser couldn't reach one or more of these CDN scripts:</p>
3145      <pre style="font-family:var(--font-mono);font-size:11px;color:var(--brass);background:var(--bg);padding:12px;border:1px solid var(--line);border-radius:2px;margin:12px 0;">${missing.join('\n')}</pre>
3146      <p style="font-size:13px">This usually means a corporate firewall, VPN, or ad-blocker is blocking <code>cdn.jsdelivr.net</code>. Try another network, disable the blocker for this site, or host the libraries yourself.</p>
3147    `;
3148    return;
3149  }
3150
3151  // ── Fast-login paths ──────────────────────────────────────
3152  // (1) URL param: ?config=<token> — bookmark-friendly, one click to connect
3153  const urlToken = new URLSearchParams(location.search).get('config');
3154  if (urlToken) {
3155    const cfg = decodeToken(urlToken);
3156    if (cfg) {
3157      saveConfig(cfg);
3158      // Strip ?config= from the URL so the key doesn't linger in the address bar
3159      history.replaceState(null, '', location.pathname);
3160      connectAndStart(cfg);
3161      attachCommonHandlers();
3162      return;
3163    }
3164  }
3165  // (2) Already saved in localStorage — just connect
3166  const cfg = loadConfig();
3167  if (cfg) { connectAndStart(cfg); }
3168  else     { showConfigGate(); }
3169
3170  attachCommonHandlers();
3171});
3172
3173function attachCommonHandlers() {
3174  document.getElementById('cfg-save').addEventListener('click', onConfigSave);
3175  // HQ-1456: "+ New" opens THE New contact form (Sales > Contacts), the one place that asks
3176  // for every red-star detail a new contact now needs server-side. This page's own quick
3177  // modal (name, type, location only) would be refused CONTACT_DETAILS_REQUIRED every time.
3178  document.getElementById('btn-new').addEventListener('click', () => { location.href = '/quote.html?view=contacts&new=contact'; });
3179  document.getElementById('new-cancel').addEventListener('click', closeNewModal);
3180  document.getElementById('new-create').addEventListener('click', createNewContact);
3181  document.getElementById('sidebar-search').addEventListener('input', e => {
3182    filter.q = e.target.value.toLowerCase();
3183    renderSidebar();
3184    scheduleGlobeRefilter();   // hide/show map dots to match the filtered list
3185  });
3186
3187  // Company-wide view toggle.
3188  // Territory toggle removed — the map always shows company-wide contacts.
3189
3190  // Map location search + product filter chips.
3191  document.getElementById('map-search-go').addEventListener('click', runMapSearch);
3192  document.getElementById('map-search-input').addEventListener('keydown', e => { if (e.key === 'Enter') runMapSearch(); });
3193  document.getElementById('map-product-chips').addEventListener('click', e => {
3194    const chip = e.target.closest('.map-product-chip'); if (!chip) return;
3195    const k = chip.dataset.product;
3196    if (mapProductFilter.has(k)) mapProductFilter.delete(k); else mapProductFilter.add(k);
3197    chip.classList.toggle('on', mapProductFilter.has(k));
3198  });
3199  document.getElementById('map-type-filter').addEventListener('change', e => { mapTypeFilter = e.target.value; });
3200  renderMapProductChips();
3201
3202  // Account / password modal.
3203  document.getElementById('btn-account').addEventListener('click', openAccountModal);
3204  document.getElementById('acc-close').addEventListener('click', closeAccountModal);
3205  document.getElementById('acc-change').addEventListener('click', doChangePassword);
3206  document.getElementById('acc-admin-reset').addEventListener('click', doAdminReset);
3207  document.getElementById('acc-2fa-start').addEventListener('click', start2fa);
3208  document.getElementById('acc-2fa-verify').addEventListener('click', verify2fa);
3209  document.getElementById('acc-2fa-disable').addEventListener('click', disable2fa);
3210  document.getElementById('new-location').addEventListener('input', debounce(onNewLocationSearch, 400));
3211
3212  // Auth gate handlers
3213  document.getElementById('auth-submit').addEventListener('click', onAuthSubmit);
3214  document.getElementById('auth-toggle').addEventListener('click', e => {
3215    e.preventDefault();
3216    setAuthMode(authMode === 'signin' ? 'signup' : 'signin');
3217    // Clear any error styling
3218    const err = document.getElementById('auth-error');
3219    err.classList.remove('show');
3220    err.style.borderLeftColor = '';
3221    err.style.color = '';
3222  });
3223  document.getElementById('auth-forgot').addEventListener('click', onForgotPassword);
3224  document.getElementById('auth-password').addEventListener('keydown', e => {
3225    if (e.key === 'Enter') onAuthSubmit();
3226  });
3227  document.getElementById('btn-signout').addEventListener('click', signOut);
3228
3229  // ── Page-lifecycle save hooks ─────────────────────────────────
3230  // The browser can yank the tab away (close, reload, OS sleep, mobile
3231  // backgrounding) at any time. We:
3232  //   • Flush pending saves on pagehide & on visibilitychange→hidden.
3233  //     Both event paths fire reliably across browsers — pagehide on
3234  //     normal close/reload, visibilitychange on iOS-style "swipe away".
3235  //   • Show a beforeunload prompt if there are unsaved edits, so the
3236  //     user gets one chance to cancel before losing their work.
3237  //   • Retry when the network comes back.
3238  function flushAllPending() {
3239    try { flushPendingContactSaves(); } catch {}
3240    try { flushPendingProjectSaves(); } catch {}
3241  }
3242  window.addEventListener('pagehide', flushAllPending);
3243  document.addEventListener('visibilitychange', () => {
3244    if (document.visibilityState === 'hidden') flushAllPending();
3245  });
3246  window.addEventListener('beforeunload', e => {
3247    const dirty = contactDirty.size + projectDirty.size + inFlightContactSaves + inFlightProjectSaves;
3248    if (dirty > 0) {
3249      // Required for the prompt to actually show in modern browsers.
3250      e.preventDefault();
3251      e.returnValue = '';
3252      return '';
3253    }
3254  });
3255  window.addEventListener('online', () => {
3256    isOffline = false;
3257    refreshSaveIndicator();
3258    // Retry every dirty item — failed flag was set when we went offline,
3259    // so they'll just go through commit*Save normally now.
3260    for (const [, entry] of Array.from(contactDirty.entries())) {
3261      commitContactSave(entry.id, entry.field, entry.value).catch(() => {});
3262    }
3263    for (const [, entry] of Array.from(projectDirty.entries())) {
3264      commitProjectSave(entry.id, entry.field, entry.value).catch(() => {});
3265    }
3266  });
3267  window.addEventListener('offline', () => {
3268    isOffline = true;
3269    refreshSaveIndicator();
3270  });
3271
3272  // Detail panel tabs
3273  document.querySelectorAll('.tab-btn').forEach(btn => {
3274    btn.addEventListener('click', () => {
3275      setActiveTab(btn.dataset.tab);
3276      if (btn.dataset.tab === 'email' && selectedId) {
3277        onEmailTabOpened();
3278        startAutoSync();
3279      } else {
3280        stopAutoSync();
3281      }
3282    });
3283  });
3284  document.getElementById('btn-add-project').addEventListener('click', addProject);
3285  initUnreadPop();
3286
3287  // Gmail (Phase B)
3288  document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
3289  document.getElementById('gmail-pill').addEventListener('click', onGmailPillClick);
3290  document.getElementById('tpl-close').addEventListener('click', closeTemplatesMo
3290dal);
3291  document.getElementById('tpl-add').addEventListener('click', addTemplate);
3292  window.addEventListener('message', (ev) => {
3293    if (ev.data && ev.data.source === 'quasar-gmail') refreshGmailStatus();
3294  });
3295
3296  // Token pastes auto-fill the URL/key fields for visual confirmation
3297  const tokenEl = document.getElementById('cfg-token');
3298  if (tokenEl) {
3299    tokenEl.addEventListener('input', () => {
3300      const c = decodeToken(tokenEl.value.trim());
3301      if (c) {
3302        document.getElementById('cfg-url').value = c.url;
3303        document.getElementById('cfg-key').value = c.key;
3304      }
3305    });
3306  }
3307
3308  // Detail form bindings — every input auto-saves
3309  bindFormFields();
3310  renderFilterRow();
3311}
3312
3313// ── Setup token (compact URL-safe encoding of url + key) ──────
3314// Format: "quasar:v1:" + base64url(JSON.stringify({url, key}))
3315function decodeToken(tok) {
3316  if (!tok || !tok.startsWith('quasar:v1:')) return null;
3317  try {
3318    const b64 = tok.slice(9).replace(/-/g,'+').replace(/_/g,'/');
3319    const pad = '='.repeat((4 - b64.length % 4) % 4);
3320    const json = decodeURIComponent(escape(atob(b64 + pad)));
3321    const c = JSON.parse(json);
3322    if (c && c.url && c.key) return c;
3323  } catch {}
3324  return null;
3325}
3326
3327// ── Config ────────────────────────────────────────────────────
3328function loadConfig() {
3329  try {
3330    const raw = localStorage.getItem(CONFIG_KEY);
3331    return raw ? JSON.parse(raw) : null;
3332  } catch { return null; }
3333}
3334function saveConfig(cfg) {
3335  localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
3336}
3337function showConfigGate() {
3338  document.getElementById('config-gate').style.display = 'grid';
3339  document.getElementById('app').classList.add('hidden');
3340}
3341function hideConfigGate() {
3342  document.getElementById('config-gate').style.display = 'none';
3343  document.getElementById('app').classList.remove('hidden');
3344}
3345async function onConfigSave() {
3346  const tokenVal = document.getElementById('cfg-token')?.value.trim();
3347  const fromToken = tokenVal ? decodeToken(tokenVal) : null;
3348
3349  const url = (fromToken?.url ?? document.getElementById('cfg-url').value).trim();
3350  const key = (fromToken?.key ?? document.getElementById('cfg-key').value).trim();
3351  const err = document.getElementById('cfg-error');
3352  err.classList.remove('show');
3353
3354  if (!url || !key) {
3355    err.textContent = tokenVal
3356      ? 'Setup token is invalid. Expected "quasar:v1:..." — or fill URL + Key manually below.'
3357      : 'Paste a setup token, or fill both URL and Key fields.';
3358    err.classList.add('show'); return;
3359  }
3360  if (!/^https:\/\/.+\.supabase\.co$/.test(url)) {
3361    err.textContent = 'URL should look like https://xxxx.supabase.co'; err.classList.add('show'); return;
3362  }
3363  // Probe connection (use the same auth storageKey as the real client to avoid
3364  // "Multiple GoTrueClient instances" warning later)
3365  try {
3366    const probe = supabase.createClient(url, key, {
3367      auth: { storageKey: 'quasar-auth', persistSession: false }
3368    });
3369    const { error } = await probe.from('contacts').select('id').limit(1);
3370    if (error && error.code !== 'PGRST116') {
3371      err.textContent = 'Could not reach "contacts" table. Did you run the SQL schema? ' + error.message;
3372      err.classList.add('show'); return;
3373    }
3374    saveConfig({ url, key });
3375    hideConfigGate();
3376    connectAndStart({ url, key });
3377  } catch (e) {
3378    err.textContent = 'Connection failed: ' + e.message;
3379    err.classList.add('show');
3380  }
3381}
3382// (Removed: onResetConfig — legacy Supabase setup-token / disconnect tool,
3383// superseded by real accounts + the Sign out button.)
3384
3385// Fill the top-bar user chip from the client session. On the deployed site
3386// quasar-nav's auth-user fetch upgrades this to the person's proper name; on a
3387// static preview (no functions) this email keeps the chip from being blank.
3388function setWho(u) { var el = document.getElementById('who'); if (el) el.textContent = (u && u.email) || ''; }
3389
3390// ── Connect & start ───────────────────────────────────────────
3391async function connectAndStart(cfg) {
3392  sb = supabase.createClient(cfg.url, cfg.key, {
3393    auth: { storageKey: 'quasar-auth' }
3394  });
3395  // Hide the gate but keep #app hidden until we know whether a session
3396  // exists — unhiding the app here flashed the empty app at signed-out users.
3397  document.getElementById('config-gate').style.display = 'none';
3398
3399  // Check auth — if signed in, start the app; else show auth gate.
3400  const { data: { session } } = await sb.auth.getSession();
3401  if (session) {
3402    document.getElementById('app').classList.remove('hidden');
3403    setWho(session.user);
3404    await startApp();
3405  } else {
3406    showAuthGate();
3407  }
3408
3409  // Also react to future auth changes (sign-in, sign-out, token refresh)
3410  sb.auth.onAuthStateChange(async (event, newSession) => {
3411    if (event === 'SIGNED_IN' && newSession) {
3412      hideAuthGate();
3413      setWho(newSession.user);
3414      await startApp();
3415    } else if (event === 'SIGNED_OUT') {
3416      location.reload();
3417    }
3418  });
3419}
3420
3421let appStarted = false;
3422async function startApp() {
3423  if (appStarted) return;
3424  appStarted = true;
3425  // HQ-411: kick off the contacts round trip immediately instead of gating it
3426  // behind initGlobe (the globe textures are MB-scale image downloads that
3427  // have nothing to do with contacts, so there is no reason the contacts fetch
3428  // should wait for them to land before it even starts).
3429  // Still await initGlobe before touching `globe`, since it's async (races
3430  // hi-res vs fallback textures) and without that, renderGlobe() below would
3431  // run while `globe` is still null and silently bail, meaning pins never make
3432  // it onto the globe.
3433  const contactsP = loadContacts();
3434  await initGlobe();
3435  await contactsP;
3436  // One more explicit render after both globe and contacts exist, belt & braces.
3437  renderGlobe();
3438  subscribeRealtime();
3439  // Account-rep roster for the contact picker (fire-and-forget; ready before a contact opens).
3440  loadReps();
3441  // Gmail (Phase B) — fire-and-forget; UI is usable while these resolve.
3442  refreshGmailStatus();
3443  loadEmailTemplates();
3444  loadUnreadCounts();
3445  // Cost of Billing Drift needs-attention box (named product-cost viewers only; the
3446  // costing function refuses everyone else, so the box simply never shows). Fire-and-forget.
3447  loadCostingDrift();
3448  // Honour `#contact=<uuid>` deep-link from projects.html / wiring.html
3449  applyContactHash();
3450  window.addEventListener('hashchange', applyContactHash);
3451}
3452
3453function applyContactHash() {
3454  const m = (location.hash || '').match(/contact=([0-9a-f-]{36})/i);
3455  if (!m) return;
3456  const id = m[1];
3457  if (contacts.find(c => c.id === id)) selectContact(id, true);
3458}
3459
3460// ── Auth gate ─────────────────────────────────────────────────
3461let authMode = 'signin';   // 'signin' | 'signup'
3462function showAuthGate() {
3463  document.getElementById('auth-gate').style.display = 'grid';
3464  document.getElementById('app').classList.add('hidden');
3465}
3466function hideAuthGate() {
3467  document.getElementById('auth-gate').style.display = 'none';
3468  document.getElementById('app').classList.remove('hidden');
3469}
3470function setAuthMode(mode) {
3471  authMode = mode;
3472  const isSignup = mode === 'signup';
3473  document.getElementById('auth-sub').textContent = isSignup ? 'Create account' : 'Sign in';
3474  document.getElementById('auth-intro').textContent = isSignup
3475    ? 'Quasar HQ is private — create your account to start.'
3476    : 'Quasar HQ is private — sign in to access your territory.';
3477  document.getElementById('auth-submit').textContent = isSignup ? 'Sign Up' : 'Sign In';
3478  document.getElementById('auth-toggle').textContent = isSignup
3479    ? '← Have an account? Sign in'
3480    : 'Need an account? Sign up →';
3481  document.getElementById('auth-password').setAttribute('autocomplete', isSignup ? 'new-password' : 'current-password');
3482  // HQ-391: the longer-session tick is a SIGN-IN concept; hide it while signing up.
3483  const rem = document.getElementById('auth-remember-row');
3484  if (rem) rem.style.display = isSignup ? 'none' : 'flex';
3485}
3486
3487// HQ-391: show/hide the password (eye toggle). Text stays in the field either way;
3488// only the input type flips, so autofill/2FA flows are untouched. The control is a
3489// span (see the markup note), so Enter/Space are wired for keyboard users.
3490(function () {
3491  const btn = document.getElementById('auth-showpw');
3492  const pw = document.getElementById('auth-password');
3493  if (!btn || !pw) return;
3494  function flip() {
3495    const show = pw.type === 'password';
3496    pw.type = show ? 'text' : 'password';
3497    document.getElementById('auth-showpw-eye').style.display = show ? 'none' : '';
3498    document.getElementById('auth-showpw-eyeoff').style.display = show ? '' : 'none';
3499    btn.setAttribute('aria-pressed', show ? 'true' : 'false');
3500    btn.title = show ? 'Hide password' : 'Show password';
3501    pw.focus();
3502  }
3503  btn.addEventListener('click', flip);
3504  btn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flip(); } });
3505})();
3506
3507async function onAuthSubmit() {
3508  const email = document.getElementById('auth-email').value.trim();
3509  const password = document.getElementById('auth-password').value;
3510  const err = document.getElementById('auth-error');
3511  err.classList.remove('show');
3512
3513  if (!email || !password) {
3514    err.textContent = 'Email and password required.'; err.classList.add('show'); return;
3515  }
3516  if (authMode === 'signup' && password.length < 8) {
3517    err.textContent = 'Password must be at least 8 characters.'; err.classList.add('show'); return;
3518  }
3519
3520  const btn = document.getElementById('auth-submit');
3521  btn.disabled = true;
3522  const origLabel = btn.textContent;
3523  btn.textContent = authMode === 'signup' ? 'Signing up…' : 'Signing in…';
3524
3525  try {
3526    if (authMode === 'signup') {
3527      const { data, error } = await sb.auth.signUp({ email, password });
3528      if (error) throw error;
3529      if (!data.session) {
3530        // Email confirmation is required in the Supabase project settings.
3531        err.textContent = 'Account created — check your email to confirm, then sign in.';
3532        err.style.borderLeftColor = 'var(--brass)';
3533        err.style.color = 'var(--brass-bright)';
3534        err.classList.add('show');
3535        setAuthMode('signin');
3536      }
3537      // If session exists, onAuthStateChange will fire and start the app
3538    } else {
3539      const code = (document.getElementById('auth-code').value || '').trim();
3540      const remember = !!(document.getElementById('auth-remember') || {}).checked;
3541      const { error } = await sb.auth.signInWithPassword({ email, password, code, remember });
3542      if (error) {
3543        // 2FA enabled: reveal the code field and let the user enter it (not a hard failure).
3544        // HQ-457: the require_2fa policy diverted an unenrolled account. The password was
3545        // right, but there is no session - only an enrolment-only credential. Run enrolment
3546        // inline here (see startPolicyEnrolment) rather than dead-ending the user.
3547        if (error.code === 'enrol_required') {
3548          await startPolicyEnrolment(error.enrol_token, error.message, err);
3549          return;
3550        }
3551        if (error.code === 'totp_required') {
3552          document.getElementById('auth-2fa-row').style.display = '';
3553          const ci = document.getElementById('auth-code'); ci.focus();
3554          err.textContent = code ? 'That code did not match — check your authenticator and try again.' : 'Enter your 6-digit authenticator code to finish signing in.';
3555          err.classList.add('show');
3556          return;
3557        }
3558        throw error;
3559      }
3560      // HQ-390: an INTERACTIVE sign-in reloads the page instead of starting the app
3561      // inline. Half the data plane (KPI/pipeline loaders, quasar-acl's verdict, the
3562      // nav chip) had already run against the signed-out state and cached it, so the
3563      // freshly-signed-in app rendered "needs a signed-in session" panes until a
3564      // manual refresh (Adam: "nothing loaded once I logged in, I had to refresh").
3565      // The session cookie is set server-side before signInWithPassword resolves, so
3566      // a reload boots every loader hydrated. Restored sessions (no gate shown) keep
3567      // the inline onAuthStateChange -> startApp() path untouched.
3568      location.reload();
3569      return;
3570    }
3571  } catch (e) {
3572    err.textContent = e.message || 'Authentication failed.';
3573    err.classList.add('show');
3574  } finally {
3575    btn.disabled = false;
3576    btn.textContent = origLabel;
3577  }
3578}
3579
3580// ── HQ-457: 2FA enrolment from the sign-in gate ("Require 2FA for all staff") ──
3581// When the policy is ON and the account has no authenticator, auth-login returns NO
3582// session - just a short-lived, enrolment-only credential (purpose:'enrol'). This is the
3583// only client code that uses it, and it is passed as a Bearer header to totp-enroll, the
3584// ONE endpoint that accepts that purpose (explicit server-side allowlist); every other
3585// endpoint refuses it, and it is never exchanged for a session. Enrolment MUST be
3586// reachable from here: it needs an authenticated caller, so dead-ending the user at the
3587// gate would lock out every unenrolled member of staff at once with no way back.
3588async function callEnrolEndpoint(token, body) {
3589  const r = await fetch('/.netlify/functions/totp-enroll', {
3590    method: 'POST',
3591    headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
3592    body: JSON.stringify(body),
3593  });
3594  return (await r.json().catch(function () { return {}; })) || {};
3595}
3596
3597async function startPolicyEnrolment(token, message, err) {
3598  return QuasarTwoFactor.start({ token, message, error: err, call: callEnrolEndpoint });
3599}
3600
3601function onForgotPassword(e) {
3602  e.preventDefault();
3603  // Resets are admin-managed (no self-service email). Point the user at the admin.
3604  const err = document.getElementById('auth-error');
3605  err.textContent = 'Password resets are handled by your team admin — email [email protected] to have yours reset.';
3606  err.style.borderLeftColor = 'var(--brass)';
3607  err.style.color = 'var(--brass-bright)';
3608  err.classList.add('show');
3609}
3610
3611async function signOut() {
3612  if (!sb) return;
3613  // Warn the user if there are unsaved edits — better to fail loudly than
3614  // to drop their last keystrokes silently when the auth session goes away.
3615  const pending = contactDirty.size + projectDirty.size + inFlightContactSaves + inFlightProjectSaves;
3616  const msg = pending > 0
3617    ? 'Sign out of Quasar HQ? You have unsaved changes — they will be flushed first.'
3618    : 'Sign out of Quasar HQ?';
3619  if (!confirm(msg)) return;
3620  // Best-effort flush before the auth session is invalidated.
3621  try { await Promise.race([
3622    Promise.all([flushPendingContactSaves(), flushPendingProjectSaves()]),
3623    new Promise(r => setTimeout(r, 4000))
3624  ]); } catch {}
3625  await sb.auth.signOut();
3626  // onAuthStateChange fires with SIGNED_OUT → reload
3627}
3628
3629// ── Data layer ────────────────────────────────────────────────
3630async function loadContacts() {
3631  setSaveState('saving', 'Loading');
3632  let qb = sb.from('contacts').select('*').is('deleted_at', null);
3633  if (companyView) qb = qb.companyView();   // also pull the team's customers + distributors
3634  const { data, error } = await qb.order('updated_at', { ascending: false });
3635  if (error) {
3636    setSaveState('error', 'Load failed');
3637    console.error(error);
3638    return;
3639  }
3640  contacts = data || [];
3641  renderAll();
3642  setSaveState('idle', 'Synced');
3643}
3644
3645function subscribeRealtime() {
3646  sb.channel('quasar-contacts')
3647    .on('postgres_changes', { event: '*', schema: 'public', table: 'contacts' }, payload => {
3648      if (payload.eventType === 'INSERT') {
3649        if (!contacts.find(c => c.id === payload.new.id)) {
3650          contacts.unshift(payload.new);
3651          renderAll();
3652        }
3653      } else if (payload.eventType === 'UPDATE') {
3654        const i = contacts.findIndex(c => c.id === payload.new.id);
3655        const incoming = payload.new;
3656        // A realtime UPDATE that flips deleted_at is a soft-delete from
3657        // another tab — drop the row from the local list, same as a hard
3658        // delete would have.
3659        if (incoming.deleted_at) {
3660          contacts = contacts.filter(c => c.id !== incoming.id);
3661          if (selectedId === incoming.id) clearSelection();
3662          renderAll();
3663          return;
3664        }
3665        if (i > -1) {
3666          const local = contacts[i];
3667          // Don't overwrite our own pending edits — if we have a dirty
3668          // entry for any field on this contact, reapply our locally
3669          // pending values on top of the incoming server row.
3670          if (new Date(incoming.updated_at) >
3670 new Date(local.updated_at || 0)) {
3671            const merged = { ...incoming };
3672            for (const [, entry] of contactDirty) {
3673              if (entry.id === incoming.id) merged[entry.field] = entry.value;
3674            }
3675            contacts[i] = merged;
3676            if (selectedId === incoming.id) fillDetailForm(merged, false);
3677            renderSidebar(); renderGlobe(); renderStats();
3678          }
3679        }
3680      } else if (payload.eventType === 'DELETE') {
3681        contacts = contacts.filter(c => c.id !== payload.old.id);
3682        if (selectedId === payload.old.id) clearSelection();
3683        renderAll();
3684      }
3685    })
3686    .subscribe();
3687}
3688
3689// upsertContact applies a single-field patch to a specific contact id WITH
3690// optimistic-concurrency. The version column is bumped server-side by a
3691// trigger; we send the version we last read as a precondition so a stale
3692// update can't silently overwrite a newer one written from another tab.
3693//
3694// On a clean update: updates the local cache and returns the row.
3695// On a version conflict (no row matched the eq): returns { conflict: true,
3696// server } so the caller can decide how to merge.
3697// On a network/server error: throws, so the retry queue can reschedule.
3698async function upsertContact(id, patch, opts = {}) {
3699  if (!id) return null;
3700  // Pull the local row to read the version we last saw. If the row isn't
3701  // in the local array (rare — happens during a realtime DELETE/INSERT
3702  // race), fall back to an unconditional update.
3703  const local = contacts.find(c => c.id === id);
3704  const baseVersion = local && typeof local.version === 'number' ? local.version : null;
3705
3706  let q = sb.from('contacts').update(patch).eq('id', id);
3707  if (baseVersion != null) q = q.eq('version', baseVersion);
3708  const { data, error } = await q.select().single();
3709
3710  if (error && error.code === 'PGRST116') {
3711    // No row matched — likely a version conflict OR row was deleted.
3712    // Pull the server's view so the caller can decide.
3713    const { data: server } = await sb.from('contacts').select('*').eq('id', id).maybeSingle();
3714    return { conflict: true, server };
3715  }
3716  if (error) throw error;
3717
3718  // Refresh local cache with the server-confirmed row (carries new version)
3719  const i = contacts.findIndex(x => x.id === id);
3720  if (i > -1) contacts[i] = data;
3721
3722  // Best-effort audit + legacy activity log. Errors here are NOT critical —
3723  // the row write succeeded; missing audit is acceptable.
3724  sb.from('activity').insert({ contact_id: id, action: 'update', details: patch })
3725    .then(() => {}, () => {});
3726  writeAudit('contacts', id, 'update', null, patch, 'index.html');
3727
3728  return data;
3729}
3730
3731async function insertContact(payload) {
3732  setSaveState('saving', 'Creating');
3733  const { data, error } = await sb.from('contacts').insert(payload).select().single();
3734  if (error) {
3735    // HQ-1456: a refused create (e.g. Duplicate of a contact missing its red-star details)
3736    // says what is missing instead of a bare "Create failed".
3737    const detailsRefused = error.code === 'CONTACT_DETAILS_REQUIRED';
3738    setSaveState('error', detailsRefused ? (error.message || 'Missing contact details') : 'Create failed');
3739    console.error('insert', error);
3740    if (!detailsRefused) refreshSaveIndicator();
3741    return null;
3742  }
3743  refreshSaveIndicator();
3744  sb.from('activity').insert({ contact_id: data.id, action: 'create', details: payload }).then(() => {}, () => {});
3745  writeAudit('contacts', data.id, 'create', null, payload, 'index.html');
3746  return data;
3747}
3748
3749// Soft-delete: mark `deleted_at` instead of removing the row. The row stays
3750// in Supabase (and in backups) and can be restored via restore_soft_deleted().
3751// All loads filter `deleted_at is null` so the UI behaves identically to a
3752// hard delete.
3753async function deleteContact(id) {
3754  setSaveState('saving', 'Deleting');
3755  const userId = (await sb.auth.getUser()).data.user?.id || null;
3756  const { error } = await sb.from('contacts')
3757    .update({ deleted_at: new Date().toISOString(), deleted_by: userId })
3758    .eq('id', id);
3759  if (error) { setSaveState('error', 'Delete failed'); console.error(error); return false; }
3760  refreshSaveIndicator();
3761  sb.from('activity').insert({ contact_id: id, action: 'delete', details: {} }).then(() => {}, () => {});
3762  writeAudit('contacts', id, 'soft-delete', null, null, 'index.html');
3763  return true;
3764}
3765
3766// ── Audit log helper ─────────────────────────────────────────
3767// Best-effort write to audit_events. Never throws — if the audit row fails
3768// to land (RLS off, table missing, transient network), the data write that
3769// preceded it is still real, and the regular Supabase logs will show the
3770// failure. We do NOT block on this.
3771function writeAudit(table_name, row_id, action, old_data, new_data, source) {
3772  try {
3773    sb.from('audit_events').insert({
3774      table_name, row_id: String(row_id), action, old_data, new_data, source
3775    }).then(() => {}, () => {});
3776  } catch { /* swallow — audit is advisory, not load-bearing */ }
3777}
3778
3779// ── Save indicator ────────────────────────────────────────────
3780// Five visible states (in priority order):
3781//   error    → "Save failed — retrying"
3782//   offline  → "Offline — changes queued"
3783//   saving   → "Saving" (in-flight write OR queued timer)
3784//   dirty    → "Unsaved changes" (timer queued, no in-flight)
3785//   idle     → "Synced"  ← ONLY when nothing is dirty AND nothing is in-flight
3786function refreshSaveIndicator() {
3787  const dirtyCount  = contactDirty.size + projectDirty.size;
3788  const inFlight    = inFlightContactSaves + inFlightProjectSaves;
3789  const anyFailed   = failedDirty > 0;
3790  if (anyFailed) { setSaveState('error', 'Save failed — retrying'); return; }
3791  if (isOffline && dirtyCount > 0) { setSaveState('saving', 'Offline — changes queued'); return; }
3792  if (inFlight > 0) { setSaveState('saving', 'Saving'); return; }
3793  if (dirtyCount > 0) { setSaveState('saving', 'Unsaved changes'); return; }
3794  setSaveState('idle', 'Synced');
3795}
3796
3797function setSaveState(state, text) {
3798  const el = document.querySelector('.save-indicator');
3799  if (!el) return;
3800  el.dataset.state = state === 'idle' ? '' : state;
3801  document.getElementById('save-text').textContent = text || (state === 'idle' ? 'Synced' : state);
3802}
3803
3804// ── Globe ─────────────────────────────────────────────────────
3805async function initGlobe() {
3806  if (globe) return;
3807  const el = document.getElementById('globe');
3808  const container = document.querySelector('main.globe-container');
3809  const loader = document.getElementById('loading-globe');
3810
3811  // Failsafe — if something hangs silently, surface it instead of spinning forever
3812  const failsafe = setTimeout(() => {
3813    const msg = document.querySelector('#loading-globe .spinner');
3814    if (msg && !loader.classList.contains('hide')) {
3815      msg.innerHTML = 'Still loading…<br><span style="font-family:var(--font-mono);font-size:12px;letter-spacing:0.1em;color:var(--ink-dim);display:block;margin-top:16px;max-width:360px;line-height:1.6;">Open DevTools (F12) → Console. If you see script load errors, your network may be blocking jsdelivr.net.</span>';
3816    }
3817  }, 6000);
3818
3819  // Texture sources. The app first tries self-hosted /assets (which you can drop
3820  // 8K textures into for maximum sharpness), then falls back to the jsDelivr
3821  // three-globe 4K textures (1.5 MB each, guaranteed reachable, CORS-enabled).
3822  // See assets/README.md for how to upgrade to 8K.
3823  const DAY_URL_HI    = 'assets/earth-day-8k.jpg';
3824  const NIGHT_URL_HI  = 'assets/earth-night-8k.jpg';
3825  const DAY_URL_FB    = 'https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-blue-marble.jpg';
3826  const NIGHT_URL_FB  = 'https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-night.jpg';
3827  const BUMP_URL      = 'https://cdn.jsdelivr.net/npm/three-globe/example/img/earth-topology.png';
3828  const SKY_URL       = 'https://cdn.jsdelivr.net/npm/three-globe/example/img/night-sky.png';
3829
3830  // Resolve day + night URLs with fallback (HEAD request; if self-hosted hi-res
3831  // isn't there, fall through to the CDN). This is a fire-and-forget race —
3832  // whichever resolves first wins.
3833  const pickUrl = (hi, fb) => new Promise(resolve => {
3834    const img = new Image();
3835    img.onload  = () => resolve(hi);
3836    img.onerror = () => resolve(fb);
3837    img.src = hi;
3838  });
3839  let DAY_URL = DAY_URL_FB, NIGHT_URL = NIGHT_URL_FB;   // default to fallback
3840
3841  // If assets/earth-day-8k.jpg etc. exist (self-hosted on Netlify), use them.
3842  [DAY_URL, NIGHT_URL] = await Promise.all([
3843    pickUrl(DAY_URL_HI,   DAY_URL_FB),
3844    pickUrl(NIGHT_URL_HI, NIGHT_URL_FB),
3845  ]);
3846
3847  try {
3848    globe = new Globe(el)
3849      .globeImageUrl(DAY_URL)           // day = blue marble; bright, colorful
3850      .bumpImageUrl(BUMP_URL)
3851      .backgroundImageUrl(SKY_URL)
3852      .showAtmosphere(true)
3853      .atmosphereColor('#6bb5ff')
3854      .atmosphereAltitude(0.18)
3855      .pointOfView({ lat: SAFIERY_HQ.lat, lng: SAFIERY_HQ.lng, altitude: 2.4 }, 0)
3856      .pointAltitude(0.012)
3857      .pointRadius(0.32)
3858      // Read the (possibly displaced) rendering position — falls back to
3859      // the contact's real coord when no overlap displacement was needed.
3860      .pointLat(d => d._displayLat ?? d.lat)
3861      .pointLng(d => d._displayLng ?? d.lng)
3862      .pointColor(d => STATUS[d.status]?.color || '#7a7a84')
3863      .pointLabel(d => `
3864        <div class="globe-tooltip">
3865          <div class="name">${escapeHtml(d.name || 'Unnamed')}</div>
3866          ${d.company ? `<div class="company">${escapeHtml(d.company)}</div>` : ''}
3867          <div class="status" style="color:${STATUS[d.status]?.color};">
3868            ${TYPES[d.contact_type]?.glyph || '●'} · ${STATUS[d.status]?.label || escapeHtml(d.status || '')}
3869          </div>
3870          ${productIconsHtml(d.products)}
3871          ${contactIsMine(d) ? '' : '<div class="tt-team">◷ team contact (view only)</div>'}
3872        </div>
3873      `)
3874      .onPointClick(d => selectContact(d.id, true))
3875      .onGlobeReady(() => {
3876        clearTimeout(failsafe);
3877        loader.classList.add('hide');
3878        installDayNightShader();   // once globe is ready, swap in the day/night shader
3879      })
3880      .ringsData([])
3881      .ringColor(d => d.color || '#3a7fc2')
3882      .ringMaxRadius(d => d.maxR ?? 2.5)
3883      .ringPropagationSpeed(d => d.speed ?? 1.2)
3884      .ringRepeatPeriod(d => d.period ?? 1800);
3885  } catch (err) {
3886    clearTimeout(failsafe);
3887    console.error('Globe init failed:', err);
3888    document.querySelector('#loading-globe .spinner').innerHTML =
3889      `Globe failed to load.<br><span style="font-family:var(--font-mono);font-size:11px;color:var(--status-lost);display:block;margin-top:12px;">${escapeHtml(err.message)}</span>`;
3890    return;
3891  }
3892
3893  // ── Day/night shader ────────────────────────────────────────────
3894  // Blends the day (blue-marble) and night (city-lights) textures based on
3895  // the sun's real-time position. Recomputed every minute so the terminator
3896  // actually drifts west as the earth turns.
3897  //
3898  // Implementation note: we don't load our own copy of three.js. Instead we
3899  // reach into globe.gl's existing material to borrow the Texture class it's
3900  // already using, and use plain {x,y,z} objects for the sunDir uniform —
3901  // three's WebGLUniforms accepts these natively for vec3.
3902  function installDayNightShader() {
3903    const mat = globe.globeMaterial();
3904    if (!mat || !mat.map || !mat.map.image) {
3905      // Day texture hasn't resolved yet; try again shortly
3906      setTimeout(installDayNightShader, 200);
3907      return;
3908    }
3909    try {
3910      const TextureCtor = mat.map.constructor;
3911      const nightTex = new TextureCtor();
3912      nightTex.colorSpace = mat.map.colorSpace;   // match day texture's color space
3913      const img = new Image();
3914      img.crossOrigin = 'anonymous';
3915      img.onerror = () => console.warn('[Quasar HQ] Night texture failed to load; day-only globe.');
3916      img.onload  = () => {
3917        nightTex.image = img;
3918        nightTex.needsUpdate = true;
3919        applyShader(mat, nightTex);
3920      };
3921      img.src = NIGHT_URL;
3922    } catch (e) {
3923      console.warn('[Quasar HQ] Day/night shader install failed (globe still works):', e);
3924    }
3925  }
3926
3927  function applyShader(mat, nightTex) {
3928    mat.onBeforeCompile = shader => {
3929      shader.uniforms.nightTex      = { value: nightTex };
3930      shader.uniforms.sunDir        = { value: computeSunDir() };
3931      shader.uniforms.forceDay      = { value: isFlatTheme() ? 1.0 : 0.0 };
3932      shader.uniforms.forceTerminal = { value: isTerminalTheme() ? 1.0 : 0.0 };
3933
3934      shader.vertexShader = shader.vertexShader
3935        .replace('#include <common>',
3936                 '#include <common>\nvarying vec3 vWorldNml;')
3937        .replace('#include <beginnormal_vertex>',
3938                 '#include <beginnormal_vertex>\nvWorldNml = normalize(mat3(modelMatrix) * normal);');
3939
3940      shader.fragmentShader = shader.fragmentShader
3941        .replace('#include <common>',
3942                 '#include <common>\nuniform sampler2D nightTex;\nuniform vec3 sunDir;\nuniform float forceDay;\nuniform float forceTerminal;\nvarying vec3 vWorldNml;')
3943        .replace('#include <map_fragment>',
3944                 `#ifdef USE_MAP
3945                    vec4 dayColor   = texture2D(map, vMapUv);
3946                    vec4 nightColor = texture2D(nightTex, vMapUv);
3947                    float sunDot = dot(normalize(vWorldNml), normalize(sunDir));
3948                    float blend  = mix(smoothstep(-0.18, 0.18, sunDot), 1.0, forceDay);
3949                    diffuseColor.rgb = mix(nightColor.rgb * 1.8, dayColor.rgb, blend);
3950                  #endif`)
3951        // forceDay branch handles BOTH light theme (flat pale ocean) and
3952        // terminal theme (near-black phosphor sphere). A second uniform
3953        // `forceTerminal` picks between them so we get a completely different
3954        // look without reshaping the sphere mesh.
3955        .replace('#include <emissivemap_fragment>',
3956                 `#include <emissivemap_fragment>
3957                  #ifdef USE_MAP
3958                    vec3 oceanLight   = vec3(0.852, 0.902, 0.929);
3959                    vec3 phosphorOcean = vec3(0.018, 0.058, 0.034);
3960                    vec3 flatColor    = mix(oceanLight, phosphorOcean, forceTerminal);
3961                    totalEmissiveRadiance = mix(totalEmissiveRadiance, flatColor, forceDay);
3962                    diffuseColor.rgb *= (1.0 - forceDay);
3963                  #endif`);
3964
3965      // Stash the shader so we can nudge uniforms over time
3966      mat.userData.shader = shader;
3967    };
3968    mat.needsUpdate = true;
3969
3970    // Drift the terminator in near-real-time. 10s keeps it locked to
3971    // actual sub-solar motion without being wasteful (sun moves 0.04°/s).
3972    setInterval(() => {
3973      const s = mat.userData.shader;
3974      if (s) s.uniforms.sunDir.value = computeSunDir();
3975    }, 10_000);
3976  }
3977
3978  function themeName() { return document.documentElement.dataset.theme || 'dark'; }
3979  function isLightTheme()    { return themeName() === 'light'; }
3980  function isTerminalTheme() { return themeName() === 'terminal'; }
3981  // "Flat" = anything that short-circuits the day/night shader to a uniform map.
3982  function isFlatTheme()     { return isLightTheme() || isTerminalTheme(); }
3983
3984  // Natural Earth 110m country polygons — ~280 KB, cached by jsDelivr.
3985  let _countriesPromise = null;
3986  async function ensureCountriesLoaded() {
3987    if (_countriesPromise) return _countriesPromise;
3988    const url = 'https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_admin_0_countries.geojson';
3989    _countriesPromise = fetch(url)
3990      .then(r => r.ok ? r.json() : Promise.reject(new Error('countries fetch ' + r.status)))
3991      .then(j => j.features || [])
3992      .catch(e => { console.warn('[Quasar HQ] countries GeoJSON failed:', e); return []; });
3993    return _countriesPromise;
3994  }
3995  // Expose a globe-theme applier that gets called on toggle
3996  window.__applyGlobeTheme = () => {
3997    try {
3998      const t = themeName();
3999      const flat = isFlatTheme();
4000      const terminal = isTerminalTheme();
4001
4002      const mat = globe.globeMaterial();
4003      const s = mat && mat.userData && mat.userData.shader;
4004      if (s) {
4005        if (s.uniforms.forceDay)      s.uniforms.forceDay.value      = flat ? 1.0 : 0.0;
4006        if (s.uniforms.forceTerminal) s.uniforms.forceTerminal.value = terminal ? 1.0 : 0.0;
4007      }
4008
4009      // Atmosphere + background + sky
4010      const atmos = { dark: '#6bb5ff', light: '#bcdcff', terminal: '#8afcb0' }[t];
4011      const bg    = { dark: '#000011', light: '#f5f1e6', terminal: '#030a07' }[t];
4012      globe.atmosphereColor(atmos);
4013      globe.backgroundColor(bg);
4014      try { globe.backgroundImageUrl(t === 'dark' ? SKY_URL : null); } catch(_) {}
4015
4016      // Scene lighting
4017      const scene = globe.scene && globe.scene();
4018      if (scene) {
4019        scene.traverse(obj => {
4020          if (obj.isDirectionalLight) obj.intensity = flat ? 0 : 0.6;
4021          if (obj.isAmbientLight)     obj.intensity = flat ? 2.1 : 0.6;
4022        });
4023      }
4024
4025      // Country polygons — styled differently per theme
4026      if (flat) {
4027        ensureCountriesLoaded().then(features => {
4028          if (!features || !isFlatTheme()) return;
4029          if (isTerminalTheme()) {
4030            globe
4031              .polygonsData(features)
4032              .polygonCapColor(() => 'rgba(138, 240, 138, 0.06)')
4033              .polygonSideColor(() => 'rgba(138, 240, 138, 0.0)')
4034              .polygonStrokeColor(() => '#7cec7c')
4035              .polygonAltitude(0.002);
4036          } else {
4037            globe
4038              .polygonsData(features)
4039              .polygonCapColor(() => '#e9d9b7')
4040              .polygonSideColor(() => 'rgba(154,121,66,0.35)')
4041              .polygonStrokeColor(() => '#a38858')
4042              .polygonAltitude(0.004);
4043          }
4044        });
4045      } else {
4046        globe.polygonsData([]);
4047      }
4048    } catch (e) { /* globe not ready yet */ }
4049  };
4050  // Apply immediately so initial load is correct
4051  window.__applyGlobeTheme();
4052
4053  // ── Controls & interaction ──────────────────────────────────────
4054  try {
4055    const controls = globe.controls();
4056    if (controls) {
4057      controls.autoRotate = true;
4058      controls.autoRotateSpeed = 0.25;
4059      controls.enableDamping = true;
4060      let idleTimer;
4061      const bumpIdle = () => {
4062        controls.autoRotate = false;
4063        clearTimeout(idleTimer);
4064        idleTimer = setTimeout(() => { controls.autoRotate = true; }, 7000);
4065      };
4066      el.addEventListener('mousedown', bumpIdle);
4067      el.addEventListener('wheel', bumpIdle);
4068      el.addEventListener('touchstart', bumpIdle, { passive: true });
4069    }
4070  } catch (e) {
4071    console.warn('[Quasar HQ] Controls not available:', e.message);
4072  }
4073
4074  // Cursor coords readout — guarded, because method names vary across
4075  // globe.gl versions and we'd rather skip the hover than crash the globe.
4076  try {
4077    if (typeof globe.onGlobeClick === 'function') {
4078      globe.onGlobeClick(({ lat, lng }) => updateCursor(lat, lng));
4079    }
4080    if (typeof globe.onGlobeHover === 'function') {
4081      globe.onGlobeHover(({ lat, lng } = {}) => { if (lat != null) updateCursor(lat, lng); });
4082    }
4083  } catch (e) {
4084    console.warn('[Quasar HQ] Globe hover/click handlers not attached:', e.message);
4085  }
4086
4087  // ── Sizing ──────────────────────────────────────────────────────
4088  // Use ResizeObserver on the grid cell so the globe always matches its
4089  // container — fixes the "cut off / off-centre" issue where the initial
4090  // measurement ran before the CSS grid had settled.
4091  const syncSize = () => {
4092    const w = container.clientWidth;
4093    const h = container.clientHeight;
4094    if (w > 0 && h > 0 && globe) {
4095      globe.width(w).height(h);
4096    }
4097  };
4098  const ro = new ResizeObserver(syncSize);
4099  ro.observe(container);
4100
4101  // Multiple sync passes after layout: some browsers need a frame or two
4102  // for grid cells to settle their final dimensions on first paint.
4103  syncSize();
4104  requestAnimationFrame(syncSize);
4105  setTimeout(syncSize, 50);
4106  setTimeout(syncSize, 200);
4107  setTimeout(syncSize, 600);
4108
4109  // Belt-and-braces: hide loader after 3 s even if onGlobeReady never fires
4110  setTimeout(() => loader.classList.add('hide'), 3000);
4111
4112  // renderGlobe() will populate rings (HQ heartbeat + unread pulses).
4113}
4114
4115// ── Sun position ───────────────────────────────────────────────
4116// Accurate sub-solar point using the NOAA low-precision solar formulas
4117// (good to ~0.01°). Returns {lat, lng} of where the sun is overhead
4118// RIGHT NOW, in degrees. Exposed on window for sanity checking.
4119function subsolarPoint(date) {
4120  const t = date || new Date();
4121  // Julian Day from Unix epoch (milliseconds)
4122  const JD = t.getTime() / 86400000 + 2440587.5;
4123  const n  = JD - 2451545.0;   // days since J2000.0
4124
4125  // Mean longitude and mean anomaly of the Sun
4126  const normDeg = d => { d = d % 360; return d < 0 ? d + 360 : d; };
4127  const L = normDeg(280.460 + 0.9856474 * n) * Math.PI / 180;
4128  const g = normDeg(357.528 + 0.9856003 * n) * Math.PI / 180;
4129
4130  // Ecliptic longitude of the Sun
4131  const lambda = L + (1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * Math.PI / 180;
4132  // Obliquity of the ecliptic
4133  const epsilon = (23.439 - 0.0000004 * n) * Math.PI / 180;
4134
4135  // Equatorial coords: right ascension α, declination δ
4136  const alpha = Math.atan2(Math.cos(epsilon) * Math.sin(lambda), Math.cos(lambda));
4137  const delta = Math.asin(Math.sin(epsilon) * Math.sin(lambda));
4138
4139  // Greenwich Mean Sidereal Time, in hours
4140  let GMST = (18.697374558 + 24.06570982441908 * n) % 24;
4141  if (GMST < 0) GMST += 24;
4142
4143  // Sub-solar point: longitude of the meridian currently facing the Sun.
4144  const alphaDeg = alpha * 180 / Math.PI;
4145  const GMSTdeg  = GMST * 15;
4146  let sunLng = ((alphaDeg - GMSTdeg + 540) % 360) - 180;
4147  const sunLat = delta * 180 / Math.PI;
4148  return { lat: sunLat, lng: sunLng };
4149}
4150// Dev helper — run `__sunNow()` in the console to verify against timeanddate.com
4151window.__sunNow = () => subsolarPoint();
4152
4153// Returns a unit-length {x,y,z} in the SAME world-space frame as the
4154// globe mesh's normals. Always routes through three-globe's own lat/lng
4155// → world projection so there's no chance of a coordinate-system mismatch.
4156function computeSunDir() {
4157  const { lat, lng } = subsolarPoint();
4158  if (globe && typeof globe.getCoords === 'function') {
4159    const p = globe.getCoords(lat, lng, 0);
4160    const m = Math.hypot(p.x, p.y, p.z) || 1;
4161    return { x: p.x / m, y: p.y / m, z: p.z / m };
4162  }
4163  // Fallback (only hit if globe isn't ready yet): matches three-globe's
4164  // convention — east = +X, north = +Y, prime meridian = +Z.
4165  const latR = lat * Math.PI / 180;
4166  const lngR = lng * Math.PI / 180;
4167  return {
4168    x: Math.cos(latR) * Math.sin(lngR),
4169    y: Math.sin(latR),
4170    z: Math.cos(latR) * Math.cos(lngR),
4171  };
4172}
4173
4174function updateCursor(lat, lng) {
4175  const fmt = (v, pos, neg) => `${Math.abs(v).toFixed(2)}° ${v >= 0 ? pos : neg}`;
4176  document.getElementById('cursor-coords').textContent = `${fmt(lat,'N','S')}  ${fmt(lng,'E','W')}`;
4177}
4178
4179// Spread overlapping pins so they don't z-fight. Pins whose original
4180// lat/lng are within a small threshold are arranged in a ring around
4181// their centroid — each pin still selects the real contact because we
4182// only override the _displayLat/_displayLng used for rendering; the
4183// underlying `lat`/`lng` fields stay intact for fly-to and persistence.
4184// Pins travel the SHORTEST distance from their true position that still
4185// clears every neighbour, so close-but-not-coincident pins don't jump.
4186function spreadOverlappingPoints(points) {
4187  if (points.length < 2) return points;
4188  // Threshold in degrees — tuned against the current pointRadius(0.32) so
4189  // adjacent pins visually touch just barely; at the equator 0.35° ≈ 39km
4190  // of arc which is enough separation at the default altitude (2.4).
4191  const THRESH = 0.35;
4192  // Group by proximity using a coarse bucket first (O(n) grouping) then
4193  // exact check. Handles small clusters (a company + its on-site tech,
4194  // boat builders in the same marina, etc.).
4195  const bucket = new Map();
4196  const keyFor = p => `${Math.round(p.lat / THRESH)}|${Math.round(p.lng / THRESH)}`;
4197  // Pre-seed every point with a cleared display position
4198  points.forEach(p => { p._displayLat = p.lat; p._displayLng = p.lng; });
4199  for (const p of points) {
4200    // Check this point's bucket and all 8 neighbours
4201    const klat = Math.round(p.lat / THRESH);
4202    const klng = Math.round(p.lng / THRESH);
4203    for (let dlat = -1; dlat <= 1; dlat++) {
4204      for (let dlng = -1; dlng <= 1; dlng++) {
4205        const k = `${klat + dlat}|${klng + dlng}`;
4206        if (!bucket.has(k)) bucket.set(k, []);
4207      }
4208    }
4209    bucket.get(`${klat}|${klng}`).push(p);
4210  }
4211  // For each bucket with multiple points, check pairwise and arrange in
4212  // a ring around their centroid when any pair overlaps.
4213  const seen = new Set();
4214  for (const [k, group] of bucket) {
4215    if (group.length < 2) continue;
4216    // Only process unique groups (skip duplicates from neighbour sweeps)
4217    const sig = group.map(p => p.id).sort().join(',');
4218    if (seen.has(sig)) continue;
4219    seen.add(sig);
4220    // Actually verify overlap — bucket membership alone isn't proof
4221    let hasOverlap = false;
4222    for (let i = 0; i < group.length && !hasOverlap; i++) {
4223      for (let j = i + 1; j < group.length && !hasOverlap; j++) {
4224        const dy = group[i].lat - group[j].lat;
4225        const dx = group[i].lng - group[j].lng;
4226        if (dy*dy + dx*dx < THRESH*THRESH) hasOverlap = true;
4227      }
4228    }
4229    if (!hasOverlap) continue;
4230    // Arrange in a ring around the centroid. Ring radius scales with the
4231    // number of overlapping pins so adjacent ring members are exactly
4232    // THRESH apart regardless of group size — two pins get a tiny ring,
4233    // twelve get a wider one. Minimum displacement means pins stay as
4234    // close to their true location as possible, which is what we want.
4235    const cx = group.reduce((s, p) => s + p.lng, 0) / group.length;
4236    const cy = group.reduce((s, p) => s + p.lat, 0) / group.length;
4237    // Stable sort so the ring order is consistent run to run
4238    group.sort((a, b) => (a.id > b.id ? 1 : -1));
4239    const n = group.length;
4240    const r = (THRESH * 1.05) / (2 * Math.sin(Math.PI / n));
4241    for (let i = 0; i < n; i++) {
4242      const angle = (i / n) * Math.PI * 2;
4243      group[i]._displayLat = cy + r * Math.sin(angle);
4244      group[i]._displayLng = cx + r * Math.cos(angle);
4245    }
4246  }
4247  return points;
4248}
4249
4250function renderGlobe() {
4251  if (!globe) return;
4252  // Only plot contacts that have coords AND match the active sidebar filter, so
4253  // the map dots track the list (filter the list → dots hide/show to match).
4254  const raw = contacts.filter(c => typeof c.lat === 'number' && typeof c.lng === 'number' && matchesFilter(c));
4255  // Decorate each point with _displayLat/_displayLng (falls back to real
4256  // lat/lng when not overlapping). globe.gl reads lat/lng from the fields
4257  // we tell it to via `pointLat`/`pointLng` below.
4258  const points = spreadOverlappingPoints(raw);
4259  globe.pointsData(points);
4260
4261  // Rings: HQ heartbeat + a pulse for every contact with unread inbound mail.
4262  const hqRing = {
4263    lat: SAFIERY_HQ.lat, lng: SAFIERY_HQ.lng,
4264    color: '#3a7fc2', maxR: 2.5, speed: 1.2, period: 1800,
4265  };
4266  const unreadRings = points
4267    .filter(c => (unreadByContact.get(c.id) || 0) > 0)
4268    .map(c => ({
4269      lat: c.lat, lng: c.lng,
4270      color: '#'+'5d9bd8', maxR: 3.0, speed: 2.0, period: 900,
4271    }));
4272  globe.ringsData([hqRing, ...unreadRings]);
4273}
4274
4275// ── Rendering ─────────────────────────────────────────────────
4276function renderAll() {
4277  renderSidebar();
4278  renderStats();
4279  renderGlobe();
4280  if (selectedId) {
4281    const c = contacts.find(x => x.id === selectedId);
4282    if (c) fillDetailForm(c, false);
4283    else clearSelection();
4284  }
4285}
4286
4287function renderStats() {
4288  document.getElementById('stat-total').textContent = contacts.length;
4289  document.getElementById('stat-customer').textContent = contacts.filter(c => c.status === 'customer').length;
4290  const pipelineStatuses = ['contacted','discussion','nda','proposal','negotiating'];
4291  document.getElementById('stat-pipeline').textContent = contacts.filter(c => pipelineStatuses.includes(c.status)).length;
4292}
4293
4294function renderFilterRow() {
4295  const row = document.getElementById('filter-row');
4296  row.innerHTML = '';
4297  const makeChip = (label, key, val) => {
4298    const b = document.createElement('button');
4299    b.className = 'filter-chip' + (filter[key] === val ? ' active' : '');
4300    b.textContent = label;
4301    b.onclick = () => {
4302      filter[key] = val;
4303      renderFilterRow();
4304      renderSidebar();
4305      renderGlobe();   // re-plot the map dots for the chosen type filter
4306    };
4307    return b;
4308  };
4309  row.appendChild(makeChip('All', 'type', 'all'));
4310  Object.entries(TYPES).forEach(([k, v]) => row.appendChild(makeChip(v.label, 'type', k)));
4311}
4312
4313// Shared filter predicate so the sidebar list and the globe dots always show the
4314// SAME contacts — filtering the list also hides/shows the matching pins on the map.
4315function matchesFilter(c) {
4316  if (filter.type !== 'all' && c.contact_type !== filter.type) return false;
4317  if (filter.q) {
4318    const q = filter.q;
4319    return (c.name||'').toLowerCase().includes(q) ||
4320      (c.company||'').toLowerCase().includes(q) ||
4321      (c.country||'').toLowerCase().includes(q) ||
4322      (c.city||'').toLowerCase().includes(q) ||
4323      (c.address||'').toLowerCase().includes(q) ||
4324      (c.notes||'').toLowerCase().includes(q);
4325  }
4326  return true;
4327}
4328
4329// Debounced globe re-filter for per-keystroke search (the list stays instant).
4330let _globeFilterTimer = null;
4331function scheduleGlobeRefilter() { clearTimeout(_globeFilterTimer); _globeFilterTimer = setTimeout(renderGlobe, 150); }
4332
4333function renderSidebar() {
4334  const list = document.getElementById('contact-list');
4335  let rows = contacts.filter(matchesFilter);
4336  if (rows.length === 0) {
4337    list.innerHTML = `
4338      <div class="empty-state">
4339        <div class="big">∅</div>
4340        <p>${contacts.length === 0 ? 'No contacts yet. Click <b>New Contact</b> to add your first.' : 'Nothing matches that filter.'}</p>
4341      </div>`;
4342    return;
4343  }
4344  list.innerHTML = rows.map(c => {
4345    const unread = unreadByContact.get(c.id) || 0;
4346    const classes = [
4347      'contact-item',
4348      c.id === selectedId ? 'selected' : '',
4349      unread > 0 ? 'has-unread' : '',
4350    ].filter(Boolean).join(' ');
4351    return `
4352    <div class="${classes}" data-id="${c.id}">
4353      <div class="name">
4354        <span class="type-glyph">${TYPES[c.contact_type]?.glyph || '●'}</span>
4355        ${escapeHtml(c.name || 'Unnamed')}
4356        ${unread > 0 ? `<span class="unread-badge" title="${unread} unread">${unread > 99 ? '99+' : unread}</span>` : ''}
4357      </div>
4358      ${c.company ? `<div class="company">${escapeHtml(c.company)}</div>` : ''}
4359      <div class="meta">
4360        <span class="status-badge">
4361          <span class="pip" style="background:${STATUS[c.status]?.color || '#666'}"></span>
4362          ${STATUS[c.status]?.label || escapeHtml(c.status || '') || 'Lead'}
4363        </span>
4364        <span class="location">${escapeHtml(c.country || c.city || '—')}</span>
4365      </div>
4366    </div>`;
4367  }).join('');
4368  list.querySelectorAll('.contact-item').forEach(el => {
4369    el.addEventListener('click', () => selectContact(el.dataset.id, true));
4370  });
4371  // Clicking the unread badge opens the review popover instead of selecting.
4372  list.querySelectorAll('.unread-badge').forEach(b => {
4373    b.addEventListener('click', (e) => {
4374      e.stopPropagation();
4375      const row = b.closest('.contact-item');
4376      if (row) openUnreadPopover(row.dataset.id, b);
4377    });
4378  });
4379}
4380
4381// ── Selection ─────────────────────────────────────────────────
4382function selectContact(id, flyTo) {
4383  // BEFORE we change the active contact, flush any debounced saves that
4384  // are still queued for the previous contact. This prevents an in-flight
4385  // timer from firing later with the OLD captured id but stale-feeling
4386  // UI implications, and guarantees ordering: previous contact's writes
4387  // hit the server before we start typing into a new one.
4388  flushPendingContactSaves();
4389  flushPendingProjectSaves();
4390
4391  selectedId = id;
4392  const c = contacts.find(x => x.id === id);
4393  if (!c) return;
4394  document.getElementById('detail-empty').style.display = 'none';
4395  document.getElementById('detail-form').classList.add('show');
4396  fillDetailForm(c, false);
4397  renderSidebar();
4398  if (flyTo && typeof c.lat === 'number' && typeof c.lng === 'number') {
4399    globe.pointOfView({ lat: c.lat, lng: c.lng, altitude: 1.5 }, 1200);
4400  }
4401  document.getElementById('detail-panel').classList.add('open');
4402  // Reset to Overview tab on every contact switch
4403  setActiveTab('overview');
4404  // Load projects for this contact (fire-and-forget)
4405  loadProjectsFor(id);
4406  // AI link suggestions: unlinked projects/proposals that may belong to this
4407  // contact. Cached per entity; pending-only; Link refreshes the project list.
4408  if (window.QuasarLink) {
4409    QuasarLink.mountBanner(document.getElementById('link-suggest-host'), {
4410      type: 'contact', id,
4411      onLinked: () => { if (selectedId === id) loadProjectsFor(id); },
4412    });
4413  }
4414  // Reset email tab state for new selection — messages loaded lazily when tab opens
4415  gmailThreads = [];
4416  document.getElementById('tab-count-email').textContent = '0';
4417  loadMessagesFor(id);
4418  // Contact switch always lands on Overview — no need to auto-sync yet.
4419  stopAutoSync();
4420}
4421function clearSelection() {
4422  flushPendingContactSaves();
4423  flushPendingProjectSaves();
4424  selectedId = null;
4425  projects = [];
4426  const lsh = document.getElementById('link-suggest-host');
4427  if (lsh) { lsh.innerHTML = ''; delete lsh.dataset.qlinkFor; }
4428  document.getElementById('detail-empty').style.display = 'grid';
4429  document.getElementById('detail-form').classList.remove('show');
4430  document.getElementById('detail-panel').classList.remove('open');
4431  renderSidebar();
4432  renderProjects();
4433  stopAutoSync();
4434}
4435
4436// ── Tabs ──────────────────────────────────────────────────────
4437function setActiveTab(name) {
4438  document.querySelectorAll('.tab-btn').forEach(b => {
4439    b.classList.toggle('active', b.dataset.tab === name);
4440  });
4441  document.querySelectorAll('.tab-panel').forEach(p => {
4442    p.classList.toggle('active', p.dataset.panel === name);
4443  });
4444}
4445
4446// ── Projects ──────────────────────────────────────────────────
4447async function loadProjectsFor(contactId) {
4448  if (!sb || !contactId) return;
4449  const { data, error } = await sb
4450    .from('projects')
4451    .select('*')
4452    .eq('contact_id', contactId)
4453    .is('deleted_at', null)
4454    .order('created_at', { ascending: false });
4455  if (contactId !== selectedId) return;
4456  if (error) {
4457    console.error('loadProjects', error);
4458    projects = [];
4459  } else {
4460    projects = data || [];
4461  }
4462  renderProjects();
4463}
4464
4465function renderProjects() {
4466  const box = document.getElementById('project-list-container');
4467  const countEl = document.getElementById('tab-count-projects');
4468  if (!box) return;
4469  countEl.textContent = projects.length;
4470
4471  if (projects.length === 0) {
4472    box.innerHTML = `
4473      <div class="empty-mini">
4474        <div class="glyph">∅</div>
4475        <p>No projects yet. Add one — each boat build, each deal, each opportunity gets its own.</p>
4476      </div>`;
4477    return;
4478  }
4479
4480  box.innerHTML = projects.map(p => `
4481    <div class="project-item" data-id="${p.id}">
4482      <div class="phead">
4483        <span class="pname">${escapeHtml(p.name || 'Untitled')}</span>
4484        <span class="pstatus ${p.status || 'active'}">${escapeHtml((p.status || 'active').toUpperCase())}</span>
4485      </div>
4486      <div class="pmeta">
4487        ${p.stage ? `<span>◦ ${escapeHtml(p.stage)}</span>` : ''}
4488        ${p.value_aud ? `<span>◦ A$${Number(p.value_aud).toLocaleString()}</span>` : ''}
4489        ${p.close_date ? `<span>◦ ${escapeHtml(p.close_date)}</span>` : ''}
4490      </div>
4491      <div class="mini-bar-wrap" data-mini-bar>${renderProjectMiniBar(p)}</div>
4492      <div class="project-body">
4493        <div class="field">
4494          <label>Name</label>
4495          <input type="text" data-pf="name" value="${escapeHtml(p.name || '')}" />
4496        </div>
4497        <div class="prow">
4498          <div class="field">
4499            <label>Status</label>
4500            <select data-pf="status">
4501              <option value="active"  ${p.status==='active'?'selected':''}>Active</option>
4502              <option value="won"     ${p.status==='won'?'selected':''}>Won</option>
4503              <option value="lost"    ${p.status==='lost'?'selected':''}>Lost</option>
4504              <option value="paused"  ${p.status==='paused'?'selected':''}>Paused</option>
4505              <option value="shipped" ${p.status==='shipped'?'selected':''}>Shipped</option>
4506            </select>
4507          </div>
4508          <div class="field">
4509            <label>Target / Close Date</label>
4510            <input type="text" data-pf="close_date" value="${escapeHtml(p.close_date || '')}" placeholder="YYYY-MM-DD" />
4511          </div>
4512        </div>
4513        <div class="prow">
4514          <div class="field">
4515            <label>Stage</label>
4516            <input type="text" data-pf="stage" value="${escapeHtml(p.stage || '')}" placeholder="e.g. Awaiting NA sign-off" />
4517          </div>
4518          <div class="field">
4519            <label>Value (AUD)</label>
4520            <input type="number" data-pf="value_aud" value="${p.value_aud ?? ''}" placeholder="0" />
4521          </div>
4522        </div>
4523        <div class="field">
4524          <label>Notes</label>
4525          <textarea data-pf="notes" placeholder="Project-specific notes — build number, specs, key dates…">${escapeHtml(p.notes || '')}</textarea>
4526        </div>
4527        <div class="pactions">
4528          <a class="po-link" href="projects.html?project=${p.id}" title="Open in Projects workspace — stages, files, wiring">
4529            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
4530            Open in Projects ↗
4531          </a>
4532          <a class="po-link" href="proposal.html?contact_id=${p.contact_id || ''}&project_id=${p.id}" title="Start an AI-generated proposal deck for this project">
4533            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M3 10h18"/><path d="M8 4v14"/></svg>
4534            Build Proposal ↗
4535          </a>
4536          <button data-paction="delete">✕ Delete project</button>
4537        </div>
4538      </div>
4539    </div>
4540  `).join('');
4541
4542  // Wire up expand/collapse on header click
4543  box.querySelectorAll('.project-item').forEach(el => {
4544    const head = el.querySelector('.phead');
4545    head.addEventListener('click', () => el.classList.toggle('expanded'));
4546
4547    // Bind each input in the body
4548    el.querySelectorAll('[data-pf]').forEach(input => {
4549      const field = input.dataset.pf;
4550      input.addEventListener('input', () => scheduleProjectSave(el.dataset.id, field, input.value, input.tagName === 'SELECT'));
4551      input.addEventListener('change', () => {
4552        // On <select> change / <input> blur, flush immediately
4553        flushProjectSave(el.dataset.id, field, input.value);
4554        // Project status drives the contact's CRM status (won → customer …)
4555        if (field === 'status') {
4556          const p = projects.find(x => x.id === el.dataset.id);
4557          if (p) syncContactStatusFromProject(p);
4558        }
4559      });
4560    });
4561
4562    // Clickable lifecycle bar — moves the stage + syncs the contact status
4563    bindProjectMiniBar(el, el.dataset.id);
4564
4565    // Delete button
4566    el.querySelector('[data-paction="delete"]').addEventListener('click', async (ev) => {
4567      ev.stopPropagation();
4568      const p = projects.find(x => x.id === el.dataset.id);
4569      if (!p) return;
4570      if (!confirm(`Delete project "${p.name || 'Untitled'}"? This cannot be undone.`)) return;
4571      await deleteProject(el.dataset.id);
4572    });
4573  });
4574}
4575
4576// Hardened project save layer (mirrors the contact one). Captures pid +
4577// field + value into projectDirty so a pagehide flush can persist it even
4578// if the timer hasn't fired yet, and flush by id so a project switch
4579// commits the previous project's edits to the right row.
4580const PROJECT_SAVE_DEBOUNCE_MS = 500;
4581
4582function scheduleProjectSave(pid, field, value, immediate) {
4583  if (!pid) return;
4584  const p = projects.find(x => x.id === pid);
4585  if (!p) return;
4586
4587  // Coerce value for numeric / date fields
4588  let v = value;
4589  if (field === 'value_aud') v = v === '' ? null : Number(v);
4590  p[field] = v;  // optimistic local
4591
4592  const timerKey = pid + '::' + field;
4593  clearTimeout(projectSaveTimers.get(timerKey));
4594  projectDirty.set(timerKey, { id: pid, field, value: v, attempts: 0, failed: false });
4595
4596  if (immediate) {
4597    return commitProjectSave(pid, field, v);
4598  }
4599  projectSaveTimers.set(timerKey, setTimeout(() => commitProjectSave(pid, field, v), PROJECT_SAVE_DEBOUNCE_MS));
4600  refreshSaveIndicator();
4601}
4602
4603// Drain ALL queued project saves immediately. Called before nav events,
4604// contact switches, deletions, sign-out, pagehide.
4605function flushPendingProjectSaves() {
4606  const promises = [];
4607  for (const [key, entry] of Array.from(projectDirty.entries())) {
4608    const t = projectSaveTimers.get(key);
4609    if (t) { clearTimeout(t); projectSaveTimers.delete(key); }
4610    promises.push(commitProjectSave(entry.id, entry.field, entry.value));
4611  }
4612  return Promise.all(promises);
4613}
4614
4615// Legacy alias — old call sites use flushProjectSave(pid, field, value).
4616async function flushProjectSave(pid, field, value) {
4617  return commitProjectSave(pid, field, value);
4618}
4619
4620async function commitProjectSave(pid, field, value) {
4621  if (!pid) return;
4622  const timerKey = pid + '::' + field;
4623  const t = projectSaveTimers.get(timerKey);
4624  if (t) { clearTimeout(t); projectSaveTimers.delete(timerKey); }
4625
4626  const entry = projectDirty.get(timerKey) || { id: pid, field, value, attempts: 0, failed: false };
4627  entry.value = value;
4628  entry.attempts = (entry.attempts || 0) + 1;
4629
4630  if (isOffline) {
4631    projectDirty.set(timerKey, entry);
4632    refreshSaveIndicator();
4633    return;
4634  }
4635
4636  let v = value;
4637  if (field === 'value_aud') v = (v === '' || v === null) ? null : Number(v);
4638
4639  // Optimistic concurrency: read the local row's version and pin it onto
4640  // the UPDATE. If someone else moved on the row, the eq fails and we
4641  // detect the conflict.
4642  const local = projects.find(x => x.id === pid);
4643  const baseVersion = local && typeof local.version === 'number' ? local.version : null;
4644
4645  inFlightProjectSaves++;
4646  refreshSaveIndicator();
4647
4648  let q = sb.from('projects').update({ [field]: v }).eq('id', pid);
4649  if (baseVersion != null) q = q.eq('version', baseVersion);
4650  const { data, error } = await q.select().single();
4651
4652  inFlightProjectSaves--;
4653
4654  if (error && error.code === 'PGRST116') {
4655    // Conflict — refresh from server, then retry against the new version.
4656    const { data: server } = await sb.from('projects').select('*').eq('id', pid).maybeSingle();
4657    if (server && server[field] !== v) {
4658      console.warn('project conflict on field', field, '— keeping dirty for review');
4659      entry.failed = true; failedDirty++;
4660      projectDirty.set(timerKey, entry);
4661      refreshSaveIndicator();
4662      return;
4663    }
4664    if (server) {
4665      const i = projects.findIndex(x => x.id === pid);
4666      if (i > -1) projects[i] = server;
4667      try {
4668        inFlightProjectSaves++;
4669        refreshSaveIndicator();
4670        const { data: second, error: err2 } = await sb.from('projects')
4671          .update({ [field]: v }).eq('id', pid).eq('version', server.version)
4672          .select().single();
4673        inFlightProjectSaves--;
4674        if (!err2) {
4675          if (entry.failed) failedDirty--;
4676          projectDirty.delete(timerKey);
4677          const i2 = projects.findIndex(x => x.id === pid); if (i2 > -1) projects[i2] = second;
4678          refreshSaveIndicator();
4679          writeAudit('projects', pid, 'update', null, { [field]: v }, 'index.html');
4680          return;
4681        }
4682      } catch {}
4683    }
4684    entry.failed = true; failedDirty++;
4685    projectDirty.set(timerKey, entry);
4686    refreshSaveIndicator();
4687    return;
4688  }
4689
4690  if (error) {
4691    console.error('project update', error);
4692    entry.failed = true; failedDirty++;
4693    projectDirty.set(timerKey, entry);
4694    scheduleProjectRetry(timerKey, entry);
4695    refreshSaveIndicator();
4696    return;
4697  }
4698
4699  // Success
4700  if (entry.failed) failedDirty--;
4701  projectDirty.delete(timerKey);
4702  refreshSaveIndicator();
4703  writeAudit('projects', pid, 'update', null, { [field]: v }, 'index.html');
4704
4705  const i = projects.findIndex(x => x.id === pid);
4706  if (i > -1) projects[i] = data;
4707  // Re-render collapsed header (name/status/meta may have changed) — but
4708  // only if the user isn't actively typing — don't re-render from under them.
4709  const itemEl = document.querySelector(`.project-item[data-id="${pid}"]`);
4710  if (itemEl) {
4711    const head = itemEl.querySelector('.phead');
4712    head.querySelector('.pname').textContent = data.name || 'Untitled';
4713    const ps = head.querySelector('.pstatus');
4714    ps.className = 'pstatus ' + (data.status || 'active');
4715    ps.textContent = (data.status || 'active').toUpperCase();
4716    const meta = itemEl.querySelector('.pmeta');
4717    meta.innerHTML = [
4718      data.stage ? `<span>◦ ${escapeHtml(data.stage)}</span>` : '',
4719      data.value_aud ? `<span>
4719◦ A$${Number(data.value_aud).toLocaleString()}</span>` : '',
4720      data.close_date ? `<span>◦ ${escapeHtml(data.close_date)}</span>` : ''
4721    ].filter(Boolean).join('');
4722    // Refresh the mini lifecycle bar — stages may have changed elsewhere
4723    // (projects.html in another tab, or a status flip here)
4724    const miniWrap = itemEl.querySelector('[data-mini-bar]');
4725    if (miniWrap) { miniWrap.innerHTML = renderProjectMiniBar(data); bindProjectMiniBar(itemEl, pid); }
4726  }
4727  document.getElementById('tab-count-projects').textContent = projects.length;
4728}
4729
4730// Default project lifecycle stages (matches projects.html DEFAULT_STAGES)
4731const DEFAULT_PROJECT_STAGES = [
4732  'Lead', 'Discovery', 'Proposal', 'Quoted',
4733  'Negotiating', 'Won', 'In Production', 'Delivered',
4734  'Installed', 'Complete'
4735];
4736function buildDefaultStages() {
4737  return DEFAULT_PROJECT_STAGES.map((name, i) => ({ name, state: i === 0 ? 'active' : 'pending' }));
4738}
4739
4740// Render the contact-level mini stages bar. CLICKABLE — clicking a segment
4741// moves the project to that stage (same semantics as projects.html) and
4742// syncs the contact's CRM status. Hidden when the project is fully complete
4743// (so finished projects don't visually clutter the contact view).
4744function renderProjectMiniBar(p) {
4745  const stages = Array.isArray(p.stages) && p.stages.length ? p.stages : null;
4746  if (!stages) return '';
4747  const last = stages[stages.length - 1];
4748  const allComplete = stages.every(s => s.state === 'complete');
4749  const lastIsActiveComplete = last && last.state === 'active' && (last.name || '').toLowerCase() === 'complete';
4750  if (allComplete || lastIsActiveComplete) return '';
4751  const segs = stages.map((s, i) =>
4752    `<span class="seg ${escapeHtml(s.state || 'pending')}" data-idx="${i}" title="${escapeHtml(s.name)}${s.done_at ? ' · ' + escapeHtml(s.done_at) : ''}"></span>`
4753  ).join('');
4754  const active = stages.find(s => s.state === 'active');
4755  return `
4756    <div class="mini-bar">${segs}</div>
4757    ${active ? `<div class="mini-bar-label"><span class="stage-name">${escapeHtml(active.name)}</span>${active.done_at ? `<span class="stage-when">· ${escapeHtml(active.done_at)}</span>` : ''}</div>` : ''}
4758  `;
4759}
4760
4761// Make a card's mini-bar segments clickable.
4762function bindProjectMiniBar(itemEl, pid) {
4763  itemEl.querySelectorAll('[data-mini-bar] .seg').forEach(seg => {
4764    seg.addEventListener('click', (e) => {
4765      e.stopPropagation();
4766      setProjectStageFromMini(pid, parseInt(seg.dataset.idx, 10));
4767    });
4768  });
4769}
4770
4771// Click stage i on a CRM project card: update the stages array (complete
4772// before, active at i, pending after), save through the normal project save
4773// path, repaint the bar, and sync the contact's CRM status.
4774function setProjectStageFromMini(pid, idx) {
4775  const p = projects.find(x => x.id === pid);
4776  if (!p) return;
4777  if (!Array.isArray(p.stages) || !p.stages.length) p.stages = buildDefaultStages();
4778  if (!QuasarLink.applyActiveStage(p.stages, idx)) return;
4779  scheduleProjectSave(pid, 'stages', p.stages, true);
4780  const itemEl = document.querySelector(`.project-item[data-id="${pid}"]`);
4781  if (itemEl) {
4782    const wrap = itemEl.querySelector('[data-mini-bar]');
4783    if (wrap) { wrap.innerHTML = renderProjectMiniBar(p); bindProjectMiniBar(itemEl, pid); }
4784  }
4785  syncContactStatusFromProject(p);
4786}
4787
4788// ── Stage ↔ status sync (project → contact) ───────────────────────
4789// A project's lifecycle drives its contact's CRM status: moving the active
4790// stage (here or in projects.html) keeps the funnel position in one place.
4791function syncContactStatusFromProject(p) {
4792  const cid = p.contact_id || selectedId;
4793  const c = contacts.find(x => x.id === cid);
4794  if (!c) return;
4795  let status = null;
4796  if ((p.status || 'active') === 'lost') status = 'lost';
4797  else if ((p.status || 'active') === 'paused') status = 'paused';
4798  else if ((p.status || 'active') === 'won' || (p.status || 'active') === 'shipped') status = 'customer';
4799  else {
4800    const stages = Array.isArray(p.stages) ? p.stages : [];
4801    const active = stages.find(s => s.state === 'active');
4802    if (stages.length && stages.every(s => s.state === 'complete')) status = 'customer';
4803    else if (active) status = QuasarLink.stageToStatus(active.name);
4804  }
4805  if (!status || c.status === status) return;
4806  c.status = status;
4807  commitContactSave(c.id, 'status', status);
4808  if (selectedId === c.id) {
4809    const sel = document.getElementById('df-status');
4810    if (sel) sel.value = status;
4811  }
4812  renderSidebar();
4813  renderGlobe();
4814}
4815
4816// ── Stage ↔ status sync (contact → project) ───────────────────────
4817// Changing the contact's CRM status moves its project too — but only when
4818// that's unambiguous: exactly one non-closed project. paused/lost map to the
4819// project's status field; other statuses to the matching lifecycle stage.
4820function syncProjectFromContactStatus(contactId, status) {
4821  const open = projects.filter(p => p.contact_id === contactId || (!p.contact_id && contactId === selectedId))
4822    .filter(p => (p.status || 'active') === 'active');
4823  if (open.length !== 1) return;
4824  const p = open[0];
4825  const m = QuasarLink.statusToStage(status);
4826  if (!m) return;
4827  if (m.projectStatus) {
4828    if (p.status === m.projectStatus) return;
4829    p.status = m.projectStatus;
4830    scheduleProjectSave(p.id, 'status', p.status, true);
4831  } else if (m.stageName) {
4832    const stages = Array.isArray(p.stages) ? p.stages : [];
4833    const idx = stages.findIndex(s => (s.name || '').trim().toLowerCase() === m.stageName.toLowerCase());
4834    if (idx === -1 || stages[idx].state === 'active') return;
4835    QuasarLink.applyActiveStage(stages, idx);
4836    scheduleProjectSave(p.id, 'stages', stages, true);
4837  }
4838  // Repaint the card so the bar/status pill reflect the move.
4839  renderProjects();
4840}
4841
4842// Retry helper for project saves — symmetric with scheduleContactRetry.
4843function scheduleProjectRetry(key, entry) {
4844  const attempts = Math.max(1, entry.attempts || 1);
4845  const delay = Math.min(30000, 1000 * Math.pow(2, attempts - 1));
4846  setTimeout(() => {
4847    const cur = projectDirty.get(key);
4848    if (!cur) return;
4849    if (cur.failed) {
4850      cur.failed = false;
4851      failedDirty = Math.max(0, failedDirty - 1);
4852    }
4853    commitProjectSave(cur.id, cur.field, cur.value).catch(() => {});
4854  }, delay);
4855}
4856
4857async function addProject() {
4858  if (!selectedId) return;
4859  const payload = { contact_id: selectedId, name: 'New project', status: 'active', stages: buildDefaultStages() };
4860  inFlightProjectSaves++; refreshSaveIndicator();
4861  const { data, error } = await sb.from('projects').insert(payload).select().single();
4862  inFlightProjectSaves--; refreshSaveIndicator();
4863  if (error) {
4864    setSaveState('error', 'Create failed');
4865    console.error('addProject', error);
4866    return;
4867  }
4868  writeAudit('projects', data.id, 'create', null, payload, 'index.html');
4869  projects.unshift(data);
4870  renderProjects();
4871  // Auto-expand the new one and focus the name input for immediate rename
4872  setTimeout(() => {
4873    const el = document.querySelector(`.project-item[data-id="${data.id}"]`);
4874    if (el) {
4875      el.classList.add('expanded');
4876      const input = el.querySelector('[data-pf="name"]');
4877      if (input) { input.focus(); input.select(); }
4878    }
4879  }, 50);
4880}
4881
4882// Soft-delete the project: mark deleted_at instead of removing the row.
4883async function deleteProject(pid) {
4884  // Flush any pending saves for this project so we don't lose the user's
4885  // last edits to the audit/recovery trail before the row is hidden.
4886  await flushPendingProjectSaves();
4887  setSaveState('saving', 'Deleting');
4888  const userId = (await sb.auth.getUser()).data.user?.id || null;
4889  const { error } = await sb.from('projects')
4890    .update({ deleted_at: new Date().toISOString(), deleted_by: userId })
4891    .eq('id', pid);
4892  if (error) {
4893    setSaveState('error', 'Delete failed');
4894    console.error('deleteProject', error);
4895    return;
4896  }
4897  writeAudit('projects', pid, 'soft-delete', null, null, 'index.html');
4898  projects = projects.filter(p => p.id !== pid);
4899  renderProjects();
4900  refreshSaveIndicator();
4901}
4902
4903// ── Detail form ───────────────────────────────────────────────
4904let fillingForm = false;
4905
4906function fillDetailForm(c, fromInsert) {
4907  fillingForm = true;
4908  document.getElementById('df-name').value     = c.name || '';
4909  document.getElementById('df-company').value  = c.company || '';
4910  document.getElementById('df-type').value     = c.contact_type || 'other';
4911  document.getElementById('df-status').value   = c.status || 'lead';
4912  const repSel = document.getElementById('df-rep');
4913  if (repSel) {
4914    // Show the assigned rep even if they're not in the current assignable roster
4915    // (e.g. their role changed since) so the value never silently reads "Unassigned".
4916    if (c.rep_id && !repSel.querySelector(`option[value="${c.rep_id}"]`)) {
4917      const o = document.createElement('option'); o.value = c.rep_id; o.textContent = '(assigned rep)'; repSel.appendChild(o);
4918    }
4919    repSel.value = c.rep_id || '';
4920  }
4921  renderEmails(contactEmails(c));
4922  document.getElementById('df-invoice-email').value = c.invoice_email || '';
4923  document.getElementById('df-phone').value    = c.phone || '';
4924  document.getElementById('df-website').value  = c.website || '';
4925  document.getElementById('df-address').value  = c.address || '';
4926  document.getElementById('df-notes').value    = c.notes || '';
4927  document.getElementById('df-last-contact').value = c.last_contact || '';
4928  document.getElementById('df-id').textContent = 'ID · ' + c.id.slice(0, 8);
4929  document.getElementById('df-coords').textContent =
4930    (typeof c.lat === 'number' && typeof c.lng === 'number')
4931      ? `${c.lat.toFixed(4)}°, ${c.lng.toFixed(4)}°`
4932      : 'Location unset';
4933  document.getElementById('df-coord-display').textContent =
4934    (typeof c.lat === 'number' && typeof c.lng === 'number')
4935      ? `lat ${c.lat.toFixed(4)}, lng ${c.lng.toFixed(4)}  ·  ${[c.city, c.country].filter(Boolean).join(', ') || '—'}`
4936      : 'Location unset — search address above and click Locate';
4937  // Structured billing + shipping (Woo address objects).
4938  const bill = (c.billing && typeof c.billing === 'object') ? c.billing : {};
4939  const ship = (c.shipping && typeof c.shipping === 'object') ? c.shipping : {};
4940  const setAddr = (id, v) => { const el = document.getElementById(id); if (el) el.value = v || ''; };
4941  setAddr('df-billing-address1', bill.address_1); setAddr('df-billing-address2', bill.address_2);
4942  setAddr('df-billing-city', bill.city); setAddr('df-billing-state', bill.state);
4943  setAddr('df-billing-postcode', bill.postcode); setAddr('df-billing-country', bill.country);
4944  setAddr('df-billing-phone', bill.phone);
4945  setAddr('df-shipping-address1', ship.address_1); setAddr('df-shipping-address2', ship.address_2);
4946  setAddr('df-shipping-city', ship.city); setAddr('df-shipping-state', ship.state);
4947  setAddr('df-shipping-postcode', ship.postcode); setAddr('df-shipping-country', ship.country);
4948  renderTags(c.tags || []);
4949  renderPriorityStars(c.priority || 0);
4950  renderProductToggles(c.products || []);
4951  applyDetailReadonly(c);
4952  fillingForm = false;
4953}
4954
4955// Render the 5 product toggle buttons reflecting the contact's `products`.
4956function renderProductToggles(products) {
4957  const wrap = document.getElementById('df-products');
4958  if (!wrap) return;
4959  const set = new Set(products || []);
4960  wrap.innerHTML = PRODUCT_KEYS.map(k => {
4961    const p = PRODUCTS[k];
4962    const on = set.has(k);
4963    return `<button type="button" class="product-btn${on ? ' on' : ''}" data-product="${k}" aria-pressed="${on}" title="${escapeHtml(p.label)}"><img src="${p.img}" alt="" /><span>${escapeHtml(p.short)}</span></button>`;
4964  }).join('');
4965}
4966
4967// Contacts are team-shared: ANY Safiery member can edit ANY contact (the gateway
4968// allows team writes and edits sync to WooCommerce team-wide), so the form is
4969// always editable. (ro kept as a switch in case a future role needs view-only.)
4970function applyDetailReadonly(c) {
4971  const ro = false;
4972  ['df-name','df-company','df-type','df-status','df-phone','df-website','df-address','df-notes','df-last-contact','df-email-input','df-tag-input','df-geocode']
4973    .forEach(id => { const el = document.getElementById(id); if (el) el.disabled = ro; });
4974  const wrap = document.getElementById('df-products'); if (wrap) wrap.classList.toggle('locked', ro);
4975  let banner = document.getElementById('df-readonly');
4976  if (ro) {
4977    if (!banner) {
4978      banner = document.createElement('div');
4979      banner.id = 'df-readonly'; banner.className = 'readonly-banner';
4980      const hdr = document.querySelector('.detail-header');
4981      if (hdr && hdr.parentNode) hdr.parentNode.insertBefore(banner, hdr.nextSibling);
4982    }
4983    banner.textContent = '◷ Team contact — view only (owned by another Safiery user).';
4984    banner.style.display = '';
4985  } else if (banner) { banner.style.display = 'none'; }
4986}
4987
4988function bindFormFields() {
4989  const bind = (id, field, parse) => {
4990    const el = document.getElementById(id);
4991    el.addEventListener('input', () => {
4992      // Capture the contact id at the moment of the keystroke. If the user
4993      // switches contact mid-debounce, we still write to the original row.
4994      const contactId = selectedId;
4995      if (!contactId || fillingForm) return;
4996      scheduleContactSave(contactId, field, parse ? parse(el.value) : el.value);
4997    });
4998    el.addEventListener('blur', () => {
4999      const contactId = selectedId;
5000      if (!contactId) return;
5001      flushContactField(contactId, field);
5002    });
5003  };
5004  bind('df-name',    'name');
5005  bind('df-company', 'company');
5006  bind('df-invoice-email', 'invoice_email');
5007  bind('df-phone',   'phone');
5008  bind('df-website', 'website');
5009  bind('df-address', 'address');
5010  bind('df-notes',   'notes');
5011  bind('df-last-contact', 'last_contact');
5012
5013  // Billing + shipping addresses (jsonb). On any sub-field edit we re-read the
5014  // WHOLE address from the form and save it as one object — so partial edits are
5015  // never lost — which flows through upsertContact → the Woo customer push.
5016  const BILL_F = ['address1','address2','city','state','postcode','country','phone'];
5017  const SHIP_F = ['address1','address2','city','state','postcode','country'];
5018  const readAddr = (kind, fields) => {
5019    const o = {};
5020    const map = { address1: 'address_1', address2: 'address_2', city: 'city', state: 'state', postcode: 'postcode', country: 'country', phone: 'phone' };
5021    fields.forEach(s => { const el = document.getElementById('df-' + kind + '-' + s); o[map[s]] = el ? el.value.trim() : ''; });
5022    return o;
5023  };
5024  const bindAddr = (kind, fields) => fields.forEach(s => {
5025    const el = document.getElementById('df-' + kind + '-' + s);
5026    if (!el) return;
5027    el.addEventListener('input', () => { const id = selectedId; if (!id || fillingForm) return; scheduleContactSave(id, kind, readAddr(kind, fields)); });
5028    el.addEventListener('blur', () => { const id = selectedId; if (id) flushContactField(id, kind); });
5029  });
5030  bindAddr('billing', BILL_F);
5031  bindAddr('shipping', SHIP_F);
5032  const shipCopy = document.getElementById('df-ship-copy');
5033  if (shipCopy) shipCopy.addEventListener('click', () => {
5034    const id = selectedId; if (!id) return;
5035    SHIP_F.forEach(s => { const src = document.getElementById('df-billing-' + s), dst = document.getElementById('df-shipping-' + s); if (src && dst) dst.value = src.value; });
5036    scheduleContactSave(id, 'shipping', readAddr('shipping', SHIP_F));
5037  });
5038
5039  // Address search-and-pick: type in line 1, choose a match, the block fills.
5040  // The picker fires 'input' on each filled field, so the bindAddr autosave
5041  // above runs exactly as if the user typed it. quasar-address.js loads
5042  // deferred, so try now AND on DOMContentLoaded, whichever comes after it
5043  // (attach is idempotent, a double call is harmless).
5044  const wireAddressPickers = () => {
5045    if (!window.QuasarAddress) return;
5046    ['billing', 'shipping'].forEach(kind => QuasarAddress.attach('df-' + kind + '-address1', {
5047      address_2: 'df-' + kind + '-address2', city: 'df-' + kind + '-city', state: 'df-' + kind + '-state',
5048      postcode: 'df-' + kind + '-postcode', country: 'df-' + kind + '-country'
5049    }));
5050  };
5051  wireAddressPickers();
5052  document.addEventListener('DOMContentLoaded', wireAddressPickers);
5053
5054  // Selects: write immediately against the captured contact id.
5055  document.getElementById('df-type').addEventListener('change', e => {
5056    const contactId = selectedId;
5057    if (!contactId) return;
5058    commitContactSave(contactId, 'contact_type', e.target.value);
5059  });
5060  document.getElementById('df-status').addEventListener('change', e => {
5061    const contactId = selectedId;
5062    if (!contactId) return;
5063    commitContactSave(contactId, 'status', e.target.value);
5064    // Keep the linked project's lifecycle in step (only when unambiguous).
5065    syncProjectFromContactStatus(contactId, e.target.value);
5066  });
5067  // Account rep — the salesperson tagged on this account's Xero invoices. Empty -> null.
5068  const repSel = document.getElementById('df-rep');
5069  if (repSel) repSel.addEventListener('change', e => {
5070    const contactId = selectedId;
5071    if (!contactId) return;
5072    commitContactSave(contactId, 'rep_id', e.target.value || null);
5073  });
5074
5075  // Product toggles — flip a product on the selected contact's `products` array.
5076  document.getElementById('df-products').addEventListener('click', e => {
5077    const btn = e.target.closest('.product-btn');
5078    if (!btn) return;
5079    const contactId = selectedId;
5080    const c = contacts.find(x => x.id === contactId);
5081    if (!contactId || !c || !contactIsMine(c)) return;   // view-only for team contacts
5082    const key = btn.dataset.product;
5083    const set = new Set(c.products || []);
5084    if (set.has(key)) set.delete(key); else set.add(key);
5085    btn.classList.toggle('on', set.has(key));
5086    btn.setAttribute('aria-pressed', set.has(key));
5087    // HQ-1335: keep any key this map does not show (the Globe panel's newer
5088    // ranges - jupiter, induction_hot_water, quasar), so a toggle here never
5089    // silently drops a tag ticked there.
5090    const kept = (c.products || []).filter(k => k && !PRODUCT_KEYS.includes(k));
5091    commitContactSave(contactId, 'products', PRODUCT_KEYS.filter(k => set.has(k)).concat(kept));
5092  });
5093
5094  document.getElementById('df-geocode').addEventListener('click', runGeocodeFromForm);
5095
5096  document.getElementById('df-flyto').addEventListener('click', () => {
5097    const c = contacts.find(x => x.id === selectedId);
5098    if (c && typeof c.lat === 'number') globe.pointOfView({ lat: c.lat, lng: c.lng, altitude: 0.8 }, 1400);
5099  });
5100  document.getElementById('df-duplicate').addEventListener('click', async () => {
5101    const c = contacts.find(x => x.id === selectedId);
5102    if (!c) return;
5103    const copy = { ...c };
5104    delete copy.id; delete copy.created_at; delete copy.updated_at;
5105    // Per-account pricing never propagates silently: the db function refuses a
5106    // non-manager insert that carries custom_pricing / discount_tier (they
5107    // override the locked B2B policy), so start the duplicate at standard
5108    // pricing - a sales manager re-assigns the tier on the new record.
5109    delete copy.custom_pricing; delete copy.discount_tier;
5110    copy.name = (c.name || '') + ' (copy)';
5111    const row = await insertContact(copy);
5112    if (row) {
5113      contacts.unshift(row);
5114      selectContact(row.id, false);
5115      renderAll();
5116    }
5117  });
5118  document.getElementById('df-delete').addEventListener('click', async () => {
5119    const c = contacts.find(x => x.id === selectedId);
5120    if (!c) return;
5121    if (!confirm(`Delete "${c.name || 'Unnamed'}"? It will be hidden from the list but kept in Supabase (soft delete) — audit log keeps a full record.`)) return;
5122    // Flush ANY pending edits before deletion so the audit log captures
5123    // the user's last keystrokes alongside the delete event.
5124    await flushPendingContactSaves();
5125    const ok = await deleteContact(c.id);
5126    if (ok) {
5127      contacts = contacts.filter(x => x.id !== c.id);
5128      clearSelection();
5129      renderAll();
5130    }
5131  });
5132  // Dismiss the detail drawer (the only affordance that closes the off-canvas
5133  // mobile drawer; clearSelection() removes .open + returns to the empty state).
5134  document.getElementById('df-close').addEventListener('click', clearSelection);
5135
5136  // Tags
5137  const tagInput = document.getElementById('df-tag-input');
5138  tagInput.addEventListener('keydown', async e => {
5139    if (e.key === 'Enter' || e.key === ',') {
5140      e.preventDefault();
5141      const val = tagInput.value.trim().replace(/,$/, '');
5142      if (!val) return;
5143      const contactId = selectedId;
5144      const c = contacts.find(x => x.id === contactId);
5145      if (!c) return;
5146      const tags = Array.from(new Set([...(c.tags || []), val]));
5147      tagInput.value = '';
5148      c.tags = tags;          // optimistic local
5149      renderTags(tags);
5150      commitContactSave(contactId, 'tags', tags);
5151    }
5152  });
5153
5154  // Emails (multi)
5155  const emailInput = document.getElementById('df-email-input');
5156  emailInput.addEventListener('keydown', async e => {
5157    if (e.key === 'Enter' || e.key === ',') {
5158      e.preventDefault();
5159      const val = emailInput.value.trim().replace(/,$/, '').toLowerCase();
5160      if (!val) return;
5161      if (!validEmail(val)) { emailInput.classList.add('invalid'); setTimeout(() => emailInput.classList.remove('invalid'), 600); return; }
5162      const c = contacts.find(x => x.id === selectedId);
5163      if (!c) return;
5164      const cur = contactEmails(c);
5165      if (cur.includes(val)) { emailInput.value = ''; return; }
5166      const next = [...cur, val];
5167      emailInput.value = '';
5168      await saveEmails(next);
5169      renderEmails(next);
5170    }
5171  });
5172  emailInput.addEventListener('blur', async () => {
5173    // Commit a pending value on blur so Jonathan doesn't lose what he typed
5174    const val = emailInput.value.trim().toLowerCase();
5175    if (!val) return;
5176    if (!validEmail(val)) return;
5177    const c = contacts.find(x => x.id === selectedId); if (!c) return;
5178    const cur = contactEmails(c);
5179    if (cur.includes(val)) { emailInput.value = ''; return; }
5180    const next = [...cur, val];
5181    emailInput.value = '';
5182    await saveEmails(next);
5183    renderEmails(next);
5184  });
5185}
5186
5187// ── Hardened contact save layer ───────────────────────────────
5188// Public API (used everywhere in this file):
5189//   scheduleContactSave(contactId, field, value)   — debounced (500ms)
5190//   commitContactSave(contactId, field, value)     — immediate (selects, etc.)
5191//   flushContactField(contactId, field)            — fire any pen
5191ding timer for that field
5192//   flushPendingContactSaves()                     — fire ALL pending contact timers
5193//
5194// Every code path captures the contactId at the moment the user touched the
5195// field. The timer key is `${contactId}::${field}` so the next contact's
5196// debounced edits cannot collide with the previous contact's queue, and
5197// commit always writes to the captured id — never to the global selectedId.
5198//
5199// Legacy aliases scheduleSaveField / flushSaveField / commitField are kept
5200// for any internal call site we missed; they just forward the current
5201// selectedId into the new id-aware functions.
5202
5203const CONTACT_SAVE_DEBOUNCE_MS = 500;
5204
5205function scheduleContactSave(contactId, field, value) {
5206  if (fillingForm || !contactId) return;
5207  const c = contacts.find(x => x.id === contactId);
5208  if (!c) return;
5209  // Optimistic local update — UI stays responsive even if Supabase is slow.
5210  c[field] = value;
5211  const key = contactId + '::' + field;
5212  clearTimeout(contactSaveTimers.get(key));
5213  // Pin the captured contactId + field + value into the dirty map so a
5214  // pagehide/visibilitychange flush can see and persist it even if the
5215  // timer hasn't fired yet.
5216  contactDirty.set(key, { id: contactId, field, value, attempts: 0, failed: false });
5217  contactSaveTimers.set(key, setTimeout(() => commitContactSave(contactId, field, value), CONTACT_SAVE_DEBOUNCE_MS));
5218  refreshSaveIndicator();
5219}
5220
5221async function commitContactSave(contactId, field, value) {
5222  if (!contactId) return;
5223  const key = contactId + '::' + field;
5224  // Cancel a queued timer for this exact field — we're firing it now.
5225  const t = contactSaveTimers.get(key);
5226  if (t) { clearTimeout(t); contactSaveTimers.delete(key); }
5227
5228  const entry = contactDirty.get(key) || { id: contactId, field, value, attempts: 0, failed: false };
5229  entry.value = value;          // pick up the latest typed value
5230  entry.attempts = (entry.attempts || 0) + 1;
5231
5232  if (isOffline) {
5233    contactDirty.set(key, entry);
5234    refreshSaveIndicator();
5235    return;
5236  }
5237
5238  inFlightContactSaves++;
5239  refreshSaveIndicator();
5240
5241  let result;
5242  try {
5243    result = await upsertContact(contactId, { [field]: value });
5244  } catch (err) {
5245    inFlightContactSaves--;
5246    // A server refusal (HQ-1394: e.g. the sales-rep gate) can never succeed by
5247    // retrying - treat it as final, not transient. Drop the entry, revert the
5248    // control to the saved value, and surface why instead of looping the
5249    // generic "retrying" state forever.
5250    if (err && err.code === 'FORBIDDEN') {
5251      console.warn('contact save refused', err);
5252      if (entry.failed) failedDirty--;
5253      contactDirty.delete(key);
5254      if (field === 'rep_id') {
5255        const repSel = document.getElementById('df-rep');
5256        const c = contacts.find(x => x.id === contactId);
5257        if (repSel) repSel.value = (c && c.rep_id != null) ? c.rep_id : '';
5258      }
5259      refreshSaveIndicator();
5260      setSaveState('error', field === 'rep_id'
5261        ? 'Not saved: you are not allowed to change the account rep'
5262        : 'Not saved: you do not have permission to change this');
5263      return;
5264    }
5265    console.error('contact save failed', err);
5266    entry.failed = true;
5267    failedDirty++;
5268    contactDirty.set(key, entry);
5269    scheduleContactRetry(key, entry);
5270    refreshSaveIndicator();
5271    return;
5272  }
5273  inFlightContactSaves--;
5274
5275  if (result && result.conflict) {
5276    // The row on the server moved on. Try to merge: if the OTHER side changed
5277    // a different field, our local field-level write is still valid — bump
5278    // our base version and retry once. If the same field changed remotely,
5279    // keep the entry dirty and warn (we never silently overwrite newer data).
5280    const server = result.server;
5281    if (server && server[field] !== value) {
5282      // Same field changed elsewhere; defer to user resolution.
5283      console.warn('contact conflict on field', field, '— keeping dirty for review');
5284      entry.failed = true;
5285      failedDirty++;
5286      contactDirty.set(key, entry);
5287      refreshSaveIndicator();
5288      // Don't auto-retry; user must edit again to override.
5289      return;
5290    }
5291    // Different field: refresh local cache to the server version, then retry
5292    // the same field-level write so the user's edit lands on top.
5293    if (server) {
5294      const i = contacts.findIndex(x => x.id === contactId);
5295      if (i > -1) contacts[i] = server;
5296      // One retry against the new version; bail if it conflicts a second time.
5297      try {
5298        const second = await upsertContact(contactId, { [field]: value });
5299        if (second && !second.conflict) {
5300          if (entry.failed) failedDirty--;
5301          contactDirty.delete(key);
5302          refreshSaveIndicator();
5303          renderSidebar(); renderGlobe(); renderStats();
5304          return;
5305        }
5306      } catch (err2) {
5307        console.error('contact retry after conflict failed', err2);
5308      }
5309    }
5310    entry.failed = true;
5311    failedDirty++;
5312    contactDirty.set(key, entry);
5313    refreshSaveIndicator();
5314    return;
5315  }
5316
5317  // Success.
5318  if (entry.failed) failedDirty--;
5319  contactDirty.delete(key);
5320  refreshSaveIndicator();
5321
5322  // Re-render dependent UI bits.
5323  renderSidebar();
5324  renderGlobe();
5325  renderStats();
5326  if (field === 'name' || field === 'contact_type' || field === 'status') {
5327    const c = contacts.find(x => x.id === contactId);
5328    if (c && contactId === selectedId) fillDetailForm(c, false);
5329  }
5330}
5331
5332// Per-field flush — used when the user blurs an input. Fires the queued
5333// save NOW (and against the captured id) instead of waiting for the timer.
5334function flushContactField(contactId, field) {
5335  if (!contactId) return;
5336  const key = contactId + '::' + field;
5337  const entry = contactDirty.get(key);
5338  if (!entry) return;
5339  const t = contactSaveTimers.get(key);
5340  if (t) { clearTimeout(t); contactSaveTimers.delete(key); }
5341  return commitContactSave(contactId, field, entry.value);
5342}
5343
5344// Fire ALL pending contact saves immediately, regardless of which contact
5345// they belong to. Called before destructive-ish nav events: selecting
5346// another contact, deleting, signing out, pagehide, visibilitychange.
5347function flushPendingContactSaves() {
5348  const promises = [];
5349  // Snapshot keys first — commit may mutate the map.
5350  for (const [key, entry] of Array.from(contactDirty.entries())) {
5351    const t = contactSaveTimers.get(key);
5352    if (t) { clearTimeout(t); contactSaveTimers.delete(key); }
5353    promises.push(commitContactSave(entry.id, entry.field, entry.value));
5354  }
5355  return Promise.all(promises);
5356}
5357
5358// Exponential-backoff retry. Capped at ~30s.
5359function scheduleContactRetry(key, entry) {
5360  const attempts = Math.max(1, entry.attempts || 1);
5361  const delay = Math.min(30000, 1000 * Math.pow(2, attempts - 1));   // 1s, 2s, 4s, ... 30s
5362  setTimeout(() => {
5363    const cur = contactDirty.get(key);
5364    if (!cur) return;                  // already drained (e.g. user re-edited and it succeeded)
5365    if (cur.failed) {
5366      cur.failed = false;
5367      failedDirty = Math.max(0, failedDirty - 1);
5368    }
5369    commitContactSave(cur.id, cur.field, cur.value).catch(() => {});
5370  }, delay);
5371}
5372
5373// Legacy thin wrappers — preserve the old call sites.
5374function scheduleSaveField(field, value) { return scheduleContactSave(selectedId, field, value); }
5375function flushSaveField(field /*, value */) { return flushContactField(selectedId, field); }
5376async function commitField(field, value) { return commitContactSave(selectedId, field, value); }
5377
5378// ── Emails (multi) ────────────────────────────────────────────
5379// Canonical list comes from `contacts.emails` (text[]); we also keep
5380// `contacts.email` in sync with emails[0] for legacy code paths.
5381function contactEmails(c) {
5382  if (Array.isArray(c.emails) && c.emails.length) return c.emails.filter(Boolean);
5383  if (c.email) return [c.email];
5384  return [];
5385}
5386function validEmail(s) {
5387  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(s || '').trim());
5388}
5389async function saveEmails(list) {
5390  const c = contacts.find(x => x.id === selectedId);
5391  if (!c) return;
5392  const primary = list[0] || null;
5393  c.emails = list;
5394  c.email = primary;
5395  // Capture the contact id at call time — if the user switches contacts
5396  // mid-await, this still writes to the original row.
5397  const targetId = selectedId;
5398  inFlightContactSaves++;
5399  refreshSaveIndicator();
5400  try {
5401    const res = await upsertContact(targetId, { emails: list, email: primary });
5402    if (res && res.conflict) console.warn('saveEmails conflict — keeping local optimistic copy');
5403  } catch (e) {
5404    console.error('saveEmails', e);
5405  } finally {
5406    inFlightContactSaves--;
5407    refreshSaveIndicator();
5408  }
5409}
5410function renderEmails(list) {
5411  const box = document.getElementById('df-emails');
5412  if (!box) return;
5413  box.querySelectorAll('.tag-pill').forEach(el => el.remove());
5414  const input = document.getElementById('df-email-input');
5415  list.forEach((addr, i) => {
5416    const el = document.createElement('span');
5417    el.className = 'tag-pill' + (i === 0 ? ' primary' : '');
5418    el.title = i === 0 ? 'Primary — used as default when composing' : 'Click to make primary';
5419    el.innerHTML =
5420      `<span class="addr">${escapeHtml(addr)}</span>` +
5421      `<button data-addr="${escapeHtml(addr)}" title="Remove">✕</button>`;
5422    // Click on the address promotes it to primary (unless it already is)
5423    el.querySelector('.addr').addEventListener('click', async () => {
5424      if (i === 0) return;
5425      const c = contacts.find(x => x.id === selectedId); if (!c) return;
5426      const cur = contactEmails(c);
5427      const next = [addr, ...cur.filter(x => x !== addr)];
5428      await saveEmails(next);
5429      renderEmails(next);
5430    });
5431    el.querySelector('button').addEventListener('click', async (ev) => {
5432      ev.stopPropagation();
5433      const c = contacts.find(x => x.id === selectedId); if (!c) return;
5434      const next = contactEmails(c).filter(x => x !== addr);
5435      await saveEmails(next);
5436      renderEmails(next);
5437    });
5438    box.insertBefore(el, input);
5439  });
5440}
5441
5442function renderTags(tags) {
5443  const box = document.getElementById('df-tags');
5444  box.querySelectorAll('.tag-pill').forEach(el => el.remove());
5445  const input = document.getElementById('df-tag-input');
5446  tags.forEach(t => {
5447    const el = document.createElement('span');
5448    el.className = 'tag-pill';
5449    el.innerHTML = `${escapeHtml(t)}<button data-tag="${escapeHtml(t)}" title="Remove">✕</button>`;
5450    el.querySelector('button').addEventListener('click', async () => {
5451      const contactId = selectedId;
5452      const c = contacts.find(x => x.id === contactId);
5453      if (!c) return;
5454      const next = (c.tags || []).filter(x => x !== t);
5455      c.tags = next;
5456      renderTags(next);
5457      commitContactSave(contactId, 'tags', next);
5458    });
5459    box.insertBefore(el, input);
5460  });
5461}
5462
5463function renderPriorityStars(level) {
5464  const box = document.getElementById('df-priority');
5465  box.innerHTML = '';
5466  for (let i = 1; i <= 5; i++) {
5467    const b = document.createElement('button');
5468    b.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="${i <= level ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.5"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>`;
5469    if (i <= level) b.classList.add('on');
5470    b.addEventListener('click', async () => {
5471      const contactId = selectedId;
5472      if (!contactId) return;
5473      const newLevel = (i === level) ? i - 1 : i;
5474      const c = contacts.find(x => x.id === contactId);
5475      if (c) c.priority = newLevel;
5476      renderPriorityStars(newLevel);
5477      commitContactSave(contactId, 'priority', newLevel);
5478    });
5479    box.appendChild(b);
5480  }
5481}
5482
5483// ── Geocoding ─────────────────────────────────────────────────
5484// ── Products + map location search ────────────────────────────
5485function productIconsHtml(products) {
5486  if (!Array.isArray(products) || !products.length) return '';
5487  const imgs = products.filter(k => PRODUCTS[k]).map(k =>
5488    `<img src="${PRODUCTS[k].img}" alt="${escapeHtml(PRODUCTS[k].label)}" title="${escapeHtml(PRODUCTS[k].label)}" />`).join('');
5489  return imgs ? `<div class="tt-products">${imgs}</div>` : '';
5490}
5491
5492function renderMapProductChips() {
5493  const wrap = document.getElementById('map-product-chips');
5494  if (!wrap) return;
5495  wrap.innerHTML = PRODUCT_KEYS.map(k => {
5496    const p = PRODUCTS[k]; const on = mapProductFilter.has(k);
5497    return `<button type="button" class="map-product-chip${on ? ' on' : ''}" data-product="${k}" title="Filter: ${escapeHtml(p.label)}"><img src="${p.img}" alt="" /></button>`;
5498  }).join('');
5499}
5500
5501function haversineKm(aLat, aLng, bLat, bLng) {
5502  const R = 6371, toRad = d => d * Math.PI / 180;
5503  const dLat = toRad(bLat - aLat), dLng = toRad(bLng - aLng);
5504  const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLng / 2) ** 2;
5505  return 2 * R * Math.asin(Math.sqrt(s));
5506}
5507
5508// Nearest contact to (lat,lng) that stocks ALL currently-filtered products.
5509function nearestContact(lat, lng) {
5510  let best = null, bestD = Infinity;
5511  for (const c of contacts) {
5512    if (typeof c.lat !== 'number' || typeof c.lng !== 'number') continue;
5513    if (mapTypeFilter !== 'all' && c.contact_type !== mapTypeFilter) continue;
5514    if (mapProductFilter.size) {
5515      const set = new Set(c.products || []);
5516      let ok = true; for (const k of mapProductFilter) if (!set.has(k)) { ok = false; break; }
5517      if (!ok) continue;
5518    }
5519    const d = haversineKm(lat, lng, c.lat, c.lng);
5520    if (d < bestD) { bestD = d; best = c; }
5521  }
5522  return best ? { contact: best, km: bestD } : null;
5523}
5524
5525async function runMapSearch() {
5526  const input = document.getElementById('map-search-input');
5527  const out = document.getElementById('map-search-result');
5528  const q = input.value.trim();
5529  if (!q) return;
5530  out.textContent = 'Searching…';
5531  try {
5532    const results = await geocode(q);
5533    if (!results.length) { out.textContent = 'No place found for “' + q + '”.'; return; }
5534    const r = results[0];
5535    const lat = parseFloat(r.lat), lng = parseFloat(r.lon);
5536    globe.pointOfView({ lat, lng, altitude: 1.1 }, 1400);
5537    const filtParts = [];
5538    if (mapTypeFilter !== 'all') filtParts.push(TYPES[mapTypeFilter]?.label || mapTypeFilter);
5539    if (mapProductFilter.size) filtParts.push(...[...mapProductFilter].map(k => PRODUCTS[k].short));
5540    const filtLabel = filtParts.join(' + ');
5541    const near = nearestContact(lat, lng);
5542    if (!near) {
5543      out.innerHTML = `Flew to <strong>${escapeHtml(r.display_name.split(',')[0])}</strong> — no ${filtLabel ? escapeHtml(filtLabel) + ' ' : ''}contact found.`;
5544      return;
5545    }
5546    const c = near.contact;
5547    out.innerHTML = `Nearest${filtLabel ? ' (' + escapeHtml(filtLabel) + ')' : ''}: <strong>${escapeHtml(c.name || 'Unnamed')}</strong> · ${Math.round(near.km)} km`;
5548    if (globe.ringsData) globe.ringsData([{ lat: c.lat, lng: c.lng, color: '#3a7fc2', maxR: 4, speed: 3, period: 700 }]);
5549    setTimeout(() => selectContact(c.id, true), 750);   // fly to & open the nearest contact
5550  } catch (e) {
5551    out.textContent = 'Search failed: ' + (e.message || e);
5552  }
5553}
5554
5555// ── Account modal: change own password + admin reset ──────────
5556function setAccMsg(id, text, kind) {
5557  const el = document.getElementById(id); if (!el) return;
5558  el.textContent = text || ''; el.className = 'acc-msg' + (kind ? ' ' + kind : '');
5559}
5560function openAccountModal() {
5561  ['acc-current', 'acc-new', 'acc-new2', 'acc-admin-email', 'acc-admin-pw'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
5562  setAccMsg('acc-msg', ''); setAccMsg('acc-admin-msg', '');
5563  document.getElementById('acc-admin').style.display = 'none';
5564  document.getElementById('account-modal').classList.add('show');
5565  // Reveal the admin section only for admins (server-verified). HQ-411: consume
5566  // the shared boot-time auth-user flight (window.QAUTH_USER, set by the shim /
5567  // quasar-acl.js) instead of firing our own POST, since that flight already
5568  // carries a fresh-enough verdict and this coalesces the round trip instead of
5569  // adding a second one on every modal open. Falls back to the direct invoke if
5570  // the shared flight isn't present, so behaviour is unchanged on a cache miss.
5571  // (shipping.html:1268 is the reference consumer pattern.)
5572  (async () => {
5573    try {
5574      let isAdmin = false;
5575      if (window.QAUTH_USER && typeof window.QAUTH_USER.then === 'function') {
5576        const r = await window.QAUTH_USER;
5577        const u = r && r.j && r.j.user;
5578        isAdmin = !!(u && u.is_admin);
5579      } else {
5580        const r = await sb.functions.invoke('auth-user');
5581        isAdmin = !!(r && r.data && r.data.user && r.data.user.is_admin);
5582      }
5583      document.getElementById('acc-admin').style.display = isAdmin ? '' : 'none';
5584    } catch (_) {}
5585  })();
5586  refresh2fa();  // HQ-12: load the user's 2FA state
5587}
5588
5589// ── Account modal: two-factor authentication (HQ-12) ──────────
5590function set2faButtons(state) {  // 'off' | 'enrolling' | 'on'
5591  document.getElementById('acc-2fa-start').style.display    = state === 'off' ? '' : 'none';
5592  document.getElementById('acc-2fa-verify').style.display   = state === 'enrolling' ? '' : 'none';
5593  document.getElementById('acc-2fa-disable').style.display  = state === 'on' ? '' : 'none';
5594  document.getElementById('acc-2fa-enrol').style.display    = state === 'enrolling' ? '' : 'none';
5595  document.getElementById('acc-2fa-code-row').style.display = (state === 'enrolling' || state === 'on') ? '' : 'none';
5596  document.getElementById('acc-2fa-code-label').textContent = state === 'on' ? 'Enter a current code to disable' : 'Code from your app';
5597}
5598async function refresh2fa() {
5599  setAccMsg('acc-2fa-msg', '');
5600  const ci = document.getElementById('acc-2fa-code'); if (ci) ci.value = '';
5601  document.getElementById('acc-2fa-status').textContent = 'Checking…';
5602  const r = await sb.functions.invoke('totp-enroll', { body: { action: 'status' } });
5603  if (r.error) { document.getElementById('acc-2fa-status').textContent = 'Could not check 2FA status.'; set2faButtons('off'); return; }
5604  const on = !!(r.data && r.data.enabled);
5605  document.getElementById('acc-2fa-status').textContent = on
5606    ? '2FA is ON — you enter an authenticator code at every sign-in.'
5607    : '2FA is OFF — add an authenticator app for a second factor at sign-in.';
5608  set2faButtons(on ? 'on' : 'off');
5609}
5610async function start2fa() {
5611  setAccMsg('acc-2fa-msg', 'Generating a setup key…');
5612  const r = await sb.functions.invoke('totp-enroll', { body: { action: 'enroll' } });
5613  if (r.error) { setAccMsg('acc-2fa-msg', (r.error.message) || 'Could not start enrolment.', 'error'); return; }
5614  document.getElementById('acc-2fa-secret').value = (r.data && r.data.secret) || '';
5615  const uri = r.data && r.data.otpauth_uri;
5616  const a = document.getElementById('acc-2fa-uri');
5617  if (uri) { a.href = uri; a.style.display = ''; } else { a.style.display = 'none'; }
5618  setAccMsg('acc-2fa-msg', '');
5619  set2faButtons('enrolling');
5620  document.getElementById('acc-2fa-code').focus();
5621}
5622async function verify2fa() {
5623  const code = (document.getElementById('acc-2fa-code').value || '').trim();
5624  if (!code) { setAccMsg('acc-2fa-msg', 'Enter the 6-digit code from your app.', 'error'); return; }
5625  setAccMsg('acc-2fa-msg', 'Verifying…');
5626  const r = await sb.functions.invoke('totp-enroll', { body: { action: 'verify', code } });
5627  if (r.error) { setAccMsg('acc-2fa-msg', (r.error.message) || 'That code did not match.', 'error'); return; }
5628  setAccMsg('acc-2fa-msg', '2FA enabled. Other devices have been signed out.', 'ok');
5629  await refresh2fa();
5630}
5631async function disable2fa() {
5632  const code = (document.getElementById('acc-2fa-code').value || '').trim();
5633  if (!code) { setAccMsg('acc-2fa-msg', 'Enter a current authenticator code to disable.', 'error'); return; }
5634  setAccMsg('acc-2fa-msg', 'Disabling…');
5635  const r = await sb.functions.invoke('totp-enroll', { body: { action: 'disable', code } });
5636  if (r.error) { setAccMsg('acc-2fa-msg', (r.error.message) || 'Could not disable 2FA.', 'error'); return; }
5637  setAccMsg('acc-2fa-msg', '2FA disabled.', 'ok');
5638  await refresh2fa();
5639}
5640function closeAccountModal() { document.getElementById('account-modal').classList.remove('show'); }
5641
5642async function doChangePassword() {
5643  const cur = document.getElementById('acc-current').value;
5644  const nw = document.getElementById('acc-new').value;
5645  const nw2 = document.getElementById('acc-new2').value;
5646  if (nw.length < 8) { setAccMsg('acc-msg', 'New password must be at least 8 characters.', 'error'); return; }
5647  if (nw !== nw2) { setAccMsg('acc-msg', 'New passwords do not match.', 'error'); return; }
5648  setAccMsg('acc-msg', 'Saving…');
5649  const r = await sb.functions.invoke('auth-change-password', { body: { currentPassword: cur, newPassword: nw } });
5650  if (r.error) { setAccMsg('acc-msg', (r.error.message) || 'Change failed.', 'error'); return; }
5651  // Server bumped token_version (revoking other sessions) and returned a fresh
5652  // token for THIS session — store it so we stay logged in here.
5653  if (r.data && r.data.token && window.quasarAuth) window.quasarAuth.setToken(r.data.token);
5654  setAccMsg('acc-msg', 'Password changed. Other devices have been signed out.', 'ok');
5655  ['acc-current', 'acc-new', 'acc-new2'].forEach(id => document.getElementById(id).value = '');
5656}
5657
5658async function doAdminReset() {
5659  const email = document.getElementById('acc-admin-email').value.trim();
5660  const pw = document.getElementById('acc-admin-pw').value;
5661  if (!email || pw.length < 8) { setAccMsg('acc-admin-msg', 'Enter an email and a password of 8+ characters.', 'error'); return; }
5662  setAccMsg('acc-admin-msg', 'Working…');
5663  const r = await sb.functions.invoke('admin-set-password', { body: { email, newPassword: pw } });
5664  if (r.error) { setAccMsg('acc-admin-msg', (r.error.message) || 'Failed.', 'error'); return; }
5665  const created = r.data && r.data.created;
5666  setAccMsg('acc-admin-msg', (created ? 'Account created for ' : 'Password reset for ') + email + ' — share the temporary password; they can change it after signing in.', 'ok');
5667  document.getElementById('acc-admin-email').value = ''; document.getElementById('acc-admin-pw').value = '';
5668}
5669
5670async function geocode(query) {
5671  const url = 'https://nominatim.openstreetmap.org/search?format=json&limit=5&q=' + encodeURIComponent(query);
5672  const res = await fetch(url, { headers: { 'Accept-Language': 'en' } });
5673  if (!res.ok) throw new Error('Geocoding failed');
5674  return await res.json();
5675}
5676
5677async function runGeocodeFromForm() {
5678  const btn = document.getElementById('df-geocode');
5679  const q = document.getElementById('df-address').value.trim();
5680  if (!q) return;
5681  btn.disabled = true; btn.textContent = '◎ Locating…';
5682  try {
5683    const results = await geocode(q);
5684    if (results.length === 0) { alert('No results for that address.'); return; }
5685    const r = results[0];
5686    const patch = {
5687      lat: parseFloat(r.lat),
5688      lng: parseFloat(r.lon),
5689      address: r.display_name,
5690      city: r.address?.city || r.address?.town || r.address?.village || null,
5691      country: r.display_name.split(',').pop().trim() || null,
5692    };
5693    // Pull city/country if available via OSM details
5694    const det = r.display_name.split(',').map(s => s.trim());
5695    patch.country = det[det.length - 1];
5696    // Capture id before await — selection may change while geocode resolves.
5697    const targetId = selectedId;
5698    inFlightContactSaves++; refreshSaveIndicator();
5699    let row = null;
5700    try {
5701      const res = await upsertContact(targetId, patch);
5702      if (res && !res.conflict) row = res;
5703      else if (res && res.conflict && res.server) row = res.server;
5704    } catch (e) {
5705      console.error('geocode upsert', e);
5706    } finally {
5707      inFlightContactSaves--; refreshSaveIndicator();
5708    }
5709    if (row) {
5710      const i = contacts.findIndex(x => x.id === row.id);
5711      if (i > -1) contacts[i] = row;
5712      // Only update the form if the user is still looking at this contact.
5713      if (targetId === selectedId) {
5714        fillDetailForm(row, false);
5715        renderGlobe();
5716        renderSidebar();
5717        if (typeof row.lat === 'number') globe.pointOfView({ lat: row.lat, lng: row.lng, altitude: 1.2 }, 1400);
5718      }
5719    }
5720  } catch (e) {
5721    alert('Geocoding error: ' + e.message);
5722  } finally {
5723    btn.disabled = false; btn.textContent = '◎ Locate';
5724  }
5725}
5726
5727// ── New-contact modal ─────────────────────────────────────────
5728let newLocationPick = null;
5729function openNewModal() {
5730  document.getElementById('new-name').value = '';
5731  document.getElementById('new-location').value = '';
5732  document.getElementById('new-type').value = 'boat_builder';
5733  document.getElementById('new-status').value = 'lead';
5734  document.getElementById('new-location-results').classList.remove('show');
5735  newLocationPick = null;
5736  document.getElementById('new-modal').classList.add('show');
5737  setTimeout(() => document.getElementById('new-name').focus(), 50);
5738}
5739function closeNewModal() {
5740  document.getElementById('new-modal').classList.remove('show');
5741}
5742async function onNewLocationSearch(e) {
5743  const q = e.target.value.trim();
5744  const box = document.getElementById('new-location-results');
5745  if (q.length < 2) { box.classList.remove('show'); box.innerHTML = ''; return; }
5746  try {
5747    const results = await geocode(q);
5748    if (results.length === 0) { box.classList.remove('show'); box.innerHTML = ''; return; }
5749    box.innerHTML = results.map((r, i) => `
5750      <div class="search-result" data-i="${i}">
5751        <div class="primary">${escapeHtml(r.display_name)}</div>
5752        <div class="coords">${parseFloat(r.lat).toFixed(3)}, ${parseFloat(r.lon).toFixed(3)}</div>
5753      </div>
5754    `).join('');
5755    box.classList.add('show');
5756    box.querySelectorAll('.search-result').forEach(el => {
5757      el.addEventListener('click', () => {
5758        const r = results[+el.dataset.i];
5759        newLocationPick = r;
5760        document.getElementById('new-location').value = r.display_name;
5761        box.classList.remove('show');
5762      });
5763    });
5764  } catch { /* ignore */ }
5765}
5766async function createNewContact() {
5767  const name = document.getElementById('new-name').value.trim();
5768  if (!name) { alert('Name is required'); return; }
5769  const payload = {
5770    name,
5771    contact_type: document.getElementById('new-type').value,
5772    status: document.getElementById('new-status').value,
5773  };
5774  if (newLocationPick) {
5775    payload.lat = parseFloat(newLocationPick.lat);
5776    payload.lng = parseFloat(newLocationPick.lon);
5777    payload.address = newLocationPick.display_name;
5778    payload.country = newLocationPick.display_name.split(',').pop().trim();
5779  } else if (document.getElementById('new-location').value.trim()) {
5780    // Try to geocode
5781    try {
5782      const results = await geocode(document.getElementById('new-location').value.trim());
5783      if (results[0]) {
5784        payload.lat = parseFloat(results[0].lat);
5785        payload.lng = parseFloat(results[0].lon);
5786        payload.address = results[0].display_name;
5787        payload.country = results[0].display_name.split(',').pop().trim();
5788      }
5789    } catch { /* ignore */ }
5790  }
5791  const row = await insertContact(payload);
5792  if (row) {
5793    contacts.unshift(row);
5794    closeNewModal();
5795    selectContact(row.id, true);
5796    renderAll();
5797  }
5798}
5799
5800// ═══════════════════════════════════════════════════════════════
5801// Gmail integration (Phase B)
5802// ═══════════════════════════════════════════════════════════════
5803
5804// ── Account reps (salesperson roster) ───────────────────────
5805// The assignable sales reps (sales/finance/admin users), id+name only. Populates the
5806// contact "Account rep" picker; the chosen rep is tagged onto the account's Xero invoices.
5807let repsCache = null;
5808async function loadReps() {
5809  if (repsCache) return repsCache;
5810  try {
5811    const r = await fn('admin-users', { method: 'POST', body: { action: 'reps' } });
5812    const j = await r.json().catch(() => ({}));
5813    repsCache = Array.isArray(j.reps) ? j.reps : [];
5814  } catch (_) { repsCache = []; }
5815  renderRepOptions();
5816  return repsCache;
5817}
5818function renderRepOptions() {
5819  const sel = document.getElementById('df-rep');
5820  if (!sel) return;
5821  const cur = sel.value;
5822  sel.innerHTML = '<option value="">— Unassigned —</option>' +
5823    (repsCache || []).map(u => `<option value="${u.id}">${escapeHtml(u.name || u.email || '')}</option>`).join('');
5824  if (cur) sel.value = cur;
5825}
5826
5827// ── Cost of Billing Drift - the management needs-attention box (product costing) ──
5828// The costing function is the gate (named product_cost_viewers authority): a refusal
5829// or an all-quiet summary leaves the box hidden. Clicking opens Inventory > Costing.
5830async function loadCostingDrift() {
5831  const box = document.getElementById('costing-drift-box');
5832  if (!box) return;
5833  try {
5834    const res = await fn('costing', { method: 'POST', body: { action: 'attention' } });
5835    const j = await res.json();
5836    const d = j && j.data;
5837    if (!d || j.error || (!(d.count > 0) && !(d.component_alerts > 0))) { box.hidden = true; return; }
5838    document.getElementById('costing-drift-count').textContent = d.count > 0 ? d.count : d.component_alerts;
5839    document.getElementById('costing-drift-sub').textContent =
5840      (d.items && d.items.length ? d.items.map(i => i.text).join(' · ') : d.headline);
5841    box.hidden = false;
5842  } catch (_) { box.hidden = true; }
5843}
5844
5845// ── Auth helper: grab the current Supabase access token ──────
5846async function currentJwt() {
5847  if (!sb) return null;
5848  const { data: { session } } = await sb.auth.getSession();
5849  return session?.access_token || null;
5850}
5851
5852// Fetch wrapper that attaches the Supabase JWT for our Netlify functions.
5853async function fn(path, opts = {}) {
5854  const jwt = await currentJwt();
5855  const headers = Object.assign({}, opts.headers || {}, jwt ? { Authorization: `Bearer ${jwt}` } : {});
5856  if (opts.body && typeof opts.body !== 'string') {
5857    headers['Content-Type'] = 'application/json';
5858    opts.body = JSON.stringify(opts.body);
5859  }
5860  return fetch(`/.netlify/functions/${path}`, { ...opts, headers });
5861}
5862
5863// ── Gmail status / connect ─────────────────────────────────────
5864function setGmailPill(state, text, title) {
5865  const pill = document.getElementById('gmail-pill');
5866  pill.dataset.state = state;
5867  document.getElementById('gmail-pill-text').textContent = text;
5868  if (title) pill.title = title;
5869}
5870
5871async function refreshGmailStatus() {
5872  setGmailPill('connecting', 'Checking…');
5873  try {
5874    const res = await fn('gmail-status');
5875    if (!res.ok) throw new Error('Status check failed (' + res.status + ')');
5876    const j = await res.json();
5877    gmailStatus = j;
5878    if (j.connected) {
5879      setGmailPill('connected', j.email || 'Gmail connected', `Gmail connected as ${j.email}. Click to manage templates.`);
5880      startBgSync();
5881    } else {
5882      setGmailPill('disconnected', 'Connect Gmail', 'Click to connect your Gmail account');
5883      stopBgSync();
5884    }
5885  } catch (e) {
5886    gmailStatus = { connected: false, email: null, last_sync_at: null };
5887    stopBgSync();
5888    setGmailPill('error', 'Gmail error', e.message);
5889    console.warn('gmail-status', e);
5890  }
5891  // If we're currently on the Email tab, re-render it
5892  if (selectedId) renderEmailTab();
5893}
5894
5895async function onGmailPillClick() {
5896  if (gmailStatus.connected) {
5897    // Connected → offer quick menu: Manage templates / Disconnect
5898    const choice = prompt(
5899      `Gmail: ${gmailStatus.email}\n\n` +
5900      `  1  — Manage email templates\n` +
5901      `  2  — Disconnect Gmail\n\n` +
5902      `Enter 1 or 2:`
5903    );
5904    if (!choice) return;
5905    if (choice.trim() === '1') openTemplatesModal();
5906    else if (choice.trim() === '2') disconnectGmail();
5907  } else {
5908    connectGmail();
5909  }
5910}
5911
5912async function connectGmail() {
5913  // Auth rides on the HttpOnly qjwt cookie (same-origin) — the JWT is no
5914  // longer readable from JS, so no ?jwt= param. auth-start falls back to the
5915  // cookie server-side.
5916  setGmailPill('connecting', 'Connecting…');
5917  const url = `/.netlify/functions/auth-start`;
5918  const popup = window.open(url, 'quasar-gmail', 'width=520,height=700');
5919  if (!popup) {
5920    // Popup blocked — fall back to same-window navigation
5921    location.href = url;
5922    return;
5923  }
5924  // If the popup is closed without completing, revert the pill
5925  const timer = setInterval(() => {
5926    if (popup.closed) {
5927      clearInterval(timer);
5928      // Slight delay so postMessage (if any) can fire first
5929      setTimeout(refreshGmailStatus, 400);
5930    }
5931  }, 600);
5932}
5933
5934async function disconnectGmail() {
5935  if (!confirm('Disconnect Gmail from Quasar HQ?\n\nExisting emails stay logged — only the link to your Google account is removed.')) return;
5936  try {
5937    const { data: { user } } = await sb.auth.getUser();
5938    const { error } = await sb.from('gmail_tokens').delete().eq('owner_id', user.id);
5939    if (error) throw error;
5940    await refreshGmailStatus();
5941  } catch (e) {
5942    alert('Disconnect failed: ' + e.message);
5943  }
5944}
5945
5946// ── Email tab render ───────────────────────────────────────────
5947function onEmailTabOpened() {
5948  renderEmailTab();
5949}
5950
5951function renderEmailTab() {
5952  const box = document.getElementById('email-tab');
5953  if (!box || !selectedId) return;
5954  const c = contacts.find(x => x.id === selectedId);
5955  if (!c) { box.innerHTML = ''; return; }
5956
5957  if (!gmailStatus.connected) {
5958    box.innerHTML = `
5959      <div class="email-disconnected">
5960        <p>Connect your Gmail account to send, receive, and log emails against this contact from inside Quasar HQ.</p>
5961        <button class="connect-btn" id="email-connect-btn">Connect Gmail</button>
5962      </div>`;
5963    document.getElementById('email-connect-btn').addEventListener('click', connectGmail);
5964    return;
5965  }
5966
5967  const addresses = contactEmails(c);
5968  if (addresses.length === 0) {
5969    box.innerHTML = `
5970      <div class="empty-mini">
5971        <div class="glyph">@</div>
5972        <p>Add an email address to this contact (Overview tab) to send or receive mail.</p>
5973      </div>`;
5974    return;
5975  }
5976
5977  const tplOptions = ['<option value="">No template</option>']
5978    .concat(emailTemplates.map(t =>
5979      `<option value="${escapeHtml(t.key)}">${escapeHtml(t.label)}</option>`))
5980    .join('');
5981
5982  const syncLabel = gmailStatus.last_sync_at
5983    ? `Last sync: ${new Date(gmailStatus.last_sync_at).toLocaleString()}`
5984    : 'Not yet synced';
5985
5986  const primary = addresses[0];
5987  const headerText = addresses.length > 1
5988    ? `${escapeHtml(primary)} <span class="hint-label">+${addresses.length - 1} more</span>`
5989    : escapeHtml(primary);
5990
5991  box.innerHTML = `
5992    <div class="email-head">
5993      <div class="left">Thread with <strong>${headerText}</strong></div>
5994      <div class="right">
5995        <button id="btn-email-templates" title="Manage templates">Templates</button>
5996        <button id="btn-email-sync" title="Pull recent messages from Gmail">Sync</button>
5997      </div>
5998    </div>
5999
6000    <div class="compose-box">
6001      <div class="compose-row">
6002        <label>Template</label>
6003        <select id="cmp-template">${tplOptions}</select>
6004      </div>
6005      <div class="compose-row">
6006        <label>To</label>
6007        <div class="tags-input" id="cmp-to">
6008          <input type="email" id="cmp-to-input" placeholder="type an address…" />
6009        </div>
6010      </div>
6011      <div class="compose-row" id="cmp-suggest-row" style="display:${addresses.length > 1 ? 'grid' : 'none'}">
6012        <label>Suggest</label>
6013        <div id="cmp-suggest" class="suggest-row"></div>
6014      </div>
6015      <div class="compose-row">
6016        <label>Subject</label>
6017        <input type="text" id="cmp-subject" placeholder="Subject" />
6018      </div>
6019      <div class="compose-row" style="grid-template-columns:1fr;">
6020        <textarea id="cmp-body" placeholder="Write your message…"></textarea>
6021      </div>
6022      <div class="compose-actions">
6023        <span class="hint" id="cmp-hint">${syncLabel}</span>
6024        <button class="send-btn" id="cmp-send">Send</button>
6025      </div>
6026    </div>
6027
6028    <div class="thread-list" id="thread-list"></div>
6029  `;
6030
6031  document.getElementById('btn-email-templates').addEventListener('click', openTemplatesModal);
6032  document.getElementById('btn-email-sync').addEventListener('click', syncCurrentContact);
6033  document.getElementById('cmp-send').addEventListener('click', sendComposed);
6034  document.getElementById('cmp-template').addEventListener('change', onTemplatePicked);
6035
6036  // Compose recipients (chip input + quick-add suggestions)
6037  composeTo = [primary];
6038  renderComposeTo(addresses);
6039
6040  renderThreads();
6041}
6042
6043// ── Compose recipients ────────────────────────────────────────
6044function renderComposeTo(contactAddresses) {
6045  const box = document.getElementById('cmp-to');
6046  if (!box) return;
6047  box.querySelectorAll('.tag-pill').forEach(el => el.remove());
6048  const input = document.getElementById('cmp-to-input');
6049  composeTo.forEach(addr => {
6050    const el = document.createElement('span');
6051    el.className = 'tag-pill';
6052    el.innerHTML = `<span class="addr">${escapeHtml(addr)}</span><button title="Remove">✕</button>`;
6053    el.querySelector('button').addEventListener('click', () => {
6054      composeTo = composeTo.filter(x => x !== addr);
6055      renderComposeTo(contactAddresses);
6056    });
6057    box.insertBefore(el, input);
6058  });
6059  // Quick-add suggestions: contact's known addresses not already in the list
6060  const suggest = document.getElementById('cmp-suggest');
6061  const row = document.getElementById('cmp-suggest-row');
6062  if (suggest) {
6063    const remaining = (contactAddresses || []).filter(a => !composeTo.includes(a));
6064    if (remaining.length === 0) { if (row) row.style.display = 'none'; }
6065    else {
6066      if (row) row.style.display = 'grid';
6067      suggest.innerHTML = remaining.map(a => `<button data-addr="${escapeHtml(a)}">+ ${escapeHtml(a)}</button>`).join('');
6068      suggest.querySelectorAll('button').forEach(b => {
6069        b.addEventListener('click', () => {
6070          composeTo.push(b.dataset.addr);
6071          renderComposeTo(contactAddresses);
6072        });
6073      });
6074    }
6075  }
6076  // Input handlers — commit on Enter / comma / blur
6077  input.onkeydown = (e) => {
6078    if (e.key === 'Enter' || e.key === ',') {
6079      e.preventDefault();
6080      commitComposeInput(input, contactAddresses);
6081    } else if (e.key === 'Backspace' && input.value === '' && composeTo.length) {
6082      composeTo.pop();
6083      renderComposeTo(contactAddresses);
6084    }
6085  };
6086  input.onblur = () => commitComposeInput(input, contactAddresses);
6087}
6088function commitComposeInput(input, contactAddresses) {
6089  const raw = input.value.trim().replace(/,$/, '').toLowerCase();
6090  if (!raw) return;
6091  if (!validEmail(raw)) {
6092    input.classList.add('invalid');
6093    setTimeout(() => input.classList.remove('invalid'), 600);
6094    return;
6095  }
6096  if (!composeTo.includes(raw)) composeTo.push(raw);
6097  input.value = '';
6098  renderComposeTo(contactAddresses);
6099}
6100
6101function onTemplatePicked(e) {
6102  const key = e.target.value;
6103  if (!key) return;
6104  const t = emailTemplates.find(x => x.key === key);
6105  if (!t) return;
6106  // Preview substitution client-side (server will do the authoritative version)
6107  const c = contacts.find(x => x.id === selectedId) || {};
6108  const vars = {
6109    name: c.name || '',
6110    first_name: (c.name || '').split(/\s+/)[0] || '',
6111    company: c.company || '',
6112    project: '',
6113    sender_name: (gmailStatus.email || '').split('@')[0] || '',
6114  };
6115  const sub = (s) => String(s || '').replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (_, k) => vars[k] ?? '');
6116  document.getElementById('cmp-subject').value = sub(t.subject);
6117  document.getElementById('cmp-body').value = sub(t.body);
6118}
6119
6120async function sendComposed() {
6121  const btn = document.getElementById('cmp-send');
6122  // Flush any pending text in the chip input into composeTo first
6123  const input = document.getElementById('cmp-to-input');
6124  if (input && input.value.trim()) {
6125    const c = contacts.find(x => x.id === selectedId);
6126    commitComposeInput(input, c ? contactEmails(c) : []);
6127  }
6128  const recipients = composeTo.slice();
6129  const subject = document.getElementById('cmp-subject').value.trim();
6130  const body = document.getElementById('cmp-body').value;
6131  const templateKey = document.getElementById('cmp-template').value || null;
6132
6133  if (recipients.length === 0) { alert('Add at least one recipient.'); return; }
6134  if (!subject && !body) { alert('Write a subject or body first.'); return; }
6135
6136  btn.disabled = true; const old = btn.textContent; btn.textContent = 'Sending…';
6137  try {
6138    const res = await fn('gmail-send', {
6139      method: 'POST',
6140      body: {
6141        contact_id: selectedId,
6142        to: recipients,
6143        subject,
6144        body,
6145        template_key: templateKey,
6146        // 'self' resolves gmail-send's fromChoice to kind:'personal', which is what
6147        // triggers the automatic company-signature append every other compose
6148        // surface (inbox/outbox New email, shipping's From-picker sends) already gets.
6149        // Without an explicit `from`, fromChoice.kind stays null and no signature is
6150        // appended at all - the gap this composer had (HQ-1268, Adam, 2026-09-21).
6151        from: 'self',
6152      },
6153    });
6154    const j = await res.json();
6155    if (!res.ok) throw new Error(j.error || 'Send failed');
6156    // Clear compose + reload messages; reset To back to primary only
6157    document.getElementById('cmp-subject').value = '';
6158    document.getElementById('cmp-body').value = '';
6159    document.getElementById('cmp-template').value = '';
6160    const c = contacts.find(x => x.id === selectedId);
6161    const addrs = c ? contactEmails(c) : [];
6162    composeTo = addrs.length ? [addrs[0]] : [];
6163    renderComposeTo(addrs);
6164    await loadMessagesFor(selectedId);
6165    renderThreads();
6166    setSaveState('idle', 'Sent');
6167  } catch (e) {
6168    alert('Send failed: ' + e.message);
6169  } finally {
6170    btn.disabled = false; btn.textContent = old;
6171  }
6172}
6173
6174async function syncCurrentContact(opts = {}) {
6175  if (!selectedId) return;
6176  if (autoSyncInFlight) return;   // prevent overlap
6177  const silent = !!opts.silent;
6178  const max = opts.max || 25;
6179  const btn = document.getElementById('btn-email-sync');
6180  const old = btn ? btn.textContent : null;
6181  if (btn && !silent) { btn.disabled = true; btn.textContent = 'Syncing…'; }
6182  autoSyncInFlight = true;
6183  try {
6184    const res = await fn('gmail-sync', {
6185      method: 'POST',
6186      body: { contact_id: selectedId, max },
6187    });
6188    const j = await res.json();
6189    if (!res.ok) throw new Error(j.error || 'Sync failed');
6190    const before = gmailThreads.length;
6191    await loadMessagesFor(selectedId);
6192    // Only refresh status (and thus re-render) when something changed or on manual
6193    if (!silent || gmailThreads.length !== before) {
6194      await refreshGmailStatus();
6195    }
6196    // Unread counts may have changed either way — cheap to refetch
6197    loadUnreadCounts();
6198  } catch (e) {
6199    if (!silent) alert('Sync failed: ' + e.message);
6200    else console.warn('auto-sync', e.message);
6201  } finally {
6202    autoSyncInFlight = false;
6203    if (btn && !silent) { btn.disabled = false; btn.textContent = old; }
6204  }
6205}
6206
6207// ── Auto-sync (foreground, 10s while Email tab is open) ──────
6208function startAutoSync() {
6209  stopAutoSync();
6210  if (!gmailStatus.connected || !selectedId) return;
6211  if (document.hidden) return;   // don't poll while tab is backgrounded
6212  autoSyncTimer = setInterval(() => {
6213    if (document.hidden) return;
6214    syncCurrentContact({ silent: true, max: AUTO_SYNC_MAX_THREADS });
6215  }, AUTO_SYNC_INTERVAL_MS);
6216}
6217function stopAutoSync() {
6218  if (autoSyncTimer) { clearInterval(autoSyncTimer); autoSyncTimer = null; }
6219}
6220function isEmailTabActive() {
6221  const btn = document.querySelector('.tab-btn[data-tab="email"]');
6222  return btn && btn.classList.contains('active');
6223}
6224document.addEventListener('visibilitychange', () => {
6225  if (document.hidden) { stopAutoSync(); stopBgSync(); }
6226  else {
6227    if (isEmailTabActive() && selectedId) startAutoSync();
6228    if (gmailStatus.connected) { startBgSync(); runBgSync(); }   // immediate catch-up
6229  }
6230});
6231
6232// ── Background sweep (5 min across all contacts with email) ──
6233function startBgSync() {
6234  stopBgSync();
6235  if (!gmailStatus.connected) return;
6236  bgSyncTimer = setInterval(runBgSync, BG_SYNC_INTERVAL_MS);
6237}
6238function stopBgSync() {
6239  if (bgSyncTimer) { clearInterval(bgSyncTimer); bgSyncTimer = null; }
6240}
6241async function runBgSync() {
6242  if (bgSyncRunning || document.hidden || !gmailStatus.connected) return;
6243  bgSyncRunning = true;
6244  try {
6245    const targets = contacts.filter(c => contactEmails(c).length).map(c => c.id);
6246    let anyNew = false;
6247    for (const id of targets) {
6248      if (document.hidden) break;
6249      try {
6250        const res = await fn('gmail-sync', {
6251          method: 'POST',
6252          body: { contact_id: id, max: BG_SYNC_MAX_THREADS },
6253        });
6254        const j = await res.json().catch(() => ({}));
6255        if (res.ok && j.messages > 0) anyNew = true;
6256      } catch (e) {
6257        console.warn('bg-sync', id, e.message);
6258      }
6259      await new Promise(r => setTimeout(r, BG_SYNC_PACE_MS));
6260    }
6261    // If something new landed and the user is currently viewing an Email tab,
6262    // refresh that contact's thread list without disturbing the compose box.
6263    if (anyNew && selectedId && isEmailTabActive()) {
6264      await loadMessagesFor(selectedId);
6265    }
6266    if (anyNew) {
6267      await refreshGmailStatus();
6268      loadUnreadCounts();
6269    }
6270  } finally {
6271    bgSyncRunning = false;
6272  }
6273}
6274
6275// ── Unread counts (all contacts) ───────────────────────────────
6276async function loadUnreadCounts() {
6277  if (!sb) return;
6278  const { data, error } = await sb
6279    .from('messages')
6280    .select('contact_id')
6281    .eq('is_unread', true)
6282    .eq('direction', 'in')
6283    .order('created_at', { ascending: false })
6284    .limit(2000);
6285  if (error) { console.warn('unread load', error); return; }
6286  const m = new Map();
6287  (data || []).forEach(r => {
6288    if (!r.contact_id) return;
6289    m.set(r.contact_id, (m.get(r.contact_id) || 0) + 1);
6290  });
6291  unreadByContact = m;
6292  renderSidebar();
6293  renderGlobe();
6294}
6295
6296// ── Unread review popover (click a sidebar badge) ──────────────
6297let unreadPopContact = null;
6298let unreadPopSeq = 0;   // guards against a stale fetch painting a newer popover
6299
6300function closeUnreadPop() {
6301  document.getElementById('unread-pop').classList.remove('show');
6302  unreadPopContact = null;
6303}
6304
6305async function openUnreadPopover(contactId, anchorEl) {
6306  const pop = document.getElementById('unread-pop');
6307  const c = contacts.find(x => x.id === contactId);
6308  unreadPopContact = contactId;
6309  const seq = ++unreadPopSeq;
6310
6311  document.getElementById('up-title').textContent = (c && (c.name || c.company)) || 'Unread mail';
6312  document.getElementById('up-count').textContent = '';
6313  document.getElementById('up-list').innerHTML = '<div class="up-empty">Loading…</div>';
6314
6315  // Position beside the badge, clamped to the viewport.
6316  const r = anchorEl.getBoundingClientRect();
6317  pop.classList.add('show');
6318  const popW = pop.offsetWidth || 400;
6319  pop.style.left = Math.max(8, Math.min(r.right + 12, window.innerWidth - popW - 8)) + 'px';
6320  const h = Math.min(pop.offsetHeight || 320, window.innerHeight * 0.7);
6321  pop.style.top = Math.max(8, Math.min(r.top - 8, window.innerHeight - h - 8)) + 'px';
6322
6323  await refreshUnreadPop(contactId, seq);
6324
6325  // Background: re-sync this contact so mail already read (or newly arrived)
6326  // in Gmail shows correctly without waiting for the 5-minute sweep.
6327  if (gmailStatus.connected) {
6328    fn('gmail-sync', { method: 'POST', body: { contact_id: contactId, max: 10 } })
6329      .then(async (res) => {
6330        if (!res.ok) return;
6331        if (unreadPopContact !== contactId || seq !== unreadPopSeq) return;
6332        await refreshUnreadPop(contactId, seq);
6333        loadUnreadCounts();
6334      }).catch(() => {});
6335  }
6336}
6337
6338async function refreshUnreadPop(contactId, seq) {
6339  const { data, error } = await sb
6340    .from('messages')
6341    .select('id,gmail_id,gmail_thread,subject,from_addr,sent_at,body_text')
6342    .eq('contact_id', contactId)
6343    .eq('is_unread', true)
6344    .eq('direction', 'in')
6345    .order('sent_at', { ascending: false });
6346  if (seq !== unreadPopSeq || unreadPopContact !== contactId) return;
6347  if (error) {
6348    document.getElementById('up-list').innerHTML = '<div class="up-empty">Could not load messages.</div>';
6349    return;
6350  }
6351  renderUnreadPop(data || []);
6352}
6353
6354function renderUnreadPop(rows) {
6355  const list = document.getElementById('up-list');
6356  document.getElementById('up-count').textContent = rows.length ? rows.length + ' unread' : '';
6357  document.getElementById('up-mark-all').style.display = rows.length ? '' : 'none';
6358  if (!rows.length) {
6359    list.innerHTML = '<div class="up-empty">All caught up ✓</div>';
6360    return;
6361  }
6362  list.innerHTML = rows.map(m => {
6363    const date = m.sent_at ? new Date(m.sent_at).toLocaleString() : '';
6364    const snip = (m.body_text || '(no plain-text body)').trim();
6365    return `
6366      <div class="up-item" data-gid="${escapeHtml(m.gmail_id || '')}">
6367        <div class="up-row">
6368          <div class="up-subj">${escapeHtml(m.subject || '(no subject)')}</div>
6369          <div class="up-date">${escapeHtml(date)}</div>
6370        </div>
6371        <div class="up-from">${escapeHtml(m.from_addr || '')}</div>
6372        <div class="up-snip">${escapeHtml(snip)}</div>
6373        <div class="up-actions"><button data-upaction="read">✓ Mark read</button></div>
6374      </div>`;
6375  }).join('');
6376
6377  list.querySelectorAll('.up-item').forEach(el => {
6378    // Click the card to expand/collapse the full message text
6379    el.addEventListener('click', () => el.classList.toggle('expanded'));
6380    el.querySelector('[data-upaction="read"]').addEventListener('click', (e) => {
6381      e.stopPropagation();
6382      const gid = el.dataset.gid;
6383      if (gid) popMarkRead({ message_ids: [gid] }, [gid]);
6384    });
6385  });
6386}
6387
6388async function popMarkRead(payload, gids) {
6389  const contactId = unreadPopContact;
6390  if (!contactId) return;
6391  try {
6392    const res = await fn('gmail-mark-read', { method: 'POST', body: payload });
6393    if (!res.ok) { console.warn('mark-read', await res.text()); return; }
6394  } catch (e) { console.warn('mark-read failed', e.message); return; }
6395  if (unreadPopContact !== contactId) return;
6396  // Optimistic: drop the rows + fix the badge, then re-fetch authoritatively.
6397  const list = document.getElementById('up-list');
6398  let removed = 0;
6399  if (gids) {
6400    gids.forEach(g => {
6401      const el = list.querySelector(`.up-item[data-gid="${CSS.escape(g)}"]`);
6402      if (el) { el.remove(); removed++; }
6403    });
6404  } else {
6405    removed = list.querySelectorAll('.up-item').length;
6406    list.innerHTML = '';
6407  }
6408  const next = Math.max(0, (unreadByContact.get(contactId) || 0) - removed);
6409  if (next > 0) unreadByContact.set(contactId, next);
6410  else unreadByContact.delete(contactId);
6411  const left = list.querySelectorAll('.up-item').length;
6412  if (!left) renderUnreadPop([]);
6413  else document.getElementById('up-count').textContent = left + ' unread';
6414  renderSidebar();
6415  renderGlobe();
6416  loadUnreadCounts();
6417  // Keep the open email tab's thread styling in sync too.
6418  if (selectedId === contactId) loadMessagesFor(contactId);
6419}
6420
6421function initUnreadPop() {
6422  document.getElementById('up-close').addEventListener('click', closeUnreadPop);
6423  document.getElementById('up-mark-all').addEventListener('click', () => {
6424    if (unreadPopContact) popMarkRead({ contact_id: unreadPopContact }, null);
6425  });
6426  document.getElementById('up-open-email').addEventListener('click', () => {
6427    const id = unreadPopContact;
6428    closeUnreadPop();
6429    if (!id) return;
6430    selectContact(id, true);
6431    setActiveTab('email');
6432    onEmailTabOpened();
6433    startAutoSync();
6434  });
6435  // Close on outside click / Esc / sidebar scroll (the anchor moves away)
6436  document.addEventListener('click', (e) => {
6437    const pop = document.getElementById('unread-pop');
6438    if (!pop.classList.contains('show')) return;
6439    if (pop.contains(e.target) || e.target.closest('.unread-badge')) return;
6440    closeUnreadPop();
6441  });
6442  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeUnreadPop(); });
6443  document.getElementById('contact-list').addEventListener('scroll', closeUnreadPop);
6444}
6445
6446// ── Messages data ──────────────────────────────────────────────
6447async function loadMessagesFor(contactId) {
6448  if (!sb || !contactId) return;
6449  const { data, error } = await sb
6450    .from('messages')
6451    .select('*')
6452    .eq('contact_id', contactId)
6453    .order('sent_at', { ascending: false, nullsFirst: false });
6454  if (contactId !== selectedId) return;
6455  if (error) { console.error('loadMessages', error); gmailThreads = []; return; }
6456  // Group by gmail_thread (fallback to message id for orphans)
6457  const byThread = new Map();
6458  (data || []).forEach(m => {
6459    const key = m.gmail_thread || ('m:' + m.id);
6460    if (!byThread.has(key)) byThread.set(key, []);
6461    byThread.get(key).push(m);
6462  });
6463  // Sort messages within each thread oldest→newest, and threads by most recent message
6464  gmailThreads = Array.from(byThread.entries()).map(([tid, msgs]) => {
6465    msgs.sort((a, b) => new Date(a.sent_at || a.created_at) - new Date(b.sent_at || b.created_at));
6466    return { id: tid, messages: msgs, latest: msgs[msgs.length - 1] };
6467  }).sort((a, b) => new Date(b.latest.sent_at || b.latest.created_at) - new Date(a.latest.sent_at || a.latest.created_at));
6468
6469  document.getElementById('tab-count-email').textContent = gmailThreads.length;
6470  renderThreads();
6471}
6472
6473function renderThreads() {
6474  const list = document.getElementById('thread-list');
6475  if (!list) return;
6476  if (gmailThreads.length === 0) {
6477    list.innerHTML = `
6478      <div class="empty-mini">
6479        <div class="glyph">∅</div>
6480        <p>No messages yet. Send one above, or click <strong>Sync</strong> to pull recent Gmail threads with this contact.</p>
6481      </div>`;
6482    return;
6483  }
6484  list.innerHTML = gmailThreads.map(t => {
6485    const m = t.latest;
6486    const snippet = (m.body_text || '').replace(/\s+/g, ' ').trim().slice(0, 180);
6487    const date = m.sent_at ? new Date(m.sent_at).toLocaleDateString() : '';
6488    const hasUnread = t.messages.some(msg => msg.is_unread);
6489    return `
6490      <div class="thread-item${hasUnread ? ' unread' : ''}" data-tid="${escapeHtml(t.id)}">
6491        <div class="thead">
6492          <div class="tsubj">${escapeHtml(m.subject || '(no subject)')}</div>
6493          <div class="tdate">${escapeHtml(date)}</div>
6494        </div>
6495        <div class="tsnip">${escapeHtml(snippet)}</div>
6496        <div class="thread-body">
6497          ${t.messages.map(msg => `
6498            <div class="msg-item">
6499              <div class="mhead">
6500                <span class="mdir ${msg.direction === 'out' ? 'out' : 'in'}">${msg.direction === 'out' ? 'Sent' : 'Received'}</span>
6501                <span class="mfrom">${escapeHtml(msg.from_addr || '')} · ${escapeHtml(msg.sent_at ? new Date(msg.sent_at).toLocaleString() : '')}</span>
6502              </div>
6503              <div class="mbody">${escapeHtml(msg.body_text || '(no plain-text body)')}</div>
6504            </div>
6505          `).join('')}
6506        </div>
6507      </div>
6508    `;
6509  }).join('');
6510
6511  list.querySelectorAll('.thread-item').forEach(el => {
6512    el.querySelector('.thead').addEventListener('click', () => {
6513      const wasExpanded = el.classList.contains('expanded');
6514      el.classList.toggle('expanded');
6515      // On expand, if the thread has unread messages, mark them read in Gmail + DB.
6516      if (!wasExpanded && el.classList.contains('unread')) {
6517        markThreadRead(el.dataset.tid, el);
6518      }
6519    });
6520  });
6521}
6522
6523async function markThreadRead(threadId, el) {
6524  if (!threadId || threadId.startsWith('m:')) return;   // orphans have no Gmail thread
6525  const t = gmailThreads.find(x => x.id === threadId);
6526  const cleared = t ? t.messages.filter(m => m.is_unread).length : 0;
6527  try {
6528    const res = await fn('gmail-mark-read', {
6529      method: 'POST',
6530      body: { thread_id: threadId },
6531    });
6532    if (!res.ok) { console.warn('mark-read', await res.text()); return; }
6533    // Optimistic UI: drop the unread styling immediately, update local state.
6534    if (el) el.classList.remove('unread');
6535    if (t) t.messages.forEach(m => { m.is_unread = false; });
6536    if (cleared > 0 && selectedId) {
6537      const next = Math.max(0, (unreadByContact.get(selectedId) || 0) - cleared);
6538      if (next > 0) unreadByContact.set(selectedId, next);
6539      else unreadByContact.delete(selectedId);
6540      renderSidebar();
6541      renderGlobe();
6542    }
6543    // Authoritative refresh on the next tick
6544    loadUnreadCounts();
6545  } catch (e) {
6546    console.warn('mark-read failed', e.message);
6547  }
6548}
6549
6550// ── Email templates ────────────────────────────────────────────
6551async function loadEmailTemplates() {
6552  if (!sb) return;
6553  const { data, error } = await sb
6554    .from('email_templates')
6555    .select('*')
6556    .order('sort_order', { ascending: true })
6557    .order('created_at', { ascending: true });
6558  if (error) { console.warn('templates load', error); return; }
6559  emailTemplates = data || [];
6560}
6561
6562function openTemplatesModal() {
6563  renderTemplatesList();
6564  document.getElementById('tpl-modal').classList.add('show');
6565}
6566function closeTemplatesModal() {
6567  document.getElementById('tpl-modal').classList.remove('show');
6568  // Re-render email tab in case templates changed
6569  if (selectedId) renderEmailTab();
6570}
6571
6572function renderTemplatesList() {
6573  const box = document.getElementById('tpl-list');
6574  if (emailTemplates.length === 0) {
6575    box.innerHTML = `
6576      <div class="empty-mini">
6577        <div class="glyph">✎</div>
6578        <p>No templates yet. Click <strong>+ New Template</strong> to add your first one.</p>
6579      </div>`;
6580    return;
6581  }
6582  box.innerHTML = emailTemplates.map(t => `
6583    <div class="tpl-item" data-id="${t.id}">
6584      <div class="tph">
6585        <span class="tplabel">${escapeHtml(t.label || '(untitled)')}</span>
6586        <span class="tpkey">${escapeHtml(t.key || '')}</span>
6587      </div>
6588      <div class="tpl-body">
6589        <div>
6590          <label>Key</label>
6591          <input type="text" data-tf="key" value="${escapeHtml(t.key || '')}" placeholder="first_touch" />
6592        </div>
6593        <div>
6594          <label>Label</label>
6595          <input type="text" data-tf="label" value="${escapeHtml(t.label || '')}" placeholder="First touch" />
6596        </div>
6597        <div>
6598          <label>Subject</label>
6599          <input type="text" data-tf="subject" value="${escapeHtml(t.subject || '')}" placeholder="Introduction — {{company}}" />
6600        </div>
6601        <div>
6602          <label>Body</label>
6603          <textarea data-tf="body" placeholder="Hi {{first_name}},&#10;&#10;...">${escapeHtml(t.body || '')}</textarea>
6604        </div>
6605        <div class="tpactions">
6606          <span class="note">Auto-saves · placeholders substituted at send-time</span>
6607          <button data-taction="delete">✕ Delete</button>
6608        </div>
6609      </div>
6610    </div>
6611  `).join('');
6612
6613  box.querySelectorAll('.tpl-item').forEach(el => {
6614    el.querySelector('.tph').addEventListener('click', () => el.classList.toggle('expanded'));
6615    el.querySelectorAll('[data-tf]').forEach(input => {
6616      input.addEventListener('input', () => scheduleTemplateSave(el.dataset.id, input.dataset.tf, input.value));
6617    });
6618    el.querySelector('[data-taction="delete"]').addEventListener('click', async (ev) => {
6619      ev.stopPropagation();
6620      const t = emailTemplates.find(x => x.id === el.dataset.id);
6621      if (!t) return;
6622      if (!confirm(`Delete template "${t.label || t.key}"?`)) return;
6623      await deleteTemplate(el.dataset.id);
6624    });
6625  });
6626}
6627
6628function scheduleTemplateSave(id, field, value) {
6629  const t = emailTemplates.find(x => x.id === id);
6630  if (!t) return;
6631  t[field] = value;
6632  const k = id + '::' + field;
6633  clearTimeout(templateSaveTimers.get(k));
6634  templateSaveTimers.set(k, setTimeout(async () => {
6635    const patch = { [field]: value };
6636    const { error } = await sb.from('email_templates').update(patch).eq('id', id);
6637    if (error) console.warn('template save', error);
6638    // Update the visible label/key in the header without a full re-render
6639    const el = document.querySelector(`.tpl-item[data-id="${id}"]`);
6640    if (el) {
6641      if (field === 'label') el.querySelector('.tplabel').textContent = value || '(untitled)';
6642      if (field === 'key') el.querySelector('.tpkey').textContent = value || '';
6643    }
6644  }, 400));
6645}
6646
6647async function addTemplate() {
6648  const { data, error } = await sb
6649    .from('email_templates')
6650    .insert({
6651      key: 'new_' + Math.random().toString(36).slice(2, 7),
6652      label: 'New template',
6653      subject: 'Subject for {{company}}',
6654      body: 'Hi {{first_name}},\n\n\n\n— {{sender_name}}',
6655    })
6656    .select().single();
6657  if (error) { alert('Could not create template: ' + error.message); return; }
6658  emailTemplates.push(data);
6659  renderTemplatesList();
6660  // Auto-expand the new one + focus its key field
6661  setTimeout(() => {
6662    const el = document.querySelector(`.tpl-item[data-id="${data.id}"]`);
6663    if (el) {
6664      el.classList.add('expanded');
6665      const inp = el.querySelector('[data-tf="key"]');
6666      if (inp) { inp.focus(); inp.select(); }
6667    }
6668  }, 50);
6669}
6670
6671async function deleteTemplate(id) {
6672  const { error } = await sb.from('email_templates').delete().eq('id', id);
6673  if (error) { alert('Delete failed: ' + error.message); return; }
6674  emailTemplates = emailTemplates.filter(x => x.id !== id);
6675  renderTemplatesList();
6676}
6677
6678// ── Utils ─────────────────────────────────────────────────────
6679function escapeHtml(s) {
6680  return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
6681}
6682function debounce(fn, ms) {
6683  let t;
6684  return (...a) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, a), ms); };
6685}
6686</script>
6686
6687<!-- Presentation-only: mirror the live stat values the app already computes
6688     into the (hidden) page-head stat slots. The page title itself renders as
6689     a normal content band inside #dashboard, like the Manufacturing header.
6690     No data logic here. -->
6691<script>
6692(function () {
6693  function mirror(srcId, dstId) {
6694    var s = document.getElementById(srcId), d = document.getElementById(dstId);
6695    if (!s || !d) return;
6696    var apply = function () { d.textContent = s.textContent; };
6697    apply();
6698    new MutationObserver(apply).observe(s, { childList: true, characterData: true, subtree: true });
6699  }
6700  function init() {
6701    mirror('stat-total', 'ph-total');
6702    mirror('stat-customer', 'ph-customer');
6703    mirror('stat-pipeline', 'ph-pipeline');
6704  }
6705  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
6706  else init();
6707})();
6708</script>
6708
6709
6710<!-- ══════════════════════════════════════════════════════════════
6711     HOME DASHBOARD renderer — presentation only. Builds the Business
6712     Overview cards from static demo figures (matches the dashboard
6713     darkmode mockup) and wires the hidden .subtabs section switch so
6714     the sidebar (Overview / Pipeline / Activity) keeps the dashboard
6715     up; #dashboard always keeps .show (the globe shell stays covered beneath).
6716     ══════════════════════════════════════════════════════════════ -->
6717<script>
6718(function () {
6719  function $(id){ return document.getElementById(id); }
6720  // Synchronous pre-gate: a cached staff verdict removes the management-only
6721  // Overview subtab BEFORE quasar-nav builds the sidebar, so staff never see
6722  // it. An absent/errored cache leaves the BUTTON in place here (this one
6723  // micro-check stays as it always was) - but HQ-417's init()-level landing
6724  // check (below, later in this file) and dashIsStaff()'s real-mode default
6725  // still fail the SECTION closed to My Dashboard for an unresolved real-mode
6726  // session, so an unknown caller never actually sees the company tiles even
6727  // though the button itself is briefly still clickable. Demo mode is
6728  // unchanged (fails open, as before - it never shows real financials).
6729  // A stale '0' for a manager is healed by applyDashGate's one-shot reload.
6730  var DASH_MGMT_KEY='quasar.dash.mgmt.v1';
6731  var DASH_HEAL_RELOAD_KEY='quasar.dash.healreload.v1';  // HQ-1410: last heal reload (ms)
6732  try{
6733    if(sessionStorage.getItem(DASH_MGMT_KEY)==='0'){
6734      var _ob=document.querySelector('#subtabs [data-sec="overview"]');
6735      if(_ob) _ob.remove();
6736    }
6737  }catch(e){}
6738  var SVGNS = 'http://www.w3.org/2000/svg';
6739  var IC = {
6740    revenue:'<path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
6741    projects:'<path d="M3 7h18v13H3zM8 7V4h8v3"/>',
6742    gauge:'<path d="M12 14l4-4M3 12a9 9 0 0 1 18 0"/><circle cx="12" cy="12" r="1.5"/>',
6743    headset:'<path d="M4 14v-2a8 8 0 0 1 16 0v2M4 14a2 2 0 0 0 2 2h1v-5H6a2 2 0 0 0-2 2zM20 14a2 2 0 0 1-2 2h-1v-5h1a2 2 0 0 1 2 2zM18 16v1a3 3 0 0 1-3 3h-3"/>',
6744    check:'<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
6745    alert:'<circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/>',
6746    box:'<path d="M21 8l-9-5-9 5v8l9 5 9-5z M3 8l9 5 9-5M12 13v8"/>',
6747    users:'<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11"/>',
6748    clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
6749    rma:'<path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/>',
6750    cart:'<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.7 13.4A2 2 0 0 0 9.6 16h8.8a2 2 0 0 0 2-1.6L23 6H6"/>',
6751    wrench:'<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.8-2.8z"/>',
6752    play:'<polygon points="6 4 20 12 6 20 6 4"/>',
6753    inbox:'<path d="M22 12h-6l-2 3h-4l-2-3H2M5 5h14l3 7v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6z"/>',
6754    shield:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
6755    cal:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
6756    so:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6"/>',
6757    capa:'<path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>',
6758    receipt:'<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1V2l-2 1-2-1-2 1-2-1-2 1-2-1zM8 8h8M8 12h8M8 16h5"/>'
6759  };
6760  function svg(path, cls){
6761    return '<svg class="'+(cls||'')+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+path+'</svg>';
6762  }
6763  // Chart accent palette (single source for the demo series colours the item-13
6764  // sparklines and item-14 role dashboards paint - keeps the hex in one place).
6765  // Values mirror the accent hexes in quasar-ui.css (--brass-bright plus the
6766  // .qnotif-ic.kind-* set). Built as '#'+hex so the shared stylesheet stays the
6767  // single home for the literal colours (ui-drift ratchet: no new hex tokens).
6768  var C={ blue:'#'+'5d9bd8', green:'#'+'5bb98b', violet:'#'+'967bdc', amber:'#'+'e0a23e', red:'#'+'e0625e' };
6769  function el(tag, cls, html){ var e=document.createElement(tag); if(cls)e.className=cls; if(html!=null)e.innerHTML=html; return e; }
6770
6771  // ── Toast (demo feedback) ──────────────────────────────────────
6772  function toast(msg, kind){
6773    var host=$('dash-toasts'); if(!host) return;
6774    var ic = kind==='good'
6775      ? '<path d="M20 6L9 17l-5-5"/>'
6776      : kind==='warn'
6777        ? '<path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>'
6778        : '<circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/>';
6779    var t=el('div','toast'+(kind?(' '+kind):''));
6780    t.innerHTML='<span class="ti">'+svg(ic)+'</span><span>'+msg+'</span>';
6781    host.appendChild(t);
6782    setTimeout(function(){ t.style.transition='opacity .3s, transform .3s'; t.style.opacity='0'; t.style.transform='translateY(6px)'; setTimeout(function(){ if(t.parentNode)t.parentNode.removeChild(t); },320); }, (kind==='ok'||kind==='good'||kind==='success'?3200:10000));
6783  }
6784
6785  // ── Modal engine ──────────────────────────────────────────────
6786  // cfg: { icon, title, sub, fields:[{key,label,type,required,options,value,placeholder,full}]
6787  //        | infoRows:[[k,v]], saveLabel, onSave(values), onSubmit(values,ui), cancelLabel }
6788  //
6789  // HQ-452: `onSubmit` is the SERVER-BACKED sibling of `onSave`. onSave is the demo
6790  // idiom - it closes the modal first and then runs, which cannot surface a server
6791  // refusal or keep the typed data (both required by .claude/rules/workflows-and-gates.md:
6792  // "refuse, return the missing fields, keep the entered data"). onSubmit instead keeps
6793  // the modal OPEN, disables the save button while the request is in flight and hands the
6794  // caller a `ui` object to write the outcome into a message row inside the modal:
6795  //   ui.busy(text)          - message row in the neutral state, save disabled
6796  //   ui.error(text)         - refusal, save re-enabled, typed values untouched
6797  //   ui.ok(text, link)      - success; `link` = {href,label} swaps save for a deep link
6798  //   ui.close()             - dismiss
6799  // onSave is left byte-compatible so every existing demo call site behaves exactly as before.
6800  function closeModal(){ var o=$('dm-overlay'); if(o) o.classList.remove('show'); }
6801  // HQ-140: the themed dropdown for openModal's select fields (.qdrop/.qdrop-pop, the same
6802  // pair people.html/support.html use). The list opens in the modal'
6802s flow rather than
6803  // floating, because .dm scrolls (overflow:auto) and would clip a floating list.
6804  function dmQdrop(sel){
6805    sel.hidden=true; sel.tabIndex=-1;
6806    var qd=el('div','qdrop dm-qdrop');
6807    var btn=el('button','dm-qbtn','<span class="dm-qlabel"></span><span class="fchev">&#9662;</span>');
6808    btn.type='button'; btn.setAttribute('aria-haspopup','listbox'); btn.setAttribute('aria-expanded','false');
6809    var pop=el('div','qdrop-pop'); pop.setAttribute('role','listbox');
6810    qd.appendChild(btn); qd.appendChild(pop);
6811    function sync(){
6812      var op=sel.options[sel.selectedIndex];
6813      btn.querySelector('.dm-qlabel').textContent=op ? op.textContent : '';
6814    }
6815    function rows(){ return Array.prototype.slice.call(pop.querySelectorAll('button[data-qv]')); }
6816    function close(refocus){
6817      pop.classList.remove('open'); btn.setAttribute('aria-expanded','false');
6818      if(refocus) btn.focus();
6819    }
6820    function open(){
6821      pop.innerHTML='';
6822      Array.prototype.forEach.call(sel.options, function(o){
6823        if(o.disabled || o.hidden) return;
6824        var b=el('button','btn ghost sm'+(o.value===sel.value?' on':''));
6825        b.type='button'; b.setAttribute('role','option'); b.setAttribute('data-qv', o.value);
6826        b.setAttribute('aria-selected', o.value===sel.value?'true':'false');
6827        b.textContent=o.textContent;
6828        b.addEventListener('click', function(){
6829          sel.value=o.value;
6830          sel.dispatchEvent(new Event('change',{bubbles:true}));
6831          close(true);
6832        });
6833        pop.appendChild(b);
6834      });
6835      pop.classList.add('open'); btn.setAttribute('aria-expanded','true');
6836      var cur=pop.querySelector('button.on') || pop.querySelector('button');
6837      if(cur) cur.focus();
6838    }
6839    btn.addEventListener('click', function(e){ e.stopPropagation(); if(pop.classList.contains('open')) close(false); else open(); });
6840    btn.addEventListener('keydown', function(e){
6841      if(e.key==='ArrowDown' || e.key==='ArrowUp'){ e.preventDefault(); if(!pop.classList.contains('open')) open(); }
6842    });
6843    pop.addEventListener('keydown', function(e){
6844      var list=rows(), i=list.indexOf(document.activeElement);
6845      if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); close(true); }
6846      else if(e.key==='ArrowDown'){ e.preventDefault(); if(list.length) list[(i+1)%list.length].focus(); }
6847      else if(e.key==='ArrowUp'){ e.preventDefault(); if(list.length) list[(i-1+list.length)%list.length].focus(); }
6848      else if(e.key==='Tab'){ close(false); }
6849    });
6850    sel.addEventListener('change', sync);
6851    if(!window._dmQdropDocWired){
6852      window._dmQdropDocWired=true;
6853      document.addEventListener('click', function(e){
6854        Array.prototype.forEach.call(document.querySelectorAll('.dm-qdrop .qdrop-pop.open'), function(p){
6855          var w=p.closest('.dm-qdrop');
6856          if(w && !w.contains(e.target)){ p.classList.remove('open'); var b=w.querySelector('.dm-qbtn'); if(b) b.setAttribute('aria-expanded','false'); }
6857        });
6858      });
6859    }
6860    sync();
6861    return qd;
6862  }
6863  function openModal(cfg){
6864    var o=$('dm-overlay'); if(!o) return;
6865    $('dm-ic').innerHTML = svg(cfg.icon || IC.projects);
6866    $('dm-title').textContent = cfg.title || '';
6867    var subEl=$('dm-sub'); subEl.textContent = cfg.sub || ''; subEl.style.display = cfg.sub ? 'block':'none';
6868    var body=$('dm-body'), foot=$('dm-foot');
6869    body.innerHTML=''; foot.innerHTML='';
6870    var inputs={};
6871    if(cfg.infoRows){
6872      var info=el('div','dm-info');
6873      cfg.infoRows.forEach(function(kv){
6874        info.appendChild(el('div','dm-kv','<span>'+kv[0]+'</span><b>'+kv[1]+'</b>'));
6875      });
6876      body.appendChild(info);
6877    }
6878    (cfg.fields||[]).forEach(function(f){
6879      var wrap=el('div','dm-field'+(f.full===false?' dm-half':''));
6880      var lbl=el('label',null,f.label+(f.required?' <span class="req">*</span>':''));
6881      wrap.appendChild(lbl);
6882      var inp;
6883      if(f.type==='select'){
6884        inp=document.createElement('select');
6885        (f.options||[]).forEach(function(op){ var o2=document.createElement('option'); o2.value=(op.value!=null?op.value:op); o2.textContent=(op.label!=null?op.label:op); inp.appendChild(o2); });
6886        if(f.value!=null) inp.value=f.value;
6887      } else if(f.type==='search'){
6888        // HQ-481: a TYPE-TO-SEARCH picker, for lists a <select> cannot serve - Adam asked
6889        // to "add a customer to search for one", and there are ~2000 accounts. A native
6890        // datalist gives real substring filtering with no new widget and no new CSS: the
6891        // person types, the browser filters, and collect() maps the chosen LABEL back to
6892        // its id through f.resolve() below. f.freeText keeps an unmatched entry (the order
6893        // number that is not in the recent list) instead of refusing it.
6894        inp=document.createElement('input');
6895        inp.type='text'; inp.placeholder=f.placeholder||''; inp.autocomplete='off';
6896        inp.setAttribute('list','dm-dl-'+f.key);
6897        var dl=document.createElement('datalist'); dl.id='dm-dl-'+f.key;
6898        (f.options||[]).forEach(function(op){
6899          var o3=document.createElement('option');
6900          o3.value=(op.label!=null?op.label:op);   // datalist matches on VALUE, so the label IS the value
6901          dl.appendChild(o3);
6902        });
6903        wrap.appendChild(dl);
6904        if(f.value!=null)inp.value=f.value;
6905        // Picking (or typing) an entry that names exactly one option fires the field's
6906        // onPick with that option and the whole inputs map - the order field uses this
6907        // to fill the customer and the site from the picked order.
6908        if(typeof f.onPick==='function'){
6909          inp.addEventListener('input', function(){
6910            var val=(inp.value||'').trim(); if(!val) return;
6911            var op=(f.options||[]).filter(function(o){ return o && (o.label!=null?o.label:String(o))===val; })[0];
6912            if(op) f.onPick(op, inputs);
6913          });
6914        }
6915      } else if(f.type==='textarea'){
6916        inp=document.createElement('textarea'); inp.placeholder=f.placeholder||''; if(f.value!=null)inp.value=f.value;
6917      } else {
6918        inp=document.createElement('input'); inp.type=f.type||'text'; inp.placeholder=f.placeholder||''; if(f.value!=null)inp.value=f.value;
6919      }
6920      if(f.scan){
6921        // HQ-481: an armable Scan control on a text field - the incoming.html wedge
6922        // discipline in modal form. Arming focuses the field so the HID scanner types
6923        // straight into it; we catch only the Enter/Tab terminator the scanner sends,
6924        // disarm, and hand the code to f.onScan(code, inputs, wrap). Nothing global:
6925        // no listener outlives the field, so the rest of the modal types normally.
6926        var srow=el('div','dm-scanrow');
6927        var sbtn=el('button','dm-scanbtn','Scan'); sbtn.type='button';
6928        srow.appendChild(inp); srow.appendChild(sbtn); wrap.appendChild(srow);
6929        (function(inp2, srow2, sbtn2, f2){
6930          var armed=false;
6931          function disarm(){ armed=false; sbtn2.textContent='Scan'; srow2.classList.remove('scanning'); }
6932          sbtn2.addEventListener('click', function(){
6933            if(armed){ disarm(); return; }
6934            armed=true; sbtn2.textContent='Scanning…'; srow2.classList.add('scanning');
6935            inp2.focus(); if(inp2.select)inp2.select();
6936          });
6937          inp2.addEventListener('keydown', function(e){
6938            if(!armed || (e.key!=='Enter' && e.key!=='Tab')) return;
6939            e.preventDefault(); disarm();
6940            var code=(inp2.value||'').trim();
6941            if(code.length>=3 && typeof f2.onScan==='function') f2.onScan(code, inputs, wrap);
6942          });
6943          inp2.addEventListener('blur', function(){ if(armed)disarm(); });
6944        })(inp, srow, sbtn, f);
6945      } else if(f.type==='select'){
6946        // HQ-140 (Adam, 2026-09-26, on Request leave: "it doesnt much our theme"). A native
6947        // <select> opens an OS-drawn list no CSS can reach, so every dropdown in this modal is
6948        // now the house .qdrop: the select stays in the DOM, hidden, as the single source of
6949        // the value (collect() still reads inp.value), fronted by a themed trigger + list.
6950        wrap.appendChild(inp);
6951        wrap.appendChild(dmQdrop(inp));
6952      } else wrap.appendChild(inp);
6953      // HQ-452 review fix: help text and the validation message are two different
6954      // things. .dm-hint is display:none until the field errors AND is coloured with
6955      // the error colour, so a `hint` written into it was invisible until the person
6956      // made a mistake and then surfaced looking like the complaint. Help text gets
6957      // its own always-visible muted row (.dm-help); .dm-hint keeps the error string.
6958      if(f.hint) wrap.appendChild(el('div','dm-help', f.hint));
6959      wrap.appendChild(el('div','dm-hint','This field is required'));
6960      body.appendChild(wrap);
6961      inputs[f.key]={inp:inp, wrap:wrap, def:f};
6962    });
6963    // group any two consecutive .dm-half into a grid row (kept simple: skip)
6964    // HQ-452: the in-modal outcome row onSubmit writes into. Built empty + hidden so
6965    // the panel does not jump height when a refusal arrives.
6966    // Review fix: created ONLY on the onSubmit path. Appending it to every modal made
6967    // it the body's last child, which stopped `.dm-field:last-child{margin-bottom:0}`
6968    // matching and so added 13px of dead space under the last field of all 15
6969    // pre-existing demo/openHref modals - a shipped layout regressing for a row those
6970    // modals never use.
6971    var msgRow=null;
6972    var cancel=el('button','dm-cancel', cfg.cancelLabel || 'Cancel');
6973    cancel.addEventListener('click', closeModal);
6974    foot.appendChild(cancel);
6975    // Shared required-field pass for both save paths: returns the trimmed values, or
6976    // null after marking the offenders (nothing is cleared - the typed data stays).
6977    function collect(){
6978      var vals={}, ok=true, firstBad=null, badMsg='Please fill the required fields';
6979      Object.keys(inputs).forEach(function(k){
6980        var it=inputs[k]; var v=(it.inp.value||'').trim(); vals[k]=v;
6981        it.wrap.classList.remove('err');
6982        if(it.def.required && !v){ ok=false; it.wrap.classList.add('err'); if(!firstBad)firstBad=it.inp; return; }
6983        // HQ-481: a 'search' field shows a LABEL and submits an id, so it gets a resolve
6984        // hook. It may also reject what was typed (an unmatched name), which is a real
6985        // validation failure and must NOT read as "fill the required fields".
6986        if(v && typeof it.def.resolve==='function'){
6987          var r=it.def.resolve(v);
6988          if(r && r.error){ ok=false; it.wrap.classList.add('err'); if(!firstBad){ firstBad=it.inp; badMsg=r.error; } return; }
6989          vals[k]=(r && 'value' in r) ? r.value : v;
6990        }
6991      });
6992      if(!ok){ if(firstBad){ var qb=firstBad.hidden && firstBad.parentNode.querySelector('.dm-qbtn'); (qb||firstBad).focus(); } toast(badMsg,'warn'); return null; }
6993      return vals;
6994    }
6995    if(cfg.onSubmit){
6996      msgRow=el('div','dm-msg'); msgRow.hidden=true; body.appendChild(msgRow);
6997      var submit=el('button','dm-save', cfg.saveLabel || 'Save');
6998      var submitLabel=submit.textContent;
6999      function setMsg(text, kind){
7000        msgRow.className='dm-msg'+(kind?(' '+kind):'');
7001        msgRow.textContent=text||'';
7002        msgRow.hidden=!text;
7003      }
7004      var ui={
7005        busy:function(t){ setMsg(t||'Working…'); submit.disabled=true; submit.textContent='Working…'; },
7006        error:function(t){ setMsg(t||'That was refused.','err'); submit.disabled=false; submit.textContent=submitLabel; },
7007        ok:function(t, link){
7008          setMsg(t||'Done.','ok');
7009          submit.disabled=true;
7010          submit.textContent=submitLabel;
7011          // Success swaps the primary action for the real deep link to what was just
7012          // created, so the person lands on the record instead of guessing where it went.
7013          if(link && link.href){
7014            var go=el('a','dm-save', link.label || 'Open');
7015            go.href=link.href;
7016            if(submit.parentNode) submit.parentNode.replaceChild(go, submit);
7017          }
7018          // Cancel becomes the dismissal now that there is nothing left to submit.
7019          cancel.textContent='Close';
7020        },
7021        close:closeModal
7022      };
7023      submit.addEventListener('click', function(){
7024        if(submit.disabled) return;
7025        var vals=collect(); if(!vals) return;
7026        cfg.onSubmit(vals, ui);
7027      });
7028      foot.appendChild(submit);
7029    } else if(cfg.openHref){
7030      // Real deep link as the primary action - cross-module pop-out idiom:
7031      // summary above, "Open in <module>" navigates for real.
7032      var open=el('a','dm-save', cfg.saveLabel || 'Open');
7033      open.href=cfg.openHref;
7034      foot.appendChild(open);
7035    } else if(cfg.onSave || cfg.saveLabel){
7036      var save=el('button','dm-save', cfg.saveLabel || 'Save');
7037      save.addEventListener('click', function(){
7038        var vals=collect(); if(!vals) return;
7039        closeModal();
7040        if(cfg.onSave) cfg.onSave(vals); else toast('Saved (demo)','good');
7041      });
7042      foot.appendChild(save);
7043    }
7044    o.classList.add('show');
7045    var firstInput=body.querySelector('input,select,textarea'); if(firstInput) setTimeout(function(){ firstInput.focus(); },30);
7046  }
7047  (function bindModalDismiss(){
7048    var o=$('dm-overlay'); if(!o) return;
7049    $('dm-x').addEventListener('click', closeModal);
7050    o.addEventListener('mousedown', function(e){ if(e.target===o) closeModal(); });
7051    document.addEventListener('keydown', function(e){ if(e.key==='Escape' && o.classList.contains('show')) closeModal(); });
7052  })();
7053
7054  // Full-width sparkline with a dot at each point - same idiom as the
7055  // Manufacturing KPI tiles: a wide+short viewBox (~the rendered aspect) so
7056  // the dots stay round even with preserveAspectRatio="none", and
7057  // vector-effect keeps the stroke width constant. Item 13 (Adam): the line now
7058  // carries a colour-fade area underneath (the compliance.html:582 canonical
7059  // dot+gradient standard) - fill derives from the tile colour at low opacity so
7060  // it is light-mode safe. Each SVG gets a unique gradient id (module counter).
7061  var _sparkSeq=0;
7062  function spark(vals, color){
7063    var n=vals.length, mn=Math.min.apply(null,vals), mx=Math.max.apply(null,vals), rng=(mx-mn)||1;
7064    var W=300, H=20, pad=3;
7065    function px(i){ return (i/(n-1))*(W-2*pad)+pad; }
7066    function py(v){ return H-pad-((v-mn)/rng)*(H-2*pad); }
7067    var line=vals.map(function(v,i){return px(i).toFixed(1)+','+py(v).toFixed(1);}).join(' ');
7068    var dots=vals.map(function(v,i){return '<circle cx="'+px(i).toFixed(1)+'" cy="'+py(v).toFixed(1)+'" r="1.7" fill="'+color+'"/>';}).join('');
7069    var area='M'+px(0).toFixed(1)+','+(H-pad).toFixed(1)+' L'+line.split(' ').join(' L')+' L'+px(n-1).toFixed(1)+','+(H-pad).toFixed(1)+' Z';
7070    var gid='dsg'+(++_sparkSeq);
7071    return '<svg class="spark" width="300" height="20" viewBox="0 0 '+W+' '+H+'" fill="none" preserveAspectRatio="none">'
7072      +'<defs><linearGradient id="'+gid+'" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="'+color+'" stop-opacity="0.22"/><stop offset="1" stop-color="'+color+'" stop-opacity="0"/></linearGradient></defs>'
7073      +'<path d="'+area+'" fill="url(#'+gid+')"/>'
7074      +'<polyline points="'+line+'" vector-effect="non-scaling-stroke" fill="none" stroke="'+color+'" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
7075      +dots+'</svg>';
7076  }
7077
7078  // HQ-423: My Dashboard graph helpers - pure functions, no DOM/network, so
7079  // each card's series shape is easy to unit-reason about and to lift into
7080  // its own widget later (HQ-421). Buckets are always LOCAL calendar days
7081  // (never UTC), matching the clock's own day/week boundary.
7082  function mdDayBuckets(rows, tsKey, nDays){
7083    var out=[], keys={}, now=new Date();
7084    for(var i=nDays-1;i>=0;i--){
7085      var d=new Date(now.getFullYear(),now.getMonth(),now.getDate()-i);
7086      keys[d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate()]=out.length;
7087      out.push(0);
7088    }
7089    (rows||[]).forEach(function(r){
7090      var t=new Date(r[tsKey]); if(isNaN(t))return;
7091      var k=t.getFullYear()+'-'+(t.getMonth()+1)+'-'+t.getDate();
7092      if(keys[k]!=null) out[keys[k]]++;
7093    });
7094    return out;
7095  }
7096  function mdZeros(n){ var a=[]; for(var i=0;i<n;i++)a.push(0); return a; }
7097  // An unavailable read is not a fourteen-day history of zero activity.
7098  function mdSeriesResult(result){
7099    if(!result || result.error || !Array.isArray(result.data)) return null;
7100    if(result.data.some(function(row){ return !row || typeof row!=='object' || !row.created_at || !isFinite(Date.parse(row.created_at)); })) return null;
7101    return mdDayBuckets(result.data,'created_at',14);
7102  }
7103  function mdLoadPersonalSeries(u){
7104    var _mdIso14=new Date(Date.now()-14*86400000).toISOString();
7105    function receive(key,query){
7106      return query.then(function(r){ _mdSeries[key]=mdSeriesResult(r); renderMyDash(); },function(){ _mdSeries[key]=null; renderMyDash(); });
7107    }
7108    return Promise.all([
7109      receive('act',sb.from('audit_events').select('id,created_at').eq('owner_id',u.id).gte('created_at',_mdIso14).order('created_at',{ascending:false}).limit(500)),
7110      receive('mail',sb.from('email_drafts').select('id,created_at').eq('owner_id',u.id).is('deleted_at',null).gte('created_at',_mdIso14).order('created_at',{ascending:false}).limit(500)),
7111      receive('needs',sb.from('notifications').select('id,created_at').eq('owner_id',u.id).is('deleted_at',null).gte('created_at',_mdIso14).order('created_at',{ascending:false}).limit(500))
7112    ]);
7113  }
7114
7115  // Use the same deductions, deduplicated entries and Brisbane day splitting as
7116  // the attendance card. The chart alone applies its existing 15-minute display.
7117  function mdClockHours(clock,nowMs){
7118    if(!clock||!Array.isArray(clock.today)||!Array.isArray(clock.week)) return null;
7119    var A=window.QuasarAttendance,C=window.QuasarMobileCore;
7120    var now=nowMs==null?Date.now():nowMs,days=A.days(clock,now),start=C.weekStart(A.dayKey(now));
7121    return Array.from({length:7},function(_,i){var day=days[C.addDays(start,i)];return day?day.hours:0;});
7122  }
7123  function mdClockDayIndex(nowMs){
7124    var day=window.QuasarAttendance.dayKey(nowMs==null?Date.now():nowMs);
7125    var start=window.QuasarMobileCore.weekStart(day);
7126    return Math.round((Date.parse(day+'T00:00:00Z')-Date.parse(start+'T00:00:00Z'))/86400000);
7127  }
7128  // HQ-523: 15-minute-block display for wage staff (Adam: "15min blocks would be
7129  // easier"). Rounds to the nearest quarter hour and trims the trailing zero:
7130  // 7.25 h, 7.5 h, 8 h, 0 h. DISPLAY ONLY - payroll's own rounding lives in
7131  // _timesheet-core.cjs; this never feeds a paid figure back anywhere.
7132  function mdQtr(h){
7133    var q=Math.round((Number(h)||0)*4)/4;
7134    return (q%1===0 ? String(q) : q.toFixed(2).replace(/0$/,''))+' h';
7135  }
7136  // HQ-523 verify panel: the week Total must equal the SUM OF THE DISPLAYED DAY
7137  // ROWS, not a separately-rounded raw sum - two 7.62h days read "7.5 h" + "7.5 h"
7138  // directly above a Total of "15.25 h". Each day rounds to its own block first
7139  // (that per-day figure IS what the list shows), then the total sums the blocks,
7140  // so the list is always self-consistent. Display only, like mdQtr.
7141  function mdQtrSum(weekHrs){
7142    return (weekHrs||[]).reduce(function(a,b){ return a+Math.round((Number(b)||0)*4)/4; },0);
7143  }
7144  // HQ-523 round 3 (Adam: the status line should "reflect the sign in for site
7145  // through the kiosk or if they go in for WFH"): map the open entry's own
7146  // server-verified fields to plain words. `location` is the clock gate's
7147  // resolved place ('office' | 'field' | 'home' - see clockLocationGate, which
7148  // refuses 'home' for anyone without the WFH allowance), `channel` is the
7149  // VERIFIED JWT channel ('web' | 'mobile' | 'kiosk'), and `state` is the
7150  // on-site/off-site working flag. Pure - executed in tests.
7151  function mdClockWhere(open){
7152    if(!open) return { where:'Off the clock', via:'' };
7153    var where = open.location==='tafe' ? 'At TAFE'
7154              : open.location==='home' ? 'Working from home'
7155              : open.location==='field' ? 'On site · field job'
7156              : (open.state==='off_site' ? 'Off site, working' : 'On site');
7157    var via = open.channel==='kiosk' ? 'signed in at the kiosk'
7158            : open.channel==='mobile' ? 'signed in on the phone' : '';
7159    return { where: where, via: via };
7160  }
7161  // The pay-week day list for the Today card (HQ-523: "show them for the 5 days
7162  // of work, thursday - till wednesday and a total for each day"). Pay-week
7163  // order per QUASAR_PAYWEEK (Thu=0..Wed=6, HQ-482); the five working days
7164  // always render, Sat/Sun only when they actually carry hours. Pure - takes
7165  // the mdClockHours buckets + today's pay-week index + whether an entry is
7166  // open, returns rows in the page's existing .fs-row idiom (no new CSS).
7167  var MD_PAYDAY_LBL=['Thursday','Friday','Saturday','Sunday','Monday','Tuesday','Wednesday'];
7168  function mdWeekRowsHtml(weekHrs, todayIdx, isOpen){
7169    var html='';
7170    for(var i=0;i<7;i++){
7171      var weekend=(i===2||i===3);
7172      if(weekend && !(weekHrs[i]>0)) continue;
7173      var isToday=(i===todayIdx);
7174      html+='<div class="fs-row">'
7175        +'<span class="fid">'+MD_PAYDAY_LBL[i]+'</span>'
7176        +'<span class="fname">'+(isToday?(isOpen?'today · on the clock, still counting':'today'):'')+'</span>'
7177        +'<span class="ftime">'+(i>todayIdx && !(weekHrs[i]>0)?'Upcoming':mdQtr(weekHrs[i]))+'</span></div>';
7178    }
7179    return html;
7180  }
7181  // HQ-523: cardSpark (the card-body spark strip) is deleted - its last two
7182  // callers went with this round's list-over-graph rework (the Today 
7182week
7183  // sparkline and Assigned Work's weekday chart; Mail/Needs-me lost theirs in
7184  // HQ-519). The .kpi tiles keep using spark() directly.
7185
7186  // Module page map + deep-link builder. ?__sec=NAME lands on the sub-section
7187  // via the quasar-nav helper; hash routes (support cases) pass through as-is.
7188  var PAGES={ sales:'/sales.html', manufacturing:'/manufacturing.html', support:'/support.html',
7189    fieldservice:'/field-service.html', inventory:'/inventory.html', operations:'/operations.html',
7190    warehouse:'/warehouse.html', compliance:'/compliance.html', people:'/people.html', reports:'/reports.html' };
7191  var MODLBL={ sales:'Sales', manufacturing:'Manufacturing', support:'Support', fieldservice:'Field Service',
7192    inventory:'Inventory', operations:'Operations', warehouse:'Warehouse', compliance:'Compliance',
7193    people:'People', reports:'Reports' };
7194  function modHref(sec, sub){
7195    var p=PAGES[sec]||'/index.html';
7196    if(sec==='sales'&&sub==='orders') return '/quote.html';
7197    if(!sub) return p;
7198    if(sub.charAt(0)==='#') return p+sub;
7199    return p+'?__sec='+encodeURIComponent(sub);
7200  }
7201  // REAL-MODE HONESTY (fake-data-render fix): the Overview cards below are demo
7202  // literals with no live company-dashboard data source yet. Outside ?demo=1
7203  // (window.QUASAR_DEMO_MODE!==true) each renderer shows an honest empty state
7204  // instead of fabricated figures; DEMO mode (?demo=1) renders exactly as before.
7205  function dashDemo(){ return window.QUASAR_DEMO_MODE===true; }
7206  function dashEmpty(id, msg){ var r=$(id); if(r) r.innerHTML='<div class="qs-empty">'+msg+'</div>'; }
7207
7208  // ── REAL-MODE Overview (fake-data-render fix) ───────────────────────────────
7209  // Outside ?demo=1 the panels above read LIVE data instead of the demo literals:
7210  // two team-fenced server aggregators (orders-report, ops-dashboard) plus direct
7211  // reads of production_orders / stock / field_jobs / audit_events / notifications.
7212  // ONE orders-report + ONE ops-dashboard + ONE field-jobs read per dashboard
7213  // render, memoised so every panel shares the same fetch (the promises reset at
7214  // the top of renderDashboard). Every read resolves to a safe empty on any
7215  // failure, so a panel shows an honest empty state - never a crash, never a
7216  // fabricated figure. Demo mode keeps the byte-identical branches above.
7217  var _dashOrdersP=null, _dashOpsP=null;
7218  // HQ-331 (Adam 2026-07-16): the first load after LOGIN can race cold functions /
7219  // the service-worker takeover; these memos then cache a null forever and every
7220  // panel settles on the signed-out placeholder until a manual refresh. One delayed
7221  // retry with cleared memos beats asking a signed-in user to reload.
7222  var _dashRetries=0, _dashRetryTimer=null;
7223  var DASH_RETRY_DELAYS=[1500,3500,7000]; // covers a cold-function window, not just a blip
7224  // HQ-452: the range-keyed memos (_dashPaidRangeP / _dashPrevRangeP /
7225  // _dashJobsRangeP) reset here too - a stale entry would serve the previous
7226  // range's numbers after a refresh.
7227  // _dashLeadRosterP is deliberately NOT cleared: a staff roster does not change with
7228  // the date range or between renders, and re-fetching it would be a request per render
7229  // for an answer that never moved. The LEADS themselves are cleared, so a fresh render
7230  // re-reads them.
7231  // HQ-1213 round 13: _dashTargetP left this list with dashSalesTarget() itself
7232  // (Sales Target tile moved to Sales Overview only). _dashReorderP joined it -
7233  // same range-independent-but-still-full-reload-refreshable category as
7234  // _dashOpsP, which it is fetched alongside in realAction().
7235  function dashMemosClear(){ dashCashFor.generation=(dashCashFor.generation||0)+1; _dashCashRangeP={}; _dashOrdersP=null; _dashOpsP=null; _dashOrdersRangeP={}; _dashPaidRangeP={}; _dashPrevRangeP={}; _dashJobsRangeP={}; _dashReorderP=null; _dashLeadsP=null; }
7236  function dashRetryOnce(){
7237    if(_dashRetries>=DASH_RETRY_DELAYS.length) return false;
7238    if(_dashRetryTimer) return true; // a retry is already scheduled - keep showing the loader
7239    var delay=DASH_RETRY_DELAYS[_dashRetries++];
7240    _dashRetryTimer=setTimeout(function(){
7241      _dashRetryTimer=null;
7242      // Re-render the WHOLE dashboard (memos cleared inside renderDashboard) - a
7243      // failed first pass affects every panel, not just the one that noticed.
7244      renderDashboard();
7245    }, delay);
7246    return true;
7247  }
7248  function dashLoad(id){ var r=$(id); if(r && !r.firstChild) r.innerHTML='<div class="qs-empty">Loading…</div>'; }
7249  function dashMoney(v){ v=Number(v)||0; if(v>=1e6) return '$'+(v/1e6).toFixed(2)+'M'; if(v>=1e3) return '$'+(v/1e3).toFixed(1)+'k'; return '$'+Math.round(v).toLocaleString(); }
7250  // HQ-457 spend withholding, re-threaded onto HQ-452's range-keyed data layer after the
7251  // merge. The server withholds company money aggregates from a role without spend
7252  // visibility by OMITTING the money keys, so a formatter that coerces (dashMoney does
7253  // `Number(v)||0`) would print a confident "$0" - a fabricated figure shown to exactly the
7254  // roles the control exists for, which is worse than the leak it replaced. The verdict
7255  // arrives out of band (spend_visible) so a REAL zero still reads as zero for someone
7256  // allowed to see it. Sticky: once withheld in a session, stays withheld.
7257  var dashSpendHidden=false;
7258  var DASH_SPEND_TIP='Company revenue is withheld for your role - ask an admin if you need spend visibility.';
7259  function noteDashSpendVerdict(j){ if(j && j.spend_visible===false) dashSpendHidden=true; return j; }
7260  function dashSpendVal(v){ return dashSpendHidden ? '-' : dashMoney(v); }
7261  function dashAgo(iso){ var t=Date.parse(iso); if(!isFinite(t)) return ''; var s=Math.max(0,(Date.now()-t)/1000); if(s<60) return 'just now'; if(s<3600) return Math.floor(s/60)+'m ago'; if(s<86400) return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; }
7262  function dashStatusLabel(s){ return String(s||'').replace(/_/g,' ').replace(/^\w/,function(c){return c.toUpperCase();}); }
7263  // The shared date windows. Revenue-this-month + the daily activity series + the
7264  // pipeline funnel all come from ONE orders-report call spanning month-start..now
7265  // (a month range yields a per-day series and an exact month revenue total).
7266  function dashMonthFrom(){ var d=new Date(Date.now()+10*3600000); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)-10*3600000).toISOString(); }
7267  function dashRangeTo(){ var d=new Date(Date.now()+10*3600000); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 23,59,59,999)-10*3600000).toISOString(); }
7268  function dashWeek(){ var n=new Date(Date.now()+10*3600000), off=(n.getUTCDay()+6)%7; var mon=Date.UTC(n.getUTCFullYear(),n.getUTCMonth(),n.getUTCDate()-off)-10*3600000; return { from:new Date(mon).toISOString(), to:new Date(mon+7*86400000).toISOString() }; }
7269  function dashOrders(){
7270    // HQ-460: routed through dashReport() rather than carrying its own copy of the chain.
7271    // It had one, and that copy did NOT note the spend verdict - and this is the DEFAULT
7272    // reader (dashOrdersFor delegates here for 'This Month'), so the most common dashboard
7273    // load fetched money aggregates outside the withholding gate. One helper, one place the
7274    // verdict is read, so a reader added later cannot miss it. Found by the parity assertion
7275    // in test/dashboard-spend-surfaces.test.js counting raw call sites.
7276    // HQ-538: dateField:'updated', matching dashPaidFor()/dashPrevFor() below and
7277    // web/quote.html's own definition of "this month". Without it this read defaulted to
7278    // orders-report's 'effective' (created) date, so early in a calendar month - when few
7279    // records have been newly DRAFTED but plenty may have been touched/paid - the Business
7280    // Snapshot chart, the Sales Pipeline funnel and the Open Orders tile (all fed by this
7281    // call) read empty while the Revenue tile (already dateField:'updated') showed real
7282    // money: one page asserting two different things about the same window.
7283    if(!_dashOrdersP){
7284      _dashOrdersP=dashReport({ dateFrom:dashMonthFrom(), dateTo:dashRangeTo(), bucket:'day', dateField:'updated' });
7285    }
7286    return _dashOrdersP;
7287  }
7288  // The Sales Pipeline range picker's window reads (HQ-331: the dropdown was demo-only
7289  // theatre in real mode). 'This Month' shares the panel-wide month read above; the other
7290  // windows memoise per key. Quarter = the calendar quarter.
7291  var _dashOrdersRangeP={};
7292  function dashRangeFrom(key){
7293    var d=new Date(Date.now()+10*3600000);
7294    if(key==='Today') return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())-10*3600000).toISOString();
7295    if(key==='This Week') return dashWeek().from;
7296    // HQ-518 (Adam: "add in last month as well in that list" - sales works by month,
7297    // so a complete closed month needs to be one click away, not just the in-progress
7298    // current one). Unlike every other range this is a COMPLETE past period, not
7299    // to-date - dashRangeWindow gives it its own "to" bound below instead of "now".
7300    if(key==='Last Month') return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth()-1, 1)-10*3600000).toISOString();
7301    if(key==='This Quarter') return new Date(Date.UTC(d.getUTCFullYear(), Math.floor(d.getUTCMonth()/3)*3, 1)-10*3600000).toISOString();
7302    // HQ-452: 'This Year' was reachable from the header menu but had NO branch here,
7303    // so it silently fell through to the month window and cached a month of data
7304    // under the 'This Year' key. It is a real calendar-year window now.
7305    if(key==='This Year') return new Date(Date.UTC(d.getUTCFullYear(), 0, 1)-10*3600000).toISOString();
7306    return dashMonthFrom();
7307  }
7308  // HQ-452: the full window contract for a picked range - every range-aware read and
7309  // label derives from this ONE place, so a range can never mean two different things
7310  // in two panels.
7311  //   from/to      the window itself (every range ends at "now", i.e. peri
7311od-TO-DATE)
7312  //   bucket       the series grain. A day bucket over a year is ~365 points crammed
7313  //                into a ~620px chart, so wide ranges step up to week/month.
7314  //   limit        orders-report caps rows (default 5000) and silently drops the
7315  //                OLDEST rows past the cap, which would UNDERSTATE a wide window.
7316  //                Wide ranges ask for the endpoint's maximum instead.
7317  //   prevFrom/To  the SAME-LENGTH preceding window, so the delta chip compares
7318  //                like with like (month-to-date vs the same days of last month, not
7319  //                vs a complete prior month, which is what it used to do).
7320  //   vs           the delta chip's wording for that baseline.
7321  //   noun         the phrase every range-aware label/tooltip/empty state uses, so
7322  //                nothing can still read "this month" while showing a year.
7323  function dashRangeWindow(key){
7324    // HQ-1542: one Brisbane calendar for timestamp reports and date-only Xero cash.
7325    var from=dashRangeFrom(key);
7326    // HQ-518: every other range is period-TO-DATE, so its "to" is dashRangeTo() (end of
7327    // today). Last Month is the one COMPLETE past period - "from..today" would run
7328    // clean through the whole of this month too, so it gets its own "to": the last
7329    // instant of that calendar month.
7330    var to=key==='Last Month'
7331      ? (function(){ var nd0=new Date(Date.parse(from)+10*3600000); return new Date(Date.UTC(nd0.getUTCFullYear(), nd0.getUTCMonth()+1, 0, 23,59,59,999)-10*3600000).toISOString(); })()
7332      : dashRangeTo();
7333    var fromMs=Date.parse(from), toMs=Date.parse(to);
7334    var span=Math.max(1, toMs-fromMs);
7335    var bucket='day', limit=5000, noun='this month', vs='vs last month to date';
7336    if(key==='Today'){ noun='today'; vs='vs yesterday'; }
7337    else if(key==='This Week'){ noun='this week'; vs='vs last week'; }
7338    else if(key==='Last Month'){ noun='last month'; vs='vs the month before'; }
7339    else if(key==='This Quarter'){ bucket='week'; limit=20000; noun='this quarter'; vs='vs last quarter'; }
7340    else if(key==='This Year'){ bucket='month'; limit=20000; noun='this year'; vs='vs last year'; }
7341    // toFull = the END of the whole calendar period, where from..to above is
7342    // period-TO-DATE. Money and order figures must be to-date (you cannot have
7343    // earned revenue tomorrow), but a SCHEDULE legitimately contains future work:
7344    // reading field jobs to-date would hide a job already booked for Friday.
7345    // Review fix (HQ-452): without this, 'This Week' (which read the whole week) could
7346    // show MORE jobs than 'This Month' (which stopped at today) - a count that falls
7347    // as you widen the range.
7348    var nd=new Date(fromMs+10*3600000), toFull;
7349    if(key==='Today') toFull=new Date(Date.UTC(nd.getUTCFullYear(), nd.getUTCMonth(), nd.getUTCDate()+1)-10*3600000).toISOString();
7350    else if(key==='This Week') toFull=new Date(fromMs+7*86400000).toISOString();
7351    else if(key==='This Quarter') toFull=new Date(Date.UTC(nd.getUTCFullYear(), nd.getUTCMonth()+3, 1)-10*3600000).toISOString();
7352    else if(key==='This Year') toFull=new Date(Date.UTC(nd.getUTCFullYear()+1, 0, 1)-10*3600000).toISOString();
7353    // This Month AND Last Month both land here: nd is derived from `from` either way,
7354    // so "the month after nd's month" is the right exclusive end for both - this
7355    // month's to-date boundary, or last month's actual close.
7356    else toFull=new Date(Date.UTC(nd.getUTCFullYear(), nd.getUTCMonth()+1, 1)-10*3600000).toISOString();
7357    // The baseline is the SAME ELAPSED SLICE of the previous calendar period, which is
7358    // the only fair comparison for a period-to-date figure.
7359    //
7360    // Review fix (HQ-452): this used to be `from - span .. from`, i.e. the span
7361    // immediately BEFORE the window. That is not the same thing: on 25 July the month
7362    // window is 1-25 Jul (span 25 days), so the trailing baseline was 30 Jun - 1 Jul
7363    // = the LAST 25 days of June, and the quarter and year ranges produced that
7364    // identical June slice as their "last quarter" and "last year" baseline. The
7365    // chip then claimed "vs last quarter" while comparing against three weeks of
7366    // June. The baseline now steps back one whole calendar period and takes the same
7367    // elapsed slice from it, clamped so a longer prior period can never bleed into
7368    // the current one.
7369    var d0=new Date(fromMs+10*3600000);
7370    var pStart;
7371    if(key==='Today') pStart=Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth(), d0.getUTCDate()-1)-10*3600000;
7372    else if(key==='This Week') pStart=fromMs-7*86400000;
7373    else if(key==='This Quarter') pStart=Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth()-3, 1)-10*3600000;
7374    else if(key==='This Year') pStart=Date.UTC(d0.getUTCFullYear()-1, 0, 1)-10*3600000;
7375    else pStart=Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth()-1, 1)-10*3600000;   // This Month -> last month; Last Month -> the month before that
7376    var prevFrom=new Date(pStart);
7377    var prevTo=new Date(key==='Last Month'?fromMs-1:Math.min(pStart+span, fromMs-1));
7378    function dates(a,b){
7379      function label(ms){ return new Date(ms+10*3600000).toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric',timeZone:'UTC'}); }
7380      var first=label(a), last=label(b); return first===last?first:first+' to '+last;
7381    }
7382    var periodText=dates(fromMs,toMs)+' | Comparison: '+dates(pStart,prevTo.getTime())+' (Brisbane)';
7383    return { key:key||'This Month', label:(key||'This Month'), from:from, to:to, toFull:toFull, bucket:bucket, limit:limit,
7384      noun:noun, vs:vs, periodText:periodText, prevFrom:prevFrom.toISOString(), prevTo:prevTo.toISOString() };
7385  }
7386  // One orders-report fetch shape for every range-aware read.
7387  function dashReport(body){
7388    body.calendarTimeZone='Australia/Brisbane';
7389    body.taxBasis='exclusive';
7390    return fn('orders-report',{ method:'POST', body:body })
7391      .then(function(r){ return r.ok ? r.json() : null; })
7392      .then(function(j){ return (j && !j.error && j.kpis) ? noteDashSpendVerdict(j) : null; })
7393      .catch(function(){ return null; });
7394  }
7395  function dashOrdersFor(rangeKey){
7396    if(!rangeKey || rangeKey==='This Month') return dashOrders();
7397    if(!_dashOrdersRangeP[rangeKey]){
7398      var w=dashRangeWindow(rangeKey);
7399      // HQ-538: dateField:'updated', for the same reason as dashOrders() above - every
7400      // range this dashboard offers must mean the same thing, and 'updated' is what the
7401      // Revenue tile and web/quote.html's Orders page already use.
7402      _dashOrdersRangeP[rangeKey]=dashReport({ dateFrom:w.from, dateTo:w.to, bucket:w.bucket, limit:w.limit, dateField:'updated' });
7403    }
7404    return _dashOrdersRangeP[rangeKey];
7405  }
7406  // HQ-1213 round 8: Popular Products (Adam, repeatedly, over reference screenshots
7407  // of a ranked-bar "Popular Product" panel: "yes i want those graphs"). Reads the
7408  // SAME orders-report response the KPI row already fetches via dashOrdersFor - the
7409  // memo means this costs no second network call. Real mode only: byProduct has no
7410  // demo fixture (same honest limit reports.html's own "What's Selling" panel
7411  // already has for this exact data - see its OV_SELLING_DEMO_NOTE).
7412  function dashPopularBarHtml(list){
7413    var max=0;
7414    list.forEach(function(p){ var v=('value' in p)?Number(p.value):Number(p.qty); if(isFinite(v)&&v>max)max=v; });
7415    return list.map(function(p){
7416      var v=('value' in p)?Number(p.value):Number(p.qty);
7417      var pct=max>0&&isFinite(v)?Math.max(4,Math.round(v/max*100)):0;
7418      var label=('value' in p)?dashSpendVal(p.value):(p.qty!=null?p.qty+' sold':'-');
7419      return '<div class="dash-pop-row"><div class="dash-pop-name" title="'+escapeHtml(p.name||p.sku||'-')+'">'+escapeHtml(p.name||p.sku||'-')+'</div>'
7420        +'<div class="dash-pop-track"><i style="width:'+pct+'%"></i></div>'
7421        +'<div class="dash-pop-val">'+escapeHtml(label)+'</div></div>';
7422    }).join('');
7423  }
7424  // HQ-1213 round 10 (Adam: "the top selling product this month like the products
7425  // polular style"). byProduct is ALREADY sorted by revenue descending by the server
7426  // (and by qty when spend is withheld, so the ranking survives either way), so the
7427  // top seller is simply list[0] - the hero costs no extra read and can never disagree
7428  // with the ranked list underneath it. The label follows the exact same
7429  // 'value' in p ? dashSpendVal(...) : qty rule the rows use, so a withheld caller
7430  // sees a units figure, never a fabricated $0.
7431  function dashPopularTop(p){
7432    if(!p) return { val:'-', name:'' };
7433    return {
7434      val: ('value' in p)?dashSpendVal(p.value):(p.qty!=null?(p.qty+' sold'):'-'),
7435      name: p.name||p.sku||'-'
7436    };
7437  }
7438  function renderPopular(){
7439    var root=$('dash-popular'); if(!root) return;
7440    var sub=$('dash-popular-sub'); if(sub) sub.textContent='';
7441    var topEl=$('dash-popular-top'), topName=$('dash-popular-topname');
7442    function setTop(val, name){ if(topEl) topEl.textContent=val; if(topName) topName.textContent=name; }
7443    setTop('-','');
7444    if(dashDemo()){ dashEmpty('dash-popular','Popular Products reads live order and quote lines - it has no demo fixtures. Open the page without ?demo=1 to run it.'); return; }
7445    dashLoad('dash-popular');
7446    var W=dashRangeWindow(DASH_RANGE);
7447    if(sub) sub.textContent='Top products by revenue '+W.noun;
7448    dashOrdersFor(DASH_RANGE).then(function(rep){
7449      if(!rep){ dashEmpty('dash-popular','The report could not be reached.'); return; }
7450      var list=Array.isArray(rep.byProduct)?rep.byProduct.slice(0,6):null;
7451      if(!list){ dashEmpty('dash-popular','The report could not be reached.'); return; }
7452      if(!list.length){ dashEmpty('dash-popular','No product lines '+W.noun+'.'); return; }
7453      var top=dashPopularTop(list[0]);
7454      setTop(top.val, 'Top seller '+W.noun+': '+top.name);
7455      root.innerHTML=dashPopularBarHtml(list);
7456    });
7457  }
7458  // HQ-1485: separate memo so AOV, pipeline and target accounting stay unchanged.
7459  var _dashCashRangeP={};
7460  function dashCashMoney(v){ return dashSpendHidden?'-':new Intl.NumberFormat('en-AU',{style:'currency',currency:'AUD',minimumFractionDigits:2}).format(Number(v)||0); }
7461  function dashCashWindow(key){
7462    var n=new Date(Date.now()+10*3600000), y=n.getUTCFullYear(), m=n.getUTCMonth(), d=n.getUTCDate();
7463    var end=Date.UTC(y,m,d), start=Date.UTC(y,m,1), prior=Date.UTC(y,m-1,1);
7464    if(key==='Today'){ start=end; prior=start-86400000; }
7465    else if(key==='This Week'){ start=end-((n.getUTCDay()+6)%7)*86400000; prior=start-7*86400000; }
7466    else if(key==='Last Month'){ start=Date.UTC(y,m-1,1); end=Date.UTC(y,m,1)-86400000; prior=Date.UTC(y,m-2,1); }
7467    else if(key==='This Quarter'){ start=Date.UTC(y,Math.floor(m/3)*3,1); prior=Date.UTC(y,Math.floor(m/3)*3-3,1); }
7468    else if(key==='This Year'){ start=Date.UTC(y,0,1); prior=Date.UTC(y-1,0,1); }
7469    function day(ms){ return new Date(ms).toISOString().slice(0,10); }
7470    return {from:day(start),to:day(end),prevFrom:day(prior),prevTo:day(key==='Last Month'?start-86400000:Math.min(prior+end-start,start-86400000))};
7471  }
7472  function dashCashFor(rangeKey, previous){
7473    var w=dashRangeWindow(rangeKey), dates=dashCashWindow(w.key), key=w.key+(previous?':previous':':current');
7474    if(!_dashCashRangeP[key]){
7475      var body={basis:'received-sales',taxBasis:'exclusive',dateFrom:previous?dates.prevFrom:dates.from,dateTo:previous?dates.prevTo:dates.to,bucket:w.bucket};
7476      if(w.key==='This Month' || w.key==='Last Month') body.invoiceMonth=body.dateFrom.slice(0,7);
7477      _dashCashRangeP[key]=dashReport(body);
7478    }
7479    return _dashCashRangeP[key];
7480  }
7481
7482
7483  // HQ-452: paid-value and its baseline, both keyed by range. Before this the two
7484  // were single-slot memos hardcoded to the calendar month, so a picked range could
7485  // only ever redraw the chart while the Revenue tile kept showing the month.
7486  var _dashPaidRangeP={}, _dashPrevRangeP={}, _dashJobsRangeP={};
7487  function dashPaidFor(rangeKey){
7488    var k=rangeKey||'This Month';
7489    if(!_dashPaidRangeP[k]){
7490      var w=dashRangeWindow(k);
7491      _dashPaidRangeP[k]=dashReport({ dateFrom:w.from, dateTo:w.to, bucket:w.bucket, limit:w.limit, paid:'paid', dateField:'updated' });
7492    }
7493    return _dashPaidRangeP[k];
7494  }
7495  function dashPrevFor(rangeKey){
7496    var k=rangeKey||'This Month';
7497    if(!_dashPrevRangeP[k]){
7498      var w=dashRangeWindow(k);
7499      _dashPrevRangeP[k]=dashReport({ dateFrom:w.prevFrom, dateTo:w.prevTo, bucket:w.bucket, limit:w.limit, paid:'paid', dateField:'updated' });
7500    }
7501    return _dashPrevRangeP[k];
7502  }
7503
7504  // HQ-1213 round 9 (Adam, from the Fikri Store reference images: "okay build the
7505  // totral revene w/" [the vs-last-period toggle]; "AVerage order value bar would be
7506  // great"). Total Revenue below reads the SAME dashPaidFor/dashPrevFor paid-order
7507  // series the "Paid order value" KPI tile's own delta chip already fetches (dRev,
7508  // above) - no second network call, and the same spend-visibility gate
7509  // (dashSpendHidden). HQ-1485 uses the shared cash report; HQ-1542 draws a real
7510  // first-day point. KPI sparklines retain the HQ-855 two-bucket minimum.
7511  var DASH_REV_COMPARE=true, _dashRevLast=null;
7512  // ONE line-chart drawer for every two-line dashboard trend - Total Revenue's paid $
7513  // line comes through here (round 10 also added Average Sales' order count; that
7514  // card was deleted in round 11, but the drawer stayed shared/generic rather than
7515  // being folded back into a Total-Revenue-only function), so the page never grows a
7516  // second charting idiom (it draws the same area+line+dot shape Business Snapshot
7517  // used, with its .snap-* classes).
7518  //
7519  //   opt.pick    reads the plotted number off a series row (default: money `value`)
7520  //   opt.axis    formats a y-axis label (default: dashMoney)
7521  //   opt.gid     the gradient element id - must be unique per chart on the page
7522  //   opt.extra   a SECOND current-period line: { series, pick, stroke }. Line + dots,
7523  //               no area, so two fills never compete.
7524  //
7525  // The prior-period overlay is dashed and muted, and aligns by INDEX, not date: the
7526  // current window compares with the same elapsed days in the previous period;
7527  // Last Month compares two complete months, which can have different day counts.
7528  // The EXTRA series is the opposite case - it comes from a different
7529  // report over the SAME window, so it aligns by BUCKET KEY, and the x domain is the
7530  // UNION of both, because a bucket holding a quote but no paid order is real and
7531  // dropping it would understate the quote line.
7532  function drawTrendChart(root, curSeries, prevSeries, grain, showCompare, opt){
7533    opt=opt||{};
7534    var pick=opt.pick||function(r){ return r.value===null?null:(Number(r.value)||0); };
7535    var fmtAxis=opt.axis||dashMoney;
7536    var extra=(opt.extra && Array.isArray(opt.extra.series) && opt.extra.series.length) ? opt.extra : null;
7537    var extraPick=(extra && extra.pick) || pick;
7538    var curMap={}, extraMap={}, order=[];
7539    function note(b){ if(order.indexOf(b)===-1) order.push(b); }
7540    curSeries.forEach(function(r){ curMap[r.bucket]=pick(r); note(r.bucket); });
7541    if(extra) extra.series.forEach(function(r){ extraMap[r.bucket]=extraPick(r); note(r.bucket); });
7542    order.sort();                                   // bucket keys are YYYY-MM-DD
7543    var days=order.map(function(b){ return dashDayLabel(b, grain); });
7544    // A bucket the report did not send is a real zero for that report: orders-report
7545    // only emits a bucket once a row lands in it.
7546    var curVals=order.map(function(b){ var v=curMap[b]; return v===null?null:(isFinite(v)?v:0); });
7547    var extraVals=extra?order.map(function(b){ var v=extraMap[b]; return isFinite(v)?v:0; }):[];
7548    var prevVals=(showCompare && Array.isArray(prevSeries)) ? prevSeries.map(pick).filter(function(v){ return v!==null; }) : [];
7549    var maxV=0;
7550    function hi(v){ if(v>maxV) maxV=v; }
7551    curVals.forEach(hi); extraVals.forEach(hi); prevVals.forEach(hi);
7552    var mx=Math.max(4, Math.ceil(maxV/4)*4), mn=0;
7553    var W=Math.max(420, root.clientWidth || 620), H=200, padL=48, padR=14, padT=16, padB=28, n=days.length;
7554    function x(i){ return padL + (n>1 ? (i/(n-1)) : 0.5)*(W-padL-padR); }
7555    function y(v){ return padT + (1-(v-mn)/(mx-mn))*(H-padT-padB); }
7556    var grid='', axis='';
7557    [0,0.25,0.5,0.75,1].forEach(function(f){ var g=Math.round(mx*f), yy=y(g); grid+='<line class="snap-grid" x1="'+padL+'" y1="'+yy+'" x2="'+(W-padR)+'" y2="'+yy+'"/>'; axis+='<text class="snap-axis" x="'+(padL-8)+'" y="'+(yy+3)+'" text-anchor="end">'+fmtAxis(g)+'</text>'; });
7558    var xl='', step=Math.max(1, Math.ceil(n/7));
7559    days.forEach(function(d,i){ if(i%step===0 || i===n-1) xl+='<text class="snap-xlbl" x="'+x(i)+'" y="'+(H-7)+'">'+d+'</text>'; });
7560    // HQ-1213 (2026-09-21, Adam: "light mode the blue lines were meant to be gold
7561    // from memory") - this main series used C.blue, a hex SNAPSHOT of
7562    // --brass-bright's dark-mode value (see the C map's own comment above), so it
7563    // could never track the theme switch to gold in light mode the way the extra
7564    // series below already does via a live token. Same fix, same reasoning: a
7565    // token here so the line and any CSS-driven swatch beside it can never drift.
7566    // Round 3 the same day: --qds-accent, the locked design doc's own gold -
7567    // both --brass and --brass-bright turned out to be stale values the doc
7568    // itself replaced (see .qattain-track > i's fuller note in quasar-ui.css).
7569    var mc='var(--qds-accent)';
7570    var gid=opt.gid||'revsnapg', defs='<linearGradient id="'+gid+'" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="'+mc+'" stop-opacity="0.22"/><stop offset="1" stop-color="'+mc+'" stop-opacity="0"/></linearGradient>';
7571    // HQ-1562: null marks later invoice dates with no receipt-day observation.
7572    var known=curVals.map(function(v,i){ return {value:v,index:i}; }).filter(function(p){ return p.value!==null; });
7573    var pts=known.map(function(p){ return x(p.index)+','+y(p.value); }).join(' ');
7574    var lines='';
7575    if(known.length){
7576      lines='<polygon class="snap-area" fill="url(#'+gid+')" points="'+pts+' '+x(known[known.length-1].index)+','+y(mn)+' '+x(known[0].index)+','+y(mn)+'"/>';
7577      lines+='<polyline class="snap-line" stroke="'+mc+'" points="'+pts+'"/>';
7578      known.forEach(function(p){ lines+='<circle class="snap-dot" fill="'+mc+'" cx="'+x(p.index)+'" cy="'+y(p.value)+'" r="3.6"/>'; });
7579    }
7580    if(extra){
7581      // Colour comes from a TOKEN, not from the C map, so the legend swatch beside the
7582      // card title (a CSS rule on the same token) and this line cannot drift apart.
7583      var ec=extra.stroke||'var(--status-violet)';
7584      var epts=extraVals.map(function(v,i){ return x(i)+','+y(v); }).join(' ');
7585      lines+='<polyline class="snap-line" stroke="'+ec+'" points="'+epts+'"/>';
7586      extraVals.forEach(function(v,i){ lines+='<circle class="snap-dot" fill="'+ec+'" cx="'+x(i)+'" cy="'+y(v)+'" r="3.2"/>'; });
7587    }
7588    if(prevVals.length){
7589      var pn=Math.min(n, prevVals.length);
7590      var ppts=prevVals.slice(0,pn).map(function(v,i){ return x(i)+','+y(v); }).join(' ');
7591      if(pn>=2) lines+='<polyline class="snap-line snap-line-prev" stroke="var(--ink-faint)" points="'+ppts+'"/>';
7592      else lines+='<circle class="snap-dot snap-line-prev" fill="none" stroke="var(--ink-faint)" stroke-width="2" cx="'+x(0)+'" cy="'+y(prevVals[0])+'" r="4.5"/>';
7593    }
7594    root.innerHTML='<svg class="snapchart" viewBox="0 0 '+W+' '+H+'"><defs>'+defs+'</defs>'+grid+axis+xl+lines+'</svg>';
7595  }
7596  function renderRevenue(){
7597    var root=$('dash-revenue'); if(!root) return;
7598    var totalEl=$('dash-revenue-total'), deltaEl=$('dash-revenue-delta');
7599    var creditEl=$('dash-credit-total'), combinedEl=$('dash-combined-total');
7600    if(creditEl) creditEl.textContent='-';
7601    if(combinedEl) combinedEl.textContent='-';
7602    var cashGeneration=dashCashFor.generation;
7603    if(dashDemo()){ dashEmpty('dash-revenue','Cash received reads live Xero payment data - it has no demo fixtures. Open the page without ?demo=1 to run it.'); if(totalEl) totalEl.textContent='-'; if(deltaEl) deltaEl.hidden=true; return; }
7604    _dashRevLast=null; root.innerHTML=''; dashLoad('dash-revenue');
7605    var W=dashRangeWindow(DASH_RANGE);
7606    if(totalEl) totalEl.textContent='-';
7607    if(deltaEl) deltaEl.hidden=true;
7608    var period=$('dash-cash-period'); if(period) period.textContent=W.periodText||'';
7609    if(period && (W.key==='This Month' || W.key==='Last Month')){
7610      var dates=dashCashWindow(W.key), parts=dates.from.split('-').map(Number);
7611      var invoiceEnd=new Date(Date.UTC(parts[0],parts[1],0)).toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric',timeZone:'UTC'});
7612      period.textContent='Cash: '+W.periodText+' | Issued invoices: 1 to '+invoiceEnd+'; current unpaid balances.';
7613    }
7614    Promise.all([dashCashFor(W.key), dashCashFor(W.key,true)]).then(function(res){
7615      if(DASH_RANGE!==W.key || cashGeneration!==dashCashFor.generation) return;
7616      var curRep=res[0], prevRep=res[1], genRep=curRep, pk=curRep && curRep.kpis;
7617      // dashSpendHidden is only reliable once a dashReport promise has resolved
7618      // (noteDashSpendVerdict runs inside that chain) - checked here, inside the
7619      // .then(), never before the fetch.
7620      if(dashSpendHidden){ dashEmpty('dash-revenue', DASH_SPEND_TIP); if(totalEl) totalEl.textContent='-'; if(deltaEl) deltaEl.hidden=true; _dashRevLast=null; return; }
7621      if(!pk){
7622        if(dashRetryOnce()){ root.innerHTML=''; dashLoad('dash-revenue'); return; }
7623        dashEmpty('dash-revenue','The report could not be reached.');
7624        var retry=document.createElement('button'); retry.type='button'; retry.className='btn ghost sm'; retry.textContent='Retry';
7625        retry.onclick=function(){ _dashRetries=0; renderDashboard(); }; root.appendChild(retry);
7626        return;
7627      }
7628      if(totalEl) totalEl.textContent=dashCashMoney(pk.paidValue);
7629      if(creditEl) creditEl.textContent=dashCashMoney(pk.creditValue);
7630      if(combinedEl) combinedEl.textContent=dashCashMoney(pk.combinedValue);
7631      var pv=prevRep && prevRep.kpis, dPct=null;
7632      if(pv && Number(pv.paidValue)>0){ dPct=((Number(pk.paidValue)||0)-Number(pv.paidValue))/Number(pv.paidValue)*100; }
7633      if(deltaEl){
7634        if(dPct==null){ deltaEl.hidden=true; }
7635        else {
7636          deltaEl.hidden=false; deltaEl.className='delta '+(dPct>=0?'up':'down');
7637          var label=(dPct>=0?'+':'')+dPct.toFixed(0)+'% '+W.vs;
7638          deltaEl.innerHTML=(dPct>
7638=0?'▲':'▼')+' <span class="sub">'+escapeHtml(label)+'</span>';
7639        }
7640      }
7641      var series=Array.isArray(curRep.series)?curRep.series:[];
7642      // HQ-1542: the 1st is real data too. Draw its point without inventing a trend.
7643      if(!series.length){ dashEmpty('dash-revenue','No payment history '+W.noun+'.'); _dashRevLast=null; return; }
7644      var grain=(curRep.bucket)||W.bucket;
7645      var prevSeries=(prevRep && Array.isArray(prevRep.series))?prevRep.series:[];
7646      // The second line is the current unpaid credit balance from this same report.
7647      var quoteSeries=(genRep && Array.isArray(genRep.series))
7648        ? genRep.series.filter(function(r){ return r && typeof r.quotesValue==='number'; })
7649        : [];
7650      _dashRevLast={ cur:series, prev:prevSeries, grain:grain, quotes:quoteSeries };
7651      drawTrendChart(root, series, prevSeries, grain, DASH_REV_COMPARE, {
7652        gid:'revsnapg',
7653        extra:{ series:quoteSeries, pick:function(r){ return Number(r.quotesValue)||0; } }
7654      });
7655    });
7656  }
7657  // AOV = paid $ / paid orders per bucket, from the same paid-order series (orders
7658  // here IS the paid-order count: the request itself is paid-filtered, so every row
7659  // assembleReport counted into a bucket's `orders` was already a paid order - see
7660  // _orders-report_impl.cjs). A bucket with no paid orders draws no bar - "no
7661  // orders" is not the same fact as "orders worth nothing" and should not plot as $0.
7662  function drawAOVBars(root, pts, grain){
7663    var days=pts.map(function(p){ return dashDayLabel(p.bucket, grain, pts.length); });
7664    var maxV=0; pts.forEach(function(p){ if(p.aov!=null && p.aov>maxV) maxV=p.aov; });
7665    var mx=Math.max(4, Math.ceil(maxV/4)*4);
7666    var W=Math.max(420, root.clientWidth || 620), H=200, padL=48, padR=14, padT=16, padB=28, n=days.length;
7667    var bw=Math.min(36, (W-padL-padR)/n*0.55);
7668    function x(i){ return padL + (n>1 ? (i/(n-1)) : 0.5)*(W-padL-padR); }
7669    function y(v){ return padT + (1-(v||0)/mx)*(H-padT-padB); }
7670    var grid='', axis='';
7671    [0,0.25,0.5,0.75,1].forEach(function(f){ var g=Math.round(mx*f), yy=y(g); grid+='<line class="aov-grid" x1="'+padL+'" y1="'+yy+'" x2="'+(W-padR)+'" y2="'+yy+'"/>'; axis+='<text class="aov-axis" x="'+(padL-8)+'" y="'+(yy+3)+'" text-anchor="end">'+dashMoney(g)+'</text>'; });
7672    var xl='', step=Math.max(1, Math.ceil(n/7)), bars='';
7673    pts.forEach(function(p,i){
7674      if(p.aov==null) return;
7675      var bx=x(i)-bw/2, by=y(p.aov), bh=(H-padB)-by;
7676      bars+='<rect class="aov-bar" x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+Math.max(0,bh)+'" rx="2"/>';
7677    });
7678    days.forEach(function(d,i){ if(i%step===0 || i===n-1) xl+='<text class="aov-xlbl" x="'+x(i)+'" y="'+(H-7)+'">'+d+'</text>'; });
7679    root.innerHTML='<svg class="aovchart" viewBox="0 0 '+W+' '+H+'">'+grid+axis+xl+bars+'</svg>';
7680  }
7681  function renderAOV(){
7682    var root=$('dash-aov'); if(!root) return;
7683    var sub=$('dash-aov-sub'); if(sub) sub.textContent='';
7684    if(dashDemo()){ dashEmpty('dash-aov','Average Order Value reads live paid-order data - it has no demo fixtures. Open the page without ?demo=1 to run it.'); return; }
7685    if(!root.querySelector('svg')) dashLoad('dash-aov');
7686    var W=dashRangeWindow(DASH_RANGE);
7687    dashPaidFor(W.key).then(function(rep){
7688      if(dashSpendHidden){ dashEmpty('dash-aov', DASH_SPEND_TIP); return; }
7689      if(!rep){ dashEmpty('dash-aov','The report could not be reached.'); return; }
7690      var k=rep.kpis;
7691      if(sub && k && k.paidCount>0) sub.textContent='Average '+dashMoney((Number(k.paidValue)||0)/k.paidCount)+' '+W.noun;
7692      var series=Array.isArray(rep.series)?rep.series:[];
7693      if(!series.length){ dashEmpty('dash-aov','No paid-order history '+W.noun+'.'); return; }
7694      var pts=series.map(function(r){ var orders=Number(r.orders)||0, value=Number(r.value)||0; return { bucket:r.bucket, aov: orders>0?(value/orders):null }; });
7695      if(!pts.some(function(p){ return p.aov!=null; })){ dashEmpty('dash-aov','No paid orders '+W.noun+' yet.'); return; }
7696      drawAOVBars(root, pts, (rep.bucket)||W.bucket);
7697    });
7698  }
7699
7700  // HQ-1213 (Adam, live review 2026-09-18: "i do not like the github commit style
7701  // for that") - the grid-of-squares heatmap read as a generic dev-tool widget, not
7702  // part of this site's own visual language. Replaced with a bar chart of the SAME
7703  // per-bucket order-count data, drawn by the EXACT SAME pattern drawAOVBars uses
7704  // just above (identical padding/scale math and the same .aov-grid/.aov-axis/
7705  // .aov-xlbl/.aov-bar classes, quasar-ui.css) so Order Activity reads as the same
7706  // charting idiom as Average Order Value rather than a second, different chart
7707  // style on the page. The only difference is the axis: plain order counts, not
7708  // dashMoney(). The Less/More legend the heatmap needed (to translate shading into
7709  // a scale) is dropped on purpose - a bar chart's own gridlines and axis numbers
7710  // already state the scale directly, so a separate legend would be redundant
7711  // chrome, not lost information.
7712  function drawOrderActivityBars(root, pts, grain){
7713    var days=pts.map(function(p){ return dashDayLabel(p.bucket, grain, pts.length); });
7714    var maxV=0; pts.forEach(function(p){ if(p.orders>maxV) maxV=p.orders; });
7715    var mx=Math.max(4, Math.ceil(maxV/4)*4);
7716    var W=Math.max(420, root.clientWidth || 620), H=200, padL=48, padR=14, padT=16, padB=28, n=days.length;
7717    var bw=Math.min(36, (W-padL-padR)/n*0.55);
7718    function x(i){ return padL + (n>1 ? (i/(n-1)) : 0.5)*(W-padL-padR); }
7719    function y(v){ return padT + (1-(v||0)/mx)*(H-padT-padB); }
7720    var grid='', axis='';
7721    [0,0.25,0.5,0.75,1].forEach(function(f){ var g=Math.round(mx*f), yy=y(g); grid+='<line class="aov-grid" x1="'+padL+'" y1="'+yy+'" x2="'+(W-padR)+'" y2="'+yy+'"/>'; axis+='<text class="aov-axis" x="'+(padL-8)+'" y="'+(yy+3)+'" text-anchor="end">'+g+'</text>'; });
7722    var xl='', step=Math.max(1, Math.ceil(n/7)), bars='';
7723    // Every bucket draws a bar, including a genuine zero-order one (height 0 at the
7724    // baseline) - the same "one mark per bucket, honestly" rule the heatmap's own
7725    // zero-order cells followed (rendered with no inline colour, never hidden).
7726    pts.forEach(function(p,i){
7727      var bx=x(i)-bw/2, by=y(p.orders), bh=(H-padB)-by;
7728      var title=p.bucket+': '+p.orders+' order'+(p.orders===1?'':'s');
7729      bars+='<rect class="aov-bar" x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+Math.max(0,bh)+'" rx="2"><title>'+escapeHtml(title)+'</title></rect>';
7730    });
7731    days.forEach(function(d,i){ if(i%step===0 || i===n-1) xl+='<text class="aov-xlbl" x="'+x(i)+'" y="'+(H-7)+'">'+d+'</text>'; });
7732    root.innerHTML='<svg class="aovchart" viewBox="0 0 '+W+' '+H+'">'+grid+axis+xl+bars+'</svg>';
7733  }
7734  // Order Activity - reuses the EXACT SAME memoised dashOrdersFor(W.key) promise the
7735  // KPI strip and Total Revenue's own Quoted line already await - no second network
7736  // call. "Orders" is the report's own per-bucket order COUNT (row.orders), sent
7737  // even to a caller whose money figures are withheld (a count is not a money key),
7738  // so this card never has to show the spend-hidden empty state the money-bearing
7739  // cards do.
7740  //
7741  // A bucket's grain follows the selected range (dashRangeWindow): 'day' for
7742  // Today/This Week/This Month/Last Month, 'week' for This Quarter, 'month' for
7743  // This Year. There is no daily-only version of this series to fall back to
7744  // without a second fetch, so a wider range reads as a coarser but still real
7745  // chart rather than being hidden or silently re-labelled as daily.
7746  function renderOrderActivity(){
7747    var root=$('dash-heat'); if(!root) return;
7748    var sub=$('dash-heat-sub'); if(sub) sub.textContent='';
7749    if(dashDemo()){ dashEmpty('dash-heat','Order Activity reads live order data - it has no demo fixtures. Open the page without ?demo=1 to run it.'); return; }
7750    dashLoad('dash-heat');
7751    var W=dashRangeWindow(DASH_RANGE);
7752    dashOrdersFor(W.key).then(function(rep){
7753      if(!rep){ dashEmpty('dash-heat','The report could not be reached.'); return; }
7754      var series=(Array.isArray(rep.series)?rep.series.slice():[]).sort(function(a,b){
7755        return a.bucket<b.bucket?-1:(a.bucket>b.bucket?1:0);
7756      });
7757      if(!series.length){ dashEmpty('dash-heat','No orders '+W.noun+' yet.'); return; }
7758      var grain=(rep.bucket)||W.bucket;
7759      if(sub) sub.textContent='Orders per '+grain+' '+W.noun+(rep.truncated?' - most recent records only':'');
7760      var pts=series.map(function(r){ return { bucket:r.bucket, orders:Number(r.orders)||0 }; });
7761      drawOrderActivityBars(root, pts, grain);
7762    });
7763  }
7764
7765  // Field jobs scheduled inside the picked window (scheduled_start is a real date
7766  // column, so this range is honest). Keyed by range; 'This Week' keeps using the
7767  // shared week memo so the tile and the schedule card share one fetch.
7768  function dashJobsFor(rangeKey){
7769    var k=rangeKey||'This Month';
7770    if(!_dashJobsRangeP[k]){
7771      if(!sb){ _dashJobsRangeP[k]=Promise.resolve([]); return _dashJobsRangeP[k]; }
7772      // The WHOLE picked period (w.toFull), not period-to-date: a schedule contains
7773      // future work, and reading to-date would both hide a job booked for later this
7774      // week and make the count FALL when the range widens (review fix, HQ-452).
7775      // No special case for 'This Week' any more - one rule for every range.
7776      var w=dashRangeWindow(k);
7777      _dashJobsRangeP[k]=sb.from('field_jobs').select('id,job_number,job_type,site_address,scheduled_start,status')
7778        .is('deleted_at',null).gte('scheduled_start',w.from).lt('scheduled_start',w.toFull)
7779        .order('scheduled_start',{ascending:true})
7780        .then(function(res){ return (res && !res.error && Array.isArray(res.data)) ? res.data : []; }, function(){ return []; });
7781    }
7782    return _dashJobsRangeP[k];
7783  }
7784  function dashOps(){
7785    if(!_dashOpsP){
7786      _dashOpsP=fn('ops-dashboard',{ method:'POST', body:{} })
7787        .then(function(r){ return r.ok ? r.json() : null; })
7788        .then(function(j){ var m={}, cs=(j && j.data && j.data.cards) || []; cs.forEach(function(c){ m[c.key]=c; }); return m; })
7789        .catch(function(){ return {}; });
7790    }
7791    return _dashOpsP;
7792  }
7793  // HQ-1213 round 13: dashSalesTarget()/dashPeriodKey()/dashPeriodLabel() (the
7794  // company-wide monthly sales target read that used to feed a Company Overview
7795  // tile) are GONE - the tile moved to Sales Overview only (HQ-1222 built its own,
7796  // per-rep version there), and nothing else on this page ever called them. See
7797  // test/hq1222-sales-target-tile.test.js, which now pins only the Sales Overview
7798  // half of that round.
7799  // ONE funnel-segment builder shared by the demo AND real Pipeline panels, so the
7800  // single per-record width= inline style has exactly one home (ui-drift: no growth).
7801  // Optional cls carries a per-stage colour class (.fseg-quotes/.fseg-orders/.fseg-paid,
7802  // defined in quasar-ui.css) - real mode's 3 stages map to it 1:1 (see realPipeline).
7803  function dashSeg(w,cls){ return '<div class="seg'+(cls?' '+cls:'')+'" style="width:'+w+'%"></div>'; }
7804
7805  function realKPIs(rangeKey){
7806    var root=$('dash-kpis'); if(!root) return;
7807    root.innerHTML=''; dashLoad('dash-kpis');
7808    var cashGeneration=dashCashFor.generation;
7809    // HQ-452: every window below comes from the PICKED range, not a hardcoded month.
7810    var W=dashRangeWindow(rangeKey||DASH_RANGE);
7811    // HQ-1213 round 13: Sales Target moved to Sales Overview only (HQ-1222 built it
7812    // there - a rep-level dollar figure has no place on a company-wide board), so
7813    // dashSalesTarget() is no longer fetched here at all. dashJobsFor() is likewise
7814    // dropped from this fetch - the Field Jobs This Week tile it fed is gone with it
7815    // (Field Service de-emphasised this round) - but the function itself stays
7816    // (test/dashboard-wiring.test.js pins its window logic independently of any tile).
7817    Promise.all([dashOrdersFor(W.key), dashOps(), dashCashFor(W.key,true), dashCashFor(W.key)]).then(function(res){
7818      if(DASH_RANGE!==W.key || cashGeneration!==dashCashFor.generation) return;
7819      var rep=res[0], ops=res[1]||{}, prev=res[2], paidRep=res[3];
7820      var k=rep && rep.kpis, series=(rep && Array.isArray(rep.series)) ? rep.series : [], sup=ops.support_open, rma=ops.rma_open;
7821      if(!k && !(rma && rma.available)){ if(dashRetryOnce()){ dashLoad('dash-kpis'); return; } dashEmpty('dash-kpis','Live KPIs need a signed-in session.'); return; }
7822      // Sparklines come ONLY from the real daily series (>=2 points); Revenue reads the
7823      // bank and verified card receipt report (value, spark and delta all from that one read),
7824      // and its delta compares the REAL prior calendar month on the same basis - no
7825      // chip when the baseline is empty, never a fabricated one.
7826      function sp(f){ if(f==='value' && dashSpendHidden) return null; return series.length>=2 ? series.map(function(s){ return Number(s[f])||0; }) : null; }
7827      var pk=paidRep && paidRep.kpis;
7828      var paidSeries=(paidRep && Array.isArray(paidRep.series)) ? paidRep.series : [];
7829      var paidSpark=(!dashSpendHidden && paidSeries.filter(function(s){ return s.value!==null; }).length>=2) ? paidSeries.filter(function(s){ return s.value!==null; }).map(function(s){ return Number(s.value)||0; }) : null;
7830      // HQ-855: fewer than two paid buckets means no revenue sparkline. The
7831      // general series includes quotes and cannot substitute for paid history.
7832      // Keep the headline on that same paid read; its failure is unavailable.
7833      var dRev=null;
7834      if(!dashSpendHidden && pk && prev && prev.kpis && Number(prev.kpis.paidValue)>0){
7835        var pd=((Number(pk.paidValue)||0)-Number(prev.kpis.paidValue))/Number(prev.kpis.paidValue)*100;
7836        // HQ-452: the baseline is the same-length window before this one, so the
7837        // wording follows the picked range instead of always claiming "last month".
7838        dRev={dir:pd>=0?'up':'down',label:(pd>=0?'+':'')+pd.toFixed(0)+'% '+W.vs};
7839      }
7840      // HQ-452: orders-report drops the OLDEST rows once its row cap is hit, which
7841      // would quietly understate a wide window. Say so rather than showing a short
7842      // number as if it were the whole truth.
7843      var capped=!!((rep && rep.truncated) || (paidRep && paidRep.truncated));
7844      // HQ-1213 (Adam: "there is no values with the up or down and % ... these need
7845      // to change for each week") - ops-dashboard now sends week_prior (a real daily
7846      // snapshot from 7 days ago, ops-kpi-snapshot-cron.mjs) alongside count/available;
7847      // week_prior is null until a real prior snapshot exists, so this returns null
7848      // rather than fabricate a delta from nothing. An absolute difference ("-1 this
7849      // week"), not a percentage - these are small integer counts, where "-14%" reads
7850      // less clearly than "-1".
7851      function dashWeekDelta(card){
7852        if(!card || !card.available || card.week_prior==null) return null;
7853        var diff=card.count-card.week_prior;
7854        if(diff===0) return null;
7855        return {dir:diff>0?'up':'down', label:(diff>0?'+':'')+diff+' this week'};
7856      }
7857      // HQ-1213 round 13 (Adam, live review): Sales Target dropped from this strip
7858      // entirely (it lives on Sales Overview only now, HQ-1222) and Field Jobs This
7859      // Week dropped too (Field Service de-emphasised this round). Open RMAs
7860      // (Warranty) takes a slot instead, reading ops.rma_open - already fetched by
7861      // dashOps() above, so this costs no second network call. Five tiles now:
7862      // Paid order value, Orders awaiting payment, Open Production Orders, Open
7863      // RMAs (Warranty), Open Support Cases.
7864      // HQ-452: labels and tooltips are built from W.noun / W.label so a tile can
7865      // never read "This Month" while the picker says This Year. The ops-dashboard
7866      // tiles are the honest exception: that endpoint takes no date window at all
7867      // (it answers "what is open right now"), so they keep a range-free label and
7868      // their tooltip says so outright.
7869      // HQ-1213 round 28 (Adam: no green/red under Orders awaiting payment/Open
7870      // Production Orders/Open RMAs/Open Support Cases): Open Production Orders now
7871      // carries the SAME week-over-week delta as Open RMAs/Support Cases just below
7872      // it - ops.production_open already carries week_prior generically (computeOpsCounts/
7873      // readWeekAgoSnapshots in _ops-dashboard_impl.cjs treat every METRICS entry the
7874      // same way), this tile just never read it. Still returns null (no chip, per
7875      // dashWeekDelta above) until a week of ops_kpi_snapshots history exists -
7876      // never a fabricated trend from nothing.
7877      // Awaiting payment is a current balance on invoices in the selected period,
7878      // so it has no count sparkline or historical snapshot delta.
7879      var rl=W.label, cappedNote=capped?' Showing the most recent records only - the window holds more than one report can return.':'';
7880      var tiles=[
7881        { cls:'',       ic:IC.revenue,  lbl:'Cash received '+rl,  val:pk?dashCashMoney(pk.paidValue):'-', spark:paidSpark,  col:C.blue,   delta:dRev, href:modHref('sales','pipeline'),       tip:dashSpendHidden?DASH_SPEND_TIP:(pk?('Xero bank and verified website card receipts by payment date '+W.noun+'. AUD excluding GST. Matches the cash chart.'):'Revenue is unavailable right now.') },
7882        { cls:'violet', ic:IC.projects, lbl:'Awaiting payment '+rl, val:pk?dashCashMoney(pk.awaitingValue):'-', spark:null, col:C.violet, href:modHref('sales','orders'), tip:dashSpendHidden?DASH_SPEND_TIP:'Current Xero balance of invoices raised '+((W.key==='This Month'||W.key==='Last Month')?'throughout the selected month':W.noun)+', including credit accounts. Partial payments and credit notes deducted. AUD excluding GST.' },
7883        { cls:'',       ic:IC.gauge,    lbl:'Open Production Orders', val:(ops.production_open && ops.production_open.available)?String(ops.production_open.count):'-', spark:null, col:C.blue, delta:dashWeekDelta(ops.production_open), href:modHref('manufacturing','overview'), tip:(ops.production_open && ops.production_open.available)?'Production orders open right now - a live count, not affected by the date range. Open Manufacturing.':'Open production orders is unavailable right now.' },
7884        { cls:'blue',   ic:IC.rma,      lbl:'Open RMAs (Warranty)', val:(rma && rma.available)?String(rma.count):'-', spark:null, col:C.blue, delta:dashWeekDelta(rma), href:modHref('support','rma'), tip:(rma && rma.available)?'RMAs open right now - a live count, not affected by the date range. Open Support.':'Open RMAs is unavailable right now.' },
7885        // HQ-1213 round 13: the old rma.count fallback (for when support_open itself
7886        // was unavailable) is dropped now that Open RMAs is its own honest tile above -
7887        // showing the RMA count a second time, mislabelled as Support Cases, would be
7888        // actively misleading rather than a helpful stand-in.
7889        { cls:'amber',  ic:IC.headset,  lbl:'Open Support Cases',  val:(sup && sup.available)?String(sup.count):'-', spark:null, col:C.amber, delta:dashWeekDelta(sup), href:modHref('support','cases'), tip:(sup && sup.available)?'Cases open right now in the Support hub - a live count, not affected by the date range. Open Support.':'Open support cases is unavailable right now.' }
7890      ];
7891      root.innerHTML='';
7892      tiles.forEach(function(t){
7893        var c=el('a','kpi '+t.cls); c.href=t.href; c.title=t.tip;
7894        c.innerHTML='<div class="khead">'
7895          +'<div class="ktext"><div class="lbl">'+t.lbl+'</div><div class="val">'+t.val+'</div></div></div>'
7896          +(t.spark?spark(t.spark, t.col):'')
7897          +(t.delta?'<div class="delta '+(t.delta.dir==='up'?'up':'down')+'">'+(t.delta.dir==='up'?'▲':'▼')+' <span class="sub">'+escapeHtml(t.delta.label)+'</span></div>':'');
7898        root.appendChild(c);
7899      });
7900    });
7901  }
7902
7903  // HQ-452 review fix: the x labels were ALWAYS the day-of-month, so once a wide
7904  // range steps the series up to a week or month grain (dashRangeWindow) a monthly
7905  // bucket of '2026-03-01' read as "1" - twelve points labelled 1,1,1... The label
7906  // now follows the grain the series was actually bucketed at. UTC parts, because the
7907  // server's bucket keys are UTC dates.
7908  function dashDayLabel(key, grain, n){
7909    var p=String(key||'').split('-');
7910    if(p.length!==3) return key;
7911    var d=new Date(key+'T00:00:00Z');
7912    if(isNaN(d.getTime())) return key;
7913    if(grain==='month') return d.toLocaleDateString('en-AU',{month:'short',timeZone:'UTC'});
7914    if(grain==='week') return d.getUTCDate()+' '+d.toLocaleDateString('en-AU',{month:'short',timeZone:'UTC'});
7915    // HQ-1213 (Adam: "can you put dates in there or no?") - a week-scale window (<=10
7916    // points) has room for a short weekday + day-of-month ('Mon 4') instead of a bare
7917    // number, which reads as a real date without crowding a month-scale axis (up to 31
7918    // points), where the bare day-of-month stays.
7919    if(n && n<=10) return d.toLocaleDateString('en-AU',{weekday:'short',timeZone:'UTC'})+' '+d.getUTCDate();
7920    return String(parseInt(p[2],10));
7921  }
7922  // HQ-1213 round 10: drawSnapshot / realSnapshot / dashSnapLegend are GONE with the
7923  // Business Snapshot card (Adam: the quote-vs-invoice revenue lines "removes that
7924  // other table"). Their .snap-* chart classes stay - drawTrendChart above draws with
7925  // exactly those classes, which is why the CSS was not deleted alongside the code.
7926
7927  // HQ-1213 round 13: a cross-module stock alert for Action Required, reusing the
7928  // SAME live source Manufacturing's own reorder/backorder proposal list already
7929  // reads - production_order_proposals, refreshed every 30 minutes by
7930  // production-proposals-cron.mjs (HQ-1115) - rather than re-deriving "below
7931  // reorder level" client-side against the stock table (which would risk quietly
7932  // disagreeing with the real MRP computation the moment the two definitions
7933  // drift). sharedRead:true, so any signed-in user can read it, same shape as
7934  // dashOpenLeads() below. One pending row per (team, SKU) is DB-enforced
7935  // (production_order_proposals_pending_uidx), so this count is exactly the
7936  // distinct-SKU count, not an estimate. Resolves to null (not 0) on a failed
7937  // read, so the card can tell "no rows" apart from "could not ask".
7938  var _dashReorderP=null;
7939  function dashReorderCount(){
7940    if(!_dashReorderP){
7941      if(!sb){ _dashReorderP=Promise.resolve(null); return _dashReorderP; }
7942      _dashReorderP=sb.from('production_order_proposals').select('id')
7943        .eq('status','pending').in('reason',['reorder','both']).is('deleted_at',null).limit(500)
7944        .then(function(r){ return (r&&Array.isArray(r.data))?r.data.length:null; },function(){ return null; });
7945    }
7946    return _dashReorderP;
7947  }
7948
7949  function realAction(){
7950    var root=$('dash-action'); if(!root) return;
7951    dashLoad('dash-action');
7952    Promise.all([dashOps(), dashReorderCount()]).then(function(res){
7953      var ops=res[0]||{}, reorderCount=res[1];
7954      // The action-worthy metrics (each with an owning-module deep link). production_open
7955      // is intentionally NOT here: an open production order is in-progress, not an action.
7956      // HQ-1213 (Adam, live review 2026-09-18): "the action required the sentence is
7957      // hard on that tile" - every row used to render the SAME literal pill text
7958      // ("Attention"), carrying zero information beyond colour, the exact thing the
7959      // shared urgency-pill pattern (renderAction's demo rows, renderDeptAttn below)
7960      // exists to avoid. `pill` gives each key its own honest, specific word 
7960instead.
7961      var MAP={
7962        rma_open:              { href:'/support.html',       pc:'blue',  pill:'Review' },
7963        credit_holds:          { href:'/quote.html',         pc:'red',   pill:'Blocked' },
7964        money_intents_pending: { href:'/support.html',       pc:'amber', pill:'Pending' },
7965        calibration_overdue:   { href:'/operations.html',    pc:'amber', pill:'Overdue' },
7966        jobs_unscheduled:      { href:'/field-service.html', pc:'blue',  pill:'Unscheduled' }
7967      };
7968      var order=['rma_open','credit_holds','money_intents_pending','calibration_overdue','jobs_unscheduled'];
7969      // rows: { n, t, href, pc, pill } - the shape renderDeptAttn's own .aq-row markup
7970      // below reads, so this card and every My Dashboard queue draw from one component.
7971      var rows=order.filter(function(key){ var c=ops[key]; return c && c.available && c.attention; })
7972        .map(function(key){ var c=ops[key], m=MAP[key]; return { n:c.count, t:c.label, href:m.href, pc:m.pc, pill:m.pill }; });
7973      if(reorderCount!=null && reorderCount>0){
7974        // HQ-1213: the title text used to be a full pluralised clause ("N products
7975        // below reorder level") - the other "sentence" Adam flagged as hard to read on
7976        // a single line. Below reorder level is a short noun phrase that needs no
7977        // singular/plural form at all; the Reorder pill carries the action word.
7978        rows.push({ n:reorderCount, t:'Below reorder level',
7979          href:'/inventory.html?__sec=reorder', pc:'red', pill:'Reorder' });
7980      }
7981      if(!rows.length){ dashEmpty('dash-action','All clear - nothing needs action right now.'); return; }
7982      // HQ-452: ops-dashboard (and the reorder read above) take no date window - every
7983      // count here is "open right now". Say that on the card so a picked range never
7984      // looks like it filtered this panel (it cannot).
7985      root.innerHTML='<div class="mc-note mc-lead">Open right now - not filtered by the date range.</div>';
7986      // HQ-1213 round 13: rebuilt onto the shared .aq-row component (quasar-ui.css,
7987      // extracted from renderDeptAttn's own markup below) instead of the page-local
7988      // .actrow shape - number, title, urgency pill, chevron. NO icon on this card
7989      // (Adam, this session: a plain agreed decision for Action Required and Sales
7990      // Overview's Needs Me specifically, not a reconsideration of renderDeptAttn's
7991      // OWN .aq-ic usage elsewhere on this page, which is untouched).
7992      rows.forEach(function(r){
7993        var d=el('div','aq-row');
7994        d.setAttribute('role','button'); d.setAttribute('tabindex','0'); d.title=r.n+' '+r.t+' - open '+r.href;
7995        d.innerHTML='<span class="aq-n">'+r.n+'</span>'
7996          +'<span class="aq-t">'+escapeHtml(r.t)+'</span>'
7997          +'<span class="urg '+r.pc+'">'+escapeHtml(r.pill)+'</span>'
7998          +'<span class="chev">'+svg('<path d="M9 18l6-6-6-6"/>')+'</span>';
7999        var go=function(){ location.href=r.href; };
8000        d.addEventListener('click', go);
8001        d.addEventListener('keydown', function(e){ if(e.key==='Enter'){ go(); } });
8002        root.appendChild(d);
8003      });
8004    });
8005  }
8006
8007  // HQ-752: company-wide OPEN leads on the Company Overview (Adam: "THis could be good
8008  // to show on the main dashboard"). new/contacted/qualified - a converted or lost lead
8009  // is finished work and should not grow a card forever - soonest due first with undated
8010  // last, the whole-board counterpart of My Dashboard's assignee-scoped My Leads card
8011  // below. Real mode only: `leads` has no demo seed, and inventing one here would show
8012  // enquiries nobody made - the honest-empty-state rule the Leads tab itself states.
8013  //
8014  // HQ-1213 round 10: the open-lead read and the roster read are SHARED memos so every
8015  // card asking "how many leads are outstanding" asks it once. That matters beyond the
8016  // saved request: "outstanding" has to mean the same thing everywhere it is counted,
8017  // and two copies of the status list is exactly how they would drift apart. Round 11
8018  // deleted the New Leads card that first justified this memo (Leads by Owner was its
8019  // co-reader even then) - dashOpenLeads()/dashLeadRoster() stay exactly as shared, kept
8020  // alive now by Leads by Owner alone; test/leads-act.test.js pins dashOpenLeads().
8021  var _dashLeadRoster=null, _dashLeadRosterP=null, _dashLeadsP=null, _dashSalesRepIdsP=null;
8022  // Who owns each lead: the same admin-users roster the Leads tab's assignee picker
8023  // reads. Best-effort - a roster failure prints "a colleague", never blocks a card.
8024  function dashLeadRoster(){
8025    if(!_dashLeadRosterP){
8026      _dashLeadRosterP=fetch('/.netlify/functions/admin-users',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({action:'roster'})})
8027        .then(function(r){ return r.json(); })
8028        .then(function(j){ _dashLeadRoster=(j&&Array.isArray(j.people))?j.people:[]; return _dashLeadRoster; })
8029        .catch(function(){ _dashLeadRoster=[]; return _dashLeadRoster; });
8030    }
8031    return _dashLeadRosterP;
8032  }
8033  function dashLeadName(id, roster){
8034    if(!id) return 'Unassigned';
8035    for(var i=0;i<(roster||[]).length;i++){ if(String(roster[i].id)===String(id)) return roster[i].name||roster[i].email||'a colleague'; }
8036    return 'a colleague';
8037  }
8038  // HQ-1213 (Adam, live review 2026-09-17): "you are also Missing Sameera and Website
8039  // in the Leads by owner and Yvette and Donovan ... Sameera and Yvette should be in
8040  // Leads." Leads by Owner used to be built purely by grouping dashOpe
8040nLeads() by
8041  // assignee_id, so a rep with ZERO open leads produced no row at all - meanwhile
8042  // Sales Targets, right next door on the Sales Overview, lists every rep on the
8043  // curated 'sales.reps' app_settings roster whether or not they have activity. Read
8044  // that SAME setting here (web/sales.html's tgtRepIds reads it identically) so the
8045  // two cards agree about who is on the team, rather than keeping a second, silently
8046  // drifting definition of "who is a sales rep". null = not yet loaded/unset, meaning
8047  // "no curated roster" - renderLeadOwners falls back to its old group-by-activity
8048  // behaviour in that case, exactly as before this round.
8049  // HQ-1412 (Adam: "Why is this box not updated then? for leads"): Leads now have their
8050  // own roster, 'sales.lead_reps', which wins here exactly as it does in the Leads assign
8051  // menus (web/sales.html leadRosterSettingFor); unset falls back to 'sales.reps'.
8052  function dashLeadRepIdsFrom(rows){
8053    var byKey={};
8054    (rows||[]).forEach(function(row){ if(row&&row.key)byKey[row.key]=row.value; });
8055    var lead=byKey['sales.lead_reps'], sales=byKey['sales.reps'];
8056    var chosen=(lead&&Array.isArray(lead.user_ids))?lead:sales;
8057    return chosen&&Array.isArray(chosen.user_ids)?chosen.user_ids.map(String):null;
8058  }
8059  function dashSalesRepIds(){
8060    if(!_dashSalesRepIdsP){
8061      if(!sb){ _dashSalesRepIdsP=Promise.resolve(null); return _dashSalesRepIdsP; }
8062      _dashSalesRepIdsP=sb.from('app_settings').select('key,value').in('key',['sales.lead_reps','sales.reps']).limit(2)
8063        .then(function(r){
8064          return (r&&!r.error&&Array.isArray(r.data))?dashLeadRepIdsFrom(r.data):null;
8065        },function(){ return null; });
8066    }
8067    return _dashSalesRepIdsP;
8068  }
8069  // The ONE definition of an outstanding lead on this page: status new / contacted /
8070  // qualified, not soft-deleted, soonest due first with undated last. Resolves to null
8071  // (not []) when the read is refused, which is how the cards tell "no session" apart
8072  // from "no leads". The 500 cap is a sanity bound, not a page size - Leads by Owner
8073  // counts every one of these.
8074  function dashOpenLeads(){
8075    if(!_dashLeadsP){
8076      if(!sb){ _dashLeadsP=Promise.resolve(null); return _dashLeadsP; }
8077      _dashLeadsP=sb.from('leads').select('id,customer_name,assignee_id,due_date,status')
8078        .in('status',['new','contacted','qualified']).is('deleted_at',null)
8079        .order('due_date',{ascending:true}).limit(500)
8080        .then(function(r){ return (r&&Array.isArray(r.data))?r.data:[]; },function(){ return null; });
8081    }
8082    return _dashLeadsP;
8083  }
8084  // HQ-1213 round 10 (Adam: "also current leads, could put that in a graphy and have
8085  // users with there name and how many leads are outstanding"). The open-lead
8086  // population dashOpenLeads() reads, grouped by assignee and ranked - a plain count,
8087  // no target and no percentage (that is Sales Targets' job, on the Sales Overview).
8088  // Built from the shared .qrank-row / .qattain-track ranked-bar component rather than
8089  // a fourth bar family; Unassigned is its own row on purpose, because unowned leads
8090  // are the ones most worth seeing.
8091  function renderLeadOwners(){
8092    var root=$('dash-leadowners'); if(!root) return;
8093    var sub=$('dash-leadowners-sub'); if(sub) sub.textContent='';
8094    if(dashDemo()||!sb){ dashEmpty('dash-leadowners','Leads by Owner reads live lead data - it has no demo fixtures. Open the page without ?demo=1 to run it.'); return; }
8095    dashLoad('dash-leadowners');
8096    Promise.all([dashOpenLeads(),dashLeadRoster(),dashSalesRepIds()]).then(function(res){
8097      var rows=res[0], roster=res[1]||[], repIds=res[2];
8098      if(rows===null){ dashEmpty('dash-leadowners','Live leads need a signed-in session.'); return; }
8099      if(!rows.length){ dashEmpty('dash-leadowners','No open leads right now - + New Lead in Sales > Leads captures the next call.'); return; }
8100      var counts={};
8101      rows.forEach(function(l){ var k=l.assignee_id?String(l.assignee_id):''; counts[k]=(counts[k]||0)+1; });
8102      // A curated roster shows every rep on the team, including a genuine zero - that
8103      // is useful information (clear, or not being assigned work), not noise to hide.
8104      // Keep the Unassigned row on top of the curated names: unowned leads matter
8105      // most, and "unassigned" is never itself a curated rep id.
8106      var list;
8107      if(repIds&&repIds.length){
8108        list=repIds.map(function(id){ return { name:dashLeadName(id, roster), n:counts[id]||0 }; });
8109        if(counts['']) list.push({ name:'Unassigned', n:counts[''] });
8110      } else {
8111        list=Object.keys(counts).map(function(k){ return { name:dashLeadName(k, roster), n:counts[k] }; });
8112      }
8113      list.sort(function(a,b){ return b.n-a.n || (a.name<b.name?-1:(a.name>b.name?1:0)); });
8114      if(sub) sub.textContent=rows.length+' open lead'+(rows.length===1?'':'s')+' across '+list.length+' owner'+(list.length===1?'':'s');
8115      var max=list[0].n||1;
8116      root.innerHTML=list.map(function(r){
8117        var pct=Math.max(4, Math.round(r.n/max*100));
8118        return '<div class="qrank-row"><div class="qrank-name" title="'+escapeHtml(r.name)+'">'+escapeHtml(r.name)+'</div>'
8119          +'<div class="qattain-row"><div class="qattain-track"><i style="width:'+pct+'%"></i></div></div>'
8120          +'<div class="qrank-val">'+r.n+'</div></div>';
8121      }).join('');
8122    });
8123  }
8124
8125  // The card's range picker drives this window in real mode (HQ-331: the dropdown was
8126  // a demo-only toast before; each window is one memoised orders-report read).
8127  // HQ-452: the Sales Pipeline card keeps its own picker (Adam's layout), but it is
8128  // now a MIRROR of DASH_RANGE rather than an independent window - setRange writes
8129  // both, so the card and the header can never claim two different periods.
8130  var PIPE_RANGE='This Month';
8131  function realPipeline(){
8132    var root=$('dash-pipeline'); if(!root) return;
8133    dashLoad('dash-pipeline');
8134    var W=dashRangeWindow(PIPE_RANGE);
8135    dashOrdersFor(W.key).then(function(rep){
8136      var k=rep && rep.kpis;
8137      if(!k){ if(dashRetryOnce()){ dashLoad('dash-pipeline'); return; } dashEmpty('dash-pipeline','Live pipeline needs a signed-in session.'); return; }
8138      var stages=[ { l:'Quotes', v:k.quotesCount||0, cls:'fseg-quotes' }, { l:'Orders', v:k.ordersCount||0, cls:'fseg-orders' }, { l:'Paid', v:k.paidCount||0, won:true, cls:'fseg-paid' } ];
8139      if(!stages.some(function(s){ return s.v>0; })){ dashEmpty('dash-pipeline','No quotes or orders '+W.noun+' yet - create a quote in Sales.'); return; }
8140      var maxC=Math.max(stages[0].v, stages[1].v, stages[2].v, 1);
8141      var funnel='<a class="funnel" href="'+modHref('sales','pipeline')+'" title="Open the sales pipeline">';
8142      stages.forEach(function(s){ funnel+=dashSeg(Math.max(12, Math.round(s.v/maxC*100)), s.cls); });
8143      funnel+='</a>';
8144      var rows='<div class="pipe-rows">';
8145      // each row carries its funnel segment's colour dot (HQ-331: "I don't know what
8146      // each colour means" - the words below now name the colours).
8147      stages.forEach(function(s){ rows+='<div class="pipe-row'+(s.won?' won':'')+'" role="button" tabindex="0" title="'+s.l+' - open in Sales"><span><span class="pipe-dot '+s.cls+'"></span>'+s.l+'</span><b>'+s.v+'</b></div>'; });
8148      rows+='</div>';
8149      var conv=(k.conversionPct==null) ? '-' : (k.conversionPct+'%');
8150      root.innerHTML=funnel+rows+'<div class="pipe-foot"><span>Orders / all records</span><b>'+conv+'</b></div><div class="mc-note">Paid orders also appear in Orders. These counts are not separate funnel stages.</div>';
8151      // Navigate directly + stop the bubble so the demo-only .pipe-row delegation
8152      // (which opens a "(demo)" modal) never fires in real mode.
8153      Array.prototype.forEach.call(root.querySelectorAll('.pipe-row'), function(rw){
8154        rw.addEventListener('click', function(e){ e.stopPropagation(); location.href=modHref('sales','pipeline'); });
8155        rw.addEventListener('keydown', function(e){ if(e.key==='Enter'){ location.href=modHref('sales','pipeline'); } });
8156      });
8157    });
8158  }
8159
8160  function realProduction(){
8161    var root=$('dash-production'); if(!root) return;
8162    if(!sb){ dashEmpty('dash-production','Live production needs a signed-in session.'); return; }
8163    dashLoad('dash-production');
8164    sb.from('production_orders').select('id,status').is('deleted_at',null).then(function(res){
8165      if(!res || res.error){ dashEmpty('dash-production','Production data is unavailable right now.'); return; }
8166      var rows=res.data||[];
8167      var summaryCard=$('qflow-production-card'); if(summaryCard)summaryCard.classList.toggle('qflow-empty-status',!rows.length);
8168      if(!rows.length){ dashEmpty('dash-production','No production orders yet. <a href="/manufacturing.html?__sec=overview">Open Manufacturing</a>.'); return; }
8169      var by={}; rows.forEach(function(r){ var s=r.status||'unknown'; by[s]=(by[s]||0)+1; });
8170      var keys=Object.keys(by).sort(function(a,b){ return by[b]-by[a]; });
8171      var html='';
8172      keys.forEach(function(s){ html+='<div class="prod-row" role="button" tabindex="0" title="'+escapeHtml(dashStatusLabel(s))+' - open in Manufacturing"><span class="pn">'+escapeHtml(dashStatusLabel(s))+'</span><span class="ppc">'+by[s]+'</span></div>'; });
8173      // HQ-452: this is a status histogram of the orders that exist right now -
8174      // production_orders is not read through a date window - so the card says so
8175      // rather than letting a picked range imply it moved.
8176      root.innerHTML='<div class="mc-note mc-lead">Live status count - not filtered by the date range.</div>'
8177        +html+'<div class="prod-foot"><span>Production orders on record</span><b>'+rows.length+'</b></div>';
8178      Array.prototype.forEach.call(root.querySelectorAll('.prod-row'), function(rw){
8179        rw.addEventListener('click', function(e){ e.stopPropagation(); location.href=modHref('manufacturing','overview'); });
8180        rw.addEventListener('keydown', function(e){ if(e.key==='Enter'){ location.href=modHref('manufacturing','overview'); } });
8181      });
8182    }, function(){ dashEmpty('dash-production','Production data is unavailable right now.'); });
8183  }
8184
8185
8186  function realRecent(){
8187    var root=$('dash-recent'); if(!root) return;
8188    if(!sb){ dashEmpty('dash-recent','Your activity needs a signed-in session.'); return; }
8189    dashLoad('dash-recent');
8190    // audit_events is owner-pinned server-side, so this is the viewer's OWN trail;
8191    // merge in the notifications addressed to the viewer. Both capped, newest first.
8192    // HQ-324: that notifications read used to carry NO owner filter, and
8193    // notifications is sharedRead in the db function (_db_impl.cjs TABLES), so the
8194    // server happily returned the whole team's feed - this card is headed "Your
8195    // Recent Activity" and was showing every signed-in user other people's
8196    // notification titles and bodies. It is now owner-scoped with the SAME audie
8196nce
8197    // rule the sibling card renderMyDashNeedsMe applies (notifications-count's
8198    // feedScope: a named team_notification_viewer also sees the OWNERLESS
8199    // team alerts; everyone else only the rows addressed to them) - one definition of
8200    // "addressed to you" on this page, never a second, diverging one. The identity
8201    // comes from fetchDashAuth(), this dashboard's own memoised auth-user resolver
8202    // (the same payload _mdMe holds), and an UNRESOLVED session yields NO
8203    // notifications at all: fail closed, never a fall-back to the unscoped read.
8204    var pA=sb.from('audit_events').select('table_name,action,row_id,created_at').order('created_at',{ascending:false}).limit(8)
8205      .then(function(res){ return (res && !res.error && Array.isArray(res.data)) ? res.data : []; }, function(){ return []; });
8206    var pN=fetchDashAuth().then(function(au){
8207      if(!au || au.id==null) return [];
8208      var mgmt=dashNamedAuthority(au,'team_notification_viewers',function(){return !!(au.is_admin||au.money_authorised);});
8209      var q=sb.from('notifications').select('title,body,created_at').is('deleted_at',null);
8210      q=mgmt ? q.or('owner_id.is.null,owner_id.eq.'+au.id) : q.eq('owner_id',au.id);
8211      return q.order('created_at',{ascending:false}).limit(8)
8212        .then(function(res){ return (res && !res.error && Array.isArray(res.data)) ? res.data : []; }, function(){ return []; });
8213    }, function(){ return []; });
8214    Promise.all([pA,pN]).then(function(r){
8215      var audit=r[0], notif=r[1];
8216      var card=root.closest('.card'), h3=card && card.querySelector('.card-head h3'); if(h3) h3.textContent='Recent activity';
8217      var items=[];
8218      notif.forEach(function(nrow){ items.push({ ts:nrow.created_at, ic:IC.inbox, html:'<b>'+escapeHtml(nrow.title||'Notification')+'</b>'+(nrow.body ? ' '+escapeHtml(nrow.body) : '') }); });
8219      audit.forEach(function(a){ items.push({ ts:a.created_at, ic:IC.receipt, html:escapeHtml(dashStatusLabel(a.action||'update'))+' on '+escapeHtml(String(a.table_name||'record').replace(/_/g,' ')) }); });
8220      items.sort(function(a,b){ return (Date.parse(b.ts)||0)-(Date.parse(a.ts)||0); });
8221      items=items.slice(0,8);
8222      if(!items.length){ dashEmpty('dash-recent','No recent activity yet - it appears here as you work.'); return; }
8223      root.innerHTML='';
8224      items.forEach(function(it){
8225        var d=el('div','af-row');
8226        d.innerHTML='<span class="afic">'+svg(it.ic)+'</span><span class="aft">'+it.html+'</span><span class="afw">'+escapeHtml(dashAgo(it.ts))+'</span>';
8227        root.appendChild(d);
8228      });
8229    });
8230  }
8231
8232  function renderKPIs(){
8233    if(!dashDemo()){ realKPIs(DASH_RANGE); return; }
8234    // HQ-1213 round 13: Sales Target and Field Jobs This Week dropped from the demo
8235    // set too, position-for-position with the real 5-tile set above (Sales Target
8236    // moved to Sales Overview only; Field Service de-emphasised this round) - so
8237    // toggling ?demo=1 shows the same tile COMPOSITION as real mode, only the
8238    // figures differ. Open RMAs (Warranty) is new, in the same slot its real
8239    // counterpart holds.
8240    var data=[
8241      { cls:'',       ic:IC.revenue,  lbl:'Revenue This Month', val:'$1.48M', spark:[60,72,55,80,68,90,84,96], col:C.blue, delta:'up',  d:'+9.4% vs last month', sec:'sales',         sub:'pipeline', tip:'Recognised revenue this calendar month. Open Sales.' },
8242      { cls:'violet', ic:IC.projects, lbl:'Open Orders',        val:'36',     spark:[28,30,29,32,31,34,33,36], col:C.violet, delta:'up',  d:'+3 this week',        sec:'sales',         sub:'orders',   tip:'Sales orders not yet fully shipped. Open Sales orders.' },
8243      { cls:'green',  ic:IC.gauge,    lbl:'Production On-Time',  val:'93.6%', spark:[88,90,89,92,91,93,92,94], col:C.green, delta:'up',  d:'+1.2pp on target',    sec:'manufacturing', sub:'overview', tip:'Jobs completed on or before due date. Open Manufacturing.' },
8244      { cls:'blue',   ic:IC.rma,      lbl:'Open RMAs (Warranty)', val:'7',    spark:[11,10,9,9,8,8,7,7
8244],       col:C.blue, delta:'down',d:'-1 this week',        sec:'support',       sub:'rma',      tip:'RMAs open right now. Open Support.' },
8245      { cls:'amber',  ic:IC.headset,  lbl:'Open Support Cases', val:'23',     spark:[31,29,30,27,28,25,24,23], col:C.amber, delta:'down',d:'-6 since Monday',     sec:'support',       sub:'cases',    tip:'Support cases still open. Open Support cases.' }
8246    ];
8247    var root=$('dash-kpis'); if(!root) return; root.innerHTML='';
8248    data.forEach(function(k){
8249      // Real deep link: each KPI tile is an anchor to its module sub-section.
8250      var c=el('a','kpi '+k.cls);
8251      c.href=modHref(k.sec, k.sub);
8252      c.title=k.tip||('View '+k.lbl);
8253      c.innerHTML='<div class="khead">'
8254        +'<div class="ktext"><div class="lbl">'+k.lbl+'</div><div class="val">'+k.val+'</div></div></div>'
8255        +spark(k.spark, k.col)
8256        +'<div class="delta '+k.delta+'">'+(k.delta==='up'?'▲':'▼')+' <span class="sub">'+k.d+'</span></div>';
8257      root.appendChild(c);
8258    });
8259  }
8260  // HQ-452: the ONE source of truth for the dashboard's date window. It was dead
8261  // code before (written by setRange, read by nothing) and defaulted to 'This Week'
8262  // while every real panel actually read month-to-date - the header label and the
8263  // numbers disagreed. It now genuinely drives the range-aware panels, and the
8264  // default is 'This Month' so it MATCHES the window those panels were already
8265  // showing rather than silently changing what the figures mean. The in-card
8266  // pickers mirror it (setRange) instead of being a second, conflicting source.
8267  var DASH_RANGE='This Month';
8268  // The one option list every range picker on this page offers (header + in-card).
8269  var DASH_RANGES=['Today','This Week','This Month','Last Month','This Quarter','This Year'];
8270
8271  // HQ-1213 round 10: renderSnapshot and its seven-day demo fixture went with the
8272  // Business Snapshot card. Nothing replaced the fixture - Total Revenue, Average
8273  // Sales, Average Order Value and Popular Products all read live reports and say so
8274  // in demo mode rather than plotting invented figures.
8275
8276  function renderAction(){
8277    if(!dashDemo()){ realAction(); return; }
8278    var root=$('dash-action'); if(!root) return;
8279    var rows=[
8280      { ic:IC.alert,  col:C.red, n:2, t:'CAPAs overdue',          pill:'Overdue',  pc:'red',   sec:'compliance',   sub:'capa' },
8281      { ic:IC.box,    col:C.amber, n:6, t:'orders awaiting stock',  pill:'High',     pc:'amber', sec:'inventory',    sub:'reorder' },
8282      { ic:IC.users,  col:C.blue, n:3, t:'leave approvals pending',pill:'Pending',  pc:'blue',  sec:'people',       sub:'leave' },
8283      { ic:IC.clock,  col:C.amber, n:1, t:'calibration due today',  pill:'Due Today',pc:'amber', sec:'compliance',   sub:'calibration' },
8284      { ic:IC.rma,    col:C.violet, n:4, t:'RMAs awaiting inspection',pill:'Pending', pc:'blue',  sec:'support',      sub:'rma' }
8285    ];
8286    root.innerHTML='';
8287    // HQ-1213 round 13: demo mode's Action Required rows now match real mode's -
8288    // the shared .aq-row component, no icon - so toggling ?demo=1 does not visibly
8289    // change this card's shape. r.ic/r.col stay on each row (openModal below still
8290    // uses r.ic) - only the row markup itself drops the icon chip.
8291    rows.forEach(function(r){
8292      var d=el('div','aq-row');
8293      d.setAttribute('role','button'); d.setAttribute('tabindex','0'); d.title='Review: '+r.n+' '+r.t+' (opens '+MODLBL[r.sec]+')';
8294      d.innerHTML='<span class="aq-n">'+r.n+'</span>'
8295        +'<span class="aq-t">'+escapeHtml(r.t)+'</span>'
8296        +'<span class="urg '+r.pc+'">'+r.pill+'</span>'
8297        +'<span class="chev">'+svg('<path d="M9 18l6-6-6-6"/>')+'</span>';
8298      d.addEventListener('click', function(){
8299        openModal({
8300          icon:r.ic, title:r.pill+' — '+r.n+' '+r.t.replace(/^./,function(c){return c.toUpperCase();}),
8301          sub:'Action required item. Review and open the owning module.',
8302          infoRows:[['Items',String(r.n)],['Priority',r.pill],['Owner module',MODLBL[r.sec]]],
8303          saveLabel:'Open in '+MODLBL[r.sec], cancelLabel:'Dismiss',
8304          openHref:modHref(r.sec, r.sub)
8305        });
8306      });
8307      d.addEventListener('keydown', function(e){ if(e.key==='Enter'){ d.click(); } });
8308      root.appendChild(d);
8309    });
8310  }
8311
8312  function renderPipeline(){
8313    if(!dashDemo()){ realPipeline(); return; }
8314    var root=$('dash-pipeline'); if(!root) return;
8315    var stages=[
8316      { l:'Lead', v:128, w:100 }, { l:'Qualified', v:64, w:80 },
8317      { l:'Quote Sent', v:32, w:62 }, { l:'Negotiation', v:18, w:46 },
8318      { l:'Won', v:9, w:30, won:true }
8319    ];
8320    var funnel='<a class="funnel" href="'+modHref('sales','pipeline')+'" title="Open the sales pipeline">';
8321    stages.forEach(function(s){ funnel+=dashSeg(s.w); });
8322    funnel+='</a>';
8323    var rows='<div class="pipe-rows">';
8324    stages.forEach(function(s){ rows+='<div class="pipe-row'+(s.won?' won':'')+'" role="button" tabindex="0" title="'+s.l+' - open in Sales"><span>'+s.l+'</span><b>'+s.v+'</b></div>'; });
8325    rows+='</div>';
8326    root.innerHTML=funnel+rows+'<div class="pipe-foot"><span>Conversion Rate</span><b>14.1%</b></div>';
8327  }
8328
8329  function renderProduction(){
8330    if(!dashDemo()){ realProduction(); return; }
8331    var root=$('dash-production'); if(!root) return;
8332    var lines=[
8333      { n:'Line 1', pc:82, run:true }, { n:'Line 2', pc:68, run:true },
8334      { n:'Line 3', pc:0,  run:false }, { n:'BMG', pc:74, run:true }
8335    ];
8336    var html='';
8337    lines.forEach(function(l){
8338      html+='<div class="prod-row'+(l.run?'':' idle')+'" role="button" tabindex="0" title="'+l.n+' - open in Manufacturing">'
8339        +'<span class="pn">'+l.n+'</span>'
8340        +'<span class="pbar"><i style="width:'+l.pc+'%"></i></span>'
8341        +'<span class="ppc">'+l.pc+'%</span></div>';
8342    });
8343    root.innerHTML=html+'<div class="prod-foot"><span>Overall Utilization</span><b>61%</b></div>';
8344  }
8345
8346  function renderRecent(){
8347    if(!dashDemo()){ realRecent(); return; }
8348    var root=$('dash-recent'); if(!root) return;
8349    // References mirror seeded demo records so every click-through resolves:
8350    // SO-10241 (sales_orders), JO-2025-0183 (FS jobs), CAPA-OPS-012 (compliance),
8351    // GR-2207/PO-5508 (goods_receipt), CASE-2606-030 (support case hash route).
8352    var rows=[
8353      { ic:IC.so,      col:C.blue, html:'Sales Order <b>SO-10241</b> confirmed for Bluewater Marine', w:'12m ago',
8354        title:'Sales Order SO-10241', sub:'Confirmed · high priority (demo)', kv:[['Order','SO-10241'],['Customer','Bluewater Marine'],['Status','Confirmed']], sec:'sales', subsec:'orders' },
8355      { ic:IC.wrench,  col:C.green, html:'Field job <b>JO-2025-0183</b> booked for Bluewater Marine', w:'1h ago',
8356        title:'Field job JO-2025-0183', sub:'Marine Installation · Gladstone, QLD (demo)', kv:[['Job','JO-2025-0183'],['Customer','Bluewater Marine'],['Status','Booked']], sec:'fieldservice', subsec:'jobs' },
8357      { ic:IC.capa,    col:C.red, html:'CAPA <b>CAPA-OPS-012</b> raised for Supplier Non-Conformance', w:'2h ago',
8358        title:'CAPA-OPS-012', sub:'Supplier non-conformance · corrective action (demo)', kv:[['CAPA','CAPA-OPS-012'],['Type','Supplier'],['Status','Open']], sec:'compliance', subsec:'capa' },
8359      { ic:IC.receipt, col:C.amber, html:'Stock receipt <b>GR-2207</b> received against <b>PO-5508</b>', w:'3h ago',
8360        title:'Goods receipt GR-2207', sub:'PowerParts AU · received (demo)', kv:[['Receipt','GR-2207'],['PO','PO-5508'],['Status','Received']], sec:'warehouse', subsec:'receiving' },
8361      { ic:IC.headset, col:C.violet, html:'Support case <b>CASE-2606-030</b> updated by the support team', w:'5h ago',
8362        title:'Support case CASE-2606-030', sub:'Case thread updated (demo)', kv:[['Case','CASE-2606-030'],['Queue','Support'],['Status','Open']], sec:'support', subsec:'#/cases/CASE-2606-030' }
8363    ];
8364    root.innerHTML='';
8365    rows.forEach(function(r){
8366      var d=el('div','af-row');
8367      d.setAttribute('role','button'); d.setAttribute('tabindex','0');
8368      d.title=r.title+' - open in '+MODLBL[r.sec];
8369      d.innerHTML='<span class="afic" style="color:'+r.col+'">'+svg(r.ic)+'</span>'
8370        +'<span class="aft">'+r.html+'</span><span class="afw">'+r.w+'</span>';
8371      function open(){
8372        openModal({ icon:r.ic, title:r.title, sub:r.sub,
8373          infoRows:r.kv,
8374          saveLabel:'Open in '+MODLBL[r.sec], cancelLabel:'Close',
8375          openHref:modHref(r.sec, r.subsec) });
8376      }
8377      d.addEventListener('click', open);
8378      d.addEventListener('keydown', function(e){ if(e.key==='Enter'){ open(); } });
8379      root.appendChild(d);
8380    });
8381  }
8382
8383  function avatarColor(s){ var h=0; for(var i=0;i<s.length;i++)h=(h*31+s.charCodeAt(i))%360; return 'hsl('+h+',42%,46%)'; }
8384  function renderTeam(){
8385    if(!dashDemo()){ dashEmpty('dash-team','Team presence arrives with the clock roll-out.'); return; }
8386    var root=$('dash-team'); if(!root) return;
8387    var onsite=[
8388      { n:'Simon Mackenzie', d:'Field Service' }, { n:'Damon Debono', d:'Field Service' }, { n:'Hong Zhenyue', d:'Manufacturing' }
8389    ];
8390    var offsite=[
8391      { n:'Adam Daglish', d:'Operations' }, { n:'Yvette Holliday', d:'Sales' }, { n:'Jonathan Turnbull', d:'Executive' }
8392    ];
8393    function col(title, dot, list, more, sub){
8394      var h='<a class="team-h" href="'+modHref('people', sub)+'" title="Open in People"><span class="td" style="background:'+dot+'"></span>'+title+'</a>';
8395      list.forEach(function(p){
8396        var initials=p.n.split(' ').map(function(w){return w[0];}).join('').slice(0,2).toUpperCase();
8397        h+='<div class="tm-row" role="button" tabindex="0" title="'+p.n+' - open in People"><span class="tm-av" style="background:'+avatarColor(p.n)+'">'+initials+'</span>'
8398          +'<span class="tm-name">'+p.n+'</span><span class="tm-dept">'+p.d+'</span></div>';
8399      });
8400      h+='<a class="tm-more" href="'+modHref('people','directory')+'" title="Open the staff directory">+'+more+' more</a>';
8401      return h;
8402    }
8403    root.innerHTML='<div class="team-cols">'
8404      +'<div>'+col('On Site (12)', C.green, onsite, 9, 'clock')+'</div>'
8405      +'<div>'+col('Off Site Working (6)', C.blue, offsite, 3, 'directory')+'</div>'
8406      +'</div>';
8407  }
8408
8409  // ══ HQ-452: REAL quick-create ═══════════════════════════════════════════════
8410  // Adam, 2026-07-25: "fix up all the new clickable items from the new drop down,
8411  // and make sure they wire back ... these might be best as pop up in the centre
8412  // like how we have created on other options". So outside ?demo=1 each "+ New"
8413  // item now opens the SAME centred modal (#dm-overlay) and posts to that module's
8414  // own role-gated create endpoint. The modal stays open on a refusal and shows
8415  // the server's own words (never a faked success toast - security.md's "a UI
8416  // button that mutates in real mode must call the real server gate and surface
8417  // the server's refusal"), and on success it swaps its primary button for a deep
8418  // link to the record that was just created.
8419  //
8420  // Endpoint map, verified against each impl (roles are the server's, not ours -
8421  // requireRole short-circuits true for is_admin):
8422  //   New Sales Order   sales-order-act  action 'create'  role: sales
8423  //   Book Field Job    field-job-act    action 'create'  role: field_service
8424  //   Receive Stock     incoming-act     action 'receive' role: warehouse   (+ client_move_id)
8425  //   Raise CAPA        capa-act         action 'raise'   roles: support_tech | support_manager
8426  //   Record Production - NO honest create path exists on the server (see
8427  //     openProductionChooser below): production_orders' quantity_* and status are
8428  //     GATE_CONTROLLED_COLUMNS, buildstation-act/consume-components count ONE unit
8429  //     per serial at the bench, and there is no create-production-order endpoint
8430  //     (manufacturing.html's own New Production Order button refuses for the same
8431  //     reason). It therefore routes to the two real places the work is recorded
8432  //     rather than pretending to write. Tracked as its own ticket.
8433  //
8434  // We deliberately do NOT hide an action a user's role cannot perform: hidden
8435  // buttons are not access control (security.md), the server is the gate, and a
8436  // refusal that names the required role teaches more than a missing menu item.
8437
8438  // One refusal formatter for every endpoint above, because the four impls do NOT
8439  // agree on where they put the extras: sales-order-act / field-job-act / capa-act
8440  // return `required_roles` and `missing` as TOP-LEVEL siblings of `error`, while
8441  // incoming-act merges them INSIDE the error object. Read both, always.
8442  function dashRefusal(j, fallback){
8443    if(!j) return fallback || 'No response from the server - check your connection and try again.';
8444    var e=j.error || {};
8445    var text=String(e.message || fallback || 'That was refused.');
8446    var missing=j.missing || e.missing;
8447    if(Array.isArray(missing) && missing.length) text+=' Missing: '+missing.join(', ')+'.';
8448    var roles=j.required_roles || e.required_roles;
8449    if(Array.isArray(roles) && roles.length) text+=' Needs one of these roles: '+roles.join(', ')+'.';
8450    if(e.code==='CONFLICT') text='That record changed underneath you - reopen this and try again.';
8451    return text;
8452  }
8453  // POST an action and always resolve to a { data, error } envelope, so an HTML
8454  // error page or a dropped connection becomes a readable refusal instead of a
8455  // JSON parse throw (the hardened support.html fn() idiom).
8456  function dashAct(name, body){
8457    return fn(name,{ method:'POST', body:body })
8458      .then(function(r){
8459        return r.json().then(function(j){ return j; }, function(){
8460          return { data:null, error:{ code:'HTTP_'+r.status, message:'The server returned an unreadable response ('+r.status+').' } };
8461        });
8462      })
8463      .catch(function(){ return { data:null, error:{ code:'NETWORK', message:'Could not reach the server.' } }; });
8464  }
8465  // Bounded option lists for the real forms (contacts / products / the roster).
8466  // Each resolves to [] on any failure so a form still opens with a free-text or
8467  // empty select rather than not opening at all. Memoised per page load.
8468  var _optCache={};
8469  function dashOptions(kind){
8470    if(_optCache[kind]) return _optCache[kind];
8471    var p;
8472    if(!sb){ p=Promise.resolve([]); }
8473    else if(kind==='contacts'){
8474      p=sb.from('contacts').select('id,name,company').is('deleted_at',null).order('name',{ascending:true}).limit(300)
8475        .then(function(r){ return ((r&&r.data)||[]).map(function(c){
8476          return { value:String(c.id), label:(c.name||c.company||'Contact '+c.id) };
8477        }); }, function(){ return []; });
8478    } else if(kind==='products'){
8479      p=QuasarProducts.load(sb,'id,sku,name')
8480        .then(function(rows){ return rows.map(function(pr){
8481          return { value:String(pr.id), sku:pr.sku||'', label:(pr.name||pr.sku||'Unnamed product')+(pr.name&&pr.sku?' ('+pr.sku+')':'') };
8482        }); }, function(){ return []; });
8483    } else if(kind==='productSkus'){
8484      p=dashOptions('products').then(function(rows){return rows.filter(function(pr){return !!pr.sku;}).map(function(pr){
8485        return {value:pr.sku,label:pr.label};
8486      });});
8487    } else if(kind==='roster'){
8488      p=fn('admin-users',{method:'POST',body:{action:'roster'}})
8489        .then(function(r){ return r.ok?r.json():null; })
8490        .then(function(j){ return ((j&&j.people)||[]).map(function(u){
8491          return { value:String(u.id), label:(u.name||u.email||('User '+u.id)) };
8492        }); })
8493        .catch(function(){ return []; });
8494    } else if(kind==='orders'){
8495      // HQ-481: "this needs to be able to link a sales job number". The real order book is
8496      // `quotes` - there is no sales_orders master on prod - which is exactly what
8497      // field-service.html's own "create job FROM order" picker reads, so this mirrors it:
8498      // recent orders, newest first, labelled by number + customer. The VALUE is the order
8499      // NUMBER, not the row id: field_jobs.sales_order_id is the sales_orders FK the G-F0
8500      // gate joins on, so a quotes.id there would be a false join (field-service.html says
8501      // the same in its own comment). The number rides the patch as data.sales_order.
8502      p=(!sb?Promise.resolve([]):sb.from('quotes')
8503        .select('id,quote_number,contact_id,status,payment_status,data')
8504        .is('deleted_at',null).order('created_at',{ascending:false}).limit(150)
8505        .then(function(r){
8506          return ((r&&r.data)||[]).map(function(q){
8507            var num=q.quote_number||q.id;
8508            var cu=(q.data&&q.data.customer)||{};
8509            var who=cu.company||cu.name||'';
8510            return { value:String(num), label:'#'+num+(who?(' - '+who):''),
8511                     contact_id:q.contact_id||'', site:dashOrderSite(cu) };
8512          });
8513        }, function(){ return []; }));
8514    } else { p=Promise.resolve([]); }
8515    _optCache[kind]=p;
8516    return p;
8517  }
8518  // One-line site address from an order's customer SNAPSHOT (quotes.data.customer),
8519  // used to prefill "Site / address" when a field job is linked to an order. Same
8520  // field names and join order as quote.html's fmtAddr - keep in sync. Prefers the
8521  // shipping address (where the work happens), falling back to billing, then the
8522  // flat city/country the snapshot always carries.
8523  function dashOrderSite(cu){
8524    cu=cu||{};
8525    var a=(cu.shipping&&Object.keys(cu.shipping).length)?cu.shipping
8526         :((cu.billing&&Object.keys(cu.billing).length)?cu.billing:null);
8527    if(a){
8528      var l1=[a.address_1,a.address_2].filter(Boolean).join(', ');
8529      var l2=[[a.city,a.state,a.postcode].filter(Boolean).join(' '),a.country].filter(Boolean).join(', ');
8530      var s=[l1,l2].filter(Boolean).join(', ');
8531      if(s) return s;
8532    }
8533    return [cu.city,cu.country].filter(Boolean).join(', ');
8534  }
8535  // Combine a date input and an optional time input into an ISO instant in the
8536  // caller's own timezone (the server stores timestamptz).
8537  function dashIsoAt(dateStr, timeStr){
8538    if(!dateStr) return null;
8539    var d=new Date(dateStr+'T'+((timeStr&&/^\d{2}:\d{2}/.test(timeStr))?timeStr:'09:00'));
8540    return isNaN(d.getTime()) ? null : d.toISOString();
8541  }
8542
8543  // "Record Production": the honest routing modal described above.
8544  function openProductionChooser(){
8545    openModal({ icon:IC.play, title:'Record Production', sub:'Where production is actually recorded',
8546      infoRows:[
8547        ['Per unit','The Build Station records each finished unit against its serial as it passes test - that is what moves a production order&#39;s built count.'],
8548        ['Per order','The Manufacturing board is where an order is scheduled, released and moved through its statuses.'],
8549        ['Not available here','HQ has no endpoint that books a bulk "units built" figure, so this dashboard will not pretend to write one. Raising one is its own ticket.']
8550      ],
8551      saveLabel:'Open Manufacturing', cancelLabel:'Close',
8552      openHref:modHref('manufacturing','overview') });
8553  }
8554
8555  // ── The real create forms ─────────────────────────────────────
8556  // Each spec is { icon, title, sub, fields, saveLabel, submit(vals, ui) }.
8557  // `submit` owns the whole server conversation: ui.busy -> post -> ui.error with
8558  // dashRefusal, or ui.ok with the real deep link.
8559  function realFormSpecs(){
8560    return {
8561      // salesOrder has no spec any more - HQ-481 routes it straight to the full
8562      // new-order page (see openRealForm), the same destination as "+ New Order".
8563      fieldJob:{
8564        icon:IC.wrench, title:'Book Field Job', sub:'Creates a real field service job',
8565        // HQ-481 (Adam): "this needs to be able to link a sales job number or add a
8566        // customer to search for one." Three server-loaded pickers now, so this spec
8567        // uses optionSets rather than the single optionKind/optionField pair.
8568        optionSets:[{kind:'orders',field:'sales_order'},{kind:'contacts',field:'contact_id'},{kind:'roster',field:'assignee_id'}],
8569        fields:[
8570          {key:'job_type',label:'Job type',type:'select',options:['install','commission','repair','site_visit']},
8571          // Order FIRST: picking one fills the customer and the site, so the rest of the
8572          // form is already answered for the common case (a job against a sold order).
8573          {key:'sales_order',label:'Sales order',type:'search',freeText:true,
8574            placeholder:'Type an order number or customer to search…',
8575            hint:'Optional. Picking one fills in the customer and the site below. An order number not in the recent list can be typed in full.',
8576            onPick:function(op, inputs){
8577              // Fill the customer from the picked order. The order row carries the real
8578              // contact_id, so pin label->id on the customer field - the pin survives
8579              // resolve even when that contact is outside the preloaded picker list.
8580              var c=inputs.contact_id;
8581              if(c && op.contact_id){
8582                var m=(c.def.options||[]).filter(function(o){ return o && String(o.value)===String(op.contact_id); })[0];
8583                var label=m?m.label:(op.label.split(' - ').slice(1).join(' - ')||('Customer on order '+op.value));
8584                c.inp.value=label;
8585                c.def._pin={ label:label, value:String(op.contact_id) };
8586              }
8587              // ...and the site, from the order's shipping snapshot - only into an empty
8588              // box, never over an address the person already typed.
8589              var s=inputs.site_address;
8590              if(s && op.site && !(s.inp.value||'').trim()){ s.inp.value=op.site; s.wrap.classList.remove('err'); }
8591            }},
8592          {key:'contact_id',label:'Customer',type:'search',
8593            placeholder:'Type a name or company to search…',
8594            hint:'Optional - or leave blank for an internal job.',
8595            noMatchHint:'Check the spelling, or add them first via Sales > Customers (+ New Contact) and reopen this form.'},
8596          {key:'site_address',label:'Site / address',type:'text',required:true,placeholder:'Where the work happens'},
8597          {key:'date',label:'Date',type:'date',required:true},
8598          {key:'time',label:'Start time',type:'time',placeholder:'09:00',hint:'Defaults to 09:00 when left blank.'},
8599          {key:'assignee_id',label:'Assign to',type:'search',
8600            placeholder:'Unassigned - type a name to search…',
8601            hint:'Can be left unassigned and scheduled later.'},
8602          {key:'notes',label:'Notes',type:'textarea',placeholder:'Acce
8602ss notes, contact on site, what to bring'}
8603        ],
8604        saveLabel:'Book job',
8605        submit:function(v, ui){
8606          ui.busy('Booking the job…');
8607          var patch={ job_type:v.job_type||'install', site_address:v.site_address };
8608          var startIso=dashIsoAt(v.date, v.time);
8609          if(startIso) patch.scheduled_start=startIso;
8610          if(v.assignee_id) patch.assignee_id=v.assignee_id;
8611          if(v.contact_id) patch.contact_id=v.contact_id;
8612          // The order NUMBER rides the patch as `sales_order` (it lands in the data jsonb
8613          // via splitPatch, since it is not a typed column) - exactly what
8614          // field-service.html's own from-order create sends, and what the G-F0 gate reads
8615          // as the linked order. We never write it to sales_order_id: that column is the
8616          // sales_orders FK, and a quotes number/id there would be a false join.
8617          if(v.sales_order) patch.sales_order=v.sales_order;
8618          if(v.notes) patch.notes=v.notes;
8619          dashAct('field-job-act',{ action:'create', patch:patch }).then(function(j){
8620            if(!j || j.error || !j.data){ ui.error(dashRefusal(j,'The job was not booked.')); return; }
8621            var num=j.data.job_number;
8622            ui.ok('Job '+(num||('id '+j.data.id))+' booked'+(v.sales_order?(' against order #'+v.sales_order):'')+'. It lands unscheduled until the schedule confirms it.',
8623              num ? { href:'/field-service.html#/job/'+encodeURIComponent(num), label:'Open the job' }
8624                  : { href:modHref('fieldservice','schedule'), label:'Open the FS schedule' });
8625          });
8626        }
8627      },
8628      receiveStock:{
8629        icon:IC.box, title:'Receive Stock', sub:'Books real stock in against a batch',
8630        optionKind:'productSkus', optionField:'sku',
8631        fields:[
8632          // HQ-481 (Adam): "recieving stock also needs a scan function to create it".
8633          // Scan FIRST - arm the button, the HID wedge scanner types the carton code in,
8634          // and barcode-act 'resolve' fills the SKU (a code it has never seen says so
8635          // honestly and gets LINKED to the SKU you enter when the receive commits -
8636          // incoming-act's own linkScannedBarcode behaviour). Same wedge discipline as
8637          // incoming.html: arm, focus, catch the Enter terminator.
8638          {key:'barcode',label:'Barcode',type:'text',scan:true,placeholder:'Scan the carton code, or type it…',
8639            hint:'Optional. Click Scan and shoot the carton barcode - a known code fills the SKU below; a new one is linked to the SKU when the stock books in.',
8640            onScan:function(code, inputs, wrap){
8641              // A code-set value fires 'change' so the themed dropdown's label follows it.
8642              if(inputs.source){ inputs.source.inp.value='scanned'; inputs.source.inp.dispatchEvent(new Event('change')); }
8643              var note=wrap.querySelector('.dm-help');
8644              if(note) note.textContent='Looking up '+code+'…';
8645              dashAct('barcode-act',{ action:'resolve', barcode:code }).then(function(j){
8646                if(!note) return;
8647                if(j && j.found){
8648                  if(inputs.sku){
8649                    var productLabel=(j.name||j.sku||'')+(j.name&&j.sku?' ('+j.sku+')':'');
8650                    inputs.sku.inp.value=productLabel;
8651                    inputs.sku.def._pin={label:productLabel,value:j.sku||''};
8652                    inputs.sku.wrap.classList.remove('err');
8653                  }
8654                  note.textContent='Resolved: '+(j.sku||'')+(j.name?(' - '+j.name):'')+(j.batch_code?(' (batch '+j.batch_code+')'):'')+'.';
8655                } else if(j && j.error){
8656                  note.textContent='Lookup failed: '+j.error;
8657                } else {
8658                  note.textContent='No product carries this code yet - enter the SKU below and receiving will link the code to it for future scans.';
8659                }
8660              });
8661            }},
8662          {key:'sku',label:'Product',type:'search',required:true,placeholder:'Search by product name or SKU'},
8663          {key:'qty',label:'Quantity received',type:'number',required:true,placeholder:'0'},
8664          {key:'source',label:'How was it identified',type:'select',
8665            options:[{value:'generated',label:'Generated - HQ mints the barcode'},
8666                     {value:'printed',label:'Printed - label printed here'},
8667                     {value:'scanned',label:'Scanned - carton already had a code'}]},
8668          // Friendly location names, same vocabulary as inventory.html LOCATION_LABELS
8669          // (KEEP IN SYNC) - the VALUE posted to incoming-act is still the raw slug.
8670          {key:'location',label:'Location',type:'select',
8671            options:[{value:'main',label:'Safiery Warehouse'},
8672                     {value:'warehouse_bin',label:'Warehouse (bin)'},
8673                     {value:'finished_goods',label:'Safiery Warehouse (Finished Goods)'},
8674                     {value:'marine_packout',label:'Marine Packout (field)'},
8675                     {value:'qc_hold',label:'QC Hold'},
8676                     {value:'vendor_rma',label:'Vendor RMA'},
8677                     {value:'scrap',label:'Scrap'},
8678                     {value:'china_warehouse',label:'China Warehouse'}],
8679            hint:'QC hold, vendor RMA and scrap land the stock quarantined (yellow).'},
8680          {key:'po_ref',label:'Purchase order',type:'text',placeholder:'e.g. PO-5510 (optional)'},
8681          {key:'unit_cost_aud',label:'Unit cost AUD ex GST',type:'number',placeholder:'0.00',
8682            hint:'May be required depending on the costing setting - the server will say so.'},
8683          {key:'note',label:'Note',type:'textarea',placeholder:'Condition, damage, anything unusual'}
8684        ],
8685        saveLabel:'Receive stock',
8686        submit:function(v, ui){
8687          ui.busy('Booking the stock in…');
8688          // Idempotency: receiving ADDS stock, so a retry must never double-book.
8689          // One key is minted per modal and reused for every retry of THIS receive
8690          // (incoming.html's armReceiveKey discipline) - the server replays the
8691          // same key as a no-op instead of a second batch.
8692          if(!this._moveId) this._moveId=dashMoveId();
8693          var body={ action:'receive', sku:v.sku, qty:Number(v.qty)||0,
8694            source:v.source||'generated', location:v.location||'main', client_move_id:this._moveId };
8695          if(v.barcode) body.barcode=v.barcode;
8696          if(v.po_ref) body.po_ref=v.po_ref;
8697          if(v.unit_cost_aud) body.unit_cost_aud=Number(v.unit_cost_aud);
8698          if(v.note) body.note=v.note;
8699          var self=this;
8700          dashAct('incoming-act',body).then(function(j){
8701            if(!j || j.error || !j.data){ ui.error(dashRefusal(j,'The stock was not received.')); return; }
8702            self._moveId=null; // accepted: a further receive is a NEW movement
8703            var d=j.data;
8704            var link={ href:'/inventory.html#/item/'+encodeURIComponent(v.sku), label:'Open the item' };
8705            if(d.no_op){ ui.ok('Already received - this exact receive was booked once, so nothing was doubled.', link); return; }
8706            var onHand=(d.on_hand!=null) ? (' On hand is now '+d.on_hand+'.') : '';
8707            ui.ok('Received '+(Number(v.qty)||0)+' of '+v.sku+' into '+(v.location||'main')
8708              +(d.batch_code?(' as batch '+d.batch_code+'.'):'.')+onHand, link);
8709          });
8710        }
8711      },
8712      capa:{
8713        icon:IC.shield, title:'Raise CAPA', sub:'Opens a real corrective action in Compliance',
8714        optionKind:'products', optionField:'product_id',
8715        fields:[
8716          {key:'product_id',label:'Product',type:'search',required:true,placeholder:'Search by product name or SKU',
8717            hint:'A CAPA is raised against a product (or from a failed RMA case in Support).'},
8718          {key:'title',label:'Title',type:'text',required:true,placeholder:'Non-conformance summary'},
8719          {key:'type',label:'Type',type:'select',options:[{value:'corrective',label:'Corrective'},{value:'preventive',label:'Preventive'}]},
8720          {key:'serial',label:'Serial',type:'text',placeholder:'Optional'},
8721          {key:'batch',label:'Batch',type:'text',placeholder:'Optional'}
8722        ],
8723        saveLabel:'Raise CAPA',
8724        // Deliberately NOT collected here: severity, owner and description. capa-act's
8725        // `raise` has no pass-through for them and capa_records has no such columns, so
8726        // asking for them would silently throw the answers away. They are set on the
8727        // CAPA in Compliance, where the analysis gate actually reads them.
8728        submit:function(v, ui){
8729          ui.busy('Raising the CAPA…');
8730          var body={ action:'raise', product_id:v.product_id, title:v.title, type:v.type||'corrective' };
8731          if(v.serial) body.serial=v.serial;
8732          if(v.batch) body.batch=v.batch;
8733          dashAct('capa-act',body).then(function(j){
8734            if(!j || j.error || !j.data){ ui.error(dashRefusal(j,'The CAPA was not raised.')); return; }
8735            var num=j.data.capa_number || ('id '+j.data.id);
8736            ui.ok('CAPA '+num+' raised and open. The analysis and close gates are in Compliance.',
8737              { href:modHref('compliance','capa'), label:'Open Compliance' });
8738          });
8739        }
8740      }
8741    };
8742  }
8743  // Idempotency key for the receive above. crypto.randomUUID where available
8744  // (every browser this app supports), with a same-shape fallback so an older
8745  // engine still sends a stable key rather than none at all.
8746  function dashMoveId(){
8747    try{ if(window.crypto && crypto.randomUUID) return crypto.randomUUID(); }catch(e){}
8748    return 'dm-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
8749  }
8750  // Map a typed search-field value back to the option it names. Exact label first,
8751  // then case-insensitive, then a UNIQUE substring (typing "zerojet" finds the one
8752  // ZeroJet). freeText fields keep an unmatched entry verbatim (an order number not
8753  // in the recent list); the rest refuse with a message naming the field, so the
8754  // person knows WHICH box did not match - not just "fill the required fields".
8755  function dashLabelResolver(f){
8756    return function(typed){
8757      var t=String(typed||'').trim();
8758      // A programmatic fill (the order picker stamping its customer) pins label->id so
8759      // it resolves even when that contact is outside the preloaded list. Any manual
8760      // re-typing changes the label and falls through to the normal matching below.
8761      if(f._pin && f._pin.label===t) return { value:f._pin.value };
8762      var opts=(f.options||[]).filter(function(o){ return o && o.label!=null; });
8763      var exact=opts.filter(function(o){ return o.label===t; })[0]
8764             || opts.filter(function(o){ return o.label.toLowerCase()===t.toLowerCase(); })[0];
8765      if(exact) return { value:exact.value, option:exact };
8766      var subs=opts.filter(function(o){ return o.label.toLowerCase().indexOf(t.toLowerCase())>=0; });
8767      if(subs.length===1) return { value:subs[0].value, option:subs[0] };
8768      if(f.freeText) return { value:t };
8769      if(subs.length>1) return { error:(f.label||'Field')+': "'+t+'" matches '+subs.length+' entries - keep typing until one remains, or pick from the list.' };
8770      return { error:(f.label||'Field')+': nothing in the system matches "'+t+'".'+(f.noMatchHint?(' '+f.noMatchHint):'') };
8771    };
8772  }
8773  function openRealForm(key){
8774    if(key==='production'){ openProductionChooser(); return; }
8775    // HQ-481 (Adam 2026-07-28): "when i click new sales order that it takes you to that
8776    // new order page" - the little draft modal is gone; the dashboard goes STRAIGHT to
8777    // the full new-order page, the same destination as every "+ New Order" button.
8778    // (sales-order-act's modal posted to the orphan sales_orders table anyway - the
8779    // real order book is quotes, which is what the order page writes.) HQ-524 round 3:
8780    // that destination now starts a QUOTE draft, not a forced invoice - Issue-to-Xero
8781    // is the gated step that converts it.
8782    if(key==='salesOrder'){ location.href='/quote.html?new=quote'; return; }
8783    var spec=realFormSpecs()[key];
8784    if(!spec){ toast('That action is not available.','warn'); return; }
8785    function show(){
8786      openModal({ icon:spec.icon, title:spec.title, sub:spec.sub, fields:spec.fields,
8787        saveLabel:spec.saveLabel, cancelLabel:'Cancel',
8788        onSubmit:function(v, ui){ spec.submit(v, ui); } });
8789    }
8790    // A picker that needs server options waits for them, so the form never opens
8791    // with an empty list the person cannot use. optionSets loads several kinds in
8792    // parallel (the field-job form has three); the legacy single optionKind pair
8793    // still works for any spec that carries it.
8794    var sets=spec.optionSets||(spec.optionKind?[{kind:spec.optionKind,field:spec.optionField}]:null);
8795    if(sets){
8796      Promise.all(sets.map(function(s){ return dashOptions(s.kind); })).then(function(all){
8797        sets.forEach(function(s, i){
8798          var f=spec.fields.filter(function(x){ return x.key===s.field; })[0];
8799          if(!f) return;
8800          var opts=all[i]||[];
8801          if(opts.length) f.options=(f.options||[]).concat(opts);
8802          else f.hint='No options loaded - '+(f.hint||'');
8803          if(f.type==='search') f.resolve=dashLabelResolver(f);
8804        });
8805        show();
8806      });
8807    } else show();
8808  }
8809
8810  // ── Demo form specs for the quick actions ("New X" modals) ────
8811  function openForm(key){
8812    // Outside ?demo=1 the quick actions post to the REAL role-gated create
8813    // endpoints (openRealForm above). Demo mode keeps its original unchanged
8814    // toast-only specs below so the ?demo=1 surface is byte-identical.
8815    if(!dashDemo()){ openRealForm(key); return; }
8816    var specs={
8817      salesOrder:{ icon:IC.cart, title:'New Sales Order', sub:'Create a sales order (demo)',
8818        fields:[
8819          {key:'customer',label:'Customer',type:'select',required:true,options:['TechNova Solutions','Coastal Marine Pty','Blue Horizon Yachts','Harbour Electrical','New customer…']},
8820          {key:'product',label:'Product / kit',type:'text',required:true,placeholder:'e.g. Safiery 5kW Lithium System'},
8821          {key:'qty',label:'Quantity',type:'number',required:true,placeholder:'1'},
8822          {key:'due',label:'Requested date',type:'date',required:false},
8823          {key:'notes',label:'Notes',type:'textarea',placeholder:'Optional order notes'}
8824        ],
8825        saveLabel:'Create order',
8826        onSave:function(v){ toast('Sales order created for '+v.customer+' (demo)','good'); } },
8827      fieldJob:{ icon:IC.wrench, title:'Book Field Job', sub:'Schedule a field service job (demo)',
8828        fields:[
8829          {key:'title',label:'Job title',type:'text',required:true,placeholder:'e.g. System commissioning'},
8830          {key:'tech',label:'Assign technician',type:'select',required:true,options:['Simon Mackenzie','Damon Debono','Unassigned']},
8831          {key:'site',label:'Site / address',type:'text',required:true,placeholder:'Customer site'},
8832          {key:'date',label:'Date',type:'date',required:true},
8833          {key:'time',label:'Start time',type:'time',required:false}
8834        ],
8835        saveLabel:'Book job',
8836        onSave:function(v){ toast('Field job booked for '+v.tech+' (demo)','good'); } },
8837      production:{ icon:IC.play, title:'Record Production', sub:'Log units built (demo)',
8838        fields:[
8839          {key:'line',label:'Line',type:'select',required:true,options:['Line 1','Line 2','
8839Line 3','BMG']},
8840          {key:'product',label:'Product',type:'text',required:true,placeholder:'e.g. Controller Board Rev C'},
8841          {key:'units',label:'Units built',type:'number',required:true,placeholder:'0'},
8842          {key:'shift',label:'Shift',type:'select',options:['Morning','Afternoon','Night']}
8843        ],
8844        saveLabel:'Record',
8845        onSave:function(v){ toast(v.units+' units recorded on '+v.line+' (demo)','good'); } },
8846      receiveStock:{ icon:IC.box, title:'Receive Stock', sub:'Record incoming stock (demo)',
8847        fields:[
8848          {key:'po',label:'Purchase order',type:'text',required:true,placeholder:'e.g. PO-5510'},
8849          {key:'item',label:'Item',type:'text',required:true,placeholder:'Item / SKU'},
8850          {key:'qty',label:'Quantity received',type:'number',required:true,placeholder:'0'},
8851          {key:'wh',label:'Warehouse',type:'select',options:['Main Warehouse','Gold Coast','Overflow']}
8852        ],
8853        saveLabel:'Receive',
8854        onSave:function(v){ toast('Received '+v.qty+' of '+v.item+' (demo)','good'); } },
8855      capa:{ icon:IC.shield, title:'Raise CAPA', sub:'Create a corrective action (demo)',
8856        fields:[
8857          {key:'title',label:'Title',type:'text',required:true,placeholder:'Non-conformance summary'},
8858          {key:'type',label:'Type',type:'select',required:true,options:['Corrective','Preventive','Supplier','Customer complaint']},
8859          {key:'severity',label:'Severity',type:'select',required:true,options:['Low','Medium','High','Critical']},
8860          {key:'owner',label:'Owner',type:'select',options:['Adam Daglish','David Nguyen','Sammera Jeffrey']},
8861          {key:'desc',label:'Description',type:'textarea',required:true,placeholder:'What happened and immediate containment'}
8862        ],
8863        saveLabel:'Raise CAPA',
8864        onSave:function(v){ toast('CAPA raised: '+v.title+' (demo)','good'); } },
8865      contact:{ icon:IC.users, title:'New Contact', sub:'Add a CRM contact (demo)',
8866        fields:[
8867          {key:'name',label:'Name',type:'text',required:true,placeholder:'Full name'},
8868          {key:'company',label:'Company',type:'text',placeholder:'Company / organisation'},
8869          {key:'type',label:'Type',type:'select',options:['Boat Builder','Distributor','Install Tech','OEM Partner','Customer / Owner','Other']},
8870          {key:'email',label:'Email',type:'email',placeholder:'[email protected]'},
8871          {key:'country',label:'Country',type:'text',placeholder:'Country'}
8872        ],
8873        saveLabel:'Add contact',
8874        onSave:function(v){ toast('Contact added: '+v.name+' (demo)','good'); } }
8875    };
8876    var s=specs[key]; if(s) openModal(s); else toast(key+' (demo)');
8877  }
8878
8879  // Item 13 declutter: the quick-create actions now live behind the page-head
8880  // "+ New" popover (a compact menu) instead of a 5-card strip in the overview.
8881  // HQ-519 (Adam: "that new button needs to be only avalinble for staff that can
8882  // action those request"): each item now carries the role(s) its own server
8883  // endpoint actually requires (see the endpoint map above dashRefusal) so the
8884  // popover only offers what THIS signed-in user could complete - is_admin still
8885  // sees every item, same as requireRole's own admin short-circuit. Record
8886  // Production has no server write at all (openProductionChooser routes to the
8887  // two real recording places instead - see the test that pins that), so it is
8888  // scoped to the manufacturing roles that actually do that work. This is UI
8889  // disclosure only, same posture as isMgmtUser below - the real gate stays
8890  // server-side (security.md: "hidden buttons are not access control").
8891  var QUICK_ACTIONS=[
8892    { ic:IC.cart,   t:'New Sales Order',   s:'Create a new sales order',     form:'salesOrder',  roles:['sales'] },
8893    { ic:IC.wrench, t:'Book Field Job',    s:'Schedule a field service job', form:'fieldJob',     roles:['field_service'] },
8894    { ic:IC.play,   t:'Record Production', s:'Log units built',              form:'production',   roles:['manufacturing','manufacturing_manager'] },
8895    { ic:IC.box,    t:'Receive Stock',     s:'Record incoming stock',        form:'receiveStock', roles:['warehouse'] },
8896    { ic:IC.shield, t:'Raise CAPA',        s:'Create a corrective action',   form:'capa',          roles:['support_tech','support_manager'] }
8897  ];
8898  // FAIL-CLOSED in real mode while the role is unresolved (mirrors isMgmtUser
8899  // below): a null/unknown auth sees NO quick actions rather than every one.
8900  // Demo mode keeps this page's existing fail-open posture (dashDemo() never
8901  // shows real financials, and every quick-create form already forks on it).
8902  function quickActionsFor(au){
8903    if(dashDemo()) return QUICK_ACTIONS;
8904    if(!au) return [];
8905    if(au.is_admin) return QUICK_ACTIONS;
8906    var rs=Array.isArray(au.roles)&&au.roles.length ? au.roles : (au.role?[au.role]:[]);
8907    return QUICK_ACTIONS.filter(function(c){ return c.roles.some(function(r){ return rs.indexOf(r)>=0; }); });
8908  }
8909  // Hides the whole button once this user's role can action none of the five
8910  // items - an empty menu is worse than no button. Called from the same
8911  // sessionSync passes that already re-run applyDashGate as auth resolves.
8912  function applyQuickNewGate(){
8913    var btn=$('ph-new'); if(!btn) return;
8914    btn.hidden = quickActionsFor(_dashAuth).length===0;
8915  }
8916  function wireQuickPop(){
8917    var btn=$('ph-new'); if(!btn) return;
8918    btn.addEventListener('click', function(){
8919      var existing=document.getElementById('dash-popmenu'); if(existing){ existing.remove(); return; }
8920      var menu=el('div',''); menu.id='dash-popmenu';
8921      menu.style.cssText='position:fixed;z-index:4100;min-width:236px;background:var(--bg-panel-solid);border:1px solid var(--line-strong);border-radius:12px;box-shadow:0 14px 40px rgba(0,0,0,.5);padding:6px;';
8922      quickActionsFor(_dashAuth).forEach(function(c){
8923        var it=el('div','qpop-item');
8924        it.innerHTML='<span class="qpop-ic">'+svg(c.ic)+'</span>'
8925          +'<span class="qpop-tt"><span class="qpop-t">'+c.t+'</span><span class="qpop-s">'+c.s+'</span></span>';
8926        it.addEventListener('click', function(){ menu.remove(); openForm(c.form); });
8927        menu.appendChild(it);
8928      });
8929      document.body.appendChild(menu);
8930      var r=btn.getBoundingClientRect();
8931      var left=r.right-menu.offsetWidth, top=r.bottom+6;
8932      if(left<8) left=8;
8933      if(top+menu.offsetHeight>window.innerHeight-8) top=r.top-6-menu.offsetHeight;
8934      menu.style.top=top+'px'; menu.style.left=left+'px';
8935      setTimeout(function(){
8936        function away(e){ if(!menu.contains(e.target) && e.target!==btn){ menu.remove(); document.removeEventListener('mousedown',away); } }
8937        document.addEventListener('mousedown',away);
8938      },0);
8939    });
8940  }
8941
8942  // ══ My Dashboard building blocks (KPI tiles, attention rows, glance rows, deep-link tiles) ══
8943  // Read-only building blocks reused by the My Dashboard cards below; no
8944  // parallel data layer - the same demo slices/idioms as the Overview cards.
8945
8946  // Small KPI tile (no delta) for the department rows, same shell as .kpi.
8947  function deptKpi(k){
8948    var c=el('div','kpi '+(k.cls||''));
8949    c.innerHTML='<div class="khead">'
8950      +'<div class="ktext"><div class="lbl">'+k.lbl+'</div><div class="val">'+k.val+'</div></div></div>'
8951      +spark(k.spark, k.col)
8952      +'<div class="delta '+k.delta+'">'+(k.delta==='up'?'▲':'▼')+' <span class="sub">'+k.d+'</span></div>';
8953    return c;
8954  }
8955  function renderDeptKpis(rootId, list){
8956    var root=$(rootId); if(!root) return; root.innerHTML='';
8957    list.forEach(function(k){ root.appendChild(deptKpi(k)); });
8958  }
8959  // Needs-attention list - the Action Required idiom, but deep-links straight
8960  // to the module page (location.href) rather than the CRM's cross-module modal.
8961  function renderDeptAttn(rootId, rows){
8962    var root=$(rootId); if(!root) return; root.innerHTML='';
8963    rows.forEach(function(r){
8964      var d=el('div','aq-row');
8965      d.setAttribute('role','button'); d.setAttribute('tabindex','0');
8966      d.title=r.n+' '+r.t+' - open '+r.href;
8967      // Chip tint is the row's own severity colour - a per-record value.
8968      d.innerHTML='<div class="aq-ic" style="background:color-mix(in srgb, '+r.col+' 14%, transparent);color:'+r.col+'">'+svg(r.ic)+'</div>'
8969        +'<span class="aq-n">'+r.n+'</span>'
8970        +'<span class="aq-t">'+r.t+'</span>'
8971        +'<span class="urg '+r.pc+'">'+r.pill+'</span>'
8972        +'<span class="chev">'+svg('<path d="M9 18l6-6-6-6"/>')+'</span>';
8973      var go=function(){ location.href=r.href; };
8974      d.addEventListener('click', go);
8975      d.addEventListener('keydown', function(e){ if(e.key==='Enter'){ go(); } });
8976      root.appendChild(d);
8977    });
8978  }
8979  // Deep-link tiles - each navigates to the real module page via location.href.
8980  function renderDeepLinks(rootId, tiles){
8981    var root=$(rootId); if(!root) return; root.innerHTML='';
8982    tiles.forEach(function(t,i){
8983      var a=el('a','deeptile t'+((i%3)+1)); a.href=t.href;
8984      a.innerHTML='<span class="dti">'+svg(t.ic)+'</span>'
8985        +'<span class="dtt"><span class="dt-name">'+t.name+'</span><span class="dt-sub">'+t.sub+'</span></span>'
8986        +'<span class="dtx">'+svg('<path d="M9 18l6-6-6-6"/>')+'</span>';
8987      root.appendChild(a);
8988    });
8989  }
8990  // Compact info list for a cockpit card (id · name · meta · status dot). These
8991  // are read-only glance rows - the card's "View all" link is the real action,
8992  // so they are deliberately not clickable (no dead clicks).
8993  function deptRows(rootId, rows, footLbl, footVal){
8994    var root=$(rootId); if(!root) return;
8995    var h=''; rows.forEach(function(r){
8996      h+='<div class="fs-row" title="'+(r.title||r.name||'')+'">'
8997        +(r.ic?'<span class="fic">'+svg(r.ic)+'</span>':'')
8998        +(r.id?'<span class="fid">'+r.id+'</span>':'')
8999        +'<span class="fname">
8999'+r.name+'</span>'
9000        +(r.meta?'<span class="ftime">'+r.meta+'</span>':'')
9001        +(r.dot?'<span class="fdot" style="background:'+r.dot+'"></span>':'')
9002        +'</div>';
9003    });
9004    if(footLbl) h+='<div class="fs-foot"><span>'+footLbl+'</span><b>'+footVal+'</b></div>';
9005    root.innerHTML=h;
9006  }
9007  // ══ My Dashboard (item 14 resolved: Option B promoted) ═══════════════
9008  // The 17 canonical staff (HQ-282 directory). mgmt mirrors the management
9009  // set (is_admin OR Director/Managing/…Manager title). profile picks the
9010  // role-family KPI/queue/links set; bookings are per-person demo literals
9011  // (refs match the module pages' own seeds where those seeds exist, so the
9012  // page-level deep links land near real demo rows; rows themselves are
9013  // read-only). leave = [annual, personal, LSL] days.
9014  var MY_PEOPLE = {
9015    bruce:     { name:'Bruce Loxton',       role:'Managing Director',        dept:'Executive',     mgmt:true,  profile:'exec',       approver:'Jonathan Turnbull', leave:[18.0,10.0,42.0], bookings:[
9016      { ic:IC.shield,  id:'REVIEW',        name:'Q3 management review pack',            meta:'Fri',         dot:C.amber },
9017      { ic:IC.revenue, id:'OPP-0042',      name:'Tech Dynamics agreement sign-off',     meta:'This week',   dot:C.blue } ] },
9018    adam:      { name:'Adam Daglish',       role:'Operations Manager',       dept:'Operations',    mgmt:true,  profile:'opsmgr',     approver:'Bruce Loxton',      leave:[14.5,7.0,22.0],  bookings:[
9019      { ic:IC.cal,     id:'JO-2025-0183',  name:'Bluewater Marine - commissioning',     meta:'Mon 9:00 AM', dot:C.green },
9020      { ic:IC.box,     id:'PO-5508',       name:'PowerParts AU receipt to check',       meta:'Tue PM',      dot:C.blue },
9021      { ic:IC.shield,  id:'WHS',           name:'Toolbox talk - workshop floor',        meta:'Thu 7:30 AM', dot:C.amber } ] },
9022    jonathan:  { name:'Jonathan Turnbull',  role:'Director',                 dept:'Executive',     mgmt:true,  profile:'exec',       approver:'Bruce Loxton',      leave:[16.0,9.0,38.0],  bookings:[
9023      { ic:IC.wrench,  id:'JO-2025-0180',  name:'Site visit - crew install',            meta:'Mon',         dot:C.green },
9024      { ic:IC.check,   id:'APPR',          name:'Approvals queue (COC and invoices)',   meta:'Daily',       dot:C.violet } ] },
9025    casey:     { name:'Casey McNeece',      role:'Sales Manager',            dept:'Sales',         mgmt:true,  profile:'salesmgr',   approver:'Bruce Loxton',      leave:[11.0,6.5,4.0],   bookings:[
9026      { ic:IC.revenue, id:'OPP-0042',      name:'Tech Dynamics - volume pricing call',  meta:'Tue 10:00 AM',dot:C.violet },
9027      { ic:IC.so,      id:'Q-2041',        name:'Coastline quote follow-up',            meta:'Wed',         dot:C.blue },
9028      { ic:IC.projects,id:'PIPE',          name:'Pipeline review with reps',            meta:'Fri 9:00 AM', dot:C.green } ] },
9029    sammera:   { name:'Sammera Jeffrey',    role:'Support Manager',          dept:'Support',       mgmt:true,  profile:'supportmgr', approver:'Bruce Loxton',      leave:[9.5,5.0,6.0],    bookings:[
9030      { ic:IC.headset, id:'QUEUE',         name:'Case queue triage',                    meta:'Daily 9:00 AM',dot:C.blue },
9031      { ic:IC.rma,     id:'RMA',           name:'Warranty replacements to authorise',   meta:'Tue',         dot:C.amber },
9032      { ic:IC.alert,   id:'ESC',           name:'Escalation review - VIP accounts',     meta:'Thu',         dot:C.red } ] },
9033    david:     { name:'David Nguyen',       role:'Manufacturing Manager',    dept:'Manufacturing', mgmt:true,  profile:'mfgmgr',     approver:'Bruce Loxton',      leave:[12.0,7.0,8.0],   bookings:[
9034      { ic:IC.gauge,   id:'LINES',         name:'Line status walk - all lines',         meta:'Daily 8:00 AM',dot:C.green },
9035      { ic:IC.capa,    id:'NCR-044',       name:'NCR close-out review',                 meta:'Wed 3:00 PM', dot:C.red },
9036      { ic:IC.users,   id:'ROSTER',        name:'Production roster and capacity',       meta:'Fri',         dot:C.violet } ] },
9037    fiona:     { name:'Fiona Hou',          role:'Procurement Lead',         dept:'Operations',    mgmt:false, profile:'procure',    approver:'Adam Daglish',      leave:[13.0,8.0,3.0],   bookings:[
9038      { ic:IC.box,     id:'PO-2606-041',   name:'Victron inverters - approval prep',    meta:'Mon',         dot:C.amber },
9039      { ic:IC.receipt, id:'ASN-3312',      name:'Victron Energy - 4 pallets inbound',   meta:'Tue AM',      dot:C.green },
9040      { ic:IC.users,   id:'SUP',           name:'Supplier review - BMG',                meta:'Thu 2:00 PM', dot:C.violet } ] },
9041    hong:      { name:'Hong Zhenyue',       role:'Production Technician',    dept:'Manufacturing', mgmt:false, profile:'production', approver:'David Nguyen',      leave:[8.0,4.5,0],      bookings:[
9042      { ic:IC.play,    id:'PO-2487',       name:'48V ESS units - Line 1 build',         meta:'Mon-Wed',     dot:C.blue },
9043      { ic:IC.gauge,   id:'QC',            name:'In-process checks - Line 1',           meta:'Daily 2:00 PM',dot:C.green } ] },
9044    xingliang: { name:'Xingliang Zhang',    role:'Production Technician',    dept:'Manufacturing', mgmt:false, profile:'production', approver:'David Nguyen',      leave:[10.0,6.0,0],     bookings:[
9045      { ic:IC.play,    id:'LINE-2',        name:'Inverter batch - Line 2 build',        meta:'Mon-Thu',     dot:C.blue },
9046      { ic:IC.capa,    id:'NCR-042',       name:'Rework - cell balancing',              meta:'Wed',         dot:C.amber } ] },
9047    luke:      { name:'Luke Frisken',       role:'Production Technician',    dept:'Manufacturing', mgmt:false, profile:'production', approver:'David Nguyen',      leave:[7.5,5.5,0],      bookings:[
9048      { ic:IC.play,    id:'LINE-3',        name:'MPPT controller run - Line 3',         meta:'Tue-Fri',     dot:C.blue },
9049      { ic:IC.so,      id:'BOM',           name:'BOM review sign-off queue',            meta:'Thu',         dot:C.violet } ] },
9050    simon:     { name:'Simon Mackenzie',    role:'Field Service Technician', dept:'Field Service', mgmt:false, profile:'field',      approver:'Adam Daglish',      leave:[9.0,4.0,0],      bookings:[
9051      { ic:IC.wrench,  id:'JO-2025-0180',  name:'Crew install - with Jonathan and David',meta:'Mon-Tue',    dot:C.green },
9052      { ic:IC.wrench,  id:'JO-2025-0191',  name:'AI-proposed booking to confirm',       meta:'Thu',         dot:C.amber } ] },
9053    damon:     { name:'Damon Debono',       role:'Field Service Technician', dept:'Field Service', mgmt:false, profile:'field',      approver:'Adam Daglish',      leave:[11.5,6.0,0],     bookings:[
9054      { ic:IC.wrench,  id:'JO-2025-0191',  name:'AI-proposed booking (with Simon)',     meta:'Thu',         dot:C.amber },
9055      { ic:IC.cal,     id:'PERTH',         name:'Perth service run',                    meta:'Wed-Fri',     dot:C.blue } ] },
9056    craig:     { name:'Craig Smith',        role:'Support Technician',       dept:'Support',       mgmt:false, profile:'support',    approver:'Sammera Jeffrey',   leave:[6.0,3.5,0],      bookings:[
9057      { ic:IC.headset, id:'CASE-2606-027', name:'Booked support call',                  meta:'Mon 10:00 AM',dot:C.blue },
9058      { ic:IC.rma,     id:'RMA-2606-014',  name:'Bluewater return - inspection',        meta:'Tue',         dot:C.violet },
9059      { ic:IC.cal,     id:'SCHED',         name:'AI-proposed slots to confirm',         meta:'Wed',         dot:C.amber } ] },
9060    donovan:   { name:'Donovan Templeman',  role:'Warehouse Lead',           dept:'Warehouse',     mgmt:false, profile:'warehouse',  approver:'Adam Daglish',      leave:[15.0,7.5,5.0],   bookings:[
9061      { ic:IC.cart,    id:'SHP-3320',      name:'Ship today - outbound queue',          meta:'Daily',       dot:C.blue },
9062      { ic:IC.receipt, id:'ASN-3312',      name:'Victron inbound - putaway',            meta:'Tue AM',      dot:C.green },
9063      { ic:IC.box,     id:'COUNT',         name:'Cycle count - bin aisle C',            meta:'Fri',         dot:C.violet } ] },
9064    oscar:     { name:'Oscar Johnson',      role:'Sales',                    dept:'Sales',         mgmt:false, profile:'sales',      approver:'Casey McNeece',     leave:[10.0,5.0,0],     bookings:[
9065      { ic:IC.revenue, id:'OPP-0047',      name:'Blue Water Marine - inverter array',   meta:'Mon 11:00 AM',dot:C.green },
9066      { ic:IC.users,   id:'CRM',           name:'New-lead qualification block',         meta:'Wed AM',      dot:C.blue } ] },
9067    yvette:    { name:'Yvette Holliday',    role:'Customer Success Lead',    dept:'Sales',         mgmt:false, profile:'csuccess',   approver:'Casey McNeece',     leave:[12.5,6.5,2.0],   bookings:[
9068      { ic:IC.projects,id:'PJ-1',          name:'Coastline 48V House Bank - check-in',  meta:'Tue 1:00 PM', dot:C.green },
9069      { ic:IC.headset, id:'QBR',           name:'Quarterly review - Harbour Energy',    meta:'Thu 10:00 AM',dot:C.violet } ] },
9070    jean:      { name:'Jean Cobine',        role:'Finance Officer',          dept:'Finance',       mgmt:false, profile:'finance',    approver:'Bruce Loxton',      leave:[14.0,8.0,12.0],  bookings:[
9071      { ic:IC.receipt, id:'SO-10241',      name:'Invoice run - sales orders',           meta:'Mon',         dot:C.blue },
9072      { ic:IC.check,   id:'XERO',          name:'Bank reconciliation',                  meta:'Wed',         dot:C.green } ] }
9073  };
9074
9075  // Role-family profiles: 4 KPI tiles + queue (Needs Attention grammar) +
9076  // 3 deep-link tiles. Sec names verified against each page's data-sec set
9077  // (support = rma singular, manufacturing = scheduling, operations approvals
9078  // live on ?__sec=dashboard).
9079  var MY_PROFILES = {
9080    exec: {
9081      kpis:[
9082        { ic:IC.revenue, lbl:'Revenue This Month', val:'$1.48M', spark:[0.9,1.0,1.1,1.2,1.3,1.35,1.4,1.48], col:C.blue, delta:'up', d:'+9.4% MoM' },
9083        { cls:'green', ic:IC.gauge, lbl:'On-Time Delivery', val:'93.6%', spark:[88,90,89,92,91,93,92,94], col:C.green, delta:'up', d:'+1.2pp' },
9084        { cls:'amber', ic:IC.headset,lbl:'Open Support Cases', val:'23', spark:[31,29,30,27,28,25,24,23], col:C.amber, delta:'down', d:'-6 since Mon' },
9085        { cls:'violet',ic:IC.check,  lbl:'Cash Collected',    val:'$1.12M', spark:[0.7,0.8,0.85,0.9,1.0,1.05,1.1,1.12], col:C.violet, delta:'up', d:'+6% vs plan' }
9086      ],
9087      queue:[
9088        { ic:IC.shield, col:C.red,   n:2, t:'CAPAs on the management review', pill:'Review',  pc:'red',   href:'/compliance.html?__sec=review' },
9089        { ic:IC.so,     col:C.blue,  n:5, t:'large quotes to sign off',       pill:'Review',  pc:'blue',  href:'/sales.html?__sec=pipeline' },
9090        { ic:IC.users,  col:C.amber, n:1, t:'senior hire to approve',         pill:'Approve', pc:'amber', href:'/people.html?__sec=directory' }
9091      ],
9092      links:[
9093        { ic:IC.receipt, name:'Reports',    sub:'Company reporting',  href:'/reports.html?__sec=overview' },
9094        { ic:IC.revenue, name:'Sales',      sub:'Pipeline and won',   href:'/sales.html?__sec=pipeline' },
9095        { ic:IC.shield,  name:'Compliance', sub:'Management review',  href:'/compliance.html?__sec=review' }
9096      ]
9097    },
9098    opsmgr: {
9099      kpis:[
9100        { ic:IC.box,   lbl:'POs Awaiting Approval', val:'6',   spark:[3,4,4,5,6,5,6,6],       col:C.blue, delta:'up',   d:'+2 today' },
9101        { cls:'green', ic:IC.check, lbl:'On-Time Delivery', val:'93.6%', spark:[88,90,89,92,91,93,92,94], col:C.green, delta:'up', d:'+1.2pp' },
9102        { cls:'amber', ic:IC.users, lbl:'People On Site',    val:'7 / 10', spark:[6,7,8,7,9,8,7,7
9102],       col:C.amber, delta:'up',   d:'2 field, 1 leave' },
9103        { cls:'violet',ic:IC.shield,lbl:'Open WHS Actions',  val:'2',    spark:[1,2,2,1,2,3,2,2],        col:C.violet, delta:'down', d:'-1 this week' }
9104      ],
9105      queue:[
9106        { ic:IC.box,   col:C.blue,  n:6, t:'purchase orders to approve', pill:'Pending', pc:'blue',  href:'/operations.html?__sec=purchasing' },
9107        { ic:IC.alert, col:C.red,   n:2, t:'WHS actions overdue',        pill:'Overdue', pc:'red',   href:'/operations.html?__sec=whs' },
9108        { ic:IC.users, col:C.amber, n:4, t:'leave requests awaiting me', pill:'Approve', pc:'amber', href:'/people.html?__sec=leave' }
9109      ],
9110      links:[
9111        { ic:IC.box,   name:'Purchasing',   sub:'Orders and inbound',  href:'/operations.html?__sec=purchasing' },
9112        { ic:IC.shield,name:'WHS',          sub:'Safety and actions',  href:'/operations.html?__sec=whs' },
9113        { ic:IC.cal,   name:'Ops Calendar', sub:'Week at a glance',    href:'/operations.html?__sec=calendar' }
9114      ]
9115    },
9116    salesmgr: {
9117      kpis:[
9118        { ic:IC.revenue, lbl:'Open Pipeline',  val:'$3.24M', spark:[2.6,2.8,2.7,3.0,2.9,3.1,3.2,3.24], col:C.blue, delta:'up', d:'+8.1% MoM' },
9119        { cls:'green', ic:IC.check, lbl:'Won This Month', val:'$1.48M', spark:[0.6,0.9,1.0,1.1,1.2,1.3,1.4,1.48], col:C.green, delta:'up', d:'+9.4%' },
9120        { cls:'violet',ic:IC.so,    lbl:'Quotes Out',     val:'32',     spark:[24,26,25,28,27,30,31,32],  col:C.violet, delta:'up', d:'+4 this week' },
9121        { cls:'amber', ic:IC.gauge, lbl:'Win Rate',       val:'14.1%',  spark:[12,13,12.5,13.4,13,13.8,14,14.1], col:C.amber, delta:'up', d:'+0.7pp QoQ' }
9122      ],
9123      queue:[
9124        { ic:IC.so,    col:C.blue,  n:5, t:'quotes awaiting follow-up', pill:'Due',     pc:'blue',  href:'/sales.html?__sec=pipeline' },
9125        { ic:IC.alert, col:C.red,   n:2, t:'deals slipping this month', pill:'At Risk', pc:'red',   href:'/sales.html?__sec=pipeline' },
9126        { ic:IC.users, col:C.amber, n:3, t:'new leads unassigned',      pill:'New',     pc:'amber', href:'/sales.html?__sec=customers' }
9127      ],
9128      links:[
9129        { ic:IC.projects, name:'Pipeline',  sub:'Deals by stage',    href:'/sales.html?__sec=pipeline' },
9130        { ic:IC.so,       name:'Orders',    sub:'Quotes and orders', href:'/quote.html' },
9131        { ic:IC.users,    name:'Customers', sub:'Accounts and CRM',  href:'/sales.html?__sec=customers' }
9132      ]
9133    },
9134    sales: {
9135      kpis:[
9136        { ic:IC.revenue, lbl:'My Open Deals', val:'7', spark:[5,6,6,7,6,7,7,7], col:C.blue, delta:'up', d:'+1 this week' },
9137        { cls:'violet',ic:IC.so,   lbl:'Quotes Out',     val:'9',     spark:[6,7,7,8,8,9,9,9],   col:C.violet, delta:'up', d:'+2 this week' },
9138        { cls:'amber', ic:IC.cal,  lbl:'Calls Booked',   val:'5',     spark:[3,4,3,4,4,5,4,5],   col:C.amber, delta:'up', d:'+1 vs last week' },
9139        { cls:'green', ic:IC.check,lbl:'Won This Month', val:'$214K', spark:[40,70,90,110,140,170,190,214], col:C.green, delta:'up', d:'+12% MoM' }
9140      ],
9141      queue:[
9142        { ic:IC.so,    col:C.blue,  n:3, t:'quotes awaiting my follow-up', pill:'Due',     pc:'blue',  href:'/sales.html?__sec=pipeline' },
9143        { ic:IC.alert, col:C.red,   n:1, t:'deal slipping',                pill:'At Risk', pc:'red',   href:'/sales.html?__sec=pipeline' },
9144        { ic:IC.users, col:C.amber, n:2, t:'new leads to qualify',         pill:'New',     pc:'amber', href:'/sales.html?__sec=customers' }
9145      ],
9146      links:[
9147        { ic:IC.projects, name:'Pipeline',  sub:'Deals by stage',    href:'/sales.html?__sec=pipeline' },
9148        { ic:IC.so,       name:'My Quotes', sub:'Quotes and orders', href:'/quote.html' },
9149        { ic:IC.users,    name:'People',    sub:'Clock and leave',   href:'/people.html?__sec=clock' }
9150      ]
9151    },
9152    csuccess: {
9153      kpis:[
9154        { ic:IC.users, lbl:'Accounts I Own', val:'14', spark:[12,12,13,13,13,14,14,14], col:C.blue, delta:'up', d:'+1 this quarter' },
9155        { cls:'green', ic:IC.check,   lbl:'Health Green',  val:'11', spark:[9,9,10,10,10,11,11,11], col:C.green, delta:'up',   d:'+1 this month' },
9156        { cls:'amber', ic:IC.cal,     lbl:'Renewals 90d',  val:'3',  spark:[5,5,4,4,4,3,3,3],       col:C.amber, delta:'down', d:'2 already booked' },
9157        { cls:'violet',ic:IC.projects,lbl:'Open Projects', val:'4',  spark:[3,3,3,4,4,4,4,4],       col:C.violet, delta:'up',   d:'+1 kicked off' }
9158      ],
9159      queue:[
9160        { ic:IC.alert,   col:C.red,   n:2, t:'at-risk accounts to call', pill:'At Risk', pc:'red',   href:'/sales.html?__sec=customers' },
9161        { ic:IC.projects,col:C.blue,  n:1, t:'project milestone due',    pill:'Due',     pc:'blue',  href:'/projects.html' },
9162        { ic:IC.cal,     col:C.amber, n:3, t:'QBRs to schedule',         pill:'Book',    pc:'amber', href:'/sales.html?__sec=customers' }
9163      ],
9164      links:[
9165        { ic:IC.users,   name:'Customers', sub:'Accounts and CRM',  href:'/sales.html?__sec=customers' },
9166        { ic:IC.projects,name:'Projects',  sub:'Delivery check-ins', href:'/projects.html' },
9167        { ic:IC.clock,   name:'People',    sub:'Clock and leave',    href:'/people.html?__sec=clock' }
9168      ]
9169    },
9170    support: {
9171      kpis:[
9172        { ic:IC.headset, lbl:'My Open Cases', val:'5', spark:[7,7,6,6,6,5,5,5], col:C.blue, delta:'down', d:'-2 since Mon' },
9173        { cls:'amber', ic:IC.clock, lbl:'Due Today',          val:'2', spark:[3,2,3,2,2,3,2,2], col:C.amber, delta:'down', d:'-1 vs yesterday' },
9174        { cls:'red',   ic:IC.alert, lbl:'SLA At Risk',        val:'1', spark:[2,2,1,2,1,1,1,1], col:C.red,   delta:'down', d:'-1 this week' },
9175        { cls:'green', ic:IC.check, lbl:'Resolved This Week', val:'9', spark:[4,5,6,6,7,8,8,9], col:C.green, delta:'up',   d:'+2 vs last week' }
9176      ],
9177      queue:[
9178        { ic:IC.alert,   col:C.red,   n:1, t:'case breaching SLA in 4h', pill:'SLA',   pc:'red',   href:'/support.html?__sec=cases' },
9179        { ic:IC.headset, col:C.blue,  n:2, t:'booked calls today',       pill:'Calls', pc:'blue',  href:'/support.html?__sec=schedule' },
9180        { ic:IC.rma,     col:C.amber, n:1, t:'RMA inspection due',       pill:'RMA',   pc:'amber', href:'/support.html?__sec=rma' }
9181      ],
9182      links:[
9183        { ic:IC.headset, name:'My Cases', sub:'Support queue',     href:'/support.html?__sec=cases' },
9184        { ic:IC.cal,     name:'Schedule', sub:'Booked calls',      href:'/support.html?__sec=schedule' },
9185        { ic:IC.clock,   name:'People',   sub:'Clock and leave',   href:'/people.html?__sec=clock' }
9186      ]
9187    },
9188    supportmgr: {
9189      kpis:[
9190        { ic:IC.headset, lbl:'Team Open Cases', val:'23', spark:[31,29,30,27,28,25,24,23], col:C.blue, delta:'down', d:'-6 since Mon' },
9191        { cls:'green', ic:IC.gauge, lbl:'SLA On-Time',   val:'94.1%', spark:[91,92,92,93,93,94,94,94.1], col:C.green, delta:'up',   d:'+0.6pp' },
9192        { cls:'red',   ic:IC.alert, lbl:'Escalations',   val:'2',     spark:[1,2,1,2,2,3,2,2],           col:C.red,   delta:'up',   d:'+1 this week' },
9193        { cls:'amber', ic:IC.rma,   lbl:'RMAs Awaiting', val:'4',     spark:[6,5,5,4,5,4,4,4],           col:C.amber, delta:'down', d:'-1 this week' }
9194      ],
9195      queue:[
9196        { ic:IC.alert,  col:C.red,   n:2, t:'escalations need a decision', pill:'Escalate', pc:'red',   href:'/support.html?__sec=cases' },
9197        { ic:IC.shield, col:C.amber, n:3, t:'warranty replacements ≤ $1,000 to authorise', pill:'Approve', pc:'amber', href:'/support.html?__sec=warranty' },
9198        { ic:IC.cal,    col:C.blue,  n:4, t:'AI rebooking proposals to review', pill:'Review', pc:'blue', href:'/support.html?__sec=schedule' }
9199      ],
9200      links:[
9201        { ic:IC.headset, name:'Case Queue', sub:'Open support cases', href:'/support.html?__sec=cases' },
9202        { ic:IC.cal,     name:'Schedule',   sub:'Booked calls',       href:'/support.html?__sec=schedule' },
9203        { ic:IC.rma,     name:'RMAs',       sub:'Returns and repairs',href:'/support.html?__sec=rma' }
9204      ]
9205    },
9206    mfgmgr: {
9207      kpis:[
9208        { ic:IC.play, lbl:'Lines Running', val:'3 / 4', spark:[2,3,3,2,3,3,3,3], col:C.blue, delta:'up', d:'Line 3 idle' },
9209        { cls:'green', ic:IC.gauge, lbl:'On-Time Build',   val:'94.2%', spark:[88,90,89,92,91,93,92,94.2], col:C.green, delta:'up',   d:'+1.2pp' },
9210        { cls:'amber', ic:IC.alert, lbl:'Open NCRs',       val:'3',     spark:[5,4,4,3,4,3,3,3],           col:C.amber, delta:'down', d:'-1 this week' },
9211        { cls:'violet',ic:IC.check, lbl:'Units This Week', val:'212',   spark:[150,160,175,180,190,200,205,212], col:C.violet, delta:'up', d:'+18 vs last week' }
9212      ],
9213      queue:[
9214        { ic:IC.alert, col:C.red,   n:1, t:'line blocked on materials', pill:'Blocked', pc:'red',   href:'/manufacturing.html?__sec=tv' },
9215        { ic:IC.capa,  col:C.amber, n:3, t:'NCRs awaiting close-out',   pill:'NCR',     pc:'amber', href:'/manufacturing.html?__sec=quality' },
9216        { ic:IC.so,    col:C.blue,  n:2, t:'BOMs awaiting approval',    pill:'Approve', pc:'blue',  href:'/manufacturing.html?__sec=boms' }
9217      ],
9218      links:[
9219        { ic:IC.gauge, name:'Production TV', sub:'Line status board', href:'/manufacturing.html?__sec=tv' },
9220        { ic:IC.check, name:'Quality',       sub:'NCRs and checks',   href:'/manufacturing.html?__sec=quality' },
9221        { ic:IC.cal,   name:'Schedule',      sub:'Build plan',        href:'/manufacturing.html?__sec=scheduling' }
9222      ]
9223    },
9224    production: {
9225      kpis:[
9226        { ic:IC.play,  lbl:'Units Today',      val:'46',     spark:[38,41,40,44,42,45,44,46], col:C.blue,  delta:'up', d:'+2 vs yesterday' },
9227        { cls:'green', ic:IC.gauge, lbl:'My Line',  val:'Running', spark:[1,1,1,1,1,1,1,1],   col:C.green, delta:'up', d:'no stops today' },
9228        { cls:'violet',ic:IC.clock, lbl:'Hours This Week', val:'32.5', spark:[7.6,8,7.6,8,7.6,8,8,8], col:C.violet, delta:'up', d:'on track for 38' },
9229        { cls:'amber', ic:IC.check, lbl:'First-Pass Yield', val:'97.2%', spark:[95,96,96,97,96,97,97,97.2], col:C.amber, delta:'up', d:'+0.4pp' }
9230      ],
9231      queue:[
9232        { ic:IC.play,  col:C.blue,  n:2, t:'build orders on my line today',   pill:'Build',  pc:'blue',  href:'/manufacturing.html?__sec=orders' },
9233        { ic:IC.capa,  col:C.amber, n:1, t:'rework task assigned to me',      pill:'Rework', pc:'amber', href:'/manufacturing.html?__sec=quality' },
9234        { ic:IC.check, col:C.green, n:3, t:'test records to sign off',        pill:'QC',     pc:'green', href:'/manufacturing.html?__sec=quality' }
9235      ],
9236      links:[
9237        { ic:IC.gauge,  name:'Production TV', sub:'Line status board',   href:'/manufacturing.html?__sec=tv' },
9238        { ic:IC.play,   name:'My Orders',     sub:'Build queue',         href:'/manufacturing.html?__sec=orders' },
9239        { ic:IC.users,  name:'People',        sub:'Clock and leave',     href:'/people.html?__sec=clock' }
9240      ]
9241    },
9242    field: {
9243      kpis:[
9244        { ic:IC.wrench, lbl:'My Jobs This Week', val:'4', spark:[2,3,3,4,3,4,4,4], col:C.blue, delta:'up', d:'+1 vs last week' },
9245        { cls:'green', ic:IC.cal,   lbl:'Next Job',    val:'Mon 9:00', spark:[1,1,1,1,1,1,1,1], col:C.green, delta:'up', d:'confirmed' },
9246        { cls:'amber', ic:IC.alert, lbl:'Awaiting Parts', val:'1', spark:[2,2,1,2,1,1,1,1], col:C.amber, delta:'down', d:'-1 this week' },
9247        { cls:'violet',ic:IC.clock, lbl:'Hours This Week', val:'30.0', spark:[7.5,8,7,7.5,8,7.5,7.5,7.5], col:C.violet, delta:'up', d:'on track' }
9248      ],
9249      queue:[
9250        { ic:IC.wrench, col:C.amber, n:1, t:'AI-proposed booking to accept',  pill:'Confirm', pc:'amber', href:'/field-service.html?__sec=schedule' },
9251        { ic:IC.box,    col:C.blue,  n:1, t:'job kit to pick up from stores', pill:'Parts',   pc:'blue',  href:'/field-service.html?__sec=jobs' },
9252        { ic:IC.check,  col:C.green, n:2, t:'completed jobs to sign off',     pill:'Sign',    pc:'green', href:'/field-service.html?__sec=jobs' }
9253      ],
9254      links:[
9255        { ic:IC.cal,    name:'My Schedule', sub:'Day and week view',  href:'/field-service.html?__sec=schedule' },
9256        { ic:IC.wrench, name:'My Jobs',     sub:'Assigned field work', href:'/field-service.html?__sec=jobs' },
9257        { ic:IC.users,  name:'People',      sub:'Clock and leave',     href:'/people.html?__sec=clock' }
9258      ]
9259    },
9260    procure: {
9261      kpis:[
9262        { ic:IC.box, lbl:'Open POs', val:'14', spark:[10,11,12,11,13,12,13,14], col:C.blue, delta:'up', d:'+2 this week' },
9263        { cls:'green', ic:IC.receipt, lbl:'Inbound This Week', val:'5',     spark:[3,4,4,3,5,4,5,5],           col:C.green, delta:'up',   d:'+1 inbound' },
9264        { cls:'amber', ic:IC.gauge,   lbl:'Supplier On-Time',  val:'91.8%', spark:[89,90,90,91,91,92,91.5,91.8], col:C.amber, delta:'up', d:'+0.8pp' },
9265        { cls:'red',   ic:IC.alert,   lbl:'Shortages Flagged', val:'2',     spark:[4,3,3,2,3,2,2,2],           col:C.red,   delta:'down', d:'-1 this week' }
9266      ],
9267      queue:[
9268        { ic:IC.alert, col:C.red,   n:2, t:'material shortages to resolve',   pill:'Shortage', pc:'red',   href:'/operations.html?__sec=purchasing' },
9269        { ic:IC.box,   col:C.amber, n:3, t:'POs awaiting supplier confirm',   pill:'Chase',    pc:'amber', href:'/operations.html?__sec=purchasing' },
9270        { ic:IC.users, col:C.blue,  n:1, t:'supplier review to prepare',      pill:'Review',   pc:'blue',  href:'/operations.html?__sec=suppliers' }
9271      ],
9272      links:[
9273        { ic:IC.box,   name:'Purchasing', sub:'POs and inbound',   href:'/operations.html?__sec=purchasing' },
9274        { ic:IC.users, name:'Suppliers',  sub:'Vendor directory',  href:'/operations.html?__sec=suppliers' },
9275        { ic:IC.clock, name:'People',     sub:'Clock and leave',   href:'/people.html?__sec=clock' }
9276      ]
9277    },
9278    warehouse: {
9279      kpis:[
9280        { ic:IC.cart, lbl:'Ship Today', val:'19', spark:[12,14,13,16,15,18,17,19], col:C.blue, delta:'up', d:'+3 vs yesterday' },
9281        { cls:'amber', ic:IC.box,     lbl:'Picks Open',       val:'7', spark:[9,8,8,7,8,7,7,7], col:C.amber, delta:'down', d:'-2 since 9am' },
9282        { cls:'green', ic:IC.receipt, lbl:'Inbound Receipts', val:'5', spark:[3,4,4,3,5,4,5,5], col:C.green, delta:'up',   d:'+1 inbound' },
9283        { cls:'red',   ic:IC.alert,   lbl:'Stock Alerts',     val:'4', spark:[6,5,5,4,5,4,4,4], col:C.red,   delta:'down', d:'-1 this week' }
9284      ],
9285      queue:[
9286        { ic:IC.alert, col:C.red,   n:4, t:'items below reorder level',  pill:'Reorder', pc:'red',   href:'/inventory.html?__sec=reorder' },
9287        { ic:IC.box,   col:C.amber, n:7, t:'picks to complete by 3pm',   pill:'Pick',    pc:'amber', href:'/warehouse.html?__sec=ship' },
9288        { ic:IC.rma,   col:C.blue,  n:1, t:'RMA return inbound',         pill:'RMA',     pc:'blue',  href:'/warehouse.html?__sec=receiving' }
9289      ],
9290      links:[
9291        { ic:IC.cart,  name:'Ship Today',     sub:'Outbound queue',       href:'/warehouse.html?__sec=ship' },
9292        { ic:IC.box,   name:'Stock and Bins', sub:'Locations and levels', href:'/warehouse.html?__sec=stock' },
9293        { ic:IC.inbox, name:'Shipping',       sub:'Labels and carriers',  href:'/shipping.html' }
9294      ]
9295    },
9296    finance: {
9297      kpis:[
9298        { ic:IC.receipt, lbl:'Invoices To Raise', val:'8', spark:[5,6,6,7,7,8,8,8], col:C.blue, delta:'up', d:'+2 from shipments' },
9299        { cls:'amber', ic:IC.alert, lbl:'Overdue Receivables', val:'$86K',   spark:[110,105,100,98,95,92,88,86], col:C.amber, delta:'down', d:'-$12K this month' },
9300        { cls:'violet',ic:IC.so,    lbl:'Bills To Approve',    val:'5',      spark:[3,4,4,5,4,5,5,5],           col:C.violet, delta:'up',   d:'+1 today' },
9301        { cls:'green', ic:IC.check, lbl:'Cash This Month',     val:'$1.12M', spark:[0.7,0.8,0.85,0.9,1.0,1.05,1.1,1.12], col:C.green, delta:'up', d:'+6% vs plan' }
9302      ],
9303      queue:[
9304        { ic:IC.so,      col:C.blue,   n:5, t:'supplier bills awaiting approval', pill:'Approve', pc:'blue',   href:'/operations.html?__sec=dashboard' },
9305        { ic:IC.alert,   col:C.amber,  n:3, t:'overdue invoices to chase',        pill:'Chase',   pc:'amber',  href:'/reports.html?__sec=overview' },
9306        { ic:IC.receipt, col:C.violet, n:1, t:'payroll export pending',           pill:'Payroll', pc:'violet', href:'/people.html?__sec=clock' }
9307      ],
9308      links:[
9309        { ic:IC.receipt, name:'Reports',   sub:'Company reporting',  href:'/reports.html?__sec=overview' },
9310        { ic:IC.check,   name:'Approvals', sub:'Ops approvals queue',href:'/operations.html?__sec=dashboard' },
9311        { ic:IC.clock,   name:'People',    sub:'Clock and leave',    href:'/people.html?__sec=clock' }
9312      ]
9313    }
9314  };
9315
9316  var MY_CUR='adam'; // demo default - Adam is the house "me" (matches people.html's fallback)
9317  var MY_PREVIEW=null; // non-null when the Preview-as select overrides the session identity
9318  // Normalise the shim session (supabase-shaped) to {email,name,admin}. Null until resolved.
9319  function currentUser(){
9320    try {
9321      var u = window.quasarAuth && typeof window.quasarAuth.currentUser==='function' ? window.quasarAuth.currentUser() : null;
9322      if(!u) return null;
9323      return { email:u.email||null,
9324               name:(u.user_metadata&&u.user_metadata.name)||null,
9325               admin:!!(u.user_metadata&&u.user_metadata.is_admin) };
9326    } catch(e){ return null; }
9327  }
9328  // Map a session to one of the 17 canonical staff keys (emails are
9329  // <firstname>@safiery.com; Adam is adamd@). Null for Demo User / unknown.
9330  function personForUser(u){
9331    if(!u) return null;
9332    var local=((u.email||'').split('@')[0]||'').toLowerCase(), n=(u.name||'').toLowerCase();
9333    var k, found=null;
9334    for(k in MY_PEOPLE){ if(local.indexOf(k)===0 || n.indexOf(k)>=0){ found=k; break; } }
9335    return found;
9336  }
9337
9338  // ── Company dashboard gate (HQ-417/HQ-857) ──
9339  // Demo mode keeps the original client-side, FAIL-OPEN posture below
9340  // unchanged (it never shows real financials - see dashDemo() call sites
9341  // throughout this file). Real mode below is a REAL role check against the
9342  // signed-in user's auth-user roles, and it FAILS CLOSED: unresolved/unknown
9343  // => not management => My Dashboard, never the company tiles. This is UI
9344  // disclosure hygiene, not the security boundary - every underlying read is
9345  // still server-gated. HQ-857 makes the Access Group authoritative: a legacy
9346  // Sales role cannot expose Company Overview after Sales has been removed from
9347  // that person's group.
9348  // Cached real-verdict resolver. Memoised: _dashAuth is the resolved
9349  // auth-user `.user` payload (id/email/is_admin/role/roles[] - matches
9350  // _auth-user_impl.cjs's response shape) once known, else null.
9351  var _dashAuth=null, _dashAuthP=null;
9352  function fetchDashAuth(){
9353    if(_dashAuth) return Promise.resolve(_dashAuth);
9354    if(!_dashAuthP){
9355      // Sync pre-paint verdict: quasar-nav.js mirrors the SAME auth-user
9356      // payload (id/email/is_admin/role/roles[]) into this session-storage key
9357      // on every page (see quasar-nav.js's applyChipUser caller) - verified
9358      // against _auth-user_impl.cjs's response shape before relying on it here.
9359      // A same-tab navigation (the common case) already has this mirror by the
9360      // time this script runs, so the gate does not have to wait on a fresh
9361      // round trip. The fetch below still runs and is authoritative - a
9362      // stale/wrong mirror self-corrects within one round trip.
9363      try{
9364        var mir=JSON.parse(sessionStorage.getItem('quasar.session.v1')||'null');
9365        if(mir && mir.u && mir.u.id!=null) _dashAuth=mir.u;
9366      }catch(e){}
9367      _dashAuthP=fn('auth-user').then(function(r){ return r.ok?r.json():null; })
9368        .then(function(j){ _dashAuth=(j&&j.user)||null; return _dashAuth; })
9369        .catch(function(){ return null; });
9370    }
9371    return _dashAuth ? Promise.resolve(_dashAuth) : _dashAuthP;
9372  }
9373  // Named per-user authorities are authoritative when present. The fallback keeps
9374  // older auth-user/session mirrors usable only until they begin carrying booleans.
9375  function dashNamedAuthority(user,key,legacyFallback){
9376    var authorities=user&&user.authorities;
9377    if(authorities&&typeof authorities[key]==='boolean')return authorities[key];
9378    return typeof legacyFallback==='function'?!!legacyFallback():false;
9379  }
9380  // FAIL-CLOSED: null/unresolved auth => not management.
9381  function isMgmtUser(au){
9382    if(!au) return false;
9383    if(au.is_admin) return true;
9384    return !!(window.QACL && typeof window.QACL.canTab==='function' && window.QACL.canTab('sales'));
9385  }
9386  // uid-scoped per-user "home dashboard" preference (HQ-417). localStorage
9387  // (device-local); server-side persistence is HQ-421. Never read/written
9388  // while the uid is unknown - dashHomeKey() returns null and every caller
9389  // treats that as "no preference".
9390  function dashHomeKey(){
9391    return (_dashAuth && _dashAuth.id!=null) ? ('quasar.dash.home.'+_dashAuth.id) : null;
9392  }
9393  function dashHomePref(){
9394    var key=dashHomeKey(); if(!key) return null;
9395    try{ var v=localStorage.getItem(key); return (v==='overview'||v==='mydash') ? v : null; }catch(e){ return null; }
9396  }
9397  // setDashHomePref removed (HQ-520 merge round): its only callers were the
9398  // ph-/mb-sethome controls round 3 replaced with the avatar-menu item, which
9399  // writes the same key itself in quasar-nav.js. dashHomePref below still READS it.
9400  function dashHasExplicitSec(){
9401    try{ return /[?&]__sec=/.test(location.search||''); }catch(e){ return false; }
9402  }
9403  // ── Demo-grade gate (unchanged from before HQ-417; client-side, fail-open -
9404  //    NOT a security boundary; real enforcement stays server-side per QACL's
9405  //    model) ── Management set (HQ-282 titles: is_admin OR Director/
9406  //    Managing/…Manager) = the mgmt:true entries in MY_PEOPLE (bruce/adam/
9407  //    jonathan/casey/sammera/david) - single source of truth. Demo-grade +
9408  //    FAIL-OPEN (same posture as quasar-acl.js): an unresolved or unknown
9409  //    session sees the full management view rather than being locked out.
9410  function isManagementDemo(u){
9411    if(!u) return true;              // fail open while the session resolves
9412    if(u.admin) return true;         // is_admin from auth-user via the shim
9413    var k=personForUser(u);
9414    return k ? !!MY_PEOPLE[k].mgmt : true; // unknown user: fail open
9415  }
9416  // HQ-417: forks on mode. Demo mode's behaviour (fail-open, MY_PEOPLE-keyed)
9417  // is untouched - it never shows real financials. Real mode is the REAL,
9418  // fail-closed role check against the resolved auth-user (_dashAuth), never
9419  // the demo fail-open path.
9420  function isManagement(u){
9421    if(dashDemo()) return isManagementDemo(u);
9422    return isMgmtUser(_dashAuth);
9423  }
9424  function applyDashGate(u){
9425    var mgmt=isManagement(u);
9426    applyQuickNewGate();  // HQ-519: re-evaluate the +New role filter on every pass too
9427    try{ sessionStorage.setItem(DASH_MGMT_KEY, mgmt?'1':'0'); }catch(e){}
9428    if(mgmt){
9429      // Company reads begin only after the signed-in Access Group confirms Sales.
9430      // A staff account therefore never downloads hidden company dashboard data.
9431      if(!window.__dashCompanyRendered){ window.__dashCompanyRendered=true; renderDashboard(); }
9432      // HQ-417: per-user home-dashboard landing, applied ONCE per page load so
9433      // it never fights a manual click later (the timed sessionSync passes
9434      // below re-run this gate several times as auth resolves). An explicit
9435      // ?__sec= is honoured elsewhere (the nav's render helper + showSection's
9436      // staff guard already own that) - never overridden by a stored pref.
9437      // Also corrects the init()-level fail-closed default (a cold tab lands
9438      // on My Dashboard before this resolves) back to Adam's stated default
9439      // ('mydash' pref wins; absent/'overview' restores Company Overview).
9440      if(!window.__dashHomeApplied && !dashDemo()){
9441        window.__dashHomeApplied = true;
9442        if(!dashHasExplicitSec()) showSection(dashHomePref()==='mydash' ? 'mydash' : 'overview');
9443      }
9444      // Heal a stale staff verdict from an earlier session in this tab: the
9445      // parse-time pre-gate already removed the Overview button and quasar-nav
9446      // built the gated sidebar, so a manager would be locked out of Overview
9447      // until reload. One-shot reload with the corrected cache ('1' was just
9448      // written above) restores the full chrome; the guard prevents loops
9449      // within a page load, and reloading only when the '1' actually persisted
9450      // prevents a cross-reload loop (reads returning a stale '0' while writes
9451      // silently fail would otherwise re-trigger the pre-gate every load).
9452      // HQ-1410 (Adam, 2026-09-27, as Donovan: "i can not exit either"): the Access Group
9453      // hide (applyMainSectionVisibility, HQ-1222/HQ-1364) removes the same Overview
9454      // button ON PURPOSE - that is not a stale cache, so never "heal" it. It used to
9455      // reload, the hide removed the button again, and it reloaded forever (tab frozen,
9456      // impersonation Exit dead). The reload is also capped at once per 30s per tab via
9457      // sessionStorage, because window.__dashGateReloaded resets on every reload.
9458      if(!document.querySelector('#subtabs [data-sec="overview"]') && !window.__dashGateReloaded && !mainOverviewHiddenByGroup){
9459        window.__dashGateReloaded = true;
9460        var stuck=false, recent=false;
9461        try{ stuck = sessionStorage.getItem(DASH_MGMT_KEY)==='1'; }catch(e){}
9462        try{ recent = (Date.now() - Number(sessionStorage.getItem(DASH_HEAL_RELOAD_KEY)||0)) < 30000; }catch(e){}
9463        if(stuck && !recent){
9464          try{ sessionStorage.setItem(DASH_HEAL_RELOAD_KEY, String(Date.now())); }catch(e){}
9465          location.reload(); return;
9466        }
9467        // Write did not persist: heal in-page instead of reloading - re-insert
9468        // the Overview subtab the pre-gate removed and unhide the sidebar item.
9469        var st=document.getElementById('subtabs');
9470        if(st && !st.querySelector('[data-sec="overview"]')){
9471          var ob=document.createElement('button');
9472          ob.type='button'; ob.className='subtab';
9473          ob.setAttribute('data-sec','overview'); ob.textContent='Company Overview';
9474          ob.addEventListener('click', function(){ showSection('overview'); });
9475          st.insertBefore(ob, st.firstChild);
9476        }
9477        Array.prototype.forEach.call(document.querySelectorAll('.qside-subs .qside-sub'), function(a){
9478          if((a.textContent||'').trim()==='Company Overview') a.style.display='';
9479        });
9480      }
9481      return; // management: landing already decided above (home-pref block)
9482    }
9483    // Staff: remove the Overview subtab (also neutralises ?__sec=overview -
9484    // the nav helper's repeated clicks no-op once the button is gone) …
9485    var ob=document.querySelector('#subtabs [data-sec="overview"]');
9486    if(ob) ob.remove();
9487    // … hide the already-built sidebar sub-item (nav snapshots at build time) …
9488    Array.prototype.forEach.call(document.querySelectorAll('.qside-sub
9488s .qside-sub'), function(a){
9489      if((a.textContent||'').trim()==='Company Overview') a.style.display='none';
9490    });
9491    // … and land on My Dashboard unless it is already up.
9492    var on=document.querySelector('#subtabs [data-sec="mydash"].on');
9493    if(!on) showSection('mydash');
9494  }
9495
9496  // ── Clock + leave localStorage (quasar.* convention) ───────────────
9497  // HONESTY: people.html's clock is in-memory demo state and does NOT read
9498  // quasar.clock.v1 yet - this is a one-way mirror. HANDOFF(people.html):
9499  // adopt quasar.clock.v1 / quasar.leave.v1 reads so the two clocks agree.
9500  // The clock is device-local "my" state: Preview-as does NOT reset it.
9501  function clockState(){
9502    try{
9503      var v=JSON.parse(localStorage.getItem('quasar.clock.v1')||'null');
9504      if(v && (v.state==='in'||v.state==='out') && v.since) return v;
9505    }catch(e){}
9506    // Seed-and-persist: staff are already clocked in at 07:56 today (mirrors
9507    // people.html's attendance_today idiom).
9508    var d=new Date(); d.setHours(7,56,0,0);
9509    var seed={state:'in', since:d.toISOString(), offsite:null};
9510    writeClock(seed);
9511    return seed;
9512  }
9513  function writeClock(st){ try{ localStorage.setItem('quasar.clock.v1', JSON.stringify(st)); }catch(e){} }
9514  function leaveList(){ try{ var a=JSON.parse(localStorage.getItem('quasar.leave.v1')||'[]'); return Array.isArray(a)?a:[]; }catch(e){ return []; } }
9515  function writeLeave(a){ try{ localStorage.setItem('quasar.leave.v1', JSON.stringify(a)); }catch(e){} }
9516  function fmtClock(iso){
9517    var d=new Date(iso); if(isNaN(d)) return '—';
9518    var h=d.getHours(), m=d.getMinutes(), ap=h>=12?'PM':'AM'; h=h%12||12;
9519    return h+':'+(m<10?'0':'')+m+' '+ap;
9520  }
9521  function fmtHMS(ms){
9522    if(!(ms>=0)) ms=0;
9523    var t=Math.floor(ms/1000), h=Math.floor(t/3600), m=Math.floor((t%3600)/60), sec=t%60;
9524    return h+':'+(m<10?'0':'')+m+':'+(sec<10?'0':'')+sec;
9525  }
9526
9527  // Today card: live-call banner (support.html's quasar.activeWork, read-only
9528  // here) + clock in/out + apply-for-leave quick action.
9529  function renderToday(me){
9530    var root=$('mb-today'); if(!root) return;
9531    var h='';
9532    try{
9533      var aw=JSON.parse(localStorage.getItem('quasar.activeWork')||'null');
9534      if(aw && aw.type==='call'){
9535        h+='<a class="mc-live" href="/support.html?__sec=cases">'+svg(IC.headset)+'On a call · '+(aw.ref||'')+(aw.gold?' · VIP':'')+'</a>';
9536      }
9537    }catch(e){}
9538    var ck=clockState(), isIn=(ck.state==='in');
9539    h+='<div class="mc-state'+(isIn?'':' out')+'"><span class="fdot-lg"></span>'
9540      +(isIn?('On site · clocked in '+fmtClock(ck.since)):('Clocked out · '+fmtClock(ck.since)))+'</div>';
9541    h+='<div class="mc-timer" id="mc-timer">'+(isIn?fmtHMS(Date.now()-Date.parse(ck.since)):'—')+'</div>';
9542    h+='<div class="mc-btns">'
9543      +(isIn?('<button class="mc-btn big clock-out" id="mc-out">'+svg(IC.clock)+'Clock out</button>')
9544            :('<button class="mc-btn big clock-in" id="mc-in">'+svg(IC.clock)+'Clock in</button>'))
9545      +'<button class="mc-btn outline" id="mc-leave">Apply for leave</button></div>';
9546    // HQ-523 verify panel: with the demo My Leave card deleted, a submitted demo
9547    // request landed in quasar.leave.v1 and was visible NOWHERE - the toast said
9548    // "sent" and the flow appeared to do nothing. The pending rows render here
9549    // now, under the same buttons that created them.
9550    var demoPend=leaveList().filter(function(r){ return r.person_key===MY_CUR && r.status==='pending'; });
9551    demoPend.slice(0,3).forEach(function(r){
9552      h+='<div class="fs-row"><span class="fic">'+svg(IC.cal)+'</span>'
9553        +'<span class="fid">'+r.from_date+'</span>'
9554        +'<span class="fname">'+(({annual:'Annual',long_service:'Long service',lwp:'LWP',tafe:'TAFE'})[r.type]||r.type)+' · '+r.days+'d leave</span>'
9555        +'<span class="ftime">pending (demo)</span></div>';
9556    });
9557    h+='<div class="mc-note">Clock writes are server-gated (clock-act) - demo only. Full timesheet in People (one-way mirror: People does not read this key yet).</div>';
9558    root.innerHTML=h;
9559    // Elapsed tick - re-entrant-safe (renderMyDash re-runs): clear then re-arm,
9560    // guard on the element. Keeps ticking while Overview is shown - accepted.
9561    clearInterval(window.__mydashTick);
9562    if(isIn){
9563      window.__mydashTick=setInterval(function(){
9564        var t=$('mc-timer'); if(!t) return;
9565        t.textContent=fmtHMS(Date.now()-Date.parse(ck.since));
9566      },1000);
9567    }
9568    var bo=$('mc-out');
9569    if(bo) bo.addEventListener('click', function(){
9570      var elapsed=Date.now()-Date.parse(ck.since);
9571      var hh=Math.floor(elapsed/3600000), mm=Math.floor((elapsed%3600000)/60000);
9572      openModal({ icon:IC.clock, title:'Clock out', sub:'End of day (demo)',
9573        infoRows:[['Clocked in',fmtClock(ck.since)],['Elapsed',hh+' h '+(mm<10?'0':'')+mm+' m'],['Unpaid break','0.5 h auto-applied']],
9574        saveLabel:'Confirm clock out', cancelLabel:'Cancel',
9575        onSave:function(){
9576          if(!dashDemo()){ toast('Clock out is not wired to the server yet.','warn'); return; }
9577          writeClock({state:'out',since:new Date().toISOString(),offsite:null});
9578          toast('Clocked out (demo)','good');
9579          renderMyDash();
9580        } });
9581    });
9582    var bi=$('mc-in');
9583    if(bi) bi.addEventListener('click', function(){
9584      if(!dashDemo()){ toast('Clock in is not wired to the server yet.','warn'); return; }
9585      writeClock({state:'in',since:new Date().toISOString(),offsite:null});
9586      toast('Clocked in (demo)','good');
9587      renderMyDash();
9588    });
9589    var bl=$('mc-leave');
9590    if(bl) bl.addEventListener('click', function(){ openLeaveModal(me, MY_CUR); });
9591  }
9592
9593  // Leave writes are server-gated (leave-act) - demo localStorage only;
9594  // people.html?__sec=leave is the full view. Sick leave is deliberately NOT
9595  // offered here - people.html's sick flow owns the Mon/Fri certificate rule.
9596  function openLeaveModal(me, key){
9597    openModal({ icon:IC.cal, title:'Apply for leave', sub:'Request goes to '+me.approver+' (demo)',
9598      fields:[
9599        {key:'type',label:'Type',type:'select',required:true,options:[{value:'annual',label:'Annual leave'},{value:'lwp',label:'Leave without pay'},{value:'tafe',label:'TAFE / study'}]},
9600        {key:'from',label:'First day',type:'date',required:true},
9601        {key:'to',label:'Last day',type:'date',required:true},
9602        {key:'note',label:'Note (optional)',type:'textarea',placeholder:'Reason or handover notes',hint:'Sick day? Log it in People so the Mon/Fri certificate rule applies.'}
9603      ],
9604      saveLabel:'Send request',
9605      onSave:function(vals){
9606        if(!dashDemo()){ toast('Leave requests are not wired to the server yet.','warn'); return; }
9607        if(vals.to < vals.from){ toast('Last day is before first day','warn'); openLeaveModal(me, key); return; }
9608        var days=0, d=new Date(vals.from+'T00:00:00'), end=new Date(vals.to+'T00:00:00');
9609        while(d<=end){ var dow=d.getDay(); if(dow!==0&&dow!==6) days++; d.setDate(d.getDate()+1); }
9610        var arr=leaveList();
9611        arr.unshift({ id:'demo-lv-web-'+Date.now(), person:me.name, person_key:key, type:vals.type,
9612          from_date:vals.from, to_date:vals.to, days:days, status:'pending',
9613          approver:me.approver, logged_at:new Date().toISOString() });
9614        writeLeave(arr);
9615        toast('Leave request sent to '+me.approver+' (demo)','good');
9616        renderMyDash();
9617      } });
9618  }
9619
9620  // HQ-523 round 2: the demo My Leave card (renderLeaveCard) is deleted with the
9621  // card itself - demo's Today card keeps its own Apply-for-leave button
9622  // (openLeaveModal above) AND renders the resulting pending rows (see
9623  // renderToday's demoPend block), so a submitted demo request stays visible.
9624
9625  // The promoted My Dashboard: welcome bar (Viewing menu - yourself + your
9626  // reports, everyone in demo), personalised KPIs, Today card, assigned work,
9627  // approvals, deep links.
9628  // Real-mode My Dashboard (HQ-331: Adam - "welcome back doesn't show at all and the my
9629  // team option doesn't even show"). Every surface with a LIVE source renders from it:
9630  // identity (auth-user / the session mirror), My team (the live roster), Today (the
9631  // gated clock's real state, read-only - the clock itself lives in People), My Leave
9632  // (leave_balances) and the queue (pending leave requests, admins). Surfaces with no
9633  // real source yet stay an honest note - never the demo's seeded staff.
9634  // HQ-452: _mdBal / _mdPendingLeave are gone - they were generic shim reads of
9635  // leave_balances / leave_requests, which had NO server table at all (UNKNOWN_TABLE
9636  // every time, which the shim RESOLVES rather than rejects, so both cards silently
9637  // settled on their empty branch and looked like real "nothing on file" answers).
9638  // Both tables exist now and are gatedRead, so leave-act's summary/pending are the
9639  // only read paths - see _mdLeave / _mdLeaveQueue.
9640  var _mdFetched=false, _mdMe=null, _mdRoster=null, _mdClock=null, _mdDir=null;
9641  // HQ-423: personal spark series (audit/mail/notifications 14-day buckets +
9642  // this week's bookings) - fetched once per page load, gated separately from
9643  // _mdFetched above because it needs u.id (auth may resolve after first paint).
9644  var _mdSeries={act:null,mail:null,needs:null}, _mdSeriesFetched=false;
9645  // HQ-417: Mail + Needs-me are self-contained (their own memo flag each, no
9646  // shared state) so HQ-421 can lift either one into its own widget later.
9647  // _mdMailN/_mdNeedsN (HQ-423) hold the last-known counts so the mb-kpis
9648  // tiles can repaint reactively when either count arrives.
9649  var _mdMailFetched=false, _mdNeedsFetched=false, _mdMailN=null, _mdNeedsN=null;
9650  // HQ-519 (Adam: "Mail could just show the list ... needs me needs to be a
9651  // list"): the actual rows behind each count, fetched once and separately from
9652  // the count above so a slow rows-read never blocks the figure from painting.
9653  var _mdMailRows=null, _mdMailRowsFetched=false, _mdNeedsRows=null, _mdNeedsRowsFetched=false;
9654  // HQ-523: one shared clock-status read, used by the first paint AND the 60s
9655  // tick. HQ-423's DST-correct local-week idiom (same as people.html): tz +
9656  // tz_offset_minutes let clock-act's WEEK_SQL_TZNAME path bucket "this week"
9657  // by the caller's own local midnight-to-midnight, not the DB session's UTC day.
9658  // Verify-panel fix: the tick must NOT repaint the whole dashboard on every
9659  // poll - a full renderMyDash rebuilds mydash-hdr and destroyed an open "My
9660  // team" menu (and any keyboard focus) up to once a minute. When the status
9661  // JSON is unchanged, only the clock surfaces repaint (renderMbToday +
9662  // renderMbKpis - the live counting Adam asked for), and even that is skipped
9663  // while focus sits inside the Today card so a half-armed withdraw row is
9664  // never yanked away; a genuine state change (clock-in/out landed) still does
9665  // the full repaint, because every card may care.
9666  var _mdClockRaw=null, _mdAttendance=null, _mdClockPending=null;
9667  function mdClockFetch(){
9668    if(_mdClockPending)return _mdClockPending;
9669    // Match the employee app's Brisbane day and Thursday-Wednesday totals,
9670    // including when this desktop is opened from another timezone.
9671    _mdClockPending=fn('clock-act',{method:'POST',body:{action:'status',tz:'Australia/Brisbane',tz_offset_minutes:600}})
9672      .then(function(r){ return r.ok?r.json():null; })
9673      .then(function(j){
9674        if(!(j&&j.ok&&Object.prototype.hasOwnProperty.call(j,'open')&&Array.isArray(j.today)&&Array.isArray(j.week)))throw new Error('Attendance could not be refreshed. Check your connection and try again.');
9675        var raw=''; try{ raw=JSON.stringify(j); }catch(_){ raw=null; }
9676        var changed=(raw===null || raw!==_mdClockRaw);
9677        _mdClockRaw=raw; _mdClock=j;
9678        if(_mdAttendance)_mdAttendance.update(j);
9679        if(changed){ renderMyDash(); return j; }
9680        var tdEl=document.getElementById('mb-today');
9681        if(tdEl && tdEl.contains(document.activeElement)) return j;
9682        renderMbToday();
9683        renderMbKpis();
9684        return j;
9685      })
9686      .catch(function(){
9687        _mdClock=null; _mdClockRaw=null;
9688        if(_mdAttendance)_mdAttendance.update(null,'Attendance could not be refreshed. Check your connection and try again.');
9689        renderMbToday(); renderMbKpis(); return null;
9690      }).finally(function(){_mdClockPending=null;});
9691    return _mdClockPending;
9692  }
9693  function openBellPanel(){ var b=document.querySelector('.qtopbar-bell'); if(b) b.click(); }
9694  function initialsOfName(n){ var p=String(n||'').trim().split(/\s+/).filter(
9694Boolean); if(!p.length) return '·'; return (p[0].charAt(0)+(p.length>1?p[p.length-1].charAt(0):'')).toUpperCase(); }
9695  // Admin "log in as" (HQ impersonation): POST impersonate-act start, then reload -
9696  // the server swapped the HttpOnly qjwt cookie so the whole app comes back as the
9697  // target. is_admin is the client affordance gate; the server is the real gate, so a
9698  // refusal is surfaced verbatim. The persistent Exit banner (quasar-nav.js) returns.
9699  function startImpersonation(targetId, btnEl){
9700    if(btnEl){ btnEl.disabled=true; btnEl.textContent='Switching…'; }
9701    fn('impersonate-act',{method:'POST',body:{action:'start',target_user_id:targetId}})
9702      .then(function(r){ return r.json().catch(function(){ return null; }); })
9703      .then(function(j){
9704        if(j && j.data && j.data.impersonating){ location.reload(); return; }
9705        var m=(j && j.error && j.error.message) || 'Could not log in as that user.';
9706        toast(m,'warn');
9707        if(btnEl){ btnEl.disabled=false; btnEl.textContent='Log in as'; }
9708      })
9709      .catch(function(){
9710        toast('Could not log in as that user - network error.','warn');
9711        if(btnEl){ btnEl.disabled=false; btnEl.textContent='Log in as'; }
9712      });
9713  }
9714  // ══ HQ-452: My Dashboard - real content behind the graphs ═══════════════════
9715  // Adam, 2026-07-25: "Now these graphys do me no good so lets put a little bit
9716  // more infomation here, so uses can actually click adn then have view option,
9717  // then the clock option with there current hours for that day should show there".
9718  // So: every card now leads with the NUMBER that matters, keeps the spark strip as
9719  // context underneath, and carries a "View" link that opens the centred detail
9720  // modal with the actual rows behind it.
9721
9722  // Bookings assigned to the signed-in person inside the PICKED range. Memoised per
9723  // range key; every entry_type stays available so the detail view can show leave and
9724  // toolbox blocks too, while the card count stays "real work" only (MD_BOOK_TYPES).
9725  var _mdBookRangeP={}, _mdBookRows={};
9726  function mdBookingsFor(rangeKey){
9727    var k=rangeKey||'This Week';
9728    if(_mdBookRangeP[k]) return _mdBookRangeP[k];
9729    var u=_mdMe;
9730    if(!u || u.id==null || !sb){
9731      // No identity yet: answer empty for THIS call but do NOT memoise it, or the
9732      // first pre-auth call would cache an empty result under the range key and the
9733      // card could never recover once auth resolved. The one call site is already
9734      // guarded on u.id, so this is belt and braces for any future caller.
9735      _mdBookRows[k]=[];
9736      return Promise.resolve([]);
9737    }
9738    var W=dashRangeWindow(k);
9739    // The WHOLE picked period (W.toFull), not period-to-date - the same rule dashJobsFor
9740    // already applies for exactly the same reason: a schedule contains FUTURE work, so reading
9741    // to-date hides a booking made for later this week. HQ-452 fixed this on the jobs read and
9742    // missed the sibling here (found by the HQ-459 post-merge review); a bookings tile that
9743    // cannot show tomorrow's booking is not a bookings tile.
9744    // linked_record_type/id ride along (HQ-523 round 4) so a field-service row
9745    // can deep-link to its job and a production row can name its order.
9746    _mdBookRangeP[k]=sb.from('calendar_entries').select('id,title,starts_at,ends_at,entry_type,status,linked_record_type,linked_record_id')
9747      .eq('person_id',u.id).is('deleted_at',null)
9748      .gte('starts_at',W.from).lt('starts_at',W.toFull)
9749      .order('starts_at',{ascending:true}).limit(200)
9750      .then(function(r){ _mdBookRows[k]=(r&&r.data)||[]; renderMyDash(); return _mdBookRows[k]; },
9751            function(){ _mdBookRows[k]=[]; renderMyDash(); return []; });
9752    return _mdBookRangeP[k];
9753  }
9754  // The five entry_types that are actually WORK. calendar_entries' CHECK also covers
9755  // leave / lunch / training / public holiday, and counting those as "bookings" would
9756  // tell someone on annual leave they have five jobs (HQ-423's original fix).
9757  var MD_BOOK_TYPES=['field_service_job','support_appointment','production_order','warehouse_job','line_shift'];
9758  function mdBookRowsFor(rangeKey){ return _mdBookRows[rangeKey||'This Week']||null; }
9759  // Hours from a set of clock entries (today's or the week's) - the same arithmetic
9760  // as mdClockHours, summed rather than bucketed: an open entry counts up to now,
9761  // break_minutes comes off, never negative.
9762  function mdHoursOf(rows){ return mdHoursSince(rows, null); }
9763  // As above, but only the part of each entry that falls AFTER `fromMs` (a local-day
9764  // boundary). An entry that opened yesterday and is still running contributes only
9765  // today's slice, so "hours today" agrees with the "on the clock since" line beside
9766  // it. fromMs null = the whole entry.
9767  function mdHoursSince(rows, fromMs){
9768    var total=0, nowMs=Date.now();
9769    (rows||[]).forEach(function(r){
9770      if(!r || !r.clock_in_at) return;
9771      var inT=Date.parse(r.clock_in_at); if(isNaN(inT)) return;
9772      var outT=r.clock_out_at?Date.parse(r.clock_out_at):nowMs;
9773      if(fromMs!=null && inT<fromMs) inT=fromMs;
9774      if(!(outT>inT)) return;
9775      total+=Math.max(0,(outT-inT)/3600000-((Number(r.break_minutes)||0)/60));
9776    });
9777    return total;
9778  }
9779  function mdTime(iso){
9780    var t=Date.parse(iso); if(isNaN(t)) return '';
9781    return new Date(t).toLocaleTimeString('en-AU',{hour:'numeric',minute:'2-digit'});
9782  }
9783  function mdDate(iso){
9784    var t=Date.parse(iso); if(isNaN(t)) return '';
9785    return new Date(t).toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short'});
9786  }
9787  function mdTypeLabel(s){ return String(s||'').replace(/_/g,' ').replace(/^\w/,function(c){return c.toUpperCase();}); }
9788
9789  // ── Leave: the real store (HQ-452) ────────────────────────────
9790  // Adam: "for the my leave have avaliable option and then any requests they have
9791  // put in on if they are pending or not." Before this round leave_requests and
9792  // leave_balances did NOT EXIST on the server - they were demo fixtures in
9793  // people.html - so this card could only ever be empty in real mode. The new
9794  // leave-act endpoint owns them: both tables are gatedRead AND gatedWrite, so
9795  // there is deliberately no generic db read path and everything comes from here.
9796  //   summary -> { balance, requests (the caller's own), approvals_pending }
9797  //   pending -> { requests } the approver's queue (approver roles only)
9798  var _mdLeave=null, _mdLeaveFetched=false, _mdLeaveQueue=null, _mdLeaveQueueFetched=false;
9799  var LEAVE_TYPE_LABELS={ annual:'Annual', personal:'Personal / Carer\'s', long_service:'Long service',
9800    lwp:'Leave without pay', tafe:'TAFE', unpaid:'Unpaid' };
9801  function leaveTypeLabel(t){ return LEAVE_TYPE_LABELS[t] || mdTypeLabel(t); }
9802  // HQ-140 (Adam, 2026-09-26: "long service should not be in this leave drop down list").
9803  // The types a person can REQUEST here. Long service stays in LEAVE_TYPE_LABELS so an
9804  // existing long service request still reads correctly on the card and in the queue.
9805  var LEAVE_REQUEST_TYPES=['annual','personal','lwp','tafe','unpaid'];
9806  // HQ-140 (Adam, 2026-09-26: "write it out like 06/09/26 as thats the formate we use").
9807  // A leave date is a plain 'YYYY-MM-DD' calendar day, so it is reshaped as text - never
9808  // through Date, which would shift it across the timezone line.
9809  function leaveDate(ymd){
9810    var m=/^(\d{4})-(\d{2})-(\d{2})$/.exec(String(ymd||''));
9811    return m ? m[3]+'/'+m[2]+'/'+m[1].slice(2) : String(ymd||'');
9812  }
9813  // Status -> an EXISTING .pill class. No new colour: these are the same status pills
9814  // the CRM stages use (quasar-ui.css .pill.st-*), mapped by meaning - pending reads as
9815  // in-flight, endorsed (HQ-525: the manager's sign-off, awaiting payroll's final one)
9816  // as still in discussion, approved as won, declined as lost, cancelled as paused.
9817  function leaveStatusPill(st){
9818    var cls={ pending:'st-negotiating', endorsed:'st-discussion', approved:'st-customer', declined:'st-lost', cancelled:'st-paused' }[st] || 'st-paused';
9819    return '<span class="pill '+cls+'">'+escapeHtml(mdTypeLabel(st))+'</span>';
9820  }
9821  function mdLeaveLoad(){
9822    if(_mdLeaveFetched || dashDemo()) return;
9823    _mdLeaveFetched=true;
9824    dashAct('leave-act',{action:'summary'}).then(function(j){
9825      // A refusal is remembered as an explicit error state, so the card can say what
9826      // went wrong instead of silently showing "no balance on file".
9827      _mdLeave=(j && j.data) ? j.data : { error:dashRefusal(j,'Leave is unavailable right now.') };
9828      renderMyDash();
9829    });
9830  }
9831  function mdLeaveQueueLoad(){
9832    if(_mdLeaveQueueFetched || dashDemo()) return;
9833    // Only worth asking once summary has told us this caller HAS an approver queue -
9834    // otherwise every staff member fires a request the server will refuse.
9835    if(!(_mdLeave && _mdLeave.approvals_pending!=null)) return;
9836    _mdLeaveQueueFetched=true;
9837    dashAct('leave-act',{action:'pending'}).then(function(j){
9838      _mdLeaveQueue=(j && j.data && Array.isArray(j.data.requests)) ? j.data.requests : [];
9839      renderMyDash();
9840    });
9841  }
9842  // Apply for leave: a real gated write. The modal stays open on a refusal with the
9843  // server's own missing-field list (workflows-and-gates.md), and the card refetches
9844  // on success so the new pending row appears without a page reload.
9845  function openLeaveApply(){
9846    openModal({ icon:IC.cal, title:'Request leave', sub:'Your manager endorses first; Finance gives final confirmation',
9847      fields:[
9848        {key:'leave_type',label:'Leave type',type:'select',required:true,
9849          options:LEAVE_REQUEST_TYPES.map(function(k){ return {value:k,label:LEAVE_TYPE_LABELS[k]}; })},
9850        {key:'start_date',label:'First day',type:'date',required:true},
9851        {key:'end_date',label:'Last day',type:'date',required:true},
9852        {key:'start_time',label:'Leave start (Brisbane, optional)',type:'time'},
9853        {key:'end_time',label:'Leave finish (Brisbane, optional)',type:'time'},
9854        {key:'reason',label:'Reason',type:'textarea',placeholder:'Optional',
9855          hint:'Without leave times, HQ counts every Monday-to-Friday day in the range. Timed leave covers one date and its hours are calculated from start to finish. Your reason stays private.'}
9856      ],
9857      saveLabel:'Submit request', cancelLabel:'Cancel',
9858      onSubmit:function(v, ui){
9859        ui.busy('Submitting your request…');
9860        var timing=v.start_time||v.end_time?QuasarLeavePolicy.timing(v.start_time,v.end_time,v.start_date,v.end_date):{};
9861        if(timing.error){ui.error(timing.error);return;}
9862        var body={ action:'apply', leave_type:v.leave_type, start_date:v.start_date, end_date:v.end_date };
9863        if(v.start_time||v.end_time){body.start_time=v.start_time;body.end_time=v.end_time;}
9864        if(v.reason) body.reason=v.reason;
9865        dashAct('leave-act',body).then(function(j){
9866          if(!j || j.error || !j.data){ ui.error(dashRefusal(j,'The request was not submitted.')); return; }
9867          var d=j.data;
9868          // HQ-140 (Adam, 2026-09-26: "once i enter the box should disappear"): a 
9868submitted
9869          // request closes the pop-up and confirms in a toast. A refusal (above) keeps it open
9870          // with the server's reason and the entered data, as before.
9871          ui.close();
9872          toast('Request submitted for '+escapeHtml(String(d.days))+' day'+(Number(d.days)===1?'':'s')
9873            +' of '+escapeHtml(leaveTypeLabel(d.leave_type))+' leave. It is awaiting manager endorsement, then Finance confirmation.','good');
9874          _mdLeaveFetched=false; _mdLeave=null; mdLeaveLoad();
9875        });
9876      } });
9877  }
9878  // Leave Approve / decline from the queue (HQ-525 wording per Adam 2026-08-02: the
9879  // manager's stage-1 'Leave Approve' - NOT the final
9880  // word). leave-act's 'decide' action still takes decision:'approved'|'declined' -
9881  // the wire value is untouched - but 'approved' now moves the request to
9882  // status='endorsed' and forwards it to payroll (finance) for the real sign-off;
9883  // nothing reaches the calendar until that second gate fires. Optimistic
9884  // concurrency on version, so two managers acting at once get an honest
9885  // "reopen this" rather than a lost decision.
9886  function openLeaveDecide(row){
9887    openModal({ icon:IC.shield, title:'Review this leave request',
9888      sub:'Endorsing sends it to Finance for final confirmation. Nothing lands on the calendar yet.',
9889      infoRows:[
9890        ['Person', escapeHtml(row.person_name||String(row.person_id))],
9891        ['Type', escapeHtml(leaveTypeLabel(row.leave_type))],
9892        ['Dates', escapeHtml(leaveDate(row.start_date))+' to '+escapeHtml(leaveDate(row.end_date))],
9893        ['Days', escapeHtml(String(row.days))],
9894        // The reason TEXT is deliberately never sent to an approver (it is a
9895        // SECRET_COLUMNS health/family disclosure and leave-act's queue projects only
9896        // the boolean). Say exactly that rather than pointing at a surface that does
9897        // not exist - if Adam wants approvers to read it, that needs its own gated
9898        // action and is a separate decision.
9899        ['Reason given', row.has_reason ? 'Yes - kept private, not shown here' : 'No']
9900      ],
9901      fields:[
9902        {key:'decision',label:'Decision',type:'select',required:true,
9903          options:[{value:'approved',label:'Endorse'},{value:'declined',label:'Decline'}]},
9904        {key:'note',label:'Note',type:'textarea',placeholder:'Optional note for the record'}
9905      ],
9906      saveLabel:'Record decision', cancelLabel:'Cancel',
9907      onSubmit:function(v, ui){
9908        ui.busy('Recording the decision…');
9909        var body={ action:'decide', request_id:row.id, version:row.version, decision:v.decision };
9910        if(v.note) body.note=v.note;
9911        dashAct('leave-act',body).then(function(j){
9912          if(!j || j.error || !j.data){ ui.error(dashRefusal(j,'The decision was not recorded.')); return; }
9913          ui.ok(v.decision==='approved'
9914            ? 'Leave endorsed - awaiting Finance confirmation.'
9915            : 'Request declined. The requester has been notified.');
9916          _mdLeaveFetched=false; _mdLeave=null; _mdLeaveQueueFetched=false; _mdLeaveQueue=null; mdLeaveLoad();
9917        });
9918      } });
9919  }
9920  // Cancel your own request while it is still live - HQ-525: that is pending
9921  // (awaiting the manager's endorsement) or endorsed (awaiting payroll's final
9922  // sign-off); the server refuses a cancel on anything past that.
9923  function openLeaveCancel(row){
9924    openModal({ icon:IC.cal, title:'Withdraw this leave request', sub:'Only a pending or endorsed request can be withdrawn',
9925      infoRows:[['Type', escapeHtml(leaveTypeLabel(row.leave_type))],
9926                ['Dates', escapeHtml(leaveDate(row.start_date))+' to '+escapeHtml(leaveDate(row.end_date))],
9927                ['Days', escapeHtml(String(row.days))]],
9928      saveLabel:'Withdraw request', cancelLabel:'Keep it',
9929      onSubmit:function(v, ui){
9930        ui.busy('Withdrawing…');
9931        dashAct('leave-act',{action:'cancel', request_id:row.id, version:row.version}).then(function(j){
9932          if(!j || j.error){ ui.error(dashRefusal(j,'The request was not withdrawn.')); return; }
9933          ui.ok('Request withdrawn.');
9934          _mdLeaveFetched=false; _mdLeave=null; mdLeaveLoad();
9935        });
9936      } });
9937  }
9938  // HQ-523 round 2: the leave strip that rides the BOTTOM of the Today card now
9939  // that the My Leave card is gone (Adam: balances and history belong in
9940  // People). Deliberately only two things survive on the dashboard: the Apply
9941  // action, and the person's own still-live requests - each row opens the real
9942  // withdraw gate. HQ-525: a request is withdrawable through BOTH of its
9943  // non-final stages, pending (awaiting the manager's endorsement) and endorsed
9944  // (awaiting payroll's final sign-off) - the server allows cancel on either, and
9945  // the pill (leaveStatusPill) tells the two apart. People > Leave now lists ALL
9946  // the caller's requests with its own Withdraw on the same two statuses (same
9947  // round), so this strip is the quick path, not the only one. A failed summary
9948  // read is stated, never silently shown as "no requests". Rows ride
9949  // wireMyDashViews' delegated listener (.mb-lvrow / #mb-lv-apply), so a card
9950  // repaint cannot kill them.
9951  function mdTodayLeaveHtml(){
9952    mdLeaveLoad();
9953    var h='';
9954    if(_mdLeave && _mdLeave.error){
9955      h+='<div class="mc-note">Leave: '+escapeHtml(_mdLeave.error)+'</div>';
9956    } else {
9957      var pend=((_mdLeave && _mdLeave.requests)||[]).filter(function(r){ return r.status==='pending'||r.status==='endorsed'; });
9958      pend.slice(0,3).forEach(function(r){
9959        h+='<div class="fs-row mb-lvrow" role="button" tabindex="0" data-lv="'+escapeHtml(String(r.id))+'" title="Withdraw this request">'
9960          +'<span class="fic">'+svg(IC.cal)+'</span>'
9961          +'<span class="fid">'+escapeHtml(leaveDate(r.start_date))+'</span>'
9962          +'<span class="fname">'+escapeHtml(leaveTypeLabel(r.leave_type))+' · '+escapeHtml(String(r.days))+'d</span>'
9963          +'<span class="ftime">'+leaveStatusPill(r.status)+'</span></div>';
9964      });
9965      if(pend.length>3) h+='<div class="mc-note">'+(pend.length-3)+' more - see People.</div>';
9966    }
9967    // HQ-523 round 3 (Adam): the accent-OUTLINE variant - blue in dark mode,
9968    // gold in light, straight from var(--brass) (quasar-ui.css .mc-btn.outline).
9969    h+='<div class="mc-btns"><button class="mc-btn outline" id="mb-lv-apply" type="button">Apply for leave</button></div>';
9970    return h;
9971  }
9972  // The Today card painter, extracted from renderMyDashReal (HQ-523 verify
9973  // panel) so the 60s tick can repaint JUST this card + the KPI tiles when the
9974  // status JSON has not changed, instead of rebuilding the whole dashboard and
9975  // destroying open menus/focus elsewhere. Its interactive rows (.mb-lvrow,
9976  // #mb-lv-apply) ride wireMyDashViews' delegated listener, so this repaint can
9977  // never kill their handlers.
9978  // HQ-1181: the day list is built from the same rounded clock hours the Today
9979  // card's own figure uses. Future days are labelled upcoming, never drawn as
9980  // zero-hour results.
9981  // HQ-1213 (this round, Adam: "the daily hours and clock for today doesnt
9982  // look that nice"): the SVG bar chart that used to sit above this list is
9983  // gone. It was drawn for the full-width card this section had BEFORE the
9984  // HQ-1213 live-review round put Today into an even half-width column
9985  // (viewBox="0 0 420 140" scaled down to fit a narrower box, preserving
9986  // aspect ratio, so it rendered small with near-illegible axis text) - and
9987  // since the "Daily hours" disclosure right below it is open by default
9988  // (HQ-1225) and shows the exact same seven numbers as plain text, the chart
9989  // had become a second, cramped copy of information already on screen. The
9990  // list is the real information (same idiom this file already applied to
9991  // Assigned Work's own weekday chart, HQ-523 round 2). The week Total now
9992  // closes THIS SAME disclosure (previously a floating sibling row below it)
9993  // so the sum sits with the rows it totals.
9994  function mdWeekSummaryHtml(weekHrs, todayIdx, isOpen){
9995    return '<details class="qflow-disclosure qflow-day-hours"><summary>Daily hours</summary>'
9996      +mdWeekRowsHtml(weekHrs,todayIdx,isOpen)
9997      +'<div class="fs-row mb-total"><span class="fid">Total</span><span class="fname">Thu to Wed this week</span><span class="ftime"><b>'+mdQtr(mdQtrSum(weekHrs))+'</b></span></div>'
9998      +'</details>';
9999  }
10000  function renderMbToday(){
10001    var td=$('mb-today'); if(!td) return;
10002    var previousHours=td.querySelector('.qflow-day-hours');
10003    // HQ-1225 dashboard round (Adam: "can the daily hours always be expanded"):
10004    // open by default on first paint; a later re-render still honours whatever
10005    // the person set it to themselves, same as before.
10006    var hoursOpen=previousHours?!!previousHours.open:true;
10007    var hoursFocused=!!(previousHours && document.activeElement===previousHours.querySelector('summary'));
10008    var c=_mdClock;
10009    var nowMs=Date.now(),todayIdx=mdClockDayIndex(nowMs),weekHrs=mdClockHours(c,nowMs);
10010    if(c){
10011      var open=c.open;
10012      var sinceTs=open?Date.parse(open.clock_in_at||open.started_at||open.created_at||''):NaN;
10013      // Include overnight entries from the week read in today's entry count,
10014      // using the same deduplication order and midnight split as A.days.
10015      var _seen=new Map();
10016      (c.week||[]).concat(c.today||[],c.open?[c.open]:[]).forEac
10016h(function(r){if(r)_seen.set(r.id||r.clock_in_at,r);});
10017      var _todayRows=window.QuasarAttendance.split(Array.from(_seen.values()),nowMs)[window.QuasarAttendance.dayKey(nowMs)]||[];
10018      var todayHrs=weekHrs?weekHrs[todayIdx]:null,todayN=_todayRows.length;
10019      // HQ-523 round 2 (Adam: "this part doesnt match the UI"): ONE lead figure
10020      // (today), and the week total closes the Thu-Wed day list as a bold Total
10021      // row instead of a second big number crammed into the same sentence. The
10022      // Total sums the DISPLAYED day blocks (mdQtrSum), so the list always adds up.
10023      // HQ-523 round 3 (Adam: the old "since 7:25 pm · Office · ..." line was "a
10024      // bit random"): the status now says WHERE in plain words - On site,
10025      // Working from home (WFH-gated server-side), or a field job - and HOW the
10026      // person signed in when it was the kiosk or the phone (mdClockWhere).
10027      var whereInfo=mdClockWhere(open);
10028      // Same status ring as People > Clock's hero (renderClock in people.html), read-only
10029      // here - reuses the .clockcard/.ringwrap/.ring/.hub/.big/.cap classes now shared via
10030      // quasar-ui.css rather than re-styling. One live segment (clock-in to now) over a
10031      // 6am-6pm day: green while on site, blue for a field job - the same real-mode-only
10032      // rule people.html follows (no fabricated demo slices; this dashboard is never demo).
10033      var working=!!(open && open.state!=='off_site');
10034      var workingHome=!!(open && open.location==='home');
10035      var inAtOk=!!(open && isFinite(sinceTs));
10036      var elapsedMs=inAtOk?(nowMs-sinceTs):0;
10037      var ringCap=!inAtOk?'Off':(workingHome?'At home':(working?'On site':'Off site'));
10038      var ringDayStart=6,ringDayEnd=18,ringSpan=ringDayEnd-ringDayStart;
10039      var ringInH=inAtOk?(new Date(sinceTs).getHours()+new Date(sinceTs).getMinutes()/60):8;
10040      var ringNowH=new Date(nowMs).getHours()+new Date(nowMs).getMinutes()/60;
10041      var ringSegs=[];
10042      if(inAtOk&&ringNowH>ringInH) ringSegs.push(working?{cls:'work',from:ringInH,to:ringNowH}:{cls:'field',from:ringInH,to:ringNowH});
10043      var ringStops=ringSegs.map(function(s){
10044        var a0=(s.from-ringDayStart)/ringSpan*360, a1=(s.to-ringDayStart)/ringSpan*360;
10045        return (s.cls==='work'?'var(--p-ok)':'var(--p-info)')+' '+a0.toFixed(1)+'deg '+a1.toFixed(1)+'deg';
10046      });
10047      var ringNowDeg=Math.max(0,Math.min(360,(ringNowH-ringDayStart)/ringSpan*360));
10048      var ringBg='conic-gradient(from 0deg,'+(ringStops.length?ringStops.join(','):'var(--line) 0deg 0deg')+',var(--line) '+ringNowDeg.toFixed(1)+'deg 360deg)';
10049      // HQ-1213 (this round, Adam: "the daily hours and clock for today doesnt
10050      // look that nice"): two real gaps, not a restyle of the ring/hub numbers
10051      // themselves (those - fmtHMS(elapsedMs)/ringCap - are untouched). First,
10052      // .clockcard's own bordered/padded box was never actually shared - only
10053      // its inner .ring/.hub numerals moved to quasar-ui.css, so this exact
10054      // markup rendered with no border, background or padding at all (see that
10055      // file's new base .clockcard rule). The lead figure and status caption
10056      // now sit INSIDE that box, beside the ring, as one "big number +
10057      // supporting detail" unit (Company Overview's hero-card idiom - a
10058      // bounded card, not text floating loose in the card body). Second, the
10059      // status caption ("On site since...") was riding .qs-empty - a real,
10060      // populated line borrowing an EMPTY-state class's centred/faint
10061      // treatment. .mc-status (quasar-ui.css) is a plain left-aligned line for
10062      // real content, same family as .mc-figure/.mc-note beside it.
10063      td.innerHTML='<div class="clockcard"><div class="ringwrap"><div class="ring" id="mb-clk-ring"><div class="hub"><div class="big" id="mb-clk-timer">'+fmtHMS(elapsedMs)+'</div><div class="cap">'+ringCap+'</div></div></div></div>'
10064        +'<div class="mb-todaybox">'
10065        +'<div class="mc-figure"><b>'+(todayHrs==null?'Unavailable':mdQtr(todayHrs))+'</b> on the clock today'
10066        +(todayN?(' <span class="sub">across '+todayN+' entr'+(todayN===1?'y':'ies')+'</span>'):'')+'</div>'
10067        +'<div class="mc-status"><b>'+whereInfo.where+'</b>'
10068        +(open&&isFinite(sinceTs)?(' since '+mdTime(open.clock_in_at)):'')
10069        +(whereInfo.via?(' · '+whereInfo.via):'')
10070        +'</div>'
10071        +'</div>'
10072        +'</div>'
10073        +(weekHrs?mdWeekSummaryHtml(weekHrs, todayIdx, !!open):'<div class="mc-note">This week\'s clock hours are unavailable. Open People to review the timesheet.</div>')
10074        +'<div class="mc-note">Attendance after recorded and automatic breaks, shown in 15-minute blocks. Open shifts are estimates; the timesheet in People is the payroll record.</div>'
10075        +mdTodayLeaveHtml();
10076      // The ring's background is a per-instance conic-gradient (today's actual segment),
10077      // so it is set as a DOM style property rather than baked into the HTML string above -
10078      // same reason people.html's own ring earns an inline-style exception (a computed
10079      // value that cannot be a class), but keeps this new render path off the ui-drift ratchet.
10080      // Queried off td (not a bare document.getElementById), matching this function's own
10081      // existing .qflow-day-hours lookup below.
10082      var ringEl=td.querySelector('#mb-clk-ring');
10083      if(ringEl) ringEl.style.background=ringBg;
10084      // Live tick - same re-entrant-safe pattern as people.html's renderClock: clear the
10085      // previous interval, self-clear if the card has since been re-rendered away. Guarded
10086      // with typeof so this stays callable in a plain-object test harness with no timers.
10087      if(typeof clearInterval==='function' && window.__mbRingTick) clearInterval(window.__mbRingTick);
10088      if(inAtOk && typeof setInterval==='function'){
10089        window.__mbRingTick=setInterval(function(){
10090          var t=td.querySelector('#mb-clk-timer');
10091          if(!t){ clearInterval(window.__mbRingTick); return; }
10092          t.textContent=fmtHMS(Date.now()-sinceTs);
10093        },1000);
10094      }
10095    } else {
10096      if(typeof clearInterval==='function' && window.__mbRingTick) clearInterval(window.__mbRingTick);
10097      td.innerHTML='<div class="qs-empty">Clock state loads once you are signed in - the clock itself lives in <a href="/people.html?__sec=clock">People</a>.</div>'
10098        +mdTodayLeaveHtml();
10099    }
10100    var hours=td.querySelector('.qflow-day-hours');
10101    if(hours){ hours.open=hoursOpen; if(hoursFocused)hours.querySelector('summary').focus(); }
10102  }
10103
10104  // ── The per-card "View" detail modals ─────────────────────────
10105  // One centred pop-out per My Dashboard card, built ONLY from data this page has
10106  // already fetched (no second data layer, nothing invented). Every one of them
10107  // ends in a real deep link to the module that owns the records, so View is a way
10108  // in rather than a dead end. infoRows values are innerHTML in openModal, so
10109  // everything server-sourced goes through escapeHtml first.
10110  function mdViewSpec(kind){
10111    var u=_mdMe, W=dashRangeWindow(DASH_RANGE);
10112    if(kind==='today'){
10113      var c=_mdClock;
10114      if(!c) return { icon:IC.clock, title:'Today on the clock', sub:'Clock state has not loaded yet',
10115        infoRows:[['Status','Not loaded - the clock lives in People']],
10116        saveLabel:'Open the clock', openHref:'/people.html?__sec=clock' };
10117      var rows=(c.today||[]).slice();
10118      var nowMs=Date.now(),weekHrs=mdClockHours(c,nowMs),todayHrs=weekHrs?weekHrs[mdClockDayIndex(nowMs)]:null;
10119      // HQ-523 verify panel: the two headline figures speak the same 15-minute
10120      // blocks as the card this modal opens from (per-entry rows below stay at
10121      // 2dp - the modal IS the precise drill-down).
10122      var kv=[['Hours today', todayHrs==null?'Unavailable':escapeHtml(mdQtr(todayHrs))+' (15-min blocks)'],
10123              ['Hours this week', weekHrs?escapeHtml(mdQtr(mdQtrSum(weekHrs)))+' (15-min blocks)':'Unavailable'],
10124              ['Right now', c.open ? ('On the clock since '+escapeHtml(mdTime(c.open.clock_in_at))) : 'Off the clock']];
10125      rows.forEach(function(r){
10126        var out=r.clock_out_at?mdTime(r.clock_out_at):'still open';
10127        kv.push([escapeHtml(mdTime(r.clock_in_at))+' - '+escapeHtml(out),
10128          escapeHtml(mdHoursOf([r]).toFixed(2))+' h'
10129          +(r.state?(' · '+escapeHtml(mdTypeLabel(r.state))):'')
10130          +((Number(r.break_minutes)||0)?(' · '+escapeHtml(String(r.break_minutes))+'m break'):'')]);
10131      });
10132      if(!rows.length) kv.push(['Clock-ins started today','None yet']);
10133      return { icon:IC.clock, title:'Today on the clock', sub:'Totals include daily break deductions. Entries show clock-ins started today, after logged breaks.',
10134        infoRows:kv, saveLabel:'Open the clock in People', openHref:'/people.html?__sec=clock' };
10135    }
10136    if(kind==='book'){
10137      var all=mdBookRowsFor(W.key)||[];
10138      var work=all.filter(function(r){ return MD_BOOK_TYPES.indexOf(r.entry_type)!==-1; });
10139      var other=all.filter(function(r){ return MD_BOOK_TYPES.indexOf(r.entry_type)===-1; });
10140      var bkv=[['Window', escapeHtml(W.label)+' ('+escapeHtml(W.noun)+')'],
10141               ['Assignments', String(work.length)]];
10142      work.forEach(function(r){
10143        bkv.push([escapeHtml(mdDate(r.starts_at))+' '+escapeHtml(mdTime(r.starts_at)),
10144          escapeHtml(r.title||mdTypeLabel(r.entry_type))
10145          +' <span class="sub">'+escapeHtml(mdTypeLabel(r.entry_type))+(r.status?(' · '+escapeHtml(mdTypeLabel(r.status))):'')+'</span>']);
10146      });
10147      if(!work.length) bkv.push(['Assignments','Nothing assigned in this window']);
10148      // Leave / lunch / training blocks are shown separately so they can never be
10149      // mistaken for work that was booked for you.
10150      if(other.length) bkv.push(['Other calendar blocks', String(other.length)+' (leave, lunch, training or public holiday)']);
10151      return { icon:IC.cal, title:'Assigned work', sub:'From the Ops calendar, for the dashboard date range',
10152        infoRows:bkv, saveLabel:'Open the Ops calendar', openHref:'/operations.html?__sec=calendar' };
10153    }
10154    if(kind==='queue'){
10155      var qkv=[];
10156      if(_mdLeave && _mdLeave.approvals_pending!=null){
10157        qkv.push(['Leave to review', String(_mdLeave.approvals_pending)+' pending request'+(_mdLeave.approvals_pending===1?'':'s')]);
10158      }
10159      qkv.push(['Notifications for you', (_mdNeedsN!=null?String(_mdNeedsN):'unknown')]);
10160      qkv.push(['Drafts in your outbox', (_mdMailN!=null?String(_mdMailN):'unknown')]);
10161      if(!(u&&u.is_admin)) qkv.push(['Your activity trail','Only an admin can read the audit trail today - your own actions are recorded, they just are not readable back here yet.']);
10162      return { icon:IC.alert, title:'Approvals', sub:'Everything currently waiting on you',
10163        infoRows:qkv, saveLabel:'Open People', openHref:'/people.html?__sec=leave' };
10164    }
10165    if(kind==='mail'){
10166      return { icon:IC.inbox, title:'My mail', sub:'Drafts waiting in your outbox',
10167        infoRows:[['Waiting', (_mdMailN!=null?String(_mdMailN):'unknown')],
10168                  ['Last 14 days', _mdSeries.mail?(_mdSeries.mail.reduce(function(a,b){return a+b;},0)+' arrived'):'not loaded'],
10169                  ['Where they go','Outbox holds a draft until it is sent; sending is server-gated.']],
10170        saveLabel:'Open Mail', openHref:'/outbox.html' };
10171    }
10172    if(kind==='needs'){
10173      return { icon:IC.alert, title:'Needs me', sub:'Notifications addressed to you',
10174        infoRows:[['Unread', (_mdNeedsN!=null?String(_mdNeedsN):'unknown')],
10175                  ['Last 14 days', _mdSeries.needs?(_mdSeries.needs.reduce(function(a,b){return a+b;},0)+' arrived'):'not loaded'],
10176                  ['Open them','Use the bell in the top bar for the full list.']],
10177        saveLabel:'Open People', openHref:'/people.html?__sec=directory' };
10178    }
10179    return null;
10180  }
10181  function mdOpenView(kind){
10182    var spec=mdViewSpec(kind);
10183    if(!spec){ toast('Nothing to show for that card yet.','warn'); return; }
10184    openModal({ icon:spec.icon, title:spec.title, sub:spec.sub, infoRows:spec.infoRows,
10185      saveLabel:spec.saveLabel, cancelLabel:'Close', openHref:spec.openHref });
10186  }
10187  // HQ-523 round 4: the read-only production-booking pop-out (Adam: "the
10188  // production orders would be view only"). Built ONLY from the calendar entry
10189  // this page already fetched - no second data layer, no write affordance; the
10190  // one action is a deep link to the Ops calendar, where scheduling is owned.
10191  function openProductionBooking(r){
10192    openModal({ icon:IC.play, title:'Production booking', sub:'View only - Manufacturing owns changes to the order',
10193      infoRows:[
10194        ['What', escapeHtml(r.title||'Production order')],
10195        ['When', escapeHtml(mdDate(r.starts_at))+' '+escapeHtml(mdTime(r.starts_at))
10196                 +(r.ends_at?(' to '+escapeHtml(mdTime(r.ends_at))):'')],
10197        ['Status', escapeHtml(mdTypeLabel(r.status||'scheduled'))],
10198        ['Order ref', r.linked_record_id?escapeHtml(String(r.linked_record_id)):'not linked'],
10199        ['Changes', 'Production orders are worked at the build stations; scheduling moves happen on the Ops calendar.']
10200      ],
10201      saveLabel:'Open the Ops calendar', cancelLabel:'Close',
10202      openHref:'/operations.html?__sec=calendar' });
10203  }
10204  // ONE delegated listener for every interactive thing on My Dashboard, bound to the
10205  // section rather than to the rows. This is load-bearing, not tidiness: the card
10206  // bodies are rebuilt by innerHTML on every renderMyDash() (and there are many
10207  // triggers - each fetch settling, a range change, the auth passes), and a handler
10208  // attached to a row dies the moment its card is repainted. The review caught
10209  // exactly that bug on the approver queue, whose rows were dead on every paint.
10210  // Rows carry their id in a data- attribute and the row object is looked up from
10211  // current state at click time, so the handler never holds a stale closure either.
10212  function wireMyDashViews(){
10213    var sec=$('dash-focus-mydash'); if(!sec || sec.__viewsWired) return;
10214    sec.__viewsWired=true;
10215    function act(t){
10216      var a=t.closest('.mb-viewlnk');
10217      if(a) return function(){ mdOpenView(a.getAttribute('data-view')); };
10218      var ap=t.closest('#mb-lv-apply');
10219      if(ap) return openLeaveApply;
10220      var q=t.closest('.mb-qrow');
10221      if(q){
10222        var qid=q.getAttribute('data-q');
10223        var qrow=(_mdLeaveQueue||[]).filter(function(r){ return String(r.id)===String(qid); })[0];
10224        if(qrow) return function(){ openLeaveDecide(qrow); };
10225      }
10226      var l=t.closest('.mb-lvrow');
10227      if(l){
10228        var lid=l.getAttribute('data-lv');
10229        var lreq=((_mdLeave && _mdLeave.requests)||[]).filter(function(r){ return String(r.id)===String(lid); })[0];
10230        if(lreq) return function(){ openLeaveCancel(lreq); };
10231      }
10232      // HQ-523 round 4: a production booking on Assigned Work opens VIEW ONLY
10233      // (Adam: "the production orders would be view only"). Looked up from the
10234      // current range's memoised rows at click time, same as the rows above.
10235      var po=t.closest('.mb-porow');
10236      if(po){
10237        var poid=po.getAttribute('data-po');
10238        var prow=(_mdBookRows[dashRangeWindow(DASH_RANGE).key]||[]).filter(function(r){ return String(r.id)===String(poid); })[0];
10239        if(prow) return function(){ openProductionBooking(prow); };
10240      }
10241      return null;
10242    }
10243    sec.addEventListener('click', function(e){
10244      if(!e.target.closest) return;
10245      var fn=act(e.target); if(!fn) return;
10246      e.preventDefault();
10247      fn();
10248    });
10249    // Keyboard parity for the role=button rows: Enter AND Space, with
10250    // preventDefault on Space so it activates instead of scrolling the page.
10251    sec.addEventListener('keydown', function(e){
10252      if(e.key!=='Enter' && e.key!==' ' && e.key!=='Spacebar') return;
10253      if(!e.target.closest) return;
10254      if(!e.target.closest('.mb-qrow,.mb-lvrow,.mb-xrow,.mb-porow,.mb-viewlnk')) return;
10255      var fn=act(e.target); if(!fn) return;
10256      e.preventDefault();
10257      fn();
10258    });
10259  }
10260
10261  // HQ-423: My Dashboard KPI tiles - the same shell as the Overview tiles
10262  // (khead/kic/ktext + spark(), no delta row), built ONLY from data this page
10263  // already fetches (the clock's own week, the three 14-day series above).
10264  // Called from renderMyDashReal and again from renderMyDashMail/
10265  // renderMyDashNeedsMe's paint() so a tile updates the moment its own number
10266  // lands, without a full-page refetch. Unknown value is '-', never a guess.
10267  function renderMbKpis(){
10268    var root=$('mb-kpis'); if(!root) return;
10269    var weekHrs=mdClockHours(_mdClock);
10270    // HQ-523 verify panel: the tile sums the DISPLAYED day blocks (mdQtrSum),
10271    // the same arithmetic as the Today card's Total row, so the two can never
10272    // disagree even on the rounding edge cases.
10273    var hrsTotal=weekHrs?mdQtrSum(weekHrs):null;
10274    // HQ-423 fix: every audit_events row has had a NULL team_id since the
10275    // 20260609_team_tenancy backfill (every insert site omits it), and
10276    // _db_impl.cjs only tolerates that NULL for an admin caller
10277    // (effectiveSharedRead/auditAdminRead) - a non-admin's read is strictly
10278    // team-pinned and always comes back empty, whether or not they were
10279    // actually active. Never show that structural gap as a real "0" - the
10280    // series is trustworthy only for an admin caller, '-' for everyone else.
10281    var actReliable=!!(_mdMe && _mdMe.is_admin);
10282    var actSeries=actReliable?_mdSeries.act:null;
10283    var actTotal=actSeries?actSeries.reduce(function(a,b){return a+b;},0):null;
10284    // HQ-523 round 3 (Adam: "add another box to fill that gap at the top"): a
10285    // fifth tile, Assigned Work - the count behind the card of the same name,
10286    // for the header's picked range. '-' until the range's rows have loaded.
10287    var asgRows=mdBookRowsFor(dashRangeWindow(DASH_RANGE).key);
10288    var asgWork=(asgRows||[]).filter(function(r){ return MD_BOOK_TYPES.indexOf(r.entry_type)!==-1; });
10289    var tiles=[
10290      { tag:'a',   href:'/people.html?__sec=clock', cls:'blue',   ic:IC.clock, lbl:'Hours This Week',
10291        // HQ-523: the same 15-min-block display as the Today card's pay-week
10292        // total, so the tile and the card can never show two different numbers.
10293        val:(hrsTotal!=null?mdQtr(hrsTotal):'-'), spark:weekHrs, col:C.blue,
10294        tip:'Hours on the clock this pay week (Thu to Wed), from your own clock entries, in 15-minute blocks.' },
10295      { tag:'div', cls:'violet', ic:IC.gauge, lbl:'My Activity (14d)',
10296        val:(actTotal!=null?String(actTotal):'-'), spark:actSeries, col:C.violet,
10297        tip: actReliable ? 'Your recorded actions across HQ, last 14 days.' : 'Not available for your role yet.' },
10298      // HQ-1225 dashboard round (Adam: "do i need the mail pending box there now?") -
10299      // the Mail Pending tile is gone, same call as the My Mail CARD it once sat
10300      // beside (HQ-1213, "remove the mail"). _mdMailN/renderMyDashMail() stay - the
10301      // Approvals view's "Drafts in your outbox" row (mdOpenView, kind 'queue')
10302      // still reads it.
10303      { tag:'div', cls:'green', ic:IC.alert, lbl:'Needs Me',
10304        val:(_mdNeedsN!=null?String(_mdNeedsN):'-'),
10305        tip:'Notifications addressed to you.', click:openBellPanel },
10306      { tag:'a',   href:'/operations.html?__sec=calendar', cls:'red', ic:IC.cal, lbl:'Assigned Work',
10307        val:(asgRows!==null?String(asgWork.length):'-'),
10308        tip:'Work assigned to you in the picked date range - the Assigned Work card lists it.' },
10309      // HQ-1213 live-review round (Adam: "add in one more tile here, something that
10310      // will work" - the strip's shared #dashboard .kpis grid is 5 columns wide
10311      // (matching Company Overview's own row), leaving a visible gap with only 4
10312      // tiles). My Leads follows the same one-tile-per-card convention as every
10313      // tile above it: the count behind the My Leads card, reusing _mdLeadRows
10314      // that card's own render (renderMyDashLeads) already fetches - no new fetch.
10315      // '-' until that card's rows have loaded or settled on the empty state.
10316      { tag:'a',   href:'/sales.html?__sec=leads', cls:'amber', ic:IC.users, lbl:'My Leads',
10317        val:(_mdLeadRows!=null?String(_mdLeadRows.length):'-'),
10318        tip:'Open leads assigned to you - the My Leads card lists them.' }
10319    ];
10320    root.innerHTML='';
10321    tiles.forEach(function(t){
10322      var c=el(t.tag==='a'?'a':'div','kpi '+t.cls);
10323      if(t.tag==='a') c.href=t.href;
10324      c.title=t.tip;
10325      c.innerHTML='<div class="qflow-kpi-reading"><div class="khead">'
10326        +'<div class="ktext"><div class="lbl">'+t.lbl+'</div><div class="val">'+t.val+'</div></div></div>'
10327        +(t.spark?spark(t.spark, t.col):'')+'</div>';
10328      if(t.click){
10329        c.setAttribute('role','button'); c.setAttribute('tabindex','0');
10330        c.addEventListener('click', t.click);
10331        c.addEventListener('keydown', function(e){ if(e.key==='Enter'||e.key===' '){e.preventDefault();t.click();} });
10332      }
10333      root.appendChild(c);
10334    });
10335  }
10336  function renderMyDashReal(){
10337    if(!_mdAttendance&&window.QuasarDashboardAttendance){
10338      var attendanceHost=$('mb-attendance');
10339      attendanceHost.hidden=false;
10340      _mdAttendance=window.QuasarDashboardAttendance.create({host:attendanceHost,request:fn,refresh:mdClockFetch});
10341      window.addEventListener('focus',function(){if(!document.hidden&&!dashDemo())mdClockFetch();});
10342      document.addEventListener('visibilitychange',function(){if(!document.hidden&&!dashDemo())mdClockFetch();});
10343    }
10344    if(!_mdFetched){
10345      _mdFetched=true;
10346      try{ var mir=JSON.parse(sessionStorage.getItem('quasar.session.v1')||'null'); if(mir&&mir.u&&mir.u.id)_mdMe=mir.u; }catch(_){}
10347      fn('auth-user').then(function(r){ return r.ok?r.json():null; }).then(function(j){ if(j&&j.user){ _mdMe=j.user; renderMyDash(); } }).catch(function(){});
10348      fn('admin-users',{method:'POST',body:{action:'roster'}}).then(function(r){ return r.ok?r.json():null; }).then(function(j){ if(j&&Array.isArray(j.people)){ _mdRoster=j.people; renderMyDash(); } }).catch(function(){});
10349      // HQ-523: the clock is no longer a one-shot read (Adam: "when my clock is
10350      // runing under people it doesnt show on my dashboard"). The first fetch
10351      // paints the card; the 60s tick - the same cadence as the nav's
10352      // outbox-count poll - refetches status so a clock-in made on People (or
10353      // another tab/device) lands here without a reload, and the on-the-clock /
10354      // pay-week totals keep moving while a wage user watches this page.
10355      // Skipped while the tab is hidden (no function invocations for a
10356      // background tab); armed once per page load.
10357      mdClockFetch();
10358      if(!window.__mbClockTick){
10359        window.__mbClockTick=setInterval(function(){
10360          if(document.hidden || dashDemo()) return;
10361          mdClockFetch();
10362        }, 60000);
10363      }
10364      if(sb){
10365        // the directory overlay is where the display NAME + role live when users.name is
10366        // blank (HQ-331: Adam's welcome read his email, and he wants the role underneath).
10367        sb.from('people_directory').select('id,version,user_id,full_name,role_title').is('deleted_at',null).then(function(r){ _mdDir=(r&&r.data)||[]; renderMyDash(); },function(){ _mdDir=[]; });
10368      }
10369    }
10370    var u=_mdMe;
10371    // HQ-423: one bounded generic read per table, gated once per page load and
10372    // only once u.id is known - every fetch above already retriggers
10373    // renderMyDash() on landing, which re-enters this check. audit_events has
10374    // no deleted_at column (softDelete:false) so it is NOT filtered on one;
10375    // all four reads carry the owner/person pin (admins are team-widened on
10376    // audit_events/notifications without it - security.md's owner-pin rule).
10377    if(!_mdSeriesFetched && u && u.id && sb && !dashDemo()){
10378      _mdSeriesFetched=true;
10379      mdLoadPersonalSeries(u);
10380    }
10381    // HQ-452: what you are assigned now follows the header's date range (Adam: "also
10382    // what they are assigned to for the week, which links to the date option at the
10383    // top"). Kept OUT of the once-per-page block above because it must refetch when
10384    // the range changes; memoised per range so re-rendering does not refetch.
10385    if(u && u.id && sb && !dashDemo()) mdBookingsFor(DASH_RANGE);
10386    var hdr=$('mydash-hdr');
10387    if(hdr){
10388      if(u){
10389        // name + role resolve from the directory overlay first (users.name is blank for
10390        // most logins; the directory holds 'Adam Daglish' + 'Operations Manager'), then
10391        // the session, then a Title-Cased email handle - never the raw email if avoidable.
10392        var dirRow=(_mdDir||[]).filter(function(d){ return d.user_id!=null&&String(d.user_id)===String(u.id); })[0];
10393        var handle=String(u.email||'').split('@')[0];
10394        var nm=(dirRow&&dirRow.full_name)||(u.name&&String(u.name).trim())||(handle?handle.charAt(0).toUpperCase()+handle.slice(1):'')||u.email||'';
10395        var role=(dirRow&&dirRow.role_title)||(u.is_admin?'Administrator':(u.role?String(u.role).replace(/_/g,' ').replace(/\b\w/g,function(c){return c.toUpperCase();}):''));
10396        // Display-only welcome bar (HQ-331 round 17: Adam - the inline pencil editor
10397        // moved to the People module's manager/admin editor drawer; this bar only
10398        // reads the directory row, it never writes it).
10399        var h='<span class="sa-av">'+initialsOfName(nm)+'</span>'
10400          +'<span class="sa-tt"><span class="sa-name">Welcome back, '+escapeHtml(nm)+'</span>'
10401          +'<span class="sa-sub">'+(role?escapeHtml(role)+' · ':'')+new Date().toLocaleDateString('en-AU',{weekday:'long',day:'numeric',month:'long'})+'</span></span>';
10402        // HQ-523 round 3 (Adam: "the my team function shouldnt show unless your
10403        // a manger over people"): the roster menu renders only for management
10404        // (isMgmtUser - the same fail-closed role check the Overview gate uses;
10405        // admins pass). UI disclosure only: the roster read stays server-gated,
10406        // and impersonation inside the menu was already admin-only.
10407        if(_mdRoster&&_mdRoster.length&&isMgmtUser(_dashAuth)){
10408          h+='<button class="sa-view" id="mb-view" type="button" title="Your team, from the live roster">'
10409            +svg(IC.users)+'My team'
10410            +'<svg class="car" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg></button>';
10411          // Admin-only "Log in as" per teammate (HQ impersonation). Hidden for
10412          // yourself and while already impersonating; the server is the real gate.
10413          var canImp=!!(u && u.is_admin && !u.is_impersonated);
10414          var pop='<div class="sa-pop" id="mb-viewpop" role="menu"><div class="sp-h">Team ('+_mdRoster.length+')</div>';
10415          _mdRoster.forEach(function(p){
10416            var isSelf=String(p.id)===String(u.id);
10417            var canRow=canImp && !isSelf && p.id!=null;
10418            pop+='<div class="sp-it" role="menuitem" tabindex="0"><span class="sp-av">'+initialsOfName(p.name)+'</span>'
10419              +'<span class="sp-tt"><span class="sp-n">'+escapeHtml(p.name||'')+(isSelf?' (you)':'')+'</span>'
10420              +'<span class="sp-r">'+escapeHtml(p.role?String(p.role).replace(/_/g,' '):'staff')+'</span></span>'
10421              +(canRow?('<button type="button" class="sp-loginas" data-uid="'+escapeHtml(String(p.id))+'" title="Open '+escapeHtml(p.name||'this teammate')+'&#39;s session">Log in as</button>'):'')
10422              +'</div>';
10423          });
10424          pop+='<div class="sp-note">'+(canImp?'Log in as opens that teammate&#39;s own session (admin action, audited). Money actions stay disabled while impersonating; use Exit in the top banner to return.':'The live roster. Per-person dashboard preview arrives with the reporting lines.')+'</div></div>';
10425          h+=pop;
10426        }
10427        // HQ-523 verify panel: carry the team menu's open state across this
10428        // rebuild (the demo branch already does) - a data-landing repaint used
10429        // to destroy the open popup mid-interaction, and with the 60s tick the
10430        // destruction recurred every minute.
10431        var hdrWasOpen=(function(){ var p=$('mb-viewpop'); return !!(p&&p.classList.contains('open')); })();
10432        hdr.innerHTML=h;
10433        var vb=$('mb-view'), vp=$('mb-viewpop');
10434        if(vb&&vp&&hdrWasOpen){ vp.classList.add('open'); vb.classList.add('open'); }
10435        if(vb&&vp){ vb.addEventListener('click', function(e){ e.stopPropagation(); var o=vp.classList.toggle('open'); vb.classList.toggle('open',o); }); }
10436        if(vp){ vp.addEventListener('click', function(e){
10437          var b=e.target.closest?e.target.closest('.sp-loginas'):null; if(!b) return;
10438          e.stopPropagation(); e.preventDefault();
10439          var uid=b.getAttribute('data-uid'); if(uid) startImpersonation(uid,b);
10440        }); }
10441        if(!window.__mbViewAway){
10442          window.__mbViewAway=true;
10443          document.addEventListener('click', function(e){
10444            if(e && e.isTrusted===false) return;
10445            var p=$('mb-viewpop'), b=$('mb-view');
10446            if(p) p.classList.remove('open');
10447            if(b) b.classList.remove('open');
10448          });
10449        }
10450      } else hdr.innerHTML='';
10451    }
10452    // mb-links: still no real per-user deep-link source - honest empty.
10453    // mb-kpis (HQ-423): sparked from data this page already reads - see
10454    // renderMbKpis above.
10455    var lk=$('mb-links'); if(lk) lk.innerHTML='';
10456    renderMbKpis();
10457    renderMbToday();
10458    var bk=$('mb-book');
10459    if(bk){
10460      // HQ-452: the card follows the header range and LISTS what you are assigned.
10461      // HQ-523 round 2 (Adam: "my booking might be better as a list for the jobs
10462      // they are assigned to? So maybe assigned work?"): renamed Assigned Work,
10463      // the list shows more rows, and the "Assignments by weekday" spark is gone -
10464      // the rows ARE the information. Title still carries the picked window so
10465      // "This Week" cannot survive a switch to This Quarter.
10466      var bW=dashRangeWindow(DASH_RANGE);
10467      var bh=$('mb-book-h'); if(bh) bh.textContent='Assigned Work · '+bW.label;
10468      var allRows=mdBookRowsFor(bW.key);
10469      var bookRows=(allRows||[]).filter(function(r){ return MD_BOOK_TYPES.indexOf(r.entry_type)!==-1; });
10470      // HQ-523 round 4 (Adam: "i want them to see the operations schedule for
10471      // them ... including there TAFE"): the LIST is the person's schedule -
10472      // work assignments plus TAFE blocks, in time order. TAFE stays OUT of
10473      // MD_BOOK_TYPES (the assignment count and the KPI tile - a study day is
10474      // not a job, HQ-423's original point), it just shows on the schedule.
10475      var listRows=(allRows||[]).filter(function(r){
10476        return MD_BOOK_TYPES.indexOf(r.entry_type)!==-1 || r.entry_type==='tafe';
10477      });
10478      if(allRows===null){
10479        // Only claim to be loading when a read is genuinely in flight; otherwise this
10480        // is the honest "nothing here yet" state (review fix - a spinner with no
10481        // fetch behind it never resolves).
10482        var loading=!!(u && u.id!=null && sb && !dashDemo());
10483        bk.innerHTML='<div class="qs-empty">'+(loading
10484          ? 'Loading your assignments…'
10485          : 'Assignments land here from the Ops calendar once you are signed in.')+'</div>';
10486      } else if(listRows.length){
10487        var bn=bookRows.length;
10488        var bhtml=bn?('<div class="mc-figure"><b>'+bn+'</b> assignment'+(bn===1?'':'s')+' '+escapeHtml(bW.noun)+'</div>'):'';
10489        // HQ-523 round 4 (Adam: "they can click into the fieldjobs ... or the
10490        // production line orders. Field service they would edit but the
10491        // production orders would be view only"): a field-service row is a real
10492        // link to its job's own page (field-service.html's #/job/id:<uuid>
10493        // direct-load route - THAT page's server gates decide what they may
10494        // edit); a production row opens a read-only pop-out here (Manufacturing
10495        // owns changes); a TAFE row is a plain schedule line.
10496        // HQ-1213 (this round, Adam: "assigned work could follow the same
10497        // theme"): rebuilt onto the shared .aq-row component, one row per
10498        // assigned item as before - only the row markup changes, not which rows
10499        // exist or how they're wired. The field-service link and the
10500        // wireMyDashViews delegated production-row handler below are UNCHANGED
10501        // (same mb-fsrow/mb-porow marker classes, same data-po); a TAFE or other
10502        // non-clickable row gets .aq-static (no false pointer/hover, no
10503        // chevron - see quasar-ui.css) instead of a bare, unmarked div. The
10504        // pill carries the row's type (its own honest word, not "Attention");
10505        // aq-n is the calendar day-of-month, so a "This Quarter" window still
10506        // reads which day each row is on.
10507        listRows.slice(0,6).forEach(function(r){
10508          var isFs=(r.entry_type==='field_service_job' && r.linked_record_id!=null);
10509          var isPo=(r.entry_type==='production_order');
10510          var tag=isFs?'a':'div';
10511          var sub=(r.entry_type==='tafe')?'TAFE':mdTypeLabel(r.entry_type);
10512          var pc=isFs?'blue':isPo?'violet':(r.entry_type==='tafe'?'amber':'green');
10513          var col=pc==='blue'?C.blue:pc==='violet'?C.violet:pc==='amber'?C.amber:C.green;
10514          var startMs=Date.parse(r.starts_at);
10515          var wd=isNaN(startMs)?'':new Date(startMs).toLocaleDateString('en-AU',{weekday:'short'});
10516          var dayNum=isNaN(startMs)?'-':new Date(startMs).getDate();
10517          var attrs=isFs?(' class="aq-row mb-fsrow" href="/field-service.html#/job/id:'+escapeHtml(String(r.linked_record_id))+'" title="Open this job in Field Service"')
10518                :isPo?(' class="aq-row mb-porow" role="button" tabindex="0" data-po="'+escapeHtml(String(r.id))+'" title="View this production booking (view only)"')
10519                :' class="aq-row aq-static"';
10520          bhtml+='<'+tag+attrs+'>'
10521            +'<div class="aq-ic" style="background:color-mix(in srgb, '+col+' 14%, transparent);color:'+col+'">'+svg(IC.cal)+'</div>'
10522            +'<span class="aq-n">'+dayNum+'</span>'
10523            +'<span class="aq-t">'+escapeHtml(wd)+' · '+escapeHtml(mdTime(r.starts_at))+' · '+escapeHtml(r.title||sub)
10524              +(isFs?' · open the job':(isPo?' · view only':''))+'</span>'
10525            +'<span class="urg '+pc+'">'+escapeHtml(sub)+'</span>'
10526            +(isFs||isPo?('<span class="chev">'+svg('<path d="M9 18l6-6-6-6"/>')+'</span>'):'')
10527          +'</'+tag+'>';
10528        });
10529        if(listRows.length>6) bhtml+='<div class="mc-note">'+(listRows.length-6)+' more - open View for the full list.</div>';
10530        bk.innerHTML=bhtml;
10531      } else {
10532        bk.innerHTML='<div class="qs-empty">Nothing assigned to you '+escapeHtml(bW.noun)+' - work lands here from the Ops calendar.</div>';
10533      }
10534    }
10535    // HQ-523 round 2: the My Leave card is gone. Its apply button + the person's
10536    // own withdrawable live requests now render at the bottom of the Today
10537    // card (mdTodayLeaveHtml above); balances and request history live in
10538    // People > Leave. The approver's queue below is untouched.
10539    var at=$('mb-attn');
10540    if(at){
10541      // HQ-452: Approvals (My Queue until HQ-523) is the approver's real queue. leave-act's 'pending'
10542      // action lists the requests this caller may actually decide (its role gate is
10543      // the same one 'decide' enforces, so nothing unactionable is ever listed), and
10544      // each row opens the real endorse/decline gate. Before this, the approval half
10545      // of the leave workflow was unreachable from any page: 'summary' returned only
10546      // a count and nothing could discover a request_id. HQ-525: what this queue
10547      // decides is stage 1 only - endorsing here forwards the request to payroll
10548      // (finance) for the actual final sign-off, so "approved" here is not the last
10549      // word and nothing lands on the calendar yet.
10550      mdLeaveQueueLoad();
10551      var qHtml='', qN=(_mdLeave && _mdLeave.approvals_pending);
10552      if(_mdLeave && _mdLeave.approvals_pending!=null && qN){
10553        qHtml+='<div class="mc-figure"><b>'+qN+'</b> leave request'+(qN===1?'':'s')+' to review</div>';
10554        if(_mdLeaveQueue===null){
10555          qHtml+='<div class="mc-note">Loading the queue…</div>';
10556        } else {
10557          _mdLeaveQueue.slice(0,3).forEach(function(r){
10558            qHtml+='<div class="fs-row mb-qrow" role="button" tabindex="0" data-q="'+escapeHtml(String(r.id))+'" title="Approve or decline this request">'
10559              +'<span class="fic">'+svg(IC.cal)+'</span>'
10560              +'<span class="fid">'+escapeHtml(leaveDate(r.start_date))+'</span>'
10561              +'<span class="fname">'+escapeHtml(r.person_name||'Teammate')+' · '+escapeHtml(leaveTypeLabel(r.leave_type))+' '+escapeHtml(String(r.days))+'d</span>'
10562              +'<span class="ftime">Review</span></div>';
10563          });
10564          if(_mdLeaveQueue.length>3) qHtml+='<div class="mc-note">'+(_mdLeaveQueue.length-3)+' more - review the rest in People.</div>';
10565        }
10566      }
10567      // Review fix (HQ-452): ONE innerHTML assignment. This card used to append a
10568      // spark with `innerHTML +=` AFTER wiring the rows, and `+=` re-serialises and
10569      // re-parses the whole subtree - every row was replaced by a fresh node and the
10570      // click handlers attached moments earlier were silently discarded, so the
10571      // approve/decline rows were dead on every paint. The row handlers now live in
10572      // wireMyDashViews' single delegated listener on the section, which no
10573      // card-body repaint can break.
10574      // HQ-519 (Adam: "the grpahys do not really work"): the trailing spark here
10575      // plotted "your recorded actions across HQ" - a series that describes none of
10576      // what this card actually shows (leave approvals) and is a flat
10577      // "no data" line for every non-admin regardless of real queue activity. The
10578      // rows above are already the real list this card needed; drop the mismatched
10579      // graph rather than keep decorating an empty state with an unrelated trend.
10580      at.innerHTML=qHtml || '<div class="qs-empty">Nothing needs you right now.</div>';
10581    }
10582    renderMyDashMail();
10583    renderMyDashNeedsMe();
10584    renderMyDashLeads();
10585  }
10586  // HQ-592: My Leads - the leads assigned to the signed-in user, soonest due first.
10587  // Self-contained, same shape as renderMyDashMail above (own memo flag, own fetch, no
10588  // shared state). Real mode only: `leads` has no demo seed, and inventing one here would
10589  // show a person work nobody assigned them.
10590  var _mdLeadRows=null, _mdLeadRowsFetched=false, _mdLeadNames=null;
10591  function renderMyDashLeads(){
10592    var host=$('mb-leads'); if(!host) return;
10593    // HQ-1213 (this round, Adam: "the my leads should look like how we have for
10594    // the company overview but just for them"): rebuilt onto the shared .aq-row
10595    // component - each row stays ONE open lead (real per-lead detail: who, what
10596    // they're interested in, when it's due), not a rolled-up count, because a
10597    // rollup here would need a grouping this data does not naturally have. aq-n
10598    // becomes the honest day-count that already drove the old .mylead-due text
10599    // (days overdue / until due), and the urgency pill speaks the same
10600    // vocabulary the rest of the page's pills use.
10601    function dueInfo(l){
10602      if(!l.due_date) return { days:null, pill:'No date', pc:'blue' };
10603      var t=Date.parse(String(l.due_date).slice(0,10)+'T00:00:00');
10604      if(isNaN(t)) return { days:null, pill:'No date', pc:'blue' };
10605      var today=new Date(); today.setHours(0,0,0,0);
10606      var d=Math.round((t-today.getTime())/86400000);
10607      if(d<0) return { days:Math.abs(d), pill:'Overdue', pc:'red' };
10608      if(d===0) return { days:0, pill:'Due today', pc:'amber' };
10609      if(d===1) return { days:1, pill:'Due tomorrow', pc:'amber' };
10610      return { days:d, pill:'Upcoming', pc:'blue' };
10611    }
10612    function paint(){
10613      // HQ-1213 live-review round: renderMbKpis() repaints on every call (same
10614      // pattern as renderMyDashMail/renderMyDashNeedsMe above) so the "My Leads"
10615      // KPI tile stays in step with this card the moment its own rows land -
10616      // covering all three exits below, not just the populated-list one.
10617      if(_mdLeadRows===null){ host.innerHTML='<div class="mc-note">Loading…</div>'; renderMbKpis(); return; }
10618      if(!_mdLeadRows.length){ host.innerHTML='<div class="qs-empty">No leads assigned to you.</div>'; renderMbKpis(); return; }
10619      // Count the overdue ones in the figure line: that is the number worth reacting to,
10620      // and it is derived from the rows on screen rather than a second query that could
10621      // disagree with them.
10622      var late=_mdLeadRows.filter(function(l){ return dueInfo(l).pc==='red'; }).length;
10623      var html='<div class="mc-figure"><b>'+_mdLeadRows.length+'</b> assigned to you'
10624        +(late?(' · <b>'+late+'</b> overdue'):'')+'</div>';
10625      _mdLeadRows.forEach(function(l){
10626        var info=dueInfo(l);
10627        var col=info.pc==='red'?C.red:(info.pc==='amber'?C.amber:C.blue);
10628        // HQ-752: the Leads tab's chips print product NAMES now - this card's interest
10629        // text follows suit, falling back to the SKU when no product row matched.
10630        var interest=(l.interested_skus&&l.interested_skus.length)
10631          ? l.interested_skus.slice(0,2).map(function(s){ var nm=_mdLeadNames&&_mdLeadNames[String(s).toLowerCase()]; return nm||s; }).join(', ')
10632          : (l.interest_note||'');
10633        html+='<a class="aq-row" href="/sales.html?__sec=leads&lead='+encodeURIComponent(String(l.id))+'" title="Open this lead">'
10634          +'<div class="aq-ic" style="background:color-mix(in srgb, '+col+' 14%, transparent);color:'+col+'">'+svg(IC.users)+'</div>'
10635          +'<span class="aq-n">'+(info.days==null?'-':info.days)+'</span>'
10636          +'<span class="aq-t">'+escapeHtml(l.customer_name||'Unnamed')
10637            +(interest?(' · '+escapeHtml(String(interest).slice(0,48))):'')+'</span>'
10638          +'<span class="urg '+info.pc+'">'+info.pill+'</span>'
10639          +'<span class="chev">'+svg('<path d="M9 18l6-6-6-6"/>')+'</span></a>';
10640      });
10641      host.innerHTML=html;
10642      renderMbKpis();
10643    }
10644    if(!_mdLeadRowsFetched && sb && _mdMe && _mdMe.id && !dashDemo()){
10645      _mdLeadRowsFetched=true;
10646      // Open leads only: a converted or lost lead is finished work, and leaving it here
10647      // would make the card grow forever and the count meaningless.
10648      sb.from('leads').select('id,customer_name,due_date,status,interested_skus,interest_note')
10649        .eq('assignee_id',_mdMe.id).in('status',['new','contacted','qualified'])
10650        .is('deleted_at',null).order('due_date',{ascending:true}).limit(8)
10651        .then(function(r){
10652          _mdLeadRows=(r&&r.data)||[];
10653          paint();   // rows land immediately; names refine the interest line in place
10654          // HQ-752: one targeted read for just the SKUs this card shows - never the whole
10655          // products master - so the interest line prints names like the Leads tab does.
10656          // Best-effort: a failed lookup leaves the SKUs, it never blanks the card.
10657          var skus=[];
10658          _mdLeadRows.forEach(function(l){ (l.interested_skus||[]).slice(0,2).forEach(function(s){ if(s&&skus.indexOf(s)<0)skus.push(s); }); });
10659          if(!skus.length)return;
10660          sb.from('products').select('sku,name').in('sku',skus).is('deleted_at',null).limit(40)
10661            .then(function(p){
10662              _mdLeadNames={};
10663              (((p&&p.data)||[])).forEach(function(row){ if(row&&row.sku)_mdLeadNames[String(row.sku).toLowerCase()]=row.name||''; });
10664              paint();
10665            }, function(){});
10666        }, function(){ _mdLeadRows=[]; paint(); });
10667      paint();   // shows "Loading…" while that lands
10668      return;
10669    }
10670    // Demo mode, no session, or no shim: there is nothing to wait for, so settle on the
10671    // honest empty state rather than leaving "Loading…" on screen forever.
10672    if(_mdLeadRows===null && !_mdLeadRowsFetched) _mdLeadRows=[];
10673    paint();
10674  }
10675  // HQ-417: My Mail - self-contained (own memo flag, no shared state). Paints
10676  // instantly from the nav's badge cache (quasar.badge.mail - the nav writes
10677  // this from outbox-count's { pending } every 60s), then confirms with its
10678  // own outbox-count call. HQ-421 can lift this into its own widget as-is.
10679  function renderMyDashMail(){
10680    var host=$('mb-mail'); if(!host) return;
10681    // HQ-423: paint is no longer gated behind the fetch-once guard below - it
10682    // also runs on every repeat call (from the rows fetch landing) so the list
10683    // repaints once _mdMailRows arrives, and renderMbKpis() keeps the "Mail
10684    // Pending" tile in step with the same number.
10685    // HQ-519 (Adam: "Mail could just show the list which they can then click
10686    // to"): the lead figure stays, but the graph underneath is replaced by the
10687    // actual pending drafts, each a real link into the draft (outbox.html's own
10688    // #draft=<id> deep link - see openDraft there).
10689    function paint(n){
10690      if(n!=null) _mdMailN=n;
10691      var html;
10692      if(_mdMailN>0){
10693        html='<div class="mc-figure"><b>'+_mdMailN+'</b> draft'+(_mdMailN===1?'':'s')+' awaiting review</div>';
10694        if(_mdMailRows===null){
10695          html+='<div class="mc-note">Loading…</div>';
10696        } else if(_mdMailRows.length){
10697          _mdMailRows.forEach(function(r){
10698            var who=r.source_from?String(r.source_from).replace(/<[^>]*>/g,'').replace(/"/g,'').trim():'';
10699            var subj=r.draft_subject||r.source_subject||'(no subject)';
10700            html+='<a class="fs-row mb-mailrow" href="/outbox.html#draft='+escapeHtml(String(r.id))+'">'
10701              +'<span class="fic">'+svg(IC.inbox)+'</span>'
10702              +'<span class="fid">'+escapeHtml(mdDate(r.created_at))+'</span>'
10703              +'<span class="fname">'+(who?(escapeHtml(who)+' · '):'')+escapeHtml(subj)+'</span>'
10704              +'<span class="ftime">'+escapeHtml(mdTime(r.created_at))+'</span></a>';
10705          });
10706          if(_mdMailN>_mdMailRows.length) html+='<div class="mc-note">'+(_mdMailN-_mdMailRows.length)+' more - open Mail for the rest.</div>';
10707        } else {
10708          html+='<div class="mc-note">Open Mail for the full list.</div>';
10709        }
10710      } else {
10711        html='<div class="qs-empty">No mail waiting.</div>';
10712      }
10713      host.innerHTML=html;
10714      renderMbKpis();
10715    }
10716    if(!_mdMailFetched){
10717      _mdMailFetched=true;
10718      try{ var s=sessionStorage.getItem('quasar.badge.mail'); if(s!==null) paint(Number(s)||0); }catch(e){}
10719      fn('outbox-count').then(function(r){ return r.ok?r.json():null; })
10720        .then(function(j){ if(j) paint(j.pending||0); }).catch(function(){});
10721    } else {
10722      paint(null);
10723    }
10724    if(!_mdMailRowsFetched && sb && _mdMe && _mdMe.id && !dashDemo()){
10725      _mdMailRowsFetched=true;
10726      sb.from('email_drafts').select('id,source_from,source_subject,draft_subject,created_at')
10727        .eq('owner_id',_mdMe.id).eq('status','pending').is('deleted_at',null)
10728        .order('created_at',{ascending:false}).limit(5)
10729        .then(function(r){ _mdMailRows=(r&&r.data)||[]; paint(null); }, function(){ _mdMailRows=[]; paint(null); });
10730    }
10731  }
10732  // HQ-417: Needs me - the generic per-user notifications surface (distinct
10733  // from the admin-scoped #mb-attn leave-approvals card above). Paints
10734  // instantly from the nav's badge cache (quasar.badge.bell - notifications-
10735  // count's { unread }), then confirms with its own call. Both the head
10736  // "Open" link and the inline link open the same bell panel quasar-nav.js
10737  // already builds - a real click on its button, never a duplicated panel.
10738  function renderMyDashNeedsMe(){
10739    var host=$('mb-needs'); if(!host) return;
10740    // HQ-423: same repaint-on-every-call fix as renderMyDashMail above - the
10741    // fetch itself stays gated once, but the paint (the list + the mb-kpis
10742    // "Needs Me" tile) repaints on every call so it reflects the latest rows.
10743    // HQ-519 (Adam: "needs me needs to be a list"): the actual unread
10744    // notifications, mirroring notifications-count / -mark-read's OWN feedScope
10745    // audience rule (own rows; named team-notification viewers also see the
10746    // ownerless team alerts) - the same rule the bell panel itself mirrors
10747    // (quasar-comms.js loadNotifs), never a third, diverging definition of
10748    // "addressed to you".
10749    // HQ-1213 (this round, Adam: "use the same actions required for staff where
10750    // the needs me box instead"): each row rebuilds onto the shared .aq-row
10751    // component (renderDeptAttn's own idiom - icon chip + title + urgency pill +
10752    // chevron) instead of the old .fs-row/mb-needsrow markup. A per-notification
10753    // row has no natural COUNT the way an Action Required category does, so aq-n
10754    // is left out here (the component is flex-laid-out, not a fixed grid - a
10755    // missing slot costs nothing); the pill instead carries how long the
10756    // notification has sat unread, in the same vocabulary the rest of the page's
10757    // urgency pills already use (New/Pending/Overdue), derived purely from its
10758    // own created_at - no invented category. A linked row stays a real <a> (no
10759    // JS needed, exactly as before); a linkless row - previously dead, nothing
10760    // happened on click - now opens the bell panel, the same fallback the
10761    // "Open the bell" empty-state note below already offers.
10762    function needsPill(createdAt){
10763      var ageMs=Date.now()-(Date.parse(createdAt)||Date.now());
10764      if(ageMs<86400000) return { pc:'blue', pill:'New', col:C.blue };
10765      if(ageMs<7*86400000) return { pc:'amber', pill:'Pending', col:C.amber };
10766      return { pc:'red', pill:'Overdue', col:C.red };
10767    }
10768    function paint(n){
10769      if(n!=null) _mdNeedsN=n;
10770      var html;
10771      if(_mdNeedsN>0){
10772        html='<div class="mc-figure"><b>'+_mdNeedsN+'</b> notification'+(_mdNeedsN===1?'':'s')+' need'+(_mdNeedsN===1?'s':'')+' you</div>';
10773        if(_mdNeedsRows===null){
10774          html+='<div class="mc-note">Loading…</div>';
10775        } else if(_mdNeedsRows.length){
10776          _mdNeedsRows.forEach(function(r,i){
10777            var body=String(r.body||'').replace(/\s+/g,' ').trim();
10778            var age=needsPill(r.created_at);
10779            var tag=r.link?'a':'div';
10780            html+='<'+tag+' class="aq-row"'+(r.link?(' href="'+escapeHtml(r.link)+'"'):(' role="button" tabindex="0" data-need-bell="'+i+'"'))+' title="'+escapeHtml(r.title||'')+'">'
10781              +'<div class="aq-ic" style="background:color-mix(in srgb, '+age.col+' 14%, transparent);color:'+age.col+'">'+svg(IC.alert)+'</div>'
10782              +'<span class="aq-t">'+escapeHtml(r.title||'')+(body?(' · '+escapeHtml(body)):'')+'</span>'
10783              +'<span class="urg '+age.pc+'">'+age.pill+'</span>'
10784              +'<span class="chev">'+svg('<path d="M9 18l6-6-6-6"/>')+'</span></'+tag+'>';
10785          });
10786          if(_mdNeedsN>_mdNeedsRows.length) html+='<div class="mc-note">'+(_mdNeedsN-_mdNeedsRows.length)+' more - open the bell in the top bar.</div>';
10787        } else {
10788          html+='<div class="mc-note"><a id="mb-needs-inline" href="#">Open the bell</a> for the full list.</div>';
10789        }
10790      } else {
10791        html='<div class="qs-empty">Nothing needs you right now.</div>';
10792      }
10793      host.innerHTML=html;
10794      var inl=host.querySelector('#mb-needs-inline');
10795      if(inl) inl.addEventListener('click', function(e){ e.preventDefault(); openBellPanel(); });
10796      Array.prototype.forEach.call(host.querySelectorAll('[data-need-bell]'), function(el){
10797        el.addEventListener('click', openBellPanel);
10798        el.addEventListener('keydown', function(e){ if(e.key==='Enter') openBellPanel(); });
10799      });
10800      renderMbKpis();
10801    }
10802    if(!_mdNeedsFetched){
10803      _mdNeedsFetched=true;
10804      try{ var s=sessionStorage.getItem('quasar.badge.bell'); if(s!==null) paint(Number(s)||0); }catch(e){}
10805      fn('notifications-count').then(function(r){ return r.ok?r.json():null; })
10806        .then(function(j){ if(j) paint(j.unread||0); }).catch(function(){});
10807    } else {
10808      paint(null);
10809    }
10810    if(!_mdNeedsRowsFetched && sb && _mdMe && _mdMe.id && !dashDemo()){
10811      _mdNeedsRowsFetched=true;
10812      var mgmt=dashNamedAuthority(_mdMe,'team_notification_viewers',function(){return !!(_mdMe.is_admin||_mdMe.money_authorised);});
10813      var q=sb.from('notifications').select('id,title,body,link,read_at,created_at,owner_id').is('deleted_at',null).is('read_at',null);
10814      q=mgmt ? q.or('owner_id.is.null,owner_id.eq.'+_mdMe.id) : q.eq('owner_id',_mdMe.id);
10815      q.order('created_at',{ascending:false}).limit(5)
10816        .then(function(r){ _mdNeedsRows=(r&&r.data)||[]; paint(null); }, function(){ _mdNeedsRows=[]; paint(null); });
10817    }
10818  }
10819  function renderMyDash(){
10820    // Real mode renders the LIVE personal slice above; demo keeps its seeded staff
10821    // theatre below (MY_PEOPLE / MY_PROFILES) untouched.
10822    if(!dashDemo()){ renderMyDashReal(); return; }
10823    // The per-card View pop-outs are built from the REAL-mode state (_mdClock,
10824    // _mdLeave, the range-keyed bookings), none of which demo mode populates - so hide
10825    // the affordance here rather than offering a link that opens an empty sheet.
10826    Array.prototype.forEach.call(document.querySelectorAll('#dash-focus-mydash .mb-viewlnk'), function(a){ a.hidden=true; });
10827    var u=currentUser();
10828    var matched=personForUser(u);
10829    var key=MY_PREVIEW||matched||MY_CUR;
10830    if(!MY_PEOPLE[key]) key='adam';
10831    MY_CUR=key;
10832    var me=MY_PEOPLE[key], prof=MY_PROFILES[me.profile];
10833    var hdr=$('mydash-hdr');
10834    if(hdr){
10835      // "Home" = the signed-in person; the demo session anchors on Adam so the
10836      // manager-filter behaviour is demoable without a login.
10837      var home=matched||'adam', isDemo=!matched;
10838      // Staff under the home person: the approver chain, expanded transitively
10839      // (Fiona reports to Adam, so Fiona's own reports would surface too).
10840      function reportsOf(rootKey){
10841        var names={}, out=[], grew=true;
10842        names[MY_PEOPLE[rootKey].name]=1;
10843        while(grew){
10844          grew=false;
10845          Object.keys(MY_PEOPLE).forEach(function(k){
10846            if(k===rootKey || out.indexOf(k)>=0) return;
10847            if(names[MY_PEOPLE[k].approver]){ out.push(k); names[MY_PEOPLE[k].name]=1; grew=true; }
10848          });
10849        }
10850        return out;
10851      }
10852      var team=reportsOf(home);
10853      function initials(n){
10854        var p=String(n).trim().split(/\s+/);
10855        return (p[0].charAt(0)+(p.length>1?p[p.length-1].charAt(0):'')).toUpperCase();
10856      }
10857      var viewingSelf=(key===home);
10858      var sub;
10859      if(viewingSelf){
10860        var qn=prof.queue.reduce(function(s,q){ return s+q.n; },0);
10861        sub=new Date().toLocaleDateString('en-AU',{weekday:'long',day:'numeric',month:'long'})
10862          +' · '+me.bookings.length+' booking'+(me.bookings.length===1?'':'s')+' this week'
10863          +(qn?(' · '+qn+' item'+(qn===1?'':'s')+' in your queue'):'');
10864      } else {
10865        sub='Viewing '+me.name+'&#39;s dashboard · <a id="mb-back">Back to my dashboard</a>';
10866      }
10867      var h='<span class="sa-av">'+initials(me.name)+'</span>'
10868        +'<span class="sa-tt"><span class="sa-name">'+(viewingSelf?('Welcome back, '+me.name):me.name)+'</span>'
10869        +'<span class="sa-sub">'+sub+'</span></span>'
10870        +'<span class="sa-role">'+me.role+' · '+me.dept+'</span>'
10871        +(isDemo?('<span class="sa-demo" title="Demo session - signed in as '+((u&&u.email)||'[email protected]')+'">Demo</span>'):'');
10872      if(team.length || isDemo){
10873        h+='<button class="sa-view" id="mb-view" type="button" title="View your own dashboard or a team member&#39;s">'
10874          +svg(IC.users)+(viewingSelf?'My team':me.name)
10875          +'<svg class="car" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg></button>';
10876        var pop='<div class="sa-pop" id="mb-viewpop" role="menu">';
10877        function itemRow(k){
10878          var p=MY_PEOPLE[k];
10879          return '<div class="sp-it" role="menuitem" tabindex="0" data-k="'+k+'">'
10880            +'<span class="sp-av">'+initials(p.name)+'</span>'
10881            +'<span class="sp-tt"><span class="sp-n">'+p.name+(k===home?' (you)':'')+'</span><span class="sp-r">'+p.role+' · '+p.dept+'</span></span>'
10882            +(k===key?'<svg class="sp-ck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>':'')
10883            +'</div>';
10884        }
10885        pop+='<div class="sp-h">Me</div>'+itemRow(home);
10886        if(team.length){
10887          pop+='<div class="sp-h">My team ('+team.length+')</div>';
10888          team.forEach(function(k){ pop+=itemRow(k); });
10889        }
10890        if(isDemo){
10891          var rest=Object.keys(MY_PEOPLE).filter(function(k){ return k!==home && team.indexOf(k)<0; });
10892          if(rest.length){
10893            pop+='<div class="sp-h">Everyone (demo)</div>';
10894            rest.forEach(function(k){ pop+=itemRow(k); });
10895          }
10896          pop+='<div class="sp-note">Demo session: preview any teammate. Signed in, this menu shows only you and the staff who report up to you.</div>';
10897        }
10898        pop+='</div>';
10899        h+=pop;
10900      }
10901      // init()'s timed sessionSync passes re-run this render while the shim
10902      // resolves - carry the menu's open state across the rebuild so a click
10903      // in the first seconds is not silently undone.
10904      var wasOpen=(function(){ var p=$('mb-viewpop'); return !!(p&&p.classList.contains('open')); })();
10905      hdr.innerHTML=h;
10906      var vb=$('mb-view'), vp=$('mb-viewpop');
10907      if(vb&&vp&&wasOpen){ vp.classList.add('open'); vb.classList.add('open'); }
10908      if(vb&&vp){
10909        vb.addEventListener('click', function(e){
10910          e.stopPropagation();
10911          var o=vp.classList.toggle('open'); vb.classList.toggle('open',o);
10912        });
10913        vp.addEventListener('click', function(e){
10914          e.stopPropagation();
10915          var it=e.target.closest('.sp-it'); if(!it) return;
10916          var k=it.getAttribute('data-k');
10917          // Picking yourself while signed in exits preview mode entirely.
10918          // Close before re-render so the carried-over open state stays fal
10918se.
10919          MY_PREVIEW=(k===home && matched)?null:k;
10920          vp.classList.remove('open');
10921          renderMyDash();
10922        });
10923        vp.addEventListener('keydown', function(e){
10924          if(e.key!=='Enter') return;
10925          var it=e.target.closest('.sp-it'); if(!it) return;
10926          MY_PREVIEW=(it.getAttribute('data-k')===home && matched)?null:it.getAttribute('data-k');
10927          vp.classList.remove('open');
10928          renderMyDash();
10929        });
10930        // One document-level closer for the life of the page (renders swap the
10931        // pop's DOM, so query at click time rather than closing over nodes).
10932        // Untrusted clicks are ignored: the ?__sec render helper synthesises
10933        // subtab clicks for 3.6s after load, which must not yank the menu shut.
10934        if(!window.__mbViewAway){
10935          window.__mbViewAway=true;
10936          document.addEventListener('click', function(e){
10937            if(e && e.isTrusted===false) return;
10938            var p=$('mb-viewpop'), b=$('mb-view');
10939            if(p) p.classList.remove('open');
10940            if(b) b.classList.remove('open');
10941          });
10942        }
10943      }
10944      var back=$('mb-back');
10945      if(back) back.addEventListener('click', function(){ MY_PREVIEW=matched?null:home; renderMyDash(); });
10946    }
10947    renderDeptKpis('mb-kpis', prof.kpis);
10948    renderToday(me);
10949    var bk=$('mb-book');
10950    if(me.bookings.length){
10951      deptRows('mb-book', me.bookings, 'This week', me.bookings.length+' booking'+(me.bookings.length===1?'':'s'));
10952    } else if(bk){
10953      bk.innerHTML='<div class="fs-row empty"><span class="fname">No bookings this week - check the Ops calendar</span></div>';
10954    }
10955    if(prof.queue.length){
10956      renderDeptAttn('mb-attn', prof.queue);
10957    } else {
10958      var at=$('mb-attn');
10959      if(at) at.innerHTML='<div class="fs-row empty"><span class="fname">All clear - nothing needs you right now</span></div>';
10960    }
10961    renderDeepLinks('mb-links', prof.links);
10962  }
10963
10964  function renderDashboard(){
10965    // Activity belongs to My Dashboard too; its existing queries enforce their own scope.
10966    renderRecent();
10967    // Real company panels are not even requested until the Access Group verdict
10968    // permits Sales. My Dashboard remains the fail-closed landing while access
10969    // resolves, and for every non-Sales staff member after it resolves.
10970    if(!dashDemo() && !(window.QACL && typeof window.QACL.canTab==='function' && window.QACL.canTab('sales'))){
10971      renderMyDash(); return;
10972    }
10973    // The legacy territory globe stays mounted beneath this overlay but is NEVER
10974    // revealed anymore - stop painting it once the dashboard is up (HQ-331: Adam saw
10975    // it flash through on a trackpad overscroll). Runtime style only (ui-drift-safe);
10976    // the login backdrop is untouched because this runs post-auth.
10977    try{ var _gw=document.getElementById('globe'); if(_gw){ var _wrap=_gw.parentElement||_gw; _wrap.style.visibility='hidden'; } }catch(_){}
10978    // Fresh render => fresh shared reads (memoised across the panels of one render).
10979    // Review fix (HQ-452): this used to inline its own list, which drifted the moment
10980    // the range-keyed memos were added - three of the nine survived a re-render and
10981    // served the PREVIOUS window's numbers. dashMemosClear() is the one list now.
10982    dashMemosClear();
10983    renderKPIs(); renderAction(); renderPipeline();
10984    renderProduction();
10985    renderTeam(); renderLeadOwners(); renderPopular();
10986    renderRevenue(); renderAOV(); renderOrderActivity();
10987    renderMyDash();
10988  }
10989
10990  // Section switch: Overview (management) shows the full company grid - no
10991  // sec- class; My Dashboard adds sec-mydash. #dashboard always keeps .show.
10992  var DEPT_SECS={
10993    mydash: { title:'My Dashboard', sub:'Your day at a glance - clock, leave and your bookings across production, field service, support and projects.' }
10994  };
10995  // HQ-1222 (2026-09-16): a SEPARATE, per-person narrowing on top of the mgmt/staff
10996  // gate above - Adam: "i onyl want certain people to have access to that module on
10997  // amounts etc, like Donovan doesnt need the company dashboard" - a named individual
10998  // who legitimately passes isMgmtUser (holds Sales, say) but whose own A
10998ccess Group
10999  // still has Company Overview unticked. Same HQ-1155 mechanism web/manufacturing.html
11000  // and web/sales.html use (netlify/functions/_roles-config.cjs MODULE_SECTION_KEYS.main
11001  // / hiddenSectionsFor), read independently via the shared boot-time auth-user flight
11002  // (window.QAUTH_USER) rather than through _dashAuth/fetchDashAuth above - those exist
11003  // to decide mgmt-vs-staff and are deliberately left alone; this is an ADDITIONAL hide,
11004  // never a replacement, same union-only rule HQ-1155 already documents for Manufacturing.
11005  // Starts false (fail open - nothing hidden) until the flight resolves, same posture as
11006  // every other HQ-1155 consumer; demo mode never narrows (see the demo branch below).
11007  var mainOverviewHiddenByGroup=false;
11008  function applyMainSectionVisibility(hidden){
11009    if(!hidden)return;
11010    var wasOverview=!!document.querySelector('#subtabs [data-sec="overview"].on');
11011    mainOverviewHiddenByGroup=true;
11012    var ob=document.querySelector('#subtabs [data-sec="overview"]');
11013    if(ob) ob.remove();
11014    // HQ-1418: refresh FIRST - quasarNavRefresh rebuilds every sidebar node, so a hide
11015    // applied before it was thrown away straight after. (The nav now also hides it itself
11016    // under the 'main' key; this stays as the belt for a stale session mirror.)
11017    if(typeof window.quasarNavRefresh==='function')window.quasarNavRefresh();
11018    Array.prototype.forEach.call(document.querySelectorAll('.qside-sub
11018s .qside-sub'), function(a){
11019      if((a.textContent||'').trim()==='Company Overview') a.style.display='none';
11020    });
11021    if(wasOverview) showSection('mydash');
11022  }
11023  function fetchMainHiddenSections(){
11024    if(dashDemo())return;
11025    var flight=(window.QAUTH_USER&&typeof window.QAUTH_USER.then==='function')
11026      ? window.QAUTH_USER.then(function(r){ return r&&r.j&&r.j.user; })
11027      : fn('auth-user').then(function(r){ return r.ok?r.json():null; }).then(function(j){ return j&&j.user; });
11028    flight.then(function(u){
11029      if(!u||u.is_admin)return;
11030      var hs=u.hidden_sections&&u.hidden_sections.main;
11031      applyMainSectionVisibility(Array.isArray(hs)&&hs.indexOf('overview')>=0);
11032    }).catch(function(){ /* unknown stays gated off - fail open */ });
11033  }
11034  // Is the current session KNOWN to be non-management? The cached verdict
11035  // (written by applyDashGate) is authoritative once present; a resolved
11036  // currentUser() is the live fallback. This is the structural counterpart to
11037  // the nav-only hide: it stops showSection from re-exposing the management
11038  // Overview grid for a staff user (e.g. via the ?__sec=overview render
11039  // helper clicking a momentarily-present button).
11040  // HQ-417: demo mode keeps its original fail-open posture (unchanged - demo
11041  // never shows real financials anyway). Real mode FAILS CLOSED: absent /
11042  // unresolved / unknown => staff => My Dashboard, never the company tiles.
11043  function dashIsStaff(){
11044    // HQ-1222: checked first and unconditionally - a person this Access Group
11045    // narrows stays narrowed regardless of what the mgmt cache below says.
11046    if(mainOverviewHiddenByGroup)return true;
11047    if(dashDemo()){
11048      try{ if(sessionStorage.getItem(DASH_MGMT_KEY)==='0') return true; }catch(e){}
11049      var u=currentUser();
11050      if(u) return !isManagement(u);   // resolved session that is not management
11051      return false;                    // demo: unresolved => fail open (unchanged)
11052    }
11053    try{
11054      var cached=sessionStorage.getItem(DASH_MGMT_KEY);
11055      if(cached==='0') return true;
11056      if(cached==='1') return false;
11057    }catch(e){}
11058    if(_dashAuth) return !isMgmtUser(_dashAuth);
11059    return true; // real mode, unresolved/unknown => staff (company numbers hidden)
11060  }
11061  function showSection(sec){
11062    var dash=$('dashboard'); if(!dash) return;
11063    // Guard: Overview is management-only. A known-staff session is redirected
11064    // to My Dashboard, so the section itself is unreachable - not merely hidden
11065    // in the nav. (HQ-417: real mode fails CLOSED while unresolved - see
11066    // dashIsStaff(); demo mode keeps its original fail-open posture.)
11067    if(sec==='overview' && dashIsStaff()) sec='mydash';
11068    dash.classList.add('show');
11069    // HQ-536: #dashboard is ONE fixed, internally-scrolling element
11070    // (overflow-y:auto) shared by BOTH Company Overview and My Dashboard -
11071    // switching the section only toggles which children are visible, it never
11072    // touched dash.scrollTop. So a management viewer who had scrolled Overview
11073    // down (or a stale/restored scroll offset from an earlier load) carried
11074    // straight into My Dashboard, leaving the container scrolled past its own
11075    // top - the welcome bar/title (the first thing in the DOM) rendered
11076    // visibly clipped because the viewport was not looking at the top of the
11077    // newly-shown section. Reset on every section switch, including the
11078    // initial landing calls above (dashHomePref/applyDashGate), so a fresh
11079    // section always opens scrolled to its own top.
11080    dash.scrollTop = 0;
11081    // Drive the focus-view CSS: strip any prior sec-* class, then add the one
11082    // for the active department (none for Overview).
11083    Array.prototype.slice.call(dash.classList).forEach(function(c){
11084      if(c.indexOf('sec-')===0) dash.classList.remove(c);
11085    });
11086    if(DEPT_SECS[sec]) dash.classList.add('sec-'+sec);
11087    var btns=document.querySelectorAll('#subtabs button');
11088    Array.prototype.forEach.call(btns, function(b){ b.classList.toggle('on', b.getAttribute('data-sec')===sec); });
11089    // Swap the relocated header title to match the active view.
11090    var dept=DEPT_SECS[sec];
11091    var t=$('page-title'), s=$('page-sub');
11092    if(t) t.textContent = dept ? dept.title : 'Company Overview';
11093    if(s) s.textContent = dept ? dept.sub
11094      : 'Company-wide overview. Sales values exclude GST.';
11095  }
11096
11097  // Small anchored popover menu used by the mini-selects and range control.
11098  function popMenu(anchor, options, onPick){
11099    var existing=document.getElementById('dash-popmenu'); if(existing) existing.remove();
11100    var menu=el('div','', ''); menu.id='dash-popmenu';
11101    menu.style.cssText='position:fixed;z-index:4100;min-width:150px;background:var(--bg-panel-solid);border:1px solid var(--line-strong);border-radius:10px;box-shadow:0 14px 40px rgba(0,0,0,.5);padding:5px;';
11102    options.forEach(function(opt){
11103      var it=el('div','',opt);
11104      it.style.cssText='padding:8px 11px;font-size:12.5px;color:var(--ink-dim);border-radius:7px;cursor:pointer;white-space:nowrap;';
11105      it.addEventListener('mouseenter',function(){ it.style.background='var(--hover-bg-strong)'; it.style.color='var(--ink)'; });
11106      it.addEventListener('mouseleave',function(){ it.style.background='transparent'; it.style.color='var(--ink-dim)'; });
11107      it.addEventListener('click', function(){ menu.remove(); onPick(opt); });
11108      menu.appendChild(it);
11109    });
11110    document.body.appendChild(menu);
11111    var r=anchor.getBoundingClientRect();
11112    var top=r.bottom+6, left=r.left;
11113    if(left+menu.offsetWidth>window.innerWidth-8) left=window.innerWidth-8-menu.offsetWidth;
11114    if(top+menu.offsetHeight>window.innerHeight-8) top=r.top-6-menu.offsetHeight;
11115    menu.style.top=top+'px'; menu.style.left=Math.max(8,left)+'px';
11116    setTimeout(function(){
11117      function away(e){ if(!menu.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',away); } }
11118      document.addEventListener('mousedown',away);
11119    },0);
11120  }
11121
11122  // HQ-452: the header range picker is REAL now. It was refused in real mode
11123  // ("not wired to real data yet") because every panel read a hardcoded
11124  // month-to-date window; dashRangeWindow + the range-keyed memos fixed that, so
11125  // this sets the one source of truth, syncs every label, drops the memos for the
11126  // ranges it is about to redraw and re-renders the panels that can honestly move.
11127  // The panels it does NOT re-render are the ones whose server takes no date window
11128  // at all (Action Required and Production Overview, plus the two ops-sourced KPI
11129  // tiles); each of those says on its own face that it is a live "right now" count,
11130  // so a picked range never implies it filtered them.
11131  function setRange(lbl){
11132    DASH_RANGE=lbl;
11133    PIPE_RANGE=lbl;                       // the in-card picker mirrors the header
11134    var el1=$('ph-range-lbl'); if(el1) el1.textContent=lbl;
11135    // Every in-card picker shows the same window as the header.
11136    Array.prototype.forEach.call(document.querySelectorAll('#dashboard .mini-sel'), function(sel){
11137      sel.textContent=lbl+' ▾';
11138    });
11139    if(dashDemo()){ toast('Dashboard range: '+lbl+' (demo)'); return; }
11140    // Real mode: a picked range must REFETCH, not reuse a memo from the last window.
11141    // Same one list as renderDashboard - adding a memo can only ever be missed once.
11142    dashMemosClear();
11143    renderKPIs();
11144    renderPipeline();
11145    renderPopular();
11146    renderRevenue();
11147    renderAOV();
11148    renderOrderActivity();
11149    // Leads by Owner is NOT here on purpose: an open lead is open right now, so it
11150    // does not read the date window (the same reason Action Required and Production
11151    // Overview are absent from this list).
11152    // My Dashboard's own range-aware card (what you are assigned in this window).
11153    renderMyDash();
11154    toast('Dashboard range: '+lbl);
11155  }
11156
11157  function wireDashControls(){
11158    var dash=$('dashboard'); if(!dash) return;
11159
11160    // Header: the date range + Filters
11161    var rng=$('ph-range');
11162    if(rng) rng.addEventListener('click', function(){
11163      popMenu(rng, DASH_RANGES, function(v){ setRange(v); });
11164    });
11165    var flt=$('ph-filters');
11166    if($('ph-filters-label')) $('ph-filters-label').textContent=dashDemo()?'Filters':'About this view';
11167    if(flt) flt.addEventListener('click', function(){
11168      // HQ-452: in real mode this used to open a form whose Owner options were two
11169      // hardcoded staff names and whose Apply then refused - a fake control wearing a
11170      // real one's clothes. Real mode now states what the picker actually does (the
11171      // date range, which IS wired) instead of offering scopes nothing implements.
11172      if(!dashDemo()){
11173        var personal=dash.classList.contains('sec-mydash');
11174        openModal({ icon:IC.projects, title:personal?'About My Dashboard':'About Company Overview', sub:'How to read this dashboard',
11175          infoRows:personal?[
11176            ['Your hours','Today shows the current day and Thursday-Wednesday working week. Hours are shown in 15-minute blocks; open Timesheets to review the recorded entries.'],
11177            ['Selected period','The date range changes Assigned Work. Your hours keep the current working week.'],
11178            ['Your queues','Mail, approvals, leads and notifications show the work currently available to you. Open a card to review its records.']
11179          ]:[
11180            ['Date range','Cash received uses Xero payment dates. Awaiting payment and unpaid credit sales show current balances on invoices raised throughout the selected calendar month in monthly views, including later invoice dates. Other ranges use their exact dates. Sales Pipeline uses record update dates. Field jobs use their scheduled dates.'],
11181            ['Cash received','Reconciled bank payments and verified website card receipts, by payment date. Same amount in the tile and graph. AUD excluding GST.'],
11182            ['Current position','Action Required, Production Overview and open production and support counts show the current position, regardless of the selected period.'],
11183            ['Explore the records','Open a card or its View all link to review the underlying work. Use the filters in that module to narrow its records.']
11184          ],
11185          saveLabel:'Open Reports', cancelLabel:'Close', openHref:modHref('reports') });
11186        return;
11187      }
11188      openModal({ icon:IC.projects, title:'Dashboard Filters', sub:'Scope the overview (demo)',
11189        fields:[
11190          {key:'module',label:'Module',type:'select',options:['All modules','Sales','Manufacturing','Field Service','Support','Inventory']},
11191          {key:'owner',label:'Owner',type:'select',options:['Everyone','Adam Daglish','Jonathan Turnbull','My team']},
11192          {key:'region',label:'Region',type:'select',options:['Global','Australia','Europe','North America','Asia-Pacific']}
11193        ],
11194        saveLabel:'Apply filters',
11195        onSave:function(v){ toast('Filters applied: '+v.module+' · '+v.owner+' (demo)','good'); } });
11196    });
11197
11198    // Total Revenue "vs Last Period" toggle - redraw only, never a re-fetch: the
11199    // prior-period series is already sitting in _dashRevLast from the last render.
11200    var revBtn=$('dash-rev-compare-btn');
11201    if(revBtn) revBtn.addEventListener('click', function(){
11202      DASH_REV_COMPARE=!DASH_REV_COMPARE;
11203      revBtn.classList.toggle('active', DASH_REV_COMPARE);
11204      revBtn.setAttribute('aria-pressed', DASH_REV_COMPARE?'true':'false');
11205      // Unpaid credit is NOT part of the toggle - it is a second view of the current
11206      // period, not a baseline, so it stays on whichever way the comparison goes.
11207      if(_dashRevLast) drawTrendChart($('dash-revenue'), _dashRevLast.cur, _dashRevLast.prev, _dashRevLast.grain, DASH_REV_COMPARE, {
11208        gid:'revsnapg',
11209        extra:{ series:_dashRevLast.quotes||[], pick:function(r){ return Number(r.quotesValue)||0; } }
11210      });
11211    });
11212
11213    // Mini-selects inside cards. HQ-452: these were three different behaviours (one
11214    // set the range, one set its own PIPE_RANGE, the rest just toasted "(demo)").
11215    // They are all the SAME control now - the header's range - so no two panels can
11216    // ever show different periods. Same option list as the header, including
11217    // 'This Year', which the in-card menu used to omit.
11218    Array.prototype.forEach.call(dash.querySelectorAll('.mini-sel'), function(sel){
11219      sel.addEventListener('click', function(){
11220        popMenu(sel, DASH_RANGES, function(v){ setRange(v); });
11221      });
11222    });
11223
11224    // "View all" links that are demo-only (href="#")
11225    Array.prototype.forEach.call(dash.querySelectorAll('.card-head a.lnk'), function(a){
11226      var href=a.getAttribute('href')||'';
11227      if(href==='#' || href===''){
11228        a.addEventListener('click', function(e){ e.preventDefault(); toast('Opening full view (demo)'); });
11229      }
11230    });
11231
11232    // HQ-1213 round 10: the Business Snapshot legend's click-to-fade is gone with the
11233    // card. It only ever faded the LABEL - no series was ever hidden - so on Total
11234    // Revenue's legend it would have been a control that does nothing.
11235
11236    // List rows: Action / Inventory / Field / Recent wired individually in
11237    // their renderers. Delegate the remaining lists (pipeline, production,
11238    // team) - each opens a pop-out with a real "Open in <module>" deep link.
11239    function rowText(row){ return (row.textContent||'').replace(/\s+/g,' ').trim(); }
11240    dash.addEventListener('click', function(e){
11241      var row=e.target.closest('.pipe-row,.prod-row,.tm-row');
11242      if(!row || !dash.contains(row)) return;
11243      if(e.target.closest('a')) return; // real links inside rows navigate themselves
11244      var txt=rowText(row);
11245      if(row.classList.contains('prod-row')){
11246        openModal({ icon:IC.gauge, title:'Production line', sub:'Line status (demo)',
11247          infoRows:[['Line',txt]],
11248          saveLabel:'Open in Manufacturing', cancelLabel:'Close',
11249          openHref:modHref('manufacturing','overview') });
11250      } else if(row.classList.contains('pipe-row')){
11251        openModal({ icon:IC.projects, title:'Pipeline stage', sub:'Sales pipeline (demo)',
11252          infoRows:[['Stage',txt]],
11253          saveLabel:'Open in Sales', cancelLabel:'Close',
11254          openHref:modHref('sales','pipeline') });
11255      } else if(row.classList.contains('tm-row')){
11256        var nm=row.querySelector('.tm-name'), dp=row.querySelector('.tm-dept');
11257        openModal({ icon:IC.users, title:(nm?nm.textContent:'Team member'), sub:(dp?dp.textContent:'')+' (demo)',
11258          infoRows:[['Name',nm?nm.textContent:'—'],['Department',dp?dp.textContent:'—'],['Availability','Working']],
11259          saveLabel:'Open in People', cancelLabel:'Close',
11260          openHref:modHref('people','directory') });
11261      }
11262    });
11263  }
11264
11265  // HQ-520 round 3: the HQ-417 "Set as my home" page controls (ph-sethome /
11266  // mb-sethome) moved into the shared shell's account menu (quasar-nav.js) -
11267  // it writes this page's quasar.dash.home.<uid> subview pref too, so
11268  // applyDashGate's overview-vs-mydash pick keeps working unchanged.
11269  function wireDashHomeControls(){
11270    var needs=$('mb-needs-open');
11271    if(needs) needs.addEventListener('click', function(e){ e.preventDefault(); openBellPanel(); });
11272  }
11273
11274  function init(){
11275    if(dashDemo()) renderDashboard();
11276    else renderMyDash();
11277    wireDashControls();
11278    wireDashHomeControls();
11279    wireMyDashViews();   // HQ-452: the per-card "View" pop-outs on My Dashboard
11280    wireQuickPop();
11281    fetchMainHiddenSections();  // HQ-1222: independent of sessionSync below - see its own comment
11282    // Login-aware: the shim resolves the session asynchronously. Re-run the
11283    // identity + gate + repaint a few times to catch it (and to outlast the
11284    // ?__sec helper's 3.6s of repeated clicks) without blocking first paint.
11285    // HQ-417: real mode forks onto the real auth verdict (fetchDashAuth) so
11286    // applyDashGate's mgmt/staff decision and the home-pref landing use the
11287    // resolved role set, never the demo fail-open path; demo mode is
11288    // unchanged (still keyed off the shim's currentUser()).
11289    function sessionSync(){
11290      if(dashDemo()){
11291        var u=currentUser();
11292        if(u) applyDashGate(u);
11293      } else {
11294        Promise.all([
11295          fetchDashAuth(),
11296          Promise.resolve(window.QACL && window.QACL.ready).catch(function(){ return null; })
11297        ]).then(function(){ applyDashGate(currentUser()); });
11298      }
11299      renderMyDash();
11300    }
11301    // Eager pass: on a warm load the shim may already have the session at
11302    // DOMContentLoaded. Running the gate now (before the ?__sec helper's first
11303    // 200ms click and the 400ms timer) writes the verdict and lands staff on My
11304    // Dashboard immediately, shrinking the fresh-tab window where Overview could
11305    // otherwise flash. A cold/unresolved session no-ops here and heals on the
11306    // timed passes (fail-closed in real mode until then, per the gate's contract).
11307    sessionSync();
11308    setTimeout(sessionSync, 400);
11309    setTimeout(sessionSync, 1500);
11310    setTimeout(sessionSync, 3800);
11311    // Cached staff verdict: land on My Dashboard immediately (any ?__sec now
11312    // either targets mydash or no-ops - the Overview button is already gone).
11313    // HQ-417: real mode also defaults a truly UNRESOLVED verdict (no cache at
11314    // all - this tab's first load ever) to My Dashboard - fail CLOSED, never
11315    // Overview. The accepted cost: a manager's very first load lands on My
11316    // Dashboard for under a second until fetchDashAuth resolves and
11317    // applyDashGate's home-pref landing (above) restores Company Overview;
11318    // every later load in the same tab is instant via the cached '1'. Demo
11319    // mode is untouched (only the explicit '0' case, as before).
11320    try{
11321      var cachedMgmt=sessionStorage.getItem(DASH_MGMT_KEY);
11322      if(cachedMgmt==='0' || (!dashDemo() && cachedMgmt!=='1')) showSection('mydash');
11323    }catch(e){}
11324    var btns=document.querySelectorAll('#subtabs button');
11325    Array.prototype.forEach.call(btns, function(b){
11326      b.addEventListener('click', function(){ showSection(b.getAttribute('data-sec')); });
11327    });
11328    // A direct personal link must not wait for the shared navigation's timed
11329    // synthetic click. Use the existing section renderer and access guard.
11330    if(new URLSearchParams(location.search).get('__sec')==='mydash') showSection('mydash');
11331    // Redraw the width-fitted charts when their panel resizes (window resize, sidebar
11332    // rail toggle, the 1920 cap engaging on large monitors). HQ-1213 round 10: this
11333    // watched #dash-snap and redrew Business Snapshot alone - with that card gone it
11334    // follows Total Revenue's panel instead, and redraws all three period charts, which
11335    // costs nothing: each re-awaits the SAME memoised orders-report promise.
11336    var rsT, lastW=0;
11337    function refitSnap(){
11338      var r=$('dash-revenue'); if(!r) return;
11339      var w=r.clientWidth;
11340      if(Math.abs(w-lastW)>2){ lastW=w; renderRevenue(); renderAOV(); }
11341    }
11342    refitSnap();
11343    window.addEventListener('resize', function(){ clearTimeout(rsT); rsT=setTimeout(refitSnap,150); });
11344    if(window.ResizeObserver && $('dash-revenue')){
11345      new ResizeObserver(function(){ clearTimeout(rsT); rsT=setTimeout(refitSnap,60); }).observe($('dash-revenue'));
11346    }
11347    // default: dashboard overview (already .show); honour ?__sec via the nav helper's clicks.
11348  }
11349  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
11350  else init();
11351})();
11352</script>
11352
11353</body>
11354</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.