1/** 2 * portal-dashboard.js â the bidder portal's dashboard landing (Phase E, E8). 3 * 4 * One GET portal/dashboard: KPI tiles (New Invitations · Active Bids · Awaiting Result · 5 * Win Rate â the banked E6 dependency) + New-invitations / Closing-soon rows + the 6 * cross-tender bidder-safe activity feed. Display strings are computed here (JsRender 7 * gets complete data); row helpers are shared with My Tenders via portal/portal-format.js. 8 */ 9import { render, loadTemplates } from '../../utils/templates.js'; 10import { portalSession } from '../../portal/portal-session.js'; 11import { portalTendersApi } from '../../api/portal-tenders.js'; 12import { closing, daysLeftChip, rowChip, fmtDate, fmtMoney } from '../../portal/portal-format.js'; 13 14let templatesLoaded = false; 15let _abort = null; 16let _routeSignal = null; // the route transition's signal â render refuses #app-content once a later route aborts it (CA-556) 17 18export async function initPortalDashboardPage(signal = null) { 19 _abort = new AbortController(); 20 _routeSignal = signal; 21 const mySignal = _abort.signal; 22 if (!templatesLoaded) { 23 // shared-partials = the canonical feed row (#tpl-feed-row-st) 24 await Promise.all([ 25 loadTemplates('portal/portal-dashboard'), 26 loadTemplates('components/shared-partials'), 27 ]); 28 templatesLoaded = true; 29 } 30 if (mySignal.aborted || signal?.aborted) return; // superseded during the template load (CA-556) 31 await loadDashboard(); 32} 33 34async function loadDashboard() { 35 const mySignal = _abort?.signal; 36 const routeSignal = _routeSignal; 37 if (!mySignal) return; 38 // restoreFocus on all three hops: the retry that reached here is destroyed by THIS render, so 39 // the chain must own focus from the skeleton on, not from the error pane (CA-223, MCF-0700). 40 if (await render('#tpl-portal-dashboard-skeleton', {}, undefined, undefined, { restoreFocus: true, signal: routeSignal }) === null) return; 41 42 let data; 43 try { 44 data = await portalTendersApi.getDashboard({ signal: mySignal }); 45 } catch (err) { 46 if (mySignal.aborted) return; 47 // Never strand a bidder on the skeleton (functionality-audit ð ) â error state + retry. 48 window.showToast?.('Could not load your dashboard', 'error', { description: err.message }); 49 const html = await render('#tpl-portal-dashboard-error', {}, undefined, undefined, { restoreFocus: true, signal: routeSignal }); 50 if (html === null || mySignal.aborted) return; 51 document.getElementById('portal-dashboard-retry')?.addEventListener('click', () => loadDashboard(), { signal: mySignal }); 52 return; 53 } 54 if (mySignal.aborted) return; 55 56 const html = await render('#tpl-portal-dashboard', decorate(data), undefined, undefined, { restoreFocus: true, signal: routeSignal }); 57 if (html === null || mySignal.aborted) return; 58 wire(mySignal); 59} 60 61export function destroyPortalDashboardPage() { 62 _abort?.abort(); 63 _abort = null; 64} 65 66function wire(signal) { 67 const opts = signal ? { signal } : {}; 68 // Row click-throughs (buttons â keyboard comes free). "View all" anchors ride page.js. 69 document.getElementById('portal-dashboard-root')?.addEventListener('click', (e) => { 70 // "Continue your bid" (glow-up) â straight into the My Bid tab (deep-linked). 71 const mybid = e.target.closest('[data-goto-mybid]'); 72 if (mybid) { navigate(`/portal/tenders/${mybid.dataset.gotoMybid}/mybid`); return; } 73 const row = e.target.closest('[data-goto-tender]'); 74 if (row) navigate(`/portal/tenders/${row.dataset.gotoTender}`); 75 }, opts); 76} 77 78// ââ Display-field computation (complete data to JsRender â lesson 7c) ââ 79 80function decorate(d) { 81 const bidder = portalSession.getBidder() || {}; 82 const firstName = (bidder.contactName || '').trim().split(/\s+/)[0] || ''; 83 84 const decided = (d.wonCount || 0) + (d.lostCount || 0); 85 // Stage-8 V2 stat-tile contract: {icon, label, value, sub?, variant?} 86 const tiles = [ 87 { icon: 'mail', label: 'New Invitations', value: String(d.newInvitations ?? 0), sub: 'awaiting your response' }, 88 { icon: 'list-checks', label: 'Active Bids', value: String(d.activeBids ?? 0), sub: 'in assembly', variant: 'neutral' }, 89 { icon: 'hourglass', label: 'Awaiting Result', value: String(d.awaitingResult ?? 0), sub: 'submitted', variant: 'warning' }, 90 { 91 icon: 'trophy', label: 'Win Rate', variant: 'success', 92 value: decided ? `${Math.round((d.wonCount / decided) * 100)}%` : 'â', 93 sub: decided ? `${d.wonCount} won of ${decided}` : 'No results yet', 94 }, 95 ]; 96 97 return { 98 firstName, 99 tiles, 100 // "Pick up where you left off" (glow-up) â server-picked soonest-closing assembling 101 // draft, carrying the monitor's REAL N/M (enriched via the bid service, never re-derived). 102 continueBid: d.continueBid ? { 103 tenderId: d.continueBid.tenderId, 104 projectName: d.continueBid.projectName, 105 readyCount: d.continueBid.bidReadyCount ?? 0, 106 requiredCount: d.continueBid.bidRequiredCount ?? 0, 107 pct: d.continueBid.bidRequiredCount 108 ? Math.round(((d.continueBid.bidReadyCount ?? 0) / d.continueBid.bidRequiredCount) * 100) : 0, 109 ...closing(d.continueBid.bidEndDate, d.continueBid.isOpen), 110 } : null, 111 // âï¸ Register ruling 1 â both row lists feed the shared #tpl-tender-row-st contract 112 // (TND eyebrow + chip · title · ð location · right days chip · chevron). 113 invitations: (d.newInvitationTenders || []).map(t => decorateRow(t)), 114 closingSoon: (d.closingSoon || []).map(t => decorateRow(t)), 115 activity: (d.activity || []).map(decorateActivity), 116 // "Since your last visit" (glow-up): server-computed per-row markers (the event postdates 117 // the bidder's last view of its tender) â the header count + per-row "New" chips. 118 newActivityCount: (d.activity || []).filter(a => a.isNew).length, 119 }; 120} 121 122/** One dashboard row on the shared portal tender-row partial (âï¸ ruling 1). */ 123function decorateRow(t) { 124 const chip = rowChip(t); 125 return { 126 tenderId: t.tenderId, 127 tnd: t.tenderNumber || '', 128 chips: [{ label: chip.chipLabel, cls: chip.chipClass, icon: chip.chipIcon }], 129 title: t.projectName,
130 locationText: [t.city, t.provState].filter(Boolean).join(', '), 131 ...daysLeftChip(t.bidEndDate, t.isOpen), 132 }; 133} 134 135// rowChip moved to portal/portal-format.js (2026-07-10) â the header tender-switcher shares it. 136 137/** One cross-tender feed row â generic texts (no buyer name here; the tender line carries context). 138 * âï¸ Ruling 5: the event phrase leads BOLD; the detail stays regular weight. */ 139function decorateActivity(a) { 140 const ref = a.refNum != null ? `TC-${String(a.refNum).padStart(2, '0')}` : ''; 141 const map = { 142 INVITED: { icon: 'mail', lead: 'You were invited to bid', text: '' }, 143 ADDENDUM: { icon: 'file-plus', lead: `Addendum ${a.addendumNum} issued`, text: a.title ? ` â ${a.title}` : '' }, 144 TC_ANSWERED: { icon: 'message-square', lead: `${ref} answered`, text: '' }, 145 TC_ASKED: { icon: 'help-circle', lead: `You asked ${ref}`, text: '' }, 146 BID_SUBMITTED: { icon: 'send', lead: 'Bid submitted', text: a.amount != null ? ` â ${fmtMoney(a.amount)}` : '' }, 147 }; 148 const m = map[a.eventType] || { icon: 'activity', text: a.eventType }; 149 // Canonical feed-row contract (#tpl-feed-row-st): goto â clickable row, ref = right meta prefix, 150 // isNew â the since-your-last-visit "New" chip (server-computed, stable â dashboards never write views) 151 return { ...m, tenderId: a.tenderId, projectName: a.projectName || '', dateStr: fmtDate(a.at), goto: a.tenderId, ref: a.projectName || '', isNew: !!a.isNew }; 152} 153 154function navigate(path) { 155 if (typeof window.page === 'function') window.page(path); 156 else window.location.href = path; 157}
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.