PageSourceSearch

https://smrtbld.ai/js/pages/portal/portal-dashboard.js

js smrtbld.ai collected 2026-10-05 15:07:56 UTC 8,157 bytes, 157 lines download raw bytes

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.