PageSourceSearch

https://medx-admin-portal-v2.netlify.app/js/views/plexus-awards.js

js medx-admin-portal-v2.netlify.app collected 2026-10-10 02:43:44 UTC 51,836 bytes, 789 lines download raw bytes

1// js/views/plexus-awards.js — the Plexus Week hub's AWARDS tab (design/AWARDS-SPEC.md §Admin).
2//
3// No artboard source; built from the hub's own vocabulary — the blockStats() stat strip, the
4// money.js table helpers and the studio.js tool-drawer — so it reads as one screen with the
5// MEETUPS tab beside it.
6//
7// WHY ITS OWN MODULE: js/views/plexus.js is already 1.9k lines and owns four screens. The awards
8// tab is a fifth, with its own data, its own drawer and its own eight handlers, so it lives here
9// and plexus.js delegates: one import, one line in TAB_ORDER, one branch in template(), and the
10// handler map merged into the hub's. Everything shared — the edition chip, the read-only rule,
11// the tab strip — stays exactly where it already was.
12//
13// Permission section `plexus-awards` (server.js mirrors the id and maps /api/v2/awards-ops to
14// it); without it the tab renders the locked state, the same way MEETUPS does.
15import { api } from '../api.js';
16import { session } from '../state.js';
17import { ui, esc, fmt } from '../ui.js';
18import { perms } from '../perms.js';
19
20export const AW_SECTION = 'plexus-awards';
21
22// ---- COPY: every string that may change in a revision ------------------------------------------
23export const COPY_AW = {
24  tab: 'AWARDS', tabLocked: 'AWARDS · LOCKED',
25  locked: 'The awards are locked for you.',
26  stats: {
27    entries: 'ENTRIES', entriesSub: n => `${n} candidate${n === 1 ? '' : 's'} after grouping`,
28    shortlist: 'SHORTLISTED', shortlistSub: 'across all four awards',
29    laureates: 'LAUREATES', laureatesSub: (n, m) => `${n} chosen · ${m} seat${m === 1 ? '' : 's'} on the gala list`,
30    panel: 'PANEL READ', panelSub: n => `${n} reader${n === 1 ? '' : 's'} on the panel`,
31    held: 'HELD FOR REVIEW', heldSub: 'waiting on your approve/reject email'
32  },
33  cats: {
34    title: 'THE FOUR AWARDS', sub: 'nominations, the reading panel, and who takes the stage',
35    windows: { none: 'ORGANIZERS CHOOSE', before: 'NOT OPEN YET', open: 'OPEN', closed: 'CLOSED' },
36    closes: n => n == null ? '' : (n < 0 ? 'closed' : n === 0 ? 'closes today' : `${n} day${n === 1 ? '' : 's'} to close`),
37    opensOn: (a, b) => `opens ${a}${b ? ' · closes ' + b : ''}`, closedOn: d => `closed ${d}`,
38    counts: (e, c) => `${e} entr${e === 1 ? 'y' : 'ies'} · ${c} candidate${c === 1 ? '' : 's'}`,
39    open: 'OPEN', ranking: 'RANKING', settings: 'SETTINGS', add: 'ADD LAUREATE', notify: 'DECIDE & NOTIFY', csv: 'CSV',
40    empty: 'No awards on this edition yet — they seed themselves on the next boot.'
41  },
42  panel: {
43    title: 'ENTRIES', back: '← ALL FOUR AWARDS',
44    filterAll: 'ALL', search: 'Search a name, an institution…',
45    head: { name: 'CANDIDATE', who: 'WHO PUT THEM FORWARD', inst: 'INSTITUTION', score: 'MEAN', n: 'READ', status: 'STATUS' },
46    noms: n => n === 1 ? '1 nomination' : `${n} nominations`,
47    self: 'SELF', merged: 'MERGED',
48    empty: 'Nothing here yet.',
49    emptyWhy: 'Entries appear as people send them — the public page is the only way in.'
50  },
51  acts: {
52    open: 'OPEN', eligible: 'ELIGIBLE', ineligible: 'NOT ELIGIBLE', shortlist: 'SHORTLIST',
53    winner: 'CHOOSE AS WINNER', decline: 'DECLINE', merge: 'MERGE', pdf: 'PDF', note: 'SAVE NOTE', close: 'CLOSE'
54  },
55  status: {
56    received: 'RECEIVED', eligible: 'ELIGIBLE', ineligible: 'NOT ELIGIBLE', shortlisted: 'SHORTLISTED',
57    winner: 'WINNER', declined: 'DECLINED', withdrawn: 'WITHDRAWN', 'pending-review': 'HELD'
58  },
59  drawer: {
60    entry: 'THE ENTRY', scores: 'WHAT THE PANEL SAID', nominators: 'PUT FORWARD BY', notes: 'YOUR NOTE',
61    noScores: 'Nobody has read this one yet.',
62    conflictNote: n => n ? `${n} reader${n === 1 ? '' : 's'} declared a conflict — left out of every number.` : '',
63    mean: (m, n) => m == null ? 'not scored' : `${m} mean · ${n} reader${n === 1 ? '' : 's'}`,
64    spread: s => s == null ? '' : `spread ${s}`,
65    saved: 'NOTE SAVED', statusDone: s => `MOVED TO ${s}`, mergeAsk: (a, b) => `Merge “${a}” into “${b}”? Their nominations become nominations of the surviving name — nothing is deleted.`,
66    mergeOk: 'MERGE', mergeKeep: 'KEEP BOTH', mergePick: 'PICK THE SURVIVING CANDIDATE FIRST',
67    pdfMissing: 'That attachment is not on file storage — nothing to download.'
68  },
69  rank: {
70    title: 'RANKING', sub: 'mean of every score, conflicts excluded',
71    head: { rank: '#', name: 'CANDIDATE', mean: 'MEAN', spread: 'SPREAD', n: 'READ', conf: 'CONFLICTS' },
72    empty: 'Nothing to rank yet — the panel has not scored anything in this award.',
73    note: 'A conflict-flagged score counts for nothing: not in the mean, not in the spread, not in n.',
74    winner: 'WINNER', shortlist: 'SHORTLIST'
75  },
76  reviewers: {
77    title: 'THE READING PANEL', sub: 'a handful of people, one link each, no login',
78    add: '+ ADD A READER', name: 'Name', email: 'Email', cats: 'WHICH AWARDS',
79    send: 'ADD & SEND THE INVITATION', sent: 'INVITATION SENT',
80    resend: 'RESEND', revoke: 'REVOKE', copy: 'COPY LINK', copied: 'LINK COPIED',
81    progress: (a, b) => `${a} of ${b} read`,
82    empty: 'Nobody on the panel yet.',
83    emptyWhy: 'Add two or three people you trust to read. Each gets one private link — no account, no password.',
84    revokeAsk: n => `Revoke ${n}? Their link stops working at once; the scores they already gave stay in the ranking.`,
85    revokeOk: 'REVOKE', revokeKeep: 'KEEP',
86    nameFirst: 'A NAME AND AN EMAIL FIRST', catFirst: 'PICK AT LEAST ONE AWARD',
87    statusTag: { invited: 'INVITED', active: 'READING', revoked: 'REVOKED' }
88  },
89  notify: {
90    title: 'DECIDE & NOTIFY', sub: 'every letter waits in the Outbox for your approval',
91    winners: 'Winners', shortlisted: 'Shortlisted, not selected', declined: 'Everyone else, kindly', nominators: 'Thank-you to nominators',
92    preview: 'PREVIEW', stage: 'STAGE THEM IN THE OUTBOX',
93    staged: n => `${n} LETTER${n === 1 ? '' : 'S'} WAITING IN THE OUTBOX — NOTHING HAS BEEN SENT`,
94    none: 'NOTHING TO STAGE — CHOOSE A WINNER FIRST',
95    note: 'Nothing leaves Med&X from this screen. The drafts land under Inbox → Email & Outbox, and only APPROVE & SEND there puts them on the wire.'
96  },
97  laureate: {
98    title: 'ADD A LAUREATE', sub: 'the Lifetime Bridge is chosen here, not nominated',
99    name: 'Full name', inst: 'Institution', cite: 'Citation (read out at the Gala)', photo: 'Photo URL', email: 'Email (for the letter)',
100    save: 'ADD THE LAUREATE', added: 'LAUREATE ADDED', nameFirst: 'TYPE THE NAME FIRST',
101    remove: 'REMOVE', removeAsk: n => `Remove ${n} from the laureates? The entry itself stays exactly as it is.`,
102    removeOk: 'REMOVE', removeKeep: 'KEEP', removed: 'LAUREATE REMOVED',
103    presents: 'SPEAKS', confirmed: 'CONFIRMED', unconfirmed: 'NOT CONFIRMED', slides: 'SLIDES ON FILE', noSlides: 'NO SLIDES',
104    seat: 'GALA SEAT · COMP', noSeat: 'NO SEAT YET'
105  },
106  settings: {
107    title: 'SETTINGS', sub: 'names, criteria, dates and the rubric — all live',
108    name: 'Name (English)', nameHr: 'Naziv (hrvatski)', cite: 'Citation', citeHr: 'Obrazloženje (hrvatski)',
109    opens: 'Opens (Zagreb time)', closes: 'Closes (Zagreb time)', max: 'How many laureates',
110    self: 'Self-nominations allowed', crit: 'Criteria (English)', critHr: 'Kriteriji (hrvatski)',
111    save: 'SAVE', saved: 'SETTINGS SAVED — THE PUBLIC PAGE READS THEM LIVE'
112  },
113  roster: { title: 'GALA ROSTER', csv: 'ROSTER CSV', sheet: 'PRINTABLE ONE-PAGER', sub: 'for the seating team and the MC' },
114  err: 'Could not load the awards.'
115};
116
117// ---- the hub's shared inline vocabulary (same values js/views/plexus.js uses) -------------------
118const HAIR = 'rgba(32,27,22,.14)', HAIR12 = 'rgba(32,27,22,.12)', HAIR07 = 'rgba(32,27,22,.07)';
119const MICRO = 'font:600 9px Inter,sans-serif;letter-spacing:.12em;color:#6d6459';
120const BTN_GHOST = 'padding:7px 11px;border:1px solid rgba(32,27,22,.2);font:600 9px Inter,sans-serif;letter-spacing:.12em;cursor:pointer;color:#201b16;white-space:nowrap';
121const INPUT2 = 'border:1px solid rgba(32,27,22,.25);background:#f6f2ea;padding:8px 10px;font:400 12.5px Inter,sans-serif;color:#201b16';
122const TONE = {
123  received: ['#f6f2ea', '#6d6459'], eligible: ['#f8f1e2', '#7a6432'], ineligible: ['#f6f2ea', '#9a9086'],
124  shortlisted: ['#1f4f7a', '#fff'], winner: ['#1e6e42', '#fff'], declined: ['#f6f2ea', '#9a9086'],
125  withdrawn: ['#f6f2ea', '#9a9086'], 'pending-review': ['#9b1b22', '#fff']
126};
127
128function tbl(headers, bodyRows, minWidth) {
129  return `
130    <div class="mxp-scroll" style="overflow-x:auto">
131      <table style="width:100%;border-collapse:collapse;min-width:${minWidth || 900}px">
132        <thead><tr>${headers.map(h => `<th style="text-align:${h.r ? 'right' : 'left'};padding:9px 10px;${MICRO};border-bottom:1px solid ${HAIR12};white-space:nowrap">${h.t}</th>`).join('')}</tr></thead>
133        <tbody>${bodyRows}</tbody>
134      </table>
135    </div>`;
136}
137const td = (v, extra) => `<td style="font-size:12.5px;padding:9px 10px;border-bottom:1px solid ${HAIR07};vertical-align:top;${extra || ''}">${v}</td>`;
138const tdNum = v => td(`<span style="font-family:Fraunces,serif;font-size:14px;white-space:nowrap">${v}</span>`, 'text-align:right');
139const tdActs = acts => td(`<span style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap">${acts}</span>`, 'text-align:right');
140const act = (a, id, label, extra) => `<span data-act="${a}" data-id="${esc(id)}"${extra || ''} style="${BTN_GHOST}" data-hover="border-color:#201b16">${label}</span>`;
141const chip = (text, bg, fg) => `<span style="background:${bg};color:${fg};font:600 8.5px Inter,sans-serif;letter-spacing:.12em;padding:3px 7px;white-space:nowrap">${esc(text)}</span>`;
142const fLab = t => `<span style="${MICRO}">${t}</span>`;
143const fText = (role, label, value, ph, span) =>
144  `<label style="display:flex;flex-direction:column;gap:5px;min-width:0${span ? ';grid-column:1 / -1' : ''}">${fLab(esc(label))}<input data-role="${role}" type="text" value="${esc(value == null ? '' : value)}" placeholder="${esc(ph || '')}" aria-label="${esc(label)}" style="width:100%;box-sizing:border-box;${INPUT2}"></label>`;
145const fArea = (role, label, value, rows) =>
146  `<label style="display:flex;flex-direction:column;gap:5px;min-width:0;grid-column:1 / -1">${fLab(esc(label))}<textarea data-role="${role}" rows="${rows || 4}" aria-label="${esc(label)}" style="width:100%;box-sizing:border-box;resize:vertical;${INPUT2}">${esc(value == null ? '' : value)}</textarea></label>`;
147
148async function fetchBlob(path) {
149  const res = await fetch(api.url(path), { headers: { Authorization: 'Bearer ' + session.token } });
150  if (!res.ok) { let j = null; try { j = JSON.parse(await res.text()); } catch (e) {} throw new Error((j && (j.message || j.error)) || ('The export failed (HTTP ' + res.status + ').')); }
151  return res.blob();
152}
153const dl = (blob, name) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
153 a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); };
154// Same anchor trick for a URL the server already signed. The presigned S3 link carries
155// Content-Disposition: attachment, so this downloads instead of navigating away.
156const dlHref = (href, name) => { const a = document.createElement('a'); a.href = href; if (name) a.download = name; a.rel = 'noopener'; a.click(); };
157
158// ---- state --------------------------------------------------------------------------------------
159// H is the host contract js/views/plexus.js hands us once per render: how to repaint, which
160// edition is chosen, and whether that edition is read-only. Nothing else crosses the line.
161let H = null, A = null, S = null;
162
163export function initAwards(host) {
164  H = host;
165  S = { cat: null, data: null, busy: false, filter: '', term: '', drawer: null, entry: null, rank: null, revs: null, notify: null, mergeFrom: null };
166}
167export function setAwardsData(d) { A = d || null; }
168export const awardsLoaded = () => !!A;
169export const canAwards = () => perms.can(AW_SECTION);
170const readOnly = () => !!(H && H.readOnly && H.readOnly()) || !!(A && A.read_only);
171const cats = () => (A && Array.isArray(A.categories)) ? A.categories : [];
172const catById = (id) => cats().find(c => String(c.id) === String(id)) || null;
173const stats = () => (A && A.stats) || {};
174const reviewers = () => (A && Array.isArray(A.reviewers)) ? A.reviewers : [];
175const opsUrl = (tail) => '/api/v2/awards-ops' + tail;
176const edQ = () => { const id = H && H.editionId && H.editionId(); return id ? '?edition=' + encodeURIComponent(id) : ''; };
177
178// ---- data ---------------------------------------------------------------------------------------
179export function loadAwards(editionId) {
180  return api.get(opsUrl('/overview') + (editionId ? '?edition=' + encodeURIComponent(editionId) : ''));
181}
182async function reloadOverview() {
183  try { A = await loadAwards(H && H.editionId && H.editionId()); } catch (e) { ui.toast(e.message, { kind: 'error' }); }
184}
185async function reloadCategory(id) {
186  S.busy = true;
187  paintPanel();
188  try { S.data = await api.get(opsUrl('/categories/' + encodeURIComponent(id) + '/entries')); }
189  catch (e) { ui.toast(e.message, { kind: 'error' }); S.data = { candidates: [], laureates: [], withdrawn: [] }; }
190  S.busy = false;
191  paintPanel();
192}
193function paintAll() { if (H && H.paint) H.paint(); }
194function paintPanel() { if (H && H.paintPart) H.paintPart('[data-block="awBody"]', blockAwBody()); }
195function paintDrawer() { if (H && H.paintPart) H.paintPart('[data-block="awDrawer"]', blockAwDrawer()); }
196
197// ================================================================ BLOCKS
198function blockAwStats() {
199  const s = stats(), c = COPY_AW.stats;
200  const seats = cats().reduce((n, x) => n + (x.laureate_rows || []).filter(l => l.gala_registration_id).length, 0);
201  const cell = (k, v, sub, last) => `
202        <div style="padding:16px 20px;${last ? '' : 'border-right:1px solid rgba(32,27,22,.1)'}">
203          <div style="font:600 9px Inter,sans-serif;letter-spacing:.15em;color:#6d6459">${k}</div>
204          <div class="mx-display-30" style="font-family:Fraunces,serif;font-size:30px;margin-top:3px">${esc(v)}</div>
205          <div style="font-size:11px;color:${sub && /HELD/.test(k) && Number(s.pending_review) ? '#9b1b22' : '#6d6459'}">${esc(sub)}</div>
206        </div>`;
207  return `
208    <!-- v2: "AWARDS — stat strip" (the Plexus hub stat-strip markup, five cells) -->
209    <div data-block="awStats" data-v2="awards stats" style="border:1px solid ${HAIR};background:#fff;margin-top:22px">
210      <div class="mx-kpi" style="display:grid;grid-template-columns:repeat(5,1fr)">
211        ${cell(c.entries, fmt.num(s.entries || 0), c.entriesSub(s.candidates || 0))}
212        ${cell(c.shortlist, fmt.num(s.shortlisted || 0), c.shortlistSub)}
213        ${cell(c.laureates, fmt.num(s.laureates || 0), c.laureatesSub(s.winners || 0, seats))}
214        ${cell(c.panel, (s.reviewer_percent || 0) + '%', c.panelSub(s.reviewers || 0))}
215        ${cell(c.held, fmt.num(s.pending_review || 0), c.heldSub, true)}
216      </div>
217    </div>
218    <!-- /v2 -->`;
219}
220
221function catRow(c) {
222  const t = COPY_AW.cats;
223  const ro = readOnly();
224  const wTone = { none: ['#f6f2ea', '#6d6459'], before: ['#f8f1e2', '#7a6432'], open: ['#1e6e42', '#fff'], closed: ['#f6f2ea', '#9a9086'] }[c.window] || ['#f6f2ea', '#6d6459'];
225  const laur = (c.laureate_rows || []);
226  // two fixed rows: the working buttons, then CSV · SETTINGS as quiet text links — six ghost buttons in one
227  // cell wrapped 3 + 3 on one award and 4 + 1 on the next, SETTINGS alone on its own line
228  const quiet = (a, label) => `<span data-act="${a}" data-id="${esc(c.id)}" style="font:600 8.5px Inter,sans-serif;letter-spacing:.13em;color:#6d6459;cursor:pointer;white-space:nowrap" data-hover="color:#201b16">${label}</span>`;
229  const main = [
230    act('awOpen', c.id, t.open),
231    act('awRank', c.id, t.ranking),
232    c.intake === 'none' && !ro ? act('awLaureate', c.id, t.add) : '',
233    ro ? '' : act('awNotify', c.id, t.notify)
234  ].filter(Boolean).join('');
235  const links = [quiet('awCsv', t.csv), ro ? '' : quiet('awSettings', t.settings)].filter(Boolean).join('');
236  const acts = `<span style="display:flex;flex-direction:column;gap:9px;align-items:flex-end"><span class="mxp-aw-acts" style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:nowrap">${main}</span><span style="display:flex;gap:16px;justify-content:flex-end">${links}</span></span>`;
237  const day = v => fmt.dayShort(String(v || '').slice(0, 10));
238  const when = c.window === 'open' ? t.closes(c.days_to_close)
239    : c.window === 'before' && c.opens_at ? t.opensOn(day(c.opens_at), c.closes_at ? day(c.closes_at) : '')
240    : c.window === 'closed' && c.closes_at ? t.closedOn(day(c.closes_at)) : '';
241  return `
242      <tr data-row="aw-${esc(c.id)}">
243        ${td(`<span style="display:flex;flex-direction:column;gap:3px;min-width:0">
244              <span style="font-size:13px;font-weight:600">${esc(c.name)}</span>
245              <span style="font-size:11.5px;color:#6d6459">${esc(c.citation || '')}</span>
246              ${laur.length ? `<span style="display:flex;gap:7px;flex-wrap:wrap;margin-top:3px">${laur.map(l => chip(l.name, '#1e6e42', '#fff')).join('')}</span>` : ''}
247            </span>`, 'min-width:280px')}
248        ${td(`<span style="display:flex;flex-direction:column;gap:3px">${chip(t.windows[c.window] || String(c.window || '').toUpperCase(), wTone[0], wTone[1])}
249              <span style="font-size:11px;color:#6d6459;white-space:nowrap">${esc(when)}</span></span>`)}
250        ${td(`<span style="font-size:12px;color:#6d6459">${esc(t.counts(c.counts.entries, c.counts.candidates))}</span>`)}
251        ${tdNum(c.counts.shortlisted || '<span style="color:#9a9086">—</span>')}
252        ${tdNum(c.laureates ? `${c.laureates} / ${c.laureates_max}` : `<span style="color:#9a9086">0 / ${c.laureates_max}</span>`)}
253        ${td(c.counts.pending_review ? chip(String(c.counts.pending_review) + ' HELD', '#9b1b22', '#fff') : '<span style="color:#9a9086">—</span>')}
254        ${tdActs(acts)}
255      </tr>`;
256}
257
258function blockAwCats() {
259  const t = COPY_AW.cats;
260  const rows = cats();
261  const headers = [{ t: 'AWARD' }, { t: 'INTAKE' }, { t: 'ENTRIES' }, { t: 'SHORT', r: 1 }, { t: 'LAUREATES', r: 1 }, { t: 'HELD' }, { t: '', r: 1 }];
262  return `
263    <div data-block="awCats" style="border:1px solid ${HAIR};background:#fff;margin-top:22px">
264      <div class="mxp-cardhead" style="padding:14px 20px;border-bottom:1px solid ${HAIR12};display:flex;align-items:center;gap:10px;flex-wrap:wrap">
265        <span style="font:600 11px Inter,sans-serif;letter-spacing:.15em;white-space:nowrap">${t.title}</span>
266        <span style="font-size:11.5px;color:#9a9086">${t.sub}</span>
267        <div style="flex:1"></div>
268        ${act('awReviewers', 'panel', COPY_AW.reviewers.title)}
269        ${act('awRoster', 'roster', COPY_AW.roster.csv)}
270        ${act('awOnePager', 'sheet', COPY_AW.roster.sheet)}
271      </div>
272      ${rows.length ? tbl(headers, rows.map(catRow).join(''), 900)   /* 900: below 1180 the actions sit 2 × 2 (plexus-hub.css), so the table fits the card down to ~960 px; wider, it fills the card */
273        : `<div class="empty" style="padding:30px 20px"><span style="width:28px;height:1px;background:#c9a962"></span><span class="empty-line">${t.empty}</span></div>`}
274    </div>`;
275}
276
277// ---- one award opened -----------------------------------------------------------------------
278function candRow(c) {
279  const p = COPY_AW.panel;
280  const tone = TONE[c.status] || TONE.received;
281  const who = c.self_nominated && !c.nominators.length
282    ? p.self
283    : (c.nominators.length ? c.nominators.map(n => n.name || n.email).filter(Boolean).slice(0, 3).join(', ') + (c.nominators.length > 3 ? ` +${c.nominators.length - 3}` : '') : '—');
284  return `
285      <tr data-row="awc-${esc(c.key)}">
286        ${td(`<span style="display:flex;flex-direction:column;gap:2px;min-width:0">
287              <span style="font-size:13px;font-weight:600">${esc(c.name)}</span>
288              <span style="display:flex;gap:8px;flex-wrap:wrap;align-items:baseline">
289                <span style="${MICRO};color:#c9a962;white-space:nowrap">${esc(p.noms(c.nominations))}</span>
290                ${c.self_nominated ? `<span style="${MICRO};color:#9a9086">${p.self}</span>` : ''}
291                ${c.email ? `<span style="font-size:11px;color:#6d6459">${esc(c.email)}</span>` : ''}
292              </span></span>`, 'min-width:230px')}
293        ${td(`<span style="font-size:12px;color:#6d6459">${esc(who)}</span>`)}
294        ${td(`<span style="font-size:12px">${esc(c.institution || '—')}</span>`)}
295        ${tdNum(c.scores.mean == null ? '<span style="color:#9a9086">—</span>' : esc(String(c.scores.mean)))}
296        ${tdNum(c.scores.n ? esc(String(c.scores.n)) : '<span style="color:#9a9086">0</span>')}
297        ${td(chip(COPY_AW.status[c.status] || String(c.status).toUpperCase(), tone[0], tone[1]))}
298        ${tdActs(act('awEntry', c.lead_entry_id, COPY_AW.acts.open) + (readOnly() ? '' : act('awMerge', c.key, COPY_AW.acts.merge)))}
299      </tr>`;
300}
301
302function laureateStrip(cat) {
303  const t = COPY_AW.laureate;
304  const rows = (S.data && S.data.laureates) || [];
305  if (!rows.length) return '';
306  return `
307      <div style="padding:14px 20px;border-top:1px solid ${HAIR12};background:#fdfbf6">
308        <div style="${MICRO};color:#6d6459">LAUREATES</div>
309        ${rows.map(l => `
310        <div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;padding:8px 0;border-bottom:1px solid ${HAIR07}">
311          <span style="font-family:Fraunces,serif;font-size:16px">${esc(l.name)}</span>
312          ${l.institution ? `<span style="font-size:12px;color:#6d6459">${esc(l.institution)}</span>` : ''}
313          ${l.present_minutes ? chip(`${t.presents} · ${l.present_minutes} MIN`, '#f8f1e2', '#7a6432') : ''}
314          ${l.present_minutes ? chip(l.presentation_confirmed ? t.confirmed : t.unconfirmed, l.presentation_confirmed ? '#1e6e42' : '#f6f2ea', l.presentation_confirmed ? '#fff' : '#9a9086') : ''}
315          ${l.present_minutes ? chip(l.slides_name ? t.slides : t.noSlides, '#f6f2ea', '#6d6459') : ''}
316          ${chip(l.gala_registration_id ? t.seat : t.noSeat, l.gala_registration_id ? '#1f4f7a' : '#f6f2ea', l.gala_registration_id ? '#fff' : '#9a9086')}
317          <div style="flex:1"></div>
318          ${l.slides_url ? act('awCopy', l.slides_url, 'SLIDES LINK') : ''}
319          ${readOnly() ? '' : act('awDelLaureate', l.id, t.remove, ` data-name="${esc(l.name)}"`)}
320        </div>`).join('')}
321      </div>`;
322}
323
324function blockAwPanel() {
325  const cat = catById(S.cat);
326  if (!cat) return '';
327  const p = COPY_AW.panel;
328  const all = (S.data && S.data.candidates) || [];
329  const rows = all
330    .filter(c => !S.filter || c.status === S.filter)
331    .filter(c => !S.term || [c.name, c.email, c.institution, c.position].filter(Boolean).some(v => String(v).toLowerCase().includes(S.term.toLowerCase())));
332  const headers = [{ t: p.head.name }, { t: p.head.who }, { t: p.head.inst }, { t: p.head.score, r: 1 }, { t: p.head.n, r: 1 }, { t: p.head.status }, { t: '', r: 1 }];
333  const filters = ['', 'pending-review', 'received', 'eligible', 'shortlisted', 'winner', 'declined', 'ineligible'].map(s => {
334    const on = S.filter === s;
335    return `<span data-act="awFilter" data-id="${esc(s)}" style="padding:5px 9px;font:600 9px Inter,sans-serif;letter-spacing:.12em;cursor:pointer;border:1px solid ${on ? '#201b16' : 'rgba(32,27,22,.2)'};background:${on ? '#201b16' : 'transparent'};color:${on ? '#f6f2ea' : '#6d6459'};white-space:nowrap" data-hover="border-color:#201b16">${esc(s ? (COPY_AW.status[s] || s.toUpperCase()) : p.filterAll)}</span>`;
336  }).join('');
337  return `
338    <div data-block="awPanel" style="border:1px solid ${HAIR};background:#fff;margin-top:22px">
339      <div class="mxp-cardhead" style="padding:14px 20px;border-bottom:1px solid ${HAIR12};display:flex;align-items:center;gap:10px;flex-wrap:wrap">
340        <span data-act="awClose" style="${BTN_GHOST}" data-hover="border-color:#201b16">${p.back}</span>
341        <span style="font:600 11px Inter,sans-serif;letter-spacing:.15em;white-space:nowrap">${esc(cat.name)}</span>
342        <span style="font-size:11.5px;color:#9a9086">${esc(COPY_AW.cats.counts(cat.counts.entries, cat.counts.candidates))}</span>
343        <div style="flex:1"></div>
344        ${act('awRank', cat.id, COPY_AW.cats.ranking)}
345        ${readOnly() ? '' : act('awNotify', cat.id, COPY_AW.cats.notify)}
346      </div>
347      <div style="padding:12px 20px;border-bottom:1px solid ${HAIR12};display:flex;gap:8px;align-items:center;flex-wrap:wrap">
348        ${filters}
349        <div style="flex:1"></div>
350        <input data-role="awSearch" type="search" value="${esc(S.term)}" placeholder="${esc(p.search)}" aria-label="${esc(p.search)}
350" style="min-width:200px;${INPUT2}">
351      </div>
352      ${S.busy ? `<div style="padding:26px 20px;font-size:12.5px;color:#6d6459">…</div>`
353        : rows.length ? tbl(headers, rows.map(candRow).join(''), 1040)
354        : `<div class="empty" style="padding:30px 20px"><span style="width:28px;height:1px;background:#c9a962"></span><span class="empty-line">${p.empty}</span><span class="empty-why">${p.emptyWhy}</span></div>`}
355      ${laureateStrip(cat)}
356    </div>`;
357}
358
359// ---- the drawer (the studio.js tool-drawer idiom: one slot, per-tool body) ---------------------
360function drawerShell(title, sub, body) {
361  return `
362    <div data-block="awDrawer" id="awDrawer" style="border:1px solid ${HAIR};background:#fff;margin-top:22px">
363      <div class="mxp-cardhead" style="padding:14px 20px;border-bottom:1px solid ${HAIR12};display:flex;align-items:center;gap:10px;flex-wrap:wrap">
364        <span style="font:600 11px Inter,sans-serif;letter-spacing:.15em;white-space:nowrap">${esc(title)}</span>
365        <span style="font-size:11.5px;color:#9a9086">${esc(sub || '')}</span>
366        <div style="flex:1"></div>
367        <span data-act="awDrawerClose" style="${BTN_GHOST}" data-hover="border-color:#201b16">${COPY_AW.acts.close}</span>
368      </div>
369      <div style="padding:18px 20px">${body}</div>
370    </div>`;
371}
372
373function drawerEntry() {
374  const d = S.entry;
375  if (!d) return drawerShell(COPY_AW.drawer.entry, '', '<div style="font-size:12.5px;color:#6d6459">…</div>');
376  const e = d.entry, sc = d.scores || { per_reviewer: [] };
377  const dd = COPY_AW.drawer;
378  const ro = readOnly();
379  const field = (k, v) => v ? `<div style="display:flex;gap:12px;padding:7px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px"><span style="min-width:120px;${MICRO}">${esc(k)}</span><span style="min-width:0;color:#201b16;white-space:pre-wrap">${esc(v)}</span></div>` : '';
380  const long = (k, v) => v ? `<div style="margin-top:14px"><div style="${MICRO}">${esc(k)}</div><div style="font-size:13px;line-height:1.7;color:#4a4239;margin-top:5px;white-space:pre-wrap">${esc(v)}</div></div>` : '';
381  const statusBtn = (s, label) => ro || e.status === s ? '' : `<span data-act="awStatus" data-id="${esc(e.id)}" data-status="${s}" style="${BTN_GHOST}" data-hover="border-color:#201b16">${label}</span>`;
382  return drawerShell(dd.entry, e.name, `
383    <div style="display:grid;grid-template-columns:1.4fr 1fr;gap:26px;align-items:start" class="mx-two">
384      <div style="min-width:0">
385        ${field('STATUS', COPY_AW.status[e.status] || e.status)}
386        ${field('KIND', e.kind)}
387        ${field('EMAIL', e.nominee_email)}
388        ${field('INSTITUTION', e.nominee_institution)}
389        ${field('POSITION', e.nominee_position)}
390        ${field('COUNTRY', e.nominee_country)}
391        ${field('BORN', e.nominee_birth_year)}
392        ${field('SCHOOL', [e.school, e.study_year].filter(Boolean).join(' · '))}
393        ${field('WILLING TO PRESENT', e.willing_to_present ? 'yes' : '')}
394        ${field('CONSENT TO PUBLISH', e.consent_publish ? 'yes' : 'no')}
395        ${field('HELD BECAUSE', e.gate_reason)}
396        ${e.links && e.links.length ? `<div style="margin-top:10px;${MICRO}">LINKS</div><div style="font-size:12px;margin-top:4px">${e.links.map(u => `<a href="${esc(u)}" target="_blank" rel="noopener noreferrer nofollow">${esc(u)}</a>`).join('<br>')}</div>` : ''}
397        ${long('STATEMENT', e.statement)}
398        ${long('THE CHALLENGE', e.challenge)}
399        ${long('THEIR SOLUTION', e.solution)}
400        ${long('WHY THEM', e.why_you)}
401        ${e.attachment ? `<div style="margin-top:14px">${act('awPdf', e.id, `${COPY_AW.acts.pdf} · ${esc(e.attachment.name)}`)}</div>` : ''}
402        <div style="margin-top:18px">
403          <div style="${MICRO}">${dd.notes}</div>
404          <textarea data-role="awNote" rows="3" style="width:100%;box-sizing:border-box;margin-top:5px;${INPUT2}">${esc(e.admin_notes || '')}</textarea>
405          ${ro ? '' : `<span data-act="awSaveNote" data-id="${esc(e.id)}" style="${BTN_GHOST};margin-top:8px;display:inline-block" data-hover="border-color:#201b16">${COPY_AW.acts.note}</span>`}
406        </div>
407      </div>
408      <div style="min-width:0">
409        <div style="${MICRO}">${dd.nominators}</div>
410        ${(d.nominators || []).length ? (d.nominators || []).map(n => `
411          <div style="padding:7px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px">
412            <div style="font-weight:600">${esc(n.name || n.email || '—')}</div>
413            <div style="font-size:11.5px;color:#6d6459">${esc([n.email, n.relation].filter(Boolean).join(' · ') || '')}</div>
414          </div>`).join('') : `<div style="font-size:12px;color:#6d6459;font-style:italic;padding:6px 0">${e.kind === 'application' ? 'A student’s own application.' : 'A self-nomination.'}</div>`}
415
416        <div style="${MICRO};margin-top:18px">${dd.scores}</div>
417        <div style="font-size:13px;margin-top:4px">${esc(dd.mean(sc.mean, sc.n))}${sc.spread != null ? ` · ${esc(dd.spread(sc.spread))}` : ''}</div>
418        ${sc.conflicts ? `<div style="font-size:11.5px;color:#9b1b22;margin-top:3px">${esc(dd.conflictNote(sc.conflicts))}</div>` : ''}
419        ${(sc.per_reviewer || []).length ? (sc.per_reviewer || []).map(r => `
420          <div style="padding:8px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px">
421            <div style="display:flex;gap:8px;align-items:baseline"><span style="font-weight:600">${esc(r.reviewer)}</span>${r.conflict ? chip('CONFLICT', '#9b1b22', '#fff') : `<span style="font-family:
421Fraunces,serif;font-size:15px">${esc(String(r.total))}</span>`}</div>
422            ${r.conflict ? '' : `<div style="font-size:11px;color:#6d6459">${esc(Object.keys(r.scores || {}).map(k => `${k} ${r.scores[k]}`).join(' · '))}</div>`}
423            ${r.comment ? `<div style="font-size:12px;color:#4a4239;margin-top:4px;line-height:1.6">${esc(r.comment)}</div>` : ''}
424          </div>`).join('') : `<div style="font-size:12px;color:#6d6459;font-style:italic;padding:6px 0">${dd.noScores}</div>`}
425
426        <div style="display:flex;gap:7px;flex-wrap:wrap;margin-top:18px">
427          ${statusBtn('eligible', COPY_AW.acts.eligible)}
428          ${statusBtn('ineligible', COPY_AW.acts.ineligible)}
429          ${statusBtn('shortlisted', COPY_AW.acts.shortlist)}
430          ${statusBtn('winner', COPY_AW.acts.winner)}
431          ${statusBtn('declined', COPY_AW.acts.decline)}
432        </div>
433      </div>
434    </div>`);
435}
436
437function drawerRank() {
438  const r = S.rank;
439  const c = COPY_AW.rank;
440  if (!r) return drawerShell(c.title, '', '<div style="font-size:12.5px;color:#6d6459">…</div>');
441  const ro = readOnly();
442  const headers = [{ t: c.head.rank }, { t: c.head.name }, { t: c.head.mean, r: 1 }, { t: c.head.spread, r: 1 }, { t: c.head.n, r: 1 }, { t: c.head.conf, r: 1 }, { t: '', r: 1 }];
443  const rows = (r.ranking || []).map(x => `
444      <tr>
445        ${td(`<span style="font-family:Fraunces,serif;font-size:16px">${x.rank}</span>`)}
446        ${td(`<span style="font-size:13px;font-weight:600">${esc(x.entry.name)}</span><div style="font-size:11.5px;color:#6d6459">${esc(x.entry.nominee_institution || '')}</div>`)}
447        ${tdNum(x.mean == null ? '<span style="color:#9a9086">—</span>' : `${esc(String(x.mean))} <span style="font-size:11px;color:#6d6459">/ ${esc(String(r.max_total))}</span>`)}
448        ${tdNum(x.spread == null ? '<span style="color:#9a9086">—</span>' : esc(String(x.spread)))}
449        ${tdNum(esc(String(x.n)))}
450        ${tdNum(x.conflicts ? `<span style="color:#9b1b22">${esc(String(x.conflicts))}</span>` : '<span style="color:#9a9086">0</span>')}
451        ${tdActs(ro ? '' : (`<span data-act="awStatus" data-id="${esc(x.entry.id)}" data-status="shortlisted" style="${BTN_GHOST}" data-hover="border-color:#201b16">${c.shortlist}</span>`
452          + `<span data-act="awStatus" data-id="${esc(x.entry.id)}" data-status="winner" style="${BTN_GHOST}" data-hover="border-color:#201b16">${c.winner}</span>`))}
453      </tr>`).join('');
454  return drawerShell(c.title, `${r.category.name} · ${c.sub}`, `
455    ${(r.ranking || []).length ? tbl(headers, rows, 820) : `<div style="font-size:12.5px;color:#6d6459;font-style:italic">${c.empty}</div>`}
456    <div style="font-size:11.5px;color:#6d6459;margin-top:12px">${c.note}</div>`);
457}
458
459function drawerReviewers() {
460  const c = COPY_AW.reviewers;
461  const ro = readOnly();
462  const list = reviewers();
463  const catOpts = cats().map(x => `<label style="display:flex;gap:7px;align-items:center;font-size:12.5px;white-space:nowrap"><input type="checkbox" data-role="awRevCat" value="${esc(x.id)}" style="accent-color:#9b1b22">${esc(x.name)}</label>`).join('');
464  const rows = list.map(r => {
465    const tone = { invited: ['#f8f1e2', '#7a6432'], active: ['#1e6e42', '#fff'], revoked: ['#f6f2ea', '#9a9086'] }[r.status] || ['#f6f2ea', '#6d6459'];
466    return `
467      <div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;padding:10px 0;border-bottom:1px solid ${HAIR07}">
468        <span style="font-size:13px;font-weight:600">${esc(r.name)}</span>
469        <span style="font-size:11.5px;color:#6d6459">${esc(r.email)}</span>
470        ${chip(c.statusTag[r.status] || String(r.status).toUpperCase(), tone[0], tone[1])}
471        <span style="font-size:12px;color:#6d6459">${esc(c.progress(r.scored, r.total))}</span>
472        <span style="flex:1;min-width:60px;height:4px;background:#f6f2ea;display:inline-block"><span style="display:block;height:4px;width:${Math.max(0, Math.min(100, r.percent))}%;background:#1e6e42"></span></span>
473        ${act('awCopy', r.room_url, c.copy)}
474        ${ro || r.status === 'revoked' ? '' : act('awResend', r.id, c.resend)}
475        ${ro || r.status === 'revoked' ? '' : act('awRevoke', r.id, c.revoke, ` data-name="${esc(r.name)}"`)}
476      </div>`;
477  }).join('');
478  return drawerShell(c.title, c.sub, `
479    ${list.length ? rows : `<div class="empty" style="padding:16px 0"><span style="width:28px;height:1px;background:#c9a962"></span><span class="empty-line">${c.empty}</span><span class="empty-why">${c.emptyWhy}</span></div>`}
480    ${ro ? '' : `
481    <div style="margin-top:18px;padding-top:16px;border-top:1px solid ${HAIR12}">
482      <div style="${MICRO}">${c.add}</div>
483      <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px">
484        ${fText('awRevName', c.name, '')}
485        ${fText('awRevEmail', c.email, '')}
486      </div>
487      <div style="${MICRO};margin-top:12px">${c.cats}</div>
488      <div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:6px">${catOpts}</div>
489      <span data-act="awAddReviewer" style="display:inline-block;margin-top:14px;padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.send}</span>
490    </div>`}`);
491}
492
493function drawerNotify() {
494  const c = COPY_AW.notify;
495  const n = S.notify || {};
496  const cat = catById(n.catId) || {};
497  const box = (role, label, on) => `<label style="display:flex;gap:8px;align-items:center;font-size:13px;padding:6px 0"><input type="checkbox" data-role="${role}" ${on ? 'checked' : ''} style="accent-color:#9b1b22;width:15px;height:15px">${esc(label)}</label>`;
498  return drawerShell(c.title, `${cat.name || ''} · ${c.sub}`, `
499    ${box('awNWin', c.winners, true)}
500    ${box('awNShort', c.shortlisted, true)}
501    ${box('awNDecl', c.declined, true)}
502    ${box('awNNoms', c.nominators, true)}
503    <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:14px">
504      ${act('awNotifyPreview', cat.id || '', c.preview)}
505      <span data-act="awNotifyStage" data-id="${esc(cat.id || '')}" style="padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.stage}</span>
506    </div>
507    ${n.drafts ? `<div style="margin-top:16px;border-top:1px solid ${HAIR12};padding-top:12px">
508      <div style="${MICRO}">${esc(n.drafts.length + ' LETTER' + (n.drafts.length === 1 ? '' : 'S'))}</div>
509      ${n.drafts.map(d => `<div style="display:flex;gap:10px;padding:6px 0;border-bottom:1px solid ${HAIR07};font-size:12.5px"><span style="${MICRO};min-width:130px">${esc(d.kind.replace(/_/g, ' ').toUpperCase())}</span><span>${esc(d.to)}</span><span style="color:#6d6459;min-width:0">${esc(d.subject)}</span></div>`).join('')}
510    </div>` : ''}
511    <div style="font-size:11.5px;color:#6d6459;margin-top:14px">${c.note}</div>`);
512}
513
514function drawerLaureate() {
515  const c = COPY_AW.laureate;
516  const cat = catById(S.drawerCat) || {};
517  return drawerShell(c.title, `${cat.name || ''} · ${c.sub}`, `
518    <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
519      ${fText('awLName', c.name, '')}
520      ${fText('awLInst', c.inst, '')}
521      ${fText('awLEmail', c.email, '')}
522      ${fText('awLPhoto', c.photo, '')}
523      ${fArea('awLCite', c.cite, cat.citation || '', 3)}
524    </div>
525    <span data-act="awSaveLaureate" data-id="${esc(cat.id || '')}" style="display:inline-block;margin-top:14px;padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.save}</span>`);
526}
527
528function drawerSettings() {
529  const c = COPY_AW.settings;
530  const cat = catById(S.drawerCat);
531  if (!cat) return '';
532  return drawerShell(c.title, cat.name, `
533    <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
534      ${fText('awSName', c.name, cat.name)}
535      ${fText('awSNameHr', c.nameHr, cat.name_hr)}
536      ${fText('awSCite', c.cite, cat.citation)}
537      ${fText('awSCiteHr', c.citeHr, cat.citation_hr)}
538      ${fText('awSOpens', c.opens, cat.opens_at, '2026-10-01T09:00')}
539      ${fText('awSCloses', c.closes, cat.closes_at, '2026-11-01T23:59')}
540      ${fText('awSMax', c.max, cat.laureates_max)}
541      <label style="display:flex;gap:8px;align-items:center;font-size:12.5px;margin-top:22px"><input type="checkbox" data-role="awSSelf" ${cat.allow_self ? 'checked' : ''} style="accent-color:#9b1b22;width:15px;height:15px">${esc(c.self)}</label>
542      ${fArea('awSCrit', c.crit, cat.criteria_md, 6)}
543      ${fArea('awSCritHr', c.critHr, cat.criteria_md_hr, 6)}
544    </div>
545    <span data-act="awSaveSettings" data-id="${esc(cat.id)}" style="display:inline-block;margin-top:14px;padding:9px 14px;background:#9b1b22;color:#fff;font:600 9.5px Inter,sans-serif;letter-spacing:.13em;cursor:pointer" data-hover="background:#7e151b">${c.save}</span>`);
546}
547
548function blockAwDrawer() {
549  if (!S.drawer) return '<div data-block="awDrawer"></div>';
550  if (S.drawer === 'entry') return drawerEntry();
551  if (S.drawer === 'rank') return drawerRank();
552  if (S.drawer === 'reviewers') return drawerReviewers();
553  if (S.drawer === 'notify') return drawerNotify();
554  if (S.drawer === 'laureate') return drawerLaureate();
555  if (S.drawer === 'settings') return drawerSettings();
556  return '<div data-block="awDrawer"></div>';
557}
558
559function blockAwBody() {
560  return `<div data-block="awBody">${S.cat ? blockAwPanel() : blockAwCats()}</div>`;
561}
562
563/** The whole AWARDS tab. js/views/plexus.js calls exactly this. */
564export function blockAwards(error) {
565  if (!canAwards()) {
566    return `
567    <div data-block="awTable" style="border:1px solid ${HAIR};background:#fff;margin-top:22px">
568      <div style="padding:14px 20px;border-bottom:1px solid ${HAIR12}"><span style="font:600 11px Inter,sans-serif;letter-spacing:.15em">${COPY_AW.cats.title}</span></div>
569      <div style="padding:10px 0">${ui.lockedBlock(perms.label(AW_SECTION))}</div>
570    </div>`;
571  }
572  if (error) {
573    return `
574    <div data-block="awTable" style="border:1px solid ${HAIR};background:#fff;margin-top:22px">
575      <div style="padding:14px 20px;border-bottom:1px solid ${HAIR12}">
575<span style="font:600 11px Inter,sans-serif;letter-spacing:.15em">${COPY_AW.cats.title}</span></div>
576      <div style="padding:10px 0">${error.isLocked ? ui.lockedBlock(perms.label(error.section)) : `<div style="padding:16px 20px;font-size:12.5px;color:#9b1b22">${esc(error.message || COPY_AW.err)}</div>`}</div>
577    </div>`;
578  }
579  return `${blockAwStats()}${blockAwBody()}${blockAwDrawer()}`;
580}
581
582// ================================================================ HANDLERS
583const val = (role) => { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector(`[data-role="${role}"]`); return el ? String(el.value || '').trim() : ''; };
584const raw = (role) => { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector(`[data-role="${role}"]`); return el ? String(el.value || '') : ''; };
585const checked = (role) => { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector(`[data-role="${role}"]`); return !!(el && el.checked); };
586async function copyText(t) {
587  try { await navigator.clipboard.writeText(t); return true; }
588  catch (e) {
589    try { const ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); return true; }
590    catch (e2) { return false; }
591  }
592}
593function scrollDrawer() { const el = H && H.rootEl && H.rootEl() && H.rootEl().querySelector('#awDrawer'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
594
595/** Read-only-safe actions: everything that only LOOKS. plexus.js merges this into its RO set. */
596export const AW_RO_SAFE = ['awOpen', 'awClose', 'awRank', 'awEntry', 'awCsv', 'awRoster', 'awOnePager',
597  'awFilter', 'awDrawerClose', 'awCopy', 'awPdf', 'awReviewers', 'awNotifyPreview'];
598
599export const awardsHandlers = {
600  awOpen: async (el) => { S.cat = el.dataset.id; S.filter = ''; S.term = ''; S.drawer = null; paintAll(); await reloadCategory(S.cat); },
601  awClose: () => { S.cat = null; S.data = null; S.drawer = null; paintAll(); },
602  awFilter: (el) => { S.filter = el.dataset.id || ''; paintPanel(); },
603
604  awEntry: async (el) => {
605    S.drawer = 'entry'; S.entry = null; paintDrawer(); scrollDrawer();
606    try { S.entry = await api.get(opsUrl('/entries/' + encodeURIComponent(el.dataset.id))); }
607    catch (e) { ui.toast(e.message, { kind: 'error' }); S.drawer = null; }
608    paintDrawer();
609  },
610
611  awStatus: async (el) => {
612    const status = el.dataset.status;
613    try {
614      const r = await api.post(opsUrl('/entries/' + encodeURIComponent(el.dataset.id) + '/status'), { status });
615      ui.toast(COPY_AW.drawer.statusDone(COPY_AW.status[status] || status.toUpperCase()));
616      if (r && r.gala_seat && r.gala_seat.created) ui.toast('GALA SEAT CREATED — COMP, ON THE GUEST LIST');
617      await reloadOverview();
618      if (S.cat) await reloadCategory(S.cat);
619      if (S.drawer === 'entry' && S.entry) { S.entry = await api.get(opsUrl('/entries/' + encodeURIComponent(el.dataset.id))); }
620      if (S.drawer === 'rank' && S.rank) { S.rank = await api.get(opsUrl('/categories/' + encodeURIComponent(S.rank.category.id) + '/ranking')); }
621      paintAll();
622    } catch (e) { ui.toast(e.message, { kind: 'error' }); }
623  },
624
625  awSaveNote: async (el) => {
626    try {
627      await api.post(opsUrl('/entries/' + encodeURIComponent(el.dataset.id) + '/notes'), { notes: raw('awNote') });
628      ui.toast(COPY_AW.drawer.saved);
629    } catch (e) { ui.toast(e.message, { kind: 'error' }); }
630  },
631
632  // window.open cannot carry the Bearer header, so it answered 401 and opened a blank tab of JSON
633  // error. Ask the route for the signed link instead (?json=1) and hand THAT to the browser.
634  awPdf: async (el) => {
635    try {
636      const r = await api.get(opsUrl('/entries/' + encodeURIComponent(el.dataset.id) + '/attachment') + '?json=1');
637      if (!r || !r.url) throw new Error(COPY_AW.drawer.pdfMissing);
638      dlHref(r.url, r.name || 'entry.pdf');
639    } catch (e) { ui.toast(e.message, { kind: 'error' }); }
640  },
641
642  awMerge: async (el) => {
643    const key = el.dataset.id;
644    if (!S.mergeFrom) { S.mergeFrom = key; ui.toast(COPY_AW.drawer.mergePick); return; }
645    if (S.mergeFrom === key) { S.mergeFrom = null; return; }
646    const from = (S.data.candidates || []).find(c => c.key === S.mergeFrom);
647    const into = (S.data.candidates || []).find(c => c.key === key);
648    const ok = await ui.confirm({ title: COPY_AW.drawer.mergeAsk((from && from.name) || S.mergeFrom, (into && into.name) || key), ok: COPY_AW.drawer.mergeOk, cancel: COPY_AW.drawer.mergeKeep });
649    if (!ok) { S.mergeFrom = null; return; }
650    try {
651      await api.post(opsUrl('/categories/' + encodeURIComponent(S.cat) + '/merge'), { from: S.mergeFrom, into: key });
652      S.mergeFrom = null;
653      await reloadOverview(); await reloadCategory(S.cat); paintAll();
654    } catch (e) { S.mergeFrom = null; ui.toast(e.message, { kind: 'error' }); }
655  },
656
657  awRank: async (el) => {
658    S.drawer = 'rank'; S.rank = null; paintDrawer(); scrollDrawer();
659    try { S.rank = await api.get(opsUrl('/categories/' + encodeURIComponent(el.dataset.id) + '/ranking')); }
660    catch (e) { ui.toast(e.message, { kind: 'error' }); S.drawer = null; }
661    paintDrawer();
662  },
663
664  awSettings: (el) => { S.drawer = 'settings'; S.drawerCat = el.dataset.id; paintDrawer(); scrollDrawer(); },
665  awSaveSettings: async (el) => {
666    const max = parseInt(val('awSMax'), 10);
667    try {
668      await api.put(opsUrl('/categories/' + encodeURIComponent(el.dataset.id)), {
669        name: val('awSName'), name_hr: val('awSNameHr'),
670        citation: val('awSCite'), citation_hr: val('awSCiteHr'),
671        opens_at: val('awSOpens'), closes_at: val('awSCloses'),
672        laureates_max: Number.isFinite(max) ? max : 2,
673        allow_self: checked('awSSelf'),
674        criteria_md: raw('awSCrit'), criteria_md_hr: raw('awSCritHr')
675      });
676      ui.toast(COPY_AW.settings.saved);
677      await reloadOverview(); paintAll();
678    } catch (e) { ui.toast(e.message, { kind: 'error' }); }
679  },
680
681  awLaureate: (el) => { S.drawer = 'laureate'; S.drawerCat = el.dataset.id; paintDrawer(); scrollDrawer(); },
682  awSaveLaureate: async (el) => {
683    const name = val('awLName');
684    if (!name) { ui.toast(COPY_AW.laureate.nameFirst); return; }
685    try {
686      await api.post(opsUrl('/categories/' + encodeURIComponent(el.dataset.id) + '/laureates'), {
687        name, institution: val('awLInst'), email: val('awLEmail'), photo_url: val('awLPhoto'), citation: raw('awLCite')
688      });
689      ui.toast(COPY_AW.laureate.added);
690      S.drawer = null;
691      await reloadOverview();
692      if (S.cat) await reloadCategory(S.cat);
693      paintAll();
694    } catch (e) { ui.toast(e.message, { kind: 'error' }); }
695  },
696  awDelLaureate: async (el) => {
697    const ok = await ui.confirm({ title: COPY_AW.laureate.removeAsk(el.dataset.name || 'this laureate'), ok: COPY_AW.laureate.removeOk, cancel: COPY_AW.laureate.removeKeep });
698    if (!ok) return;
699    try {
700      await api.del(opsUrl('/laureates/' + encodeURIComponent(el.dataset.id)));
701      ui.toast(COPY_AW.laureate.removed);
702      await reloadOverview();
703      if (S.cat) await reloadCategory(S.cat);
704      paintAll();
705    } catch (e) { ui.toast(e.message, { kind: 'error' }); }
706  },
707
708  awNotify: (el) => { S.drawer = 'notify'; S.notify = { catId: el.dataset.id, drafts: null }; paintDrawer(); scrollDrawer(); },
709  awNotifyPreview: async (el) => { await stageNotify(el.dataset.id, true); },
710  awNotifyStage: async (el) => { await stageNotify(el.dataset.id, false); },
711
712  awReviewers: () => { S.drawer = 'reviewers'; paintDrawer(); scrollDrawer(); },
713  awAddReviewer: async () => {
714    const name = val('awRevName'), email = val('awRevEmail');
715    if (!name || !email) { ui.toast(COPY_AW.reviewers.nameFirst); return; }
716    const root = H.rootEl();
717    const picked = [...root.querySelectorAll('[data-role="awRevCat"]')].filter(x => x.checked).map(x => x.value);
718    if (!picked.length) { ui.toast(COPY_AW.reviewers.catFirst); return; }
719    try {
720      const r = await api.post(opsUrl('/reviewers') + edQ(), { name, email, categories: picked });
721      ui.toast(r && r.invited ? COPY_AW.reviewers.sent : 'READER ADDED');
722      await reloadOverview(); paintAll();
723    } catch (e) { ui.toast(e.message, { kind: 'error' }); }
724  },
725  awResend: async (el) => {
726    try { const r = await api.post(opsUrl('/reviewers/' + encodeURIComponent(el.dataset.id) + '/resend'), {}); ui.toast(r && r.sent ? COPY_AW.reviewers.sent : 'COULD NOT SEND — COPY THE 
726LINK INSTEAD'); }
727    catch (e) { ui.toast(e.message, { kind: 'error' }); }
728  },
729  awRevoke: async (el) => {
730    const ok = await ui.confirm({ title: COPY_AW.reviewers.revokeAsk(el.dataset.name || 'this reader'), ok: COPY_AW.reviewers.revokeOk, cancel: COPY_AW.reviewers.revokeKeep });
731    if (!ok) return;
732    try { await api.del(opsUrl('/reviewers/' + encodeURIComponent(el.dataset.id))); await reloadOverview(); paintAll(); }
733    catch (e) { ui.toast(e.message, { kind: 'error' }); }
734  },
735
736  awCopy: async (el) => { ui.toast(await copyText(el.dataset.id) ? COPY_AW.reviewers.copied : el.dataset.id); },
737  awDrawerClose: () => { S.drawer = null; S.entry = null; S.rank = null; paintDrawer(); },
738
739  awCsv: async (el) => {
740    try { dl(await fetchBlob(opsUrl('/categories/' + encodeURIComponent(el.dataset.id) + '/entries.csv')), 'medx-awards-entries.csv'); }
741    catch (e) { ui.toast(e.message, { kind: 'error' }); }
742  },
743  awRoster: async () => {
744    try { dl(await fetchBlob(opsUrl('/roster.csv') + edQ()), 'medx-awards-gala-roster.csv'); }
745    catch (e) { ui.toast(e.message, { kind: 'error' }); }
746  },
747  // Same 401 as awPdf: window.open sends no Authorization header, so the MC's sheet opened as a
748  // blank tab. Fetch it WITH the header and save the page — it prints from disk with the same
749  // PRINT button, and the file is what the MC actually wants to carry.
750  awOnePager: async () => {
751    try { dl(await fetchBlob(opsUrl('/one-pager') + edQ()), 'medx-awards-running-order.html'); }
752    catch (e) { ui.toast(e.message, { kind: 'error' }); }
753  }
754};
755
756async function stageNotify(catId, preview) {
757  const body = {
758    preview: !!preview,
759    winners: checked('awNWin'), shortlisted: checked('awNShort'),
760    declined: checked('awNDecl'), nominators: checked('awNNoms')
761  };
762  try {
763    const r = await api.post(opsUrl('/categories/' + encodeURIComponent(catId) + '/notify'), body);
764    S.notify = { catId, drafts: r.drafts || [] };
765    if (!preview) {
766      ui.toast(r.staged ? COPY_AW.notify.staged(r.staged) : COPY_AW.notify.none);
767      await reloadOverview();
768      if (S.cat) await reloadCategory(S.cat);
769    }
770    paintDrawer();
771  } catch (e) { ui.toast(e.message, { kind: 'error' }); }
772}
773
774// The search box repaints only its own block, so typing never loses focus mid-word.
775export function awardsOnInput(e) {
776  const t = e.target;
777  if (!t || !t.matches || !t.matches('[data-role="awSearch"]')) return false;
778  S.term = t.value;
779  const rows = H.rootEl().querySelector('[data-block="awPanel"]');
780  if (rows) {
781    const pos = t.selectionStart;
782    H.paintPart('[data-block="awPanel"]', blockAwPanel());
783    const again = H.rootEl().querySelector('[data-role="awSearch"]');
784    if (again) { again.focus(); try { again.setSelectionRange(pos, pos); } catch (err) {} }
785  }
786  return true;
787}
788
789export default { initAwards, setAwardsData, loadAwards, blockAwards, awardsHandlers, awardsOnInput, AW_SECTION, AW_RO_SAFE, COPY_AW, canAwards };

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.