1// team-group-analysis.js â Team dashboard module (classic global script, NOT an ES module). 2// Mechanically extracted from site/dashboard/team/index.html inline <script> (orig lines 7025-7367). 3// Load order & original code preserved byte-for-byte; do not reorder relative to sibling team-*.js. 4 5// === GROUP ANALYSIS TAB === 6var _grpLoaded = false; 7var _grpPeriod = 7; 8var _grpLevel = 0; 9var _grpMinMembers = 3; 10var _grpStat = 'avg'; // 'avg' or 'median' 11var _grpEndDate = new Date().toISOString().slice(0, 10); 12var _grpData = null; 13 14async function lazyLoadGroup() { 15 if (!currentOrg) return; 16 _grpLoaded = true; 17 // URLìì ë³µìë ê°ì¼ë¡ ë²í¼ active ë기í 18 document.querySelectorAll('.grp-period').forEach(function(b) { b.classList.toggle('active', Number(b.dataset.val) === _grpPeriod); }); 19 document.querySelectorAll('.grp-level').forEach(function(b) { b.classList.toggle('active', Number(b.dataset.level) === _grpLevel); }); 20 document.querySelectorAll('.grp-min').forEach(function(b) { b.classList.toggle('active', Number(b.dataset.val) === _grpMinMembers); }); 21 document.querySelectorAll('.grp-stat').forEach(function(b) { b.classList.toggle('active', b.dataset.val === _grpStat); }); 22 var el = document.getElementById('tab-group-loading'); 23 if (el) el.style.display = ''; 24 try { 25 var res = await apiFetch(API_BASE + '/api/me/orgs/' + currentOrg.id + '/leaderboard?period=' + _grpPeriod + '&end=' + _grpEndDate, { headers: authHeaders() }); 26 _grpData = await res.json(); 27 renderGroupAnalysis(); 28 updateGrpDateLabel(); 29 } catch(err) { console.error('group load failed:', err); } 30 if (el) el.style.display = 'none'; 31} 32 33function syncGrpUrl() { 34 updateUrlParam('grp_period', _grpPeriod, 7); 35 var today = new Date().toISOString().slice(0, 10); 36 updateUrlParam('grp_end', _grpEndDate === today ? null : _grpEndDate); 37 updateUrlParam('grp_level', _grpLevel, 0); 38 updateUrlParam('grp_min', _grpMinMembers, 3); 39 updateUrlParam('grp_stat', _grpStat, 'avg'); 40} 41 42async function setGrpPeriod(days, e) { 43 _grpPeriod = days; 44 document.querySelectorAll('.grp-period').forEach(function(b) { b.classList.remove('active'); }); 45 if (e && e.target) e.target.classList.add('active'); 46 syncGrpUrl(); 47 _grpData = null; 48 lazyLoadGroup(); 49} 50 51function loadGroup() { _grpData = null; lazyLoadGroup(); } 52 53function shiftGrpDate(dir) { 54 var d = new Date(_grpEndDate + 'T00:00:00Z'); 55 d.setUTCDate(d.getUTCDate() + dir * _grpPeriod); 56 var today = new Date().toISOString().slice(0, 10); 57 if (d.toISOString().slice(0, 10) > today) d = new Date(today + 'T00:00:00Z'); 58 var start = new Date(d); start.setUTCDate(start.getUTCDate() - Math.max(0, _grpPeriod - 1)); 59 var minStart = new Date(); minStart.setUTCDate(minStart.getUTCDate() - 90); 60 if (start.toISOString().slice(0, 10) < minStart.toISOString().slice(0, 10)) return; 61 _grpEndDate = d.toISOString().slice(0, 10); 62 syncGrpUrl(); 63 loadGroup(); 64} 65 66function resetGrpToday() { 67 _grpEndDate = new Date().toISOString().slice(0, 10); 68 syncGrpUrl(); 69 loadGroup(); 70} 71 72function updateGrpDateLabel() { 73 var end = new Date(_grpEndDate + 'T00:00:00Z'); 74 var start = new Date(end); 75 start.setUTCDate(start.getUTCDate() - Math.max(0, _grpPeriod - 1)); 76 var label = document.getElementById('grp-date-label'); 77 if (label) label.textContent = start.toISOString().slice(0, 10) + ' ~ ' + end.toISOString().slice(0, 10); 78 var isToday = _grpEndDate >= new Date().toISOString().slice(0, 10); 79 var nextBtn = document.getElementById('grp-date-next'); 80 if (nextBtn) nextBtn.disabled = isToday; 81 var todayBtn = document.getElementById('grp-date-today'); 82 if (todayBtn) todayBtn.disabled = isToday; 83} 84 85function setGrpLevel(level, e) { 86 _grpLevel = level; 87 document.querySelectorAll('.grp-level').forEach(function(b) { b.classList.remove('active'); }); 88 if (e && e.target) e.target.classList.add('active'); 89 syncGrpUrl(); 90 renderGroupAnalysis(); 91} 92 93function setGrpMin(n, e) { 94 _grpMinMembers = n; 95 document.querySelectorAll('.grp-min').forEach(function(b) { b.classList.remove('active'); }); 96 if (e && e.target) e.target.classList.add('active'); 97 syncGrpUrl(); 98 renderGroupAnalysis(); 99} 100 101function setGrpStat(stat, e) { 102 _grpStat = stat; 103 document.querySelectorAll('.grp-stat').forEach(function(b) { b.classList.remove('active'); }); 104 if (e && e.target) e.target.classList.add('active'); 105 syncGrpUrl(); 106 renderGroupAnalysis(); 107} 108 109function goToLbWithFilter(teamFilter) { 110 var params = new URLSearchParams(); 111 params.set('tab', 'leaderboard'); 112 params.set('team', teamFilter); 113 params.set('lb_period', String(_grpPeriod)); 114 var today = new Date().toISOString().slice(0, 10); 115 if (_grpEndDate !== today) params.set('lb_end', _grpEndDate); 116 window.open(location.pathname + '?' + params.toString(), '_blank'); 117} 118 119function renderGroupAnalysis() { 120 var container = document.getElementById('group-analysis-content'); 121 if (!container || !_grpData) return; 122 var lb = _grpData.leaderboard || []; 123 124 // Provider scope (shared scope bar): 'all' uses each member's top-level totals; a specific provider 125 // uses that member's by_provider[pf] tokens/active_days (already delivered in the leaderboard payload), 126 // and members with no usage on the selected service are dropped â same rule as leaderboard/ranking. 127 var pf = (typeof _teamProvFilter !== 'undefined') ? _teamProvFilter : 'all'; 128 // Clamp to 'all' if the (older/transition) payload has no by_provider at all â else every member would 129 // be dropped and all groups would render empty. Mirrors the leaderboard's local provider clamp. 130 if (pf !== 'all' && !lb.some(function(e) { return e && e.by_provider; })) pf = 'all'; 131 132 // ê·¸ë£¹ë³ ì§ê³ (í íí° + íë¡ë°ì´ë ì¤ì½í ë°ì) 133 // Per-person daily average is ADDITIVE across services: for 'ì ì²´' it equals the SUM of each service's 134 // group daily average â Σ_p (그룹 tokens_p / 그룹 active_days_p) â so 'ì ì²´' = Claudeê° + ChatGPTê° 135 // (+ Gemini), matching the per-provider tabs and the Total column (which already sum providers). We 136 // therefore accumulate per-provider (tokens, active_days) sums per group, NOT a single combined 137 // total/union-days pair (that would blend services and read as an average, not a sum). 138 var GRP_PROVS = ['claude', 'chatgpt', 'gemini']; 139 var groups = {}; 140 for (var i = 0; i < lb.length; i++) { 141 var entry = lb[i]; 142 if (_currentTeamFilter && !passesTeamFilter(entry.user_email)) continue;
143 var bp = (pf !== 'all' && entry.by_provider) ? entry.by_provider[pf] : null; 144 var eTok = (pf === 'all') ? (entry.est_tokens || 0) : ((bp && bp.est_tokens) || 0); 145 if (pf !== 'all' && eTok <= 0) continue; // member has no usage on the selected service 146 147 // Per-service (tokens, days) contributions for this member under the active scope. 148 var contribs = []; 149 if (pf === 'all') { 150 if (entry.by_provider) { 151 for (var pi = 0; pi < GRP_PROVS.length; pi++) { 152 var pv = entry.by_provider[GRP_PROVS[pi]]; 153 if (pv && ((pv.est_tokens || 0) > 0 || (pv.active_days || 0) > 0)) contribs.push({ p: GRP_PROVS[pi], tok: pv.est_tokens || 0, days: pv.active_days || 0 }); 154 } 155 } 156 // Fallback for older payloads with no by_provider: attribute the combined total to one bucket. 157 if (contribs.length === 0) contribs.push({ p: 'claude', tok: entry.est_tokens || 0, days: entry.active_days || 0 }); 158 } else { 159 contribs.push({ p: pf, tok: (bp && bp.est_tokens) || 0, days: (bp && bp.active_days) || 0 }); 160 } 161 162 var team = getTeamForEmail(entry.user_email) || ''; 163 var groupKey; 164 if (_grpLevel === 0) { 165 groupKey = team ? team.split('/')[0] : t('grp_unassigned') || '(미ì§ì )'; 166 } else { 167 groupKey = team || t('grp_unassigned') || '(미ì§ì )'; 168 } 169 if (!groups[groupKey]) groups[groupKey] = { name: groupKey, totalTokens: 0, members: 0, byProv: { claude: { tok: 0, days: 0 }, chatgpt: { tok: 0, days: 0 }, gemini: { tok: 0, days: 0 } }, memDailies: [] }; 170 var gAcc = groups[groupKey]; 171 gAcc.members += 1; 172 var memTok = 0, memDaily = 0, memHasDays = false; 173 for (var ci = 0; ci < contribs.length; ci++) { 174 var c = contribs[ci]; 175 gAcc.byProv[c.p].tok += c.tok; 176 gAcc.byProv[c.p].days += c.days; 177 memTok += c.tok; 178 if (c.days > 0) { memDaily += Math.round(c.tok / c.days); memHasDays = true; } // member's sum of per-service daily averages 179 } 180 gAcc.totalTokens += memTok; 181 // Include any active member in the median input, even one whose daily value is 0 (e.g. a Gemini-only 182 // member: active_days>0 but est_tokens~0) â dropping them would bias the median upward. 183 if (memHasDays) gAcc.memDailies.push(memDaily); 184 } 185 186 // ë°°ì´ë¡ ë³í 187 var rows = []; 188 for (var key in groups) { 189 var g = groups[key]; 190 // avg = sum of per-service group daily averages. Round EACH service's tokens/days (exactly what its 191 // own provider tab displays), THEN sum â so 'ì ì²´' equals the manual sum of the per-provider tabs to 192 // the token. For a single provider only that bucket has data, so this reduces to the tab's value. 193 var avg = 0; 194 for (var gp = 0; gp < GRP_PROVS.length; gp++) { 195 var b = g.byProv[GRP_PROVS[gp]]; 196 if (b.days > 0) avg += Math.round(b.tok / b.days); 197 } 198 var sorted = g.memDailies.slice().sort(function(a, b) { return a - b; }); 199 var med = 0; 200 if (sorted.length > 0) { 201 var mid = Math.floor(sorted.length / 2); 202 med = sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid]; 203 } 204 rows.push({ 205 name: g.name, 206 members: g.members, 207 perPerson: _grpStat === 'median' ? med : avg, 208 avg: avg, 209 median: med, 210 totalTokens: g.totalTokens, 211 }); 212 } 213 if (_grpMinMembers > 0) rows = rows.filter(function(r) { return r.members >= _grpMinMembers; }); 214 rows.sort(function(a, b) { return b.perPerson - a.perPerson; }); 215 216 var maxPP = rows.length > 0 ? rows[0].perPerson : 1; 217 218 // HTML ë ëë§ (리ëë³´ëì ì ì¬í í ì´ë¸) 219 var html = '<table style="width:100%;border-collapse:collapse;font-size:13px">'; 220 html += '<thead><tr style="border-bottom:2px solid var(--gray-200);font-size:11px;color:var(--gray-400)">'; 221 html += '<th style="text-align:left;padding:8px 4px;font-weight:500">#</th>'; 222 html += '<th style="text-align:left;padding:8px 4px;font-weight:500">' + (t('grp_level' + _grpLevel) || 'Group') + '</th>'; 223 html += '<th style="text-align:right;padding:8px 4px;font-weight:500">' + t('grp_members') + '</th>'; 224 html += '<th style="text-align:left;padding:8px 4px 8px 12px;font-weight:500;width:40%">' + t('grp_per_person') + '</th>'; 225 html += '<th style="text-align:right;padding:8px 4px;font-weight:500">' + t('grp_total') + '</th>'; 226 html += '</tr></thead><tbody>'; 227 228 for (var ri = 0; ri < rows.length; ri++) { 229 var r = rows[ri]; 230 var barPct = maxPP > 0 ? Math.max(2, Math.round(r.perPerson / maxPP * 100)) : 0; 231 var isTop = ri < 3; 232 var rankLabel = ri === 0 ? 'ð¥' : ri === 1 ? 'ð¥' : ri === 2 ? 'ð¥' : String(ri + 1); 233 html += '<tr style="border-bottom:1px solid var(--gray-100)">'; 234 html += '<td style="padding:10px 4px;' + (isTop ? 'font-size:16px' : 'font-size:11px;color:var(--gray-400)') + '">' + rankLabel + '</td>'; 235 var nameParts = r.name.split('/'); 236 var nameHtml = ''; 237 for (var ni = 0; ni < nameParts.length; ni++) { 238 var filterVal = nameParts.slice(0, ni + 1).join('/'); 239 if (ni > 0) nameHtml += '<span style="color:var(--gray-300);margin:0 2px">/</span>'; 240 nameHtml += '<span style="color:#5b21b6;cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px" onclick="event.stopPropagation();goToLbWithFilter(\'' + escHtml(filterVal) + '\')">' + escHtml(nameParts[ni]) + '</span>'; 241 } 242 html += '<td style="padding:10px 4px;font-weight:600">' + nameHtml + '</td>'; 243 html += '<td style="padding:10px 4px;text-align:right;color:var(--gray-500);font-size:12px">' + r.members + '</td>'; 244 html += '<td style="padding:10px 4px 10px 12px">'; 245 html += '<div style="display:flex;align-items:center;gap:8px">'; 246 html += '<div style="flex:1;background:var(--gray-100);border-radius:4px;
246height:8px;min-width:60px"><div style="background:#7c3aed;height:8px;border-radius:4px;width:' + barPct + '%"></div></div>'; 247 html += '<span style="font-size:12px;font-weight:600;white-space:nowrap;min-width:50px;text-align:right">' + fmtTokens(r.perPerson) + '</span>'; 248 html += '</div></td>'; 249 html += '<td style="padding:10px 4px;text-align:right;color:var(--gray-500);font-size:12px">' + fmtTokens(r.totalTokens) + '</td>'; 250 html += '</tr>'; 251 } 252 html += '</tbody></table>'; 253 254 if (_orgTeams.length === 0) { 255 html = '<div style="text-align:center;padding:60px 24px">' 256 + '<div style="font-size:40px;margin-bottom:12px">ð</div>' 257 + '<div style="font-size:15px;color:var(--gray-500);font-weight:600;margin-bottom:8px">' + t('grp_no_groups_title') + '</div>' 258 + '<div style="font-size:13px;color:var(--gray-400);margin-bottom:16px">' + t('grp_no_groups_desc') + '</div>' 259 + '<button class="btn btn-primary" onclick="switchTab(\'settings\')" style="font-size:13px">' + t('grp_go_settings') + '</button>' 260 + '</div>'; 261 } else if (rows.length === 0) { 262 html = '<div style="text-align:center;padding:40px;color:var(--gray-400)">' + t('no_data') + '</div>'; 263 } 264 265 container.innerHTML = html; 266} 267 268// === CSV export === 269async function exportCSV() { 270 try { 271 var res = await apiFetch(API_BASE + '/api/me/orgs/' + currentOrg.id + '/export?format=csv', { headers: authHeaders() }); 272 var text = await res.text(); 273 var blob = new Blob([text], { type: 'text/csv' }); 274 var url = URL.createObjectURL(blob); 275 var a = document.createElement('a'); 276 a.href = url; a.download = (currentOrg.name || 'org') + '-members.csv'; 277 a.click(); URL.revokeObjectURL(url); 278 } catch(e) { alert(t('err_csv')); } 279} 280 281// === Tab switch === 282function updateUrlParam(key, value, defaultVal) { 283 var params = new URLSearchParams(location.search); 284 if (value === defaultVal || value === null || value === undefined) params.delete(key); 285 else params.set(key, String(value)); 286 var qs = params.toString(); 287 history.replaceState(null, '', location.pathname + (qs ? '?' + qs : '')); 288} 289 290function switchTab(tab) { 291 // Soft-launch gate: block direct entry to 'ìë¹ì¤ë³ ìí¸' (e.g. ?tab=provseats in the URL) for 292 // anyone outside the allowlist, so they don't land on a tab whose endpoint will 403. 293 if (tab === 'provseats' && SEATS_ALLOWLIST.indexOf(currentUserEmail) < 0) tab = 'overview'; 294 var tabs = ['overview', 'costs', 'breaches', 'leaderboard', 'group', 'members', 'settings', 'activity', 'ranking', 'provseats']; 295 for (var i = 0; i < tabs.length; i++) { 296 var el = document.getElementById('tab-' + tabs[i]); 297 if (el) el.style.display = tabs[i] === tab ? '' : 'none'; 298 } 299 document.querySelectorAll('.team-tab-btn[data-tab]').forEach(function(btn) { 300 btn.classList.toggle('active', btn.dataset.tab === tab); 301 }); 302 // The tab row is one horizontally scrolling line on narrow screens (.team-tab-row) â keep the 303 // active tab in view. Scrolls the row only, never the page. 304 var activeTabBtn = document.querySelector('.team-tab-row .team-tab-btn.active'); 305 if (activeTabBtn) { 306 var tabRow = activeTabBtn.parentElement; 307 var btnL = activeTabBtn.offsetLeft, btnR = btnL + activeTabBtn.offsetWidth; 308 if (btnL < tabRow.scrollLeft) tabRow.scrollLeft = btnL; 309 else if (btnR > tabRow.scrollLeft + tabRow.clientWidth) tabRow.scrollLeft = btnR - tabRow.clientWidth; 310 } 311 // Update URL query string (without reload) 312 var params = new URLSearchParams(location.search); 313 if (tab === 'overview') params.delete('tab'); 314 else params.set('tab', tab); 315 var qs = params.toString(); 316 history.replaceState(null, '', location.pathname + (qs ? '?' + qs : '')); 317 // Show/hide + refresh the shared provider scope bar for this tab (state preserved while hidden). 318 if (typeof refreshTeamProvScope === 'function') refreshTeamProvScope(tab); 319 // A provider-aware tab whose shared filter changed while it was hidden re-renders from cache here. 320 if (_provDirty[tab]) { _provDirty[tab] = false; if (typeof _applyProvFilterToTab === 'function') _applyProvFilterToTab(tab); } 321 // Lazy load tab data on first switch 322 if (tab === 'costs' && !_costsLoaded) lazyLoadCosts(); 323 if (tab === 'breaches' && !_breachLoaded) lazyLoadBreaches(); 324 if (tab === 'leaderboard' && !_lbLoaded) lazyLoadLeaderboard(); 325 if (tab === 'group' && !_grpLoaded) lazyLoadGroup(); 326 if (tab === 'activity' && !_actLoaded) actLoadChart(_actDate); 327 if (tab === 'settings' && !_orderHistoryLoaded) loadOrderHistory(); 328 if (tab === 'ranking' && !_rankLoaded) lazyLoadRanking(); 329 if (tab === 'provseats' && !_seatsLoaded) lazyLoadProviderSeats(); 330 // Resize ECharts in newly visible tab 331 setTimeout(function() { 332 var tabEl = document.getElementById('tab-' + tab); 333 if (tabEl) tabEl.querySelectorAll('[_echarts_instance_]').forEach(function(div) { 334 var inst = echarts.getInstanceByDom(div); 335 if (inst) inst.resize(); 336 }); 337 }, 50); 338} 339 340function switchOrg(orgId) { 341 var org = currentOrgList.find(function(o) { return String(o.id) === String(orgId); }); 342 if (org) selectOrg(org); 343} 344
345// === Member search filter === 346// === Sortable table === 347function sortTable(th) { 348 var table = th.closest('table'); 349 var tbody = table.querySelector('tbody'); 350 var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr')); 351 var colIdx = Array.prototype.indexOf.call(th.parentNode.children, th); 352 var isAsc = th.classList.contains('sort-asc'); 353 354 // Reset all headers 355 table.querySelectorAll('th.sortable').forEach(function(h) { 356 h.classList.remove('sort-asc', 'sort-desc'); 357 var icon = h.querySelector('.sort-icon'); 358 if (icon) icon.textContent = '\u25B2'; 359 }); 360 361 // Toggle direction 362 if (isAsc) { 363 th.classList.add('sort-desc'); 364 th.querySelector('.sort-icon').textContent = '\u25BC'; 365 } else { 366 th.classList.add('sort-asc'); 367 th.querySelector('.sort-icon').textContent = '\u25B2'; 368 } 369 var dir = isAsc ? -1 : 1; 370 371 rows.sort(function(a, b) { 372 var cellA = a.children[colIdx]; 373 var cellB = b.children[colIdx]; 374 var va = cellA.getAttribute('data-sort') || cellA.textContent.trim(); 375 var vb = cellB.getAttribute('data-sort') || cellB.textContent.trim(); 376 377 // Try numeric sort (only if the whole string is numeric) 378 var na = Number(va), nb = Number(vb); 379 if (va !== '' && vb !== '' && !isNaN(na) && !isNaN(nb)) return (na - nb) * dir; 380 381 // String sort (works for ISO dates, text, etc.) 382 return va.localeCompare(vb) * dir; 383 }); 384 385 for (var i = 0; i < rows.length; i++) { 386 tbody.appendChild(rows[i]); 387 } 388} 389 390function filterMembers() { 391 var q = document.getElementById('member-search').value.toLowerCase(); 392 document.querySelectorAll('#members-list .member-card').forEach(function(card) { 393 var name = card.dataset.name || ''; 394 var email = card.dataset.email || ''; 395 card.style.display = (name.indexOf(q) >= 0 || email.indexOf(q) >= 0) ? '' : 'none'; 396 }); 397} 398
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.