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 & 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 & 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 & 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 > 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 & 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 & 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}}, ...">${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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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">▾</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'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')+''s session">Log in as</button>'):'') 10422 +'</div>'; 10423 }); 10424 pop+='<div class="sp-note">'+(canImp?'Log in as opens that teammate'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+''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'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.