diff --git a/admin.html b/admin.html index 61f63e2..9dde665 100644 --- a/admin.html +++ b/admin.html @@ -82,6 +82,10 @@ .import-member-row { display:flex; align-items:center; gap:10px; padding:8px 12px; border-bottom:1px solid var(--border); cursor:pointer; } .import-member-row:hover { background:#f8f9fa; } .import-member-row:last-child { border-bottom:none; } + .coord-metric-row { display:grid; grid-template-columns:1fr 90px; gap:8px; align-items:center; padding:5px 0; border-bottom:1px solid var(--border); } + .coord-metric-row:last-child { border-bottom:none; } + .coord-metric-row input { padding:5px 8px; font-size:0.85rem; text-align:right; } + .coord-section-header { font-size:0.72rem; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:1px; margin:12px 0 6px; border-top:1px solid var(--border); padding-top:10px; } @@ -148,6 +152,56 @@
Alle Chapter
Lade…
+
+
Mitgliederkoordinator-Bericht
+
+
+ + +
+
+ + +
+
+ + +
+
Update-Rollout

Neue Versionsnummer in version.json pushen → alle Nutzer aller Chapter sehen das Update-Banner beim nächsten Start.

@@ -729,6 +783,8 @@ async function uploadMembersAdmin() { // ============================================================ // SUPERADMIN // ============================================================ +let _allChaptersData = []; + async function loadAllChapters() { const container = document.getElementById('all-chapters-list'); container.innerHTML = '
Lade…
'; @@ -736,6 +792,7 @@ async function loadAllChapters() { const res = await fetch(`${GITEA_API}/contents/chapters?ref=main`); const items = await res.json(); const chapters = Array.isArray(items) ? items.filter(i => i.type === 'dir') : []; + _allChaptersData = []; container.innerHTML = ''; for (const ch of chapters) { try { @@ -743,16 +800,21 @@ async function loadAllChapters() { const cfg = cfgRes.ok ? await cfgRes.json() : {}; const membersRes = await fetch(giteaUrl(`chapters/${ch.name}/members.json`)); const membersData = membersRes.ok ? await membersRes.json() : {}; + _allChaptersData.push({ code: ch.name, cfg }); + const isEnabled = !!cfg.memberCoordinatorReport; const div = document.createElement('div'); div.className = 'chapter-item'; div.innerHTML = `
${escHtml(cfg.chapterName||ch.name)}
Code: ${ch.name} · Mitglieder: ${(membersData.members||[]).length} · Stand: ${membersData.lastUpdated||'—'}
-
`; +
+ Bericht: ${isEnabled?'Aktiv':'Inaktiv'} + `; container.appendChild(div); } catch(e) { /* skip */ } } if (!container.children.length) container.innerHTML = '
Keine Chapter gefunden.
'; + _populateCoordChapterDropdown(); } catch(e) { container.innerHTML = '
Fehler beim Laden.
'; } @@ -819,6 +881,279 @@ function saveSettingsToken() { setTimeout(() => { document.getElementById('token-status').textContent = ''; }, 3000); } +// ============================================================ +// COORDINATOR REPORT +// ============================================================ +const _coordMetrics = [ + { key: 'attendance', label: 'Anwesenheit', unit: '%', cumulative: false }, + { key: 'members', label: 'Mitgliederzahl', unit: '', cumulative: false }, + { key: 'newMembers', label: 'Neue Mitglieder', unit: '', cumulative: true }, + { key: 'guests', label: 'Gäste / Besucher', unit: '', cumulative: true }, + { key: 'oneOnOne', label: '4-Augen-Gespräche', unit: '', cumulative: true }, + { key: 'cte', label: 'CTE', unit: '', cumulative: true }, + { key: 'referrals', label: 'Empfehlungen', unit: '', cumulative: true }, + { key: 'revenueThank', label: 'Umsatzdanke', unit: '', cumulative: true }, +]; + +let _coordReportData = null; + +function _getBniYearAdmin() { + const now = new Date(); + const m = now.getMonth(); + const y = now.getFullYear(); + return m >= 3 ? `${y}-${y + 1}` : `${y - 1}-${y}`; +} + +function _populateCoordChapterDropdown() { + const sel = document.getElementById('coord-chapter-select'); + if (!sel) return; + const prev = sel.value; + sel.innerHTML = ''; + for (const ch of _allChaptersData) { + if (!ch.cfg.memberCoordinatorReport) continue; + const opt = document.createElement('option'); + opt.value = ch.code; + opt.textContent = ch.cfg.chapterName || ch.code; + if (ch.code === prev) opt.selected = true; + sel.appendChild(opt); + } + // Pre-select current BNI year + const ySel = document.getElementById('coord-year-select'); + if (ySel && !ySel.value) ySel.value = _getBniYearAdmin(); + if (!sel.value) document.getElementById('coord-metrics-form').style.display = 'none'; +} + +async function toggleCoordinatorReport(code, currentEnabled) { + const token = getAdminToken(); + if (!token) { alert('Bitte Admin-Token in den Einstellungen eintragen.'); return; } + try { + const cfgRes = await fetch(giteaUrl(`chapters/${code}/config.json`), { cache: 'no-store' }); + if (!cfgRes.ok) { alert('Config nicht gefunden.'); return; } + const cfg = await cfgRes.json(); + const shaRes = await fetch(giteaAuthUrl(`chapters/${code}/config.json`, token)); + if (!shaRes.ok) { alert('SHA-Fetch fehlgeschlagen.'); return; } + const sha = (await shaRes.json()).sha; + cfg.memberCoordinatorReport = !currentEnabled; + const content = btoa(unescape(encodeURIComponent(JSON.stringify(cfg, null, 2)))); + const res = await fetch(giteaAuthUrl(`chapters/${code}/config.json`, token), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: `Koordinatoren-Bericht ${cfg.memberCoordinatorReport ? 'aktiviert' : 'deaktiviert'} für ${code}`, content, sha }) + }); + if (res.ok) { loadAllChapters(); } + else { alert('Fehler beim Speichern der Konfiguration.'); } + } catch(e) { alert('Verbindungsfehler: ' + e.message); } +} + +async function loadCoordinatorReportData() { + const code = document.getElementById('coord-chapter-select').value; + const year = document.getElementById('coord-year-select').value; + const form = document.getElementById('coord-metrics-form'); + if (!code) { form.style.display = 'none'; _coordReportData = null; return; } + form.style.display = 'block'; + _coordReportData = null; + try { + const res = await fetch(giteaUrl(`chapters/${code}/coordinator-report-${year}.json`), { cache: 'no-store' }); + if (res.ok) _coordReportData = await res.json(); + } catch(e) {} + if (!_coordReportData) { + _coordReportData = { year, metrics: _coordMetrics.map(m => ({ key: m.key, label: m.label, unit: m.unit, annualTarget: 0, entries: [] })) }; + } + // Ensure all metrics exist + for (const m of _coordMetrics) { + if (!_coordReportData.metrics.find(x => x.key === m.key)) { + _coordReportData.metrics.push({ key: m.key, label: m.label, unit: m.unit, annualTarget: 0, entries: [] }); + } + } + // Drop malformed entries (missing month) to avoid sort/compare crashes downstream + for (const m of _coordReportData.metrics) { + if (Array.isArray(m.entries)) m.entries = m.entries.filter(e => e && e.month); + } + // Render target rows + const targetsContainer = document.getElementById('coord-targets-rows'); + targetsContainer.innerHTML = ''; + for (const m of _coordMetrics) { + const existing = _coordReportData.metrics.find(x => x.key === m.key) || {}; + const row = document.createElement('div'); + row.className = 'coord-metric-row'; + row.innerHTML = `
${escHtml(m.label)}${m.unit ? ' (' + m.unit + ')' : ''}
+ `; + targetsContainer.appendChild(row); + } + // Populate month dropdown and select current month + _buildMonthDropdown(year); + loadMonthlyEntryForMonth(); + updateEntriesSummary(); +} + +function _bniMonths(year) { + const [y1, y2] = year.split('-').map(Number); + const DE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; + return [ + { val: `${y1}-04`, label: `April ${y1}` }, + { val: `${y1}-05`, label: `Mai ${y1}` }, + { val: `${y1}-06`, label: `Juni ${y1}` }, + { val: `${y1}-07`, label: `Juli ${y1}` }, + { val: `${y1}-08`, label: `August ${y1}` }, + { val: `${y1}-09`, label: `September ${y1}` }, + { val: `${y1}-10`, label: `Oktober ${y1}` }, + { val: `${y1}-11`, label: `November ${y1}` }, + { val: `${y1}-12`, label: `Dezember ${y1}` }, + { val: `${y2}-01`, label: `Januar ${y2}` }, + { val: `${y2}-02`, label: `Februar ${y2}` }, + { val: `${y2}-03`, label: `März ${y2}` }, + ]; +} + +function _buildMonthDropdown(year) { + const sel = document.getElementById('coord-month-select'); + if (!sel) return; + const now = new Date(); + const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const months = _bniMonths(year); + sel.innerHTML = months.map(m => + `` + ).join(''); +} + +function loadMonthlyEntryForMonth() { + const monthStr = document.getElementById('coord-month-select').value; + const container = document.getElementById('coord-monthly-rows'); + const deleteBtn = document.getElementById('coord-delete-month-btn'); + container.innerHTML = ''; + if (!_coordReportData || !monthStr) return; + const existing = {}; + for (const m of _coordReportData.metrics) { + const entry = (m.entries || []).find(e => e.month === monthStr); + if (entry) existing[m.key] = entry.value; + } + const hasEntry = Object.keys(existing).length > 0; + if (deleteBtn) deleteBtn.style.display = hasEntry ? '' : 'none'; + for (const m of _coordMetrics) { + const row = document.createElement('div'); + row.className = 'coord-metric-row'; + row.innerHTML = `
${escHtml(m.label)}${m.unit ? ' (' + m.unit + ')' : ''}
+ `; + container.appendChild(row); + } +} + +function updateEntriesSummary() { + const el = document.getElementById('coord-entries-summary'); + if (!_coordReportData || !el) return; + const allMonths = new Set(); + for (const m of _coordReportData.metrics) { + for (const e of (m.entries || [])) allMonths.add(e.month); + } + const months = [...allMonths].sort(); + el.textContent = months.length === 0 + ? 'Noch keine Einträge für dieses Jahr.' + : `${months.length} Monat(e) erfasst – zuletzt: ${months[months.length - 1]}`; +} + +function _coordStatus(msg, kind) { + const el = document.getElementById('coord-status'); + if (!el) return; + el.textContent = msg; + el.style.color = kind === 'error' ? '#dc2626' : kind === 'success' ? 'var(--success)' : 'var(--muted)'; +} + +async function saveCoordinatorTargets() { + const code = document.getElementById('coord-chapter-select').value; + if (!code) { _coordStatus('✗ Bitte zuerst ein Chapter auswählen.', 'error'); return; } + if (!_coordReportData) { _coordStatus('✗ Bericht nicht geladen – Chapter neu auswählen.', 'error'); return; } + try { + for (const m of _coordMetrics) { + const metric = _coordReportData.metrics.find(x => x.key === m.key); + const inputEl = document.getElementById(`coord-target-${m.key}`); + if (metric && inputEl) metric.annualTarget = parseFloat(inputEl.value) || 0; + } + _coordReportData.updatedAt = new Date().toISOString().slice(0, 10); + await _saveCoordinatorReportToGitea(code, _coordReportData); + } catch(e) { + _coordStatus('✗ Fehler: ' + (e && e.message ? e.message : e), 'error'); + } +} + +async function saveMonthlyEntry() { + const code = document.getElementById('coord-chapter-select').value; + const monthStr = document.getElementById('coord-month-select').value; + if (!code) { _coordStatus('✗ Bitte zuerst ein Chapter auswählen.', 'error'); return; } + if (!monthStr) { _coordStatus('✗ Bitte Monat auswählen.', 'error'); return; } + if (!_coordReportData) { _coordStatus('✗ Bericht nicht geladen – Chapter neu auswählen.', 'error'); return; } + try { + for (const m of _coordMetrics) { + const input = document.getElementById(`coord-monthly-${m.key}`); + if (!input || input.value === '') continue; + const value = parseFloat(input.value); + if (isNaN(value)) continue; + let metric = _coordReportData.metrics.find(x => x.key === m.key); + if (!metric) { metric = { key: m.key, label: m.label, unit: m.unit, annualTarget: 0, entries: [] }; _coordReportData.metrics.push(metric); } + if (!metric.entries) metric.entries = []; + const idx = metric.entries.findIndex(e => e.month === monthStr); + if (idx >= 0) metric.entries[idx].value = value; + else { metric.entries.push({ month: monthStr, value }); metric.entries.sort((a, b) => (a.month || '').localeCompare(b.month || '')); } + } + _coordReportData.updatedAt = new Date().toISOString().slice(0, 10); + await _saveCoordinatorReportToGitea(code, _coordReportData); + loadMonthlyEntryForMonth(); + updateEntriesSummary(); + } catch(e) { + _coordStatus('✗ Fehler: ' + (e && e.message ? e.message : e), 'error'); + } +} + +async function deleteMonthlyEntry() { + const code = document.getElementById('coord-chapter-select').value; + const monthStr = document.getElementById('coord-month-select').value; + if (!code || !monthStr || !_coordReportData) return; + if (!confirm(`Eintrag für ${monthStr} löschen?`)) return; + for (const m of _coordReportData.metrics) { + m.entries = (m.entries || []).filter(e => e.month !== monthStr); + } + _coordReportData.updatedAt = new Date().toISOString().slice(0, 10); + await _saveCoordinatorReportToGitea(code, _coordReportData); + loadMonthlyEntryForMonth(); + updateEntriesSummary(); +} + +async function _saveCoordinatorReportToGitea(code, data) { + const token = getAdminToken(); + if (!token) { + _coordStatus('✗ Admin-Token fehlt – bitte in den Einstellungen eintragen.', 'error'); + alert('Bitte Admin-Token in den Einstellungen eintragen.'); + return; + } + const year = data.year || document.getElementById('coord-year-select').value; + const path = `chapters/${code}/coordinator-report-${year}.json`; + _coordStatus('Speichere…'); + let sha = null; + try { + const shaRes = await fetch(giteaAuthUrl(path, token)); + if (shaRes.ok) sha = (await shaRes.json()).sha; + } catch(e) {} + const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2)))); + const body = { message: `Update Koordinatoren-Bericht ${year} für ${code}`, content }; + if (sha) body.sha = sha; + try { + const res = await fetch(giteaAuthUrl(path, token), { + method: sha ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (res.ok) { + _coordStatus('✓ Gespeichert (' + path + ')', 'success'); + setTimeout(() => _coordStatus('', ''), 5000); + } else { + let detail = ''; + try { detail = (await res.text()).slice(0, 200); } catch(e) {} + _coordStatus(`✗ Fehler beim Speichern (HTTP ${res.status}). ${detail}`, 'error'); + } + } catch(e) { + _coordStatus('✗ Verbindungsfehler: ' + (e && e.message ? e.message : e), 'error'); + } +} + // ============================================================ // INIT // ============================================================ diff --git a/index.html b/index.html index a5743d2..3695e2a 100644 --- a/index.html +++ b/index.html @@ -1147,6 +1147,13 @@ + + +
BNI @@ -1227,6 +1234,7 @@ + @@ -1757,10 +1765,11 @@ function defaultState() { visitors: [], // { id, name, company, specialty, photoUrl } visitorsOrder: [], // visitor IDs in order visitorsCurrentIndex: -1, - mode: 'members', // 'members' | 'absent-summary' | 'visitors' + mode: 'members', // 'members' | 'absent-summary' | 'visitors' | 'coordinator-report' defaultTimer: 60, // seconds (standard speaking time) memberTimers: {}, // { memberId: customSeconds } showTimer: true, // false = timer completely hidden + coordinatorReportData: null, }; } @@ -2176,6 +2185,28 @@ function renderDisplay() { const mainCard = document.getElementById('main-card'); const displayHeader = document.querySelector('#view-display .display-header'); const absentSummaryEl = document.getElementById('display-absent-summary'); + const coordReportEl = document.getElementById('display-coordinator-report'); + + // COORDINATOR REPORT MODE + if (state.mode === 'coordinator-report') { + emptyEl.style.display = 'none'; + mainCard.style.display = 'none'; + contentEl.style.display = 'none'; + nextBar.style.display = 'none'; + nextBarEnd.style.display = 'none'; + roundBadge.classList.remove('visible'); + progressText.textContent = ''; + if (displayHeader) displayHeader.style.display = ''; + if (absentSummaryEl) absentSummaryEl.style.display = 'none'; + const qrWrap = document.getElementById('qr-wrap'); + if (qrWrap) qrWrap.style.display = 'none'; + if (coordReportEl) { + coordReportEl.style.display = 'flex'; + renderCoordinatorReport(state.coordinatorReportData); + } + return; + } + if (coordReportEl) coordReportEl.style.display = 'none'; // ABSENT SUMMARY MODE if (state.mode === 'absent-summary') { @@ -2188,6 +2219,9 @@ function renderDisplay() { if (displayHeader) displayHeader.style.display = ''; absentSummaryEl.style.display = 'flex'; + const qrWrap = document.getElementById('qr-wrap'); + if (qrWrap) qrWrap.style.display = 'none'; + const medicalIds = state.medicalIds || []; const medicalMembers = medicalIds.map(id => state.members.find(m => m.id === id)).filter(Boolean); const absentMembers = state.absentIds.map(id => state.members.find(m => m.id === id)).filter(Boolean); @@ -2200,11 +2234,12 @@ function renderDisplay() { const total = medicalMembers.length + absentMembers.length; // Scale font size down as list grows - const fontSize = total <= 8 ? '1.35rem' - : total <= 14 ? '1.1rem' - : total <= 22 ? '0.95rem' - : total <= 32 ? '0.82rem' - : '0.7rem'; + const fontSize = total <= 4 ? '2.2rem' + : total <= 8 ? '1.7rem' + : total <= 14 ? '1.35rem' + : total <= 22 ? '1.1rem' + : total <= 32 ? '0.9rem' + : '0.75rem'; absentSummaryEl.style.setProperty('--absent-name-size', fontSize); // Auto-columns within each section @@ -2546,6 +2581,13 @@ function getDisplayData(presenter) { } } + // Stammvertreter-Foto: photoB64 is stored in extras keyed by name + // (set by pickStammvertreter; see ~line 1948). Use it if nothing else is set. + if (!photoUrl) { + const ex = loadExtras()[extrasKey(name)]; + if (ex && ex.photoB64) photoUrl = ex.photoB64; + } + // If still no photo, try matching by name in members list if (!photoUrl) { const match = state.members.find(m => @@ -2750,8 +2792,9 @@ function renderVisitorQueue() { else if (isNext) badge = `Nächster`; else if (isDone) badge = `✓`; - const photoHtml = visitor.photoUrl - ? `` + const visitorDisplay = getDisplayData(visitor); + const photoHtml = visitorDisplay.photoUrl + ? `` : `
${getInitials(visitor.name)}
`; const subTag = visitor.isSubstituteVisitor @@ -2892,6 +2935,12 @@ function updateNextButton() { const nameEl = document.getElementById('btn-next-name'); if (!btn) return; + if (state.mode === 'coordinator-report') { + btn.disabled = false; + nameEl.textContent = '↩ Schließen'; + return; + } + if (state.mode === 'round-transition') { btn.disabled = false; nameEl.textContent = '2. Runde'; @@ -2952,6 +3001,15 @@ function updateNextButton() { // ============================================================ // ACTIONS // ============================================================ +function _coordReportClose() { + state.mode = state._coordPrevMode || 'members'; + delete state._coordPrevMode; + state.coordinatorReportData = null; + saveState(); + renderControl(); + renderDisplay(); +} + function startRoundTransition() { state.mode = 'round-transition'; saveState(); @@ -2960,6 +3018,10 @@ function startRoundTransition() { } function goNext() { + if (state.mode === 'coordinator-report') { + _coordReportClose(); + return; + } if (state.mode === 'round-transition') { state.mode = 'visitors'; state.visitorsCurrentIndex = 0; @@ -4287,6 +4349,8 @@ function applyChapterUI(config) { syncLink.href = config.syncUrl; syncLink.textContent = name + ' – Mitgliederliste'; } + const coordBtn = document.getElementById('btn-coordinator-report'); + if (coordBtn) coordBtn.style.display = config.memberCoordinatorReport ? '' : 'none'; } async function checkLicenseAndUpdates() { @@ -4529,6 +4593,217 @@ function confirmSwitchChapter() { renderCurrentView(); } +// ============================================================ +// COORDINATOR REPORT +// ============================================================ +const _COORD_CUMULATIVE = { + attendance: false, members: false, + newMembers: true, guests: true, + oneOnOne: true, cte: true, + referrals: true, revenueThank: true, +}; + +function _getBniYear() { + const now = new Date(); + const m = now.getMonth(); + const y = now.getFullYear(); + return m >= 3 ? `${y}-${y + 1}` : `${y - 1}-${y}`; +} + +function _monthToIdx(monthStr, y1) { + const [y, m] = monthStr.split('-').map(Number); + return y === y1 ? m - 4 : m + 8; // Apr=0 … Dec=8, Jan=9 … Mar=11 +} + +const _MON_DE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; +function _monthAbbr(monthStr) { + const m = parseInt((monthStr || '').split('-')[1], 10); + return _MON_DE[m - 1] || ''; +} + +function makeSparklineSvg(entries, annualTarget, yearStr, isCumulative) { + const W = 200, H = 56; + const y1 = parseInt((yearStr || '2025-2026').split('-')[0]); + + // Today's month index (0–11 in BNI year) + const now = new Date(); + const todayIdx = _monthToIdx( + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`, y1 + ); + const todayX = ((Math.max(0, Math.min(11, todayIdx)) + 0.5) / 12 * W).toFixed(1); + + // Filter to entries with `month` field (new format) + const sorted = [...(entries || [])] + .filter(e => e.month) + .sort((a, b) => a.month.localeCompare(b.month)); + + if (sorted.length === 0) { + return ` + + Keine Daten`; + } + + const toX = monthStr => ((Math.max(0, Math.min(11, _monthToIdx(monthStr, y1))) + 0.5) / 12 * W); + const vals = sorted.map(e => e.value); + const yMax = Math.max(...vals, annualTarget || 0, 1) * 1.08; + const toY = v => H - (v / yMax) * H * 0.9; + + const pts = sorted.map(e => [toX(e.month), toY(e.value)]); + const linePath = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); + const areaPath = `${linePath} L${pts[pts.length-1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H}Z`; + + // Dynamic target line: starts from first actual value, ends at annualTarget in March + let targetPath, currentTarget; + if (isCumulative) { + const firstIdx = _monthToIdx(sorted[0].month, y1); + const firstVal = sorted[0].value; + const span = 11 - firstIdx; + const startX = ((firstIdx + 0.5) / 12 * W).toFixed(1); + const endX = ((11.5) / 12 * W).toFixed(1); + targetPath = `M${startX},${toY(firstVal).toFixed(1)} L${endX},${toY(annualTarget).toFixed(1)}`; + const clampedToday = Math.max(firstIdx, Math.min(11, todayIdx)); + currentTarget = span > 0 + ? firstVal + (annualTarget - firstVal) * ((clampedToday - firstIdx) / span) + : annualTarget; + } else { + const ty = toY(annualTarget).toFixed(1); + targetPath = `M0,${ty} L${W},${ty}`; + currentTarget = annualTarget; + } + + const latestVal = sorted[sorted.length - 1].value; + const onTrack = currentTarget > 0 ? latestVal >= currentTarget : latestVal > 0; + const col = onTrack ? '#22c55e' : '#ef4444'; + const lp = pts[pts.length - 1]; + + return ` + + + + + + `; +} + +function renderCoordinatorReport(data) { + const grid = document.getElementById('coord-report-grid'); + const updatedEl = document.getElementById('coord-report-updated'); + if (!grid) return; + if (!data || !data.metrics || !data.metrics.length) { + grid.innerHTML = '
Keine Berichtsdaten vorhanden.
'; + if (updatedEl) updatedEl.textContent = ''; + return; + } + const yearStr = data.year || _getBniYear(); + const y1 = parseInt(yearStr.split('-')[0]); + const now = new Date(); + const todayMonthIdx = _monthToIdx( + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`, y1 + ); + if (updatedEl) updatedEl.textContent = `BNI-Jahr ${yearStr.replace('-', '/')}${data.updatedAt ? ' · Stand: ' + data.updatedAt : ''}`; + + grid.innerHTML = ''; + for (const m of data.metrics) { + const isCumulative = _COORD_CUMULATIVE[m.key] !== false; + const annualTarget = m.annualTarget || 0; + const entries = m.entries || []; + const sorted = [...entries].filter(e => e.month).sort((a, b) => a.month.localeCompare(b.month)); + const latestEntry = sorted[sorted.length - 1]; + const latestVal = latestEntry ? latestEntry.value : null; + // Dynamic target: linear from first actual value to annualTarget by March + let currentTarget, monthlyNeeded = null; + if (isCumulative && sorted.length > 0) { + const firstVal = sorted[0].value; + const firstIdx = _monthToIdx(sorted[0].month, y1); + const span = 11 - firstIdx; + const clampedToday = Math.max(firstIdx, Math.min(11, todayMonthIdx)); + currentTarget = span > 0 + ? firstVal + (annualTarget - firstVal) * ((clampedToday - firstIdx) / span) + : annualTarget; + if (latestVal !== null && annualTarget > 0) { + const latestIdx = _monthToIdx(latestEntry.month, y1); + const monthsLeft = 11 - latestIdx; + if (monthsLeft > 0) monthlyNeeded = (annualTarget - latestVal) / monthsLeft; + } + } else { + currentTarget = annualTarget; + } + const onTrack = latestVal !== null && currentTarget > 0 ? latestVal >= currentTarget : null; + const valColor = onTrack === null ? 'rgba(255,255,255,0.5)' : onTrack ? '#22c55e' : '#ef4444'; + const progressPct = annualTarget > 0 && latestVal !== null ? Math.round((latestVal / annualTarget) * 100) : null; + + const monthlyHtml = sorted.length > 0 + ? `
+ ${sorted.map(e => `${_monthAbbr(e.month)} ${e.value}${m.unit || ''}`).join('')} +
` + : ''; + + const tile = document.createElement('div'); + tile.style.cssText = 'background:rgba(255,255,255,0.07);border-radius:12px;padding:clamp(8px,1.2vw,14px);display:flex;flex-direction:column;gap:clamp(4px,0.6vh,8px);overflow:hidden;'; + tile.innerHTML = ` +
${escHtml(m.label)}${m.unit ? ' (' + m.unit + ')' : ''}
+
${makeSparklineSvg(entries, annualTarget, yearStr, isCumulative)}
+ ${monthlyHtml} +
+
${latestVal !== null ? latestVal + (m.unit || '') : '—'}
+
+ Ziel: ${annualTarget}${m.unit || ''}${progressPct !== null ? `
${progressPct}%` : ''}${monthlyNeeded !== null ? `
${monthlyNeeded >= 0 ? '+' : ''}${monthlyNeeded.toFixed(1)}/Mo` : ''} +
+
`; + grid.appendChild(tile); + } +} + +async function showCoordinatorReport() { + if (!activeChapter) return; + if (state.mode === 'coordinator-report') { _coordReportClose(); return; } + const btn = document.getElementById('btn-coordinator-report'); + if (btn) { btn.disabled = true; btn.textContent = 'Lade…'; } + + // Try current BNI year and the two preceding ones, plus legacy unsuffixed file. + // Pick whichever file has the most recent entry month — protects against + // stale data sitting in a year file the user hasn't updated. + const currentYear = _getBniYear(); + const y1 = parseInt(currentYear.split('-')[0]); + const candidates = [ + `coordinator-report-${currentYear}.json`, + `coordinator-report-${y1 - 1}-${y1}.json`, + `coordinator-report-${y1 - 2}-${y1 - 1}.json`, + 'coordinator-report.json', + ]; + const datas = await Promise.all(candidates.map(async f => { + try { + const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/${f}`), { cache: 'no-store' }); + if (!res.ok) return null; + return await res.json(); + } catch(e) { return null; } + })); + + let reportData = null; + let bestLatestMonth = ''; + for (const d of datas) { + if (!d || !Array.isArray(d.metrics)) continue; + let latestMonth = ''; + for (const m of d.metrics) { + for (const e of (m.entries || [])) { + if (e.month && e.month > latestMonth) latestMonth = e.month; + } + } + if (latestMonth && latestMonth > bestLatestMonth) { + bestLatestMonth = latestMonth; + reportData = d; + } + } + + if (btn) { btn.disabled = false; btn.textContent = '📊 Koordinatoren-Bericht'; } + state._coordPrevMode = state.mode; + state.mode = 'coordinator-report'; + state.coordinatorReportData = reportData; + saveState(); + renderControl(); + renderDisplay(); +} + // ============================================================ // INIT // ============================================================