Mitgliederkoordinator-Bericht + Anzeige-Verbesserungen

Anzeige (index.html):
- Mitgliederkoordinator-Bericht-Modus: 4x2 Kacheln mit
  Jahreszielen, Sparkline-Trend und Monatswerte-Reihe pro
  Kennzahl; jüngste Jahres-JSON-Datei wird automatisch gewählt
- QR-Code wird auf Abwesenheits- und Bericht-Anzeige
  ausgeblendet (war zuvor vom letzten Mitglied übrig)
- Abwesenheits-Namen skalieren dynamisch (2.2rem bei wenigen
  Einträgen bis 0.75rem bei vollen Listen)
- Fotos für Besucher und Vertreter in Runde 2: getDisplayData
  fällt für Besucher zusätzlich auf loadExtras() zurück, damit
  Stammvertreter-Fotos (photoB64) erscheinen; Besucher-Queue
  nutzt dieselbe Fallback-Kette

Admin (admin.html):
- Karte "Mitgliederkoordinator-Bericht": Jahresziele und
  Monatswerte je Chapter und BNI-Jahr erfassen
- _coordStatus()-Helfer für einheitliches Speicher-Feedback;
  explizite Fehlermeldungen bei fehlendem Chapter/Monat,
  HTTP-Detail bei Server-Fehlern, fehlendes Admin-Token auch
  im Statusfeld
- Sort-Crash bei alten entries-Datensätzen ohne month-Feld
  behoben (null-sicher) + Bereinigung beim Laden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Heubaum
2026-05-22 18:07:38 +02:00
parent 8aed9c646c
commit 0797cc8984
2 changed files with 619 additions and 9 deletions

View File

@@ -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 { 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:hover { background:#f8f9fa; }
.import-member-row:last-child { border-bottom:none; } .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; }
</style> </style>
</head> </head>
<body> <body>
@@ -148,6 +152,56 @@
<div class="card-title">Alle Chapter</div> <div class="card-title">Alle Chapter</div>
<div id="all-chapters-list"><div style="color:var(--muted);font-size:0.88rem;">Lade…</div></div> <div id="all-chapters-list"><div style="color:var(--muted);font-size:0.88rem;">Lade…</div></div>
</div> </div>
<div class="card">
<div class="card-title">Mitgliederkoordinator-Bericht</div>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;">
<div style="flex:1;min-width:140px;">
<label>Chapter</label>
<select id="coord-chapter-select" onchange="loadCoordinatorReportData()">
<option value="">— Chapter auswählen —</option>
</select>
</div>
<div style="width:130px;">
<label>BNI-Jahr</label>
<select id="coord-year-select" onchange="loadCoordinatorReportData()">
<option value="2024-2025">2024 / 2025</option>
<option value="2025-2026" selected>2025 / 2026</option>
<option value="2026-2027">2026 / 2027</option>
</select>
</div>
</div>
<div id="coord-metrics-form" style="display:none;">
<!-- Jahresziele -->
<div class="coord-section-header">Jahresziele</div>
<div style="display:grid;grid-template-columns:1fr 90px;gap:8px;padding-bottom:3px;border-bottom:2px solid var(--border);margin-bottom:2px;">
<div style="font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;">Kennzahl</div>
<div style="font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;text-align:right;">Jahresziel</div>
</div>
<div id="coord-targets-rows"></div>
<button class="btn btn-secondary btn-sm" onclick="saveCoordinatorTargets()" style="margin-top:8px;">Ziele speichern</button>
<!-- Monat erfassen -->
<div class="coord-section-header">Monatswerte erfassen</div>
<div style="margin-bottom:8px;">
<label>Monat</label>
<select id="coord-month-select" onchange="loadMonthlyEntryForMonth()">
<option value="">— Monat wählen —</option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 90px;gap:8px;padding-bottom:3px;border-bottom:2px solid var(--border);margin-bottom:2px;">
<div style="font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;">Kennzahl</div>
<div style="font-size:0.72rem;font-weight:700;color:var(--muted);text-transform:uppercase;text-align:right;">Ist-Wert</div>
</div>
<div id="coord-monthly-rows"></div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn btn-primary btn-sm" onclick="saveMonthlyEntry()">Monat speichern</button>
<button class="btn btn-danger btn-sm" id="coord-delete-month-btn" onclick="deleteMonthlyEntry()" style="display:none;">Löschen</button>
</div>
<div id="coord-entries-summary" style="margin-top:8px;font-size:0.78rem;color:var(--muted);"></div>
</div>
<div class="status-msg" id="coord-status"></div>
</div>
<div class="card"> <div class="card">
<div class="card-title">Update-Rollout</div> <div class="card-title">Update-Rollout</div>
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">Neue Versionsnummer in version.json pushen → alle Nutzer aller Chapter sehen das Update-Banner beim nächsten Start.</p> <p style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">Neue Versionsnummer in version.json pushen → alle Nutzer aller Chapter sehen das Update-Banner beim nächsten Start.</p>
@@ -729,6 +783,8 @@ async function uploadMembersAdmin() {
// ============================================================ // ============================================================
// SUPERADMIN // SUPERADMIN
// ============================================================ // ============================================================
let _allChaptersData = [];
async function loadAllChapters() { async function loadAllChapters() {
const container = document.getElementById('all-chapters-list'); const container = document.getElementById('all-chapters-list');
container.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade…</div>'; container.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade…</div>';
@@ -736,6 +792,7 @@ async function loadAllChapters() {
const res = await fetch(`${GITEA_API}/contents/chapters?ref=main`); const res = await fetch(`${GITEA_API}/contents/chapters?ref=main`);
const items = await res.json(); const items = await res.json();
const chapters = Array.isArray(items) ? items.filter(i => i.type === 'dir') : []; const chapters = Array.isArray(items) ? items.filter(i => i.type === 'dir') : [];
_allChaptersData = [];
container.innerHTML = ''; container.innerHTML = '';
for (const ch of chapters) { for (const ch of chapters) {
try { try {
@@ -743,16 +800,21 @@ async function loadAllChapters() {
const cfg = cfgRes.ok ? await cfgRes.json() : {}; const cfg = cfgRes.ok ? await cfgRes.json() : {};
const membersRes = await fetch(giteaUrl(`chapters/${ch.name}/members.json`)); const membersRes = await fetch(giteaUrl(`chapters/${ch.name}/members.json`));
const membersData = membersRes.ok ? await membersRes.json() : {}; const membersData = membersRes.ok ? await membersRes.json() : {};
_allChaptersData.push({ code: ch.name, cfg });
const isEnabled = !!cfg.memberCoordinatorReport;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'chapter-item'; div.className = 'chapter-item';
div.innerHTML = `<div style="flex:1;"> div.innerHTML = `<div style="flex:1;">
<div style="font-weight:600;">${escHtml(cfg.chapterName||ch.name)}</div> <div style="font-weight:600;">${escHtml(cfg.chapterName||ch.name)}</div>
<div style="font-size:0.78rem;color:var(--muted);">Code: <code>${ch.name}</code> · Mitglieder: ${(membersData.members||[]).length} · Stand: ${membersData.lastUpdated||'—'}</div> <div style="font-size:0.78rem;color:var(--muted);">Code: <code>${ch.name}</code> · Mitglieder: ${(membersData.members||[]).length} · Stand: ${membersData.lastUpdated||'—'}</div>
</div>`; </div>
<span style="font-size:0.72rem;padding:2px 8px;border-radius:20px;background:${isEnabled?'#dcfce7':'#f3f4f6'};color:${isEnabled?'#16a34a':'#6b7280'};font-weight:600;white-space:nowrap;">Bericht: ${isEnabled?'Aktiv':'Inaktiv'}</span>
<button class="btn btn-sm ${isEnabled?'btn-ghost':'btn-secondary'}" onclick="toggleCoordinatorReport('${ch.name}', ${isEnabled})" style="white-space:nowrap;font-size:0.75rem;">${isEnabled?'Deaktivieren':'Freischalten'}</button>`;
container.appendChild(div); container.appendChild(div);
} catch(e) { /* skip */ } } catch(e) { /* skip */ }
} }
if (!container.children.length) container.innerHTML = '<div style="color:var(--muted);">Keine Chapter gefunden.</div>'; if (!container.children.length) container.innerHTML = '<div style="color:var(--muted);">Keine Chapter gefunden.</div>';
_populateCoordChapterDropdown();
} catch(e) { } catch(e) {
container.innerHTML = '<div style="color:#dc2626;">Fehler beim Laden.</div>'; container.innerHTML = '<div style="color:#dc2626;">Fehler beim Laden.</div>';
} }
@@ -819,6 +881,279 @@ function saveSettingsToken() {
setTimeout(() => { document.getElementById('token-status').textContent = ''; }, 3000); 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 = '<option value="">— Chapter auswählen —</option>';
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 = `<div style="font-size:0.88rem;">${escHtml(m.label)}${m.unit ? ' (' + m.unit + ')' : ''}</div>
<input type="number" id="coord-target-${m.key}" value="${existing.annualTarget || ''}" placeholder="Ziel" min="0" step="any">`;
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 =>
`<option value="${m.val}"${m.val === currentMonth ? ' selected' : ''}>${m.label}</option>`
).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 = `<div style="font-size:0.88rem;">${escHtml(m.label)}${m.unit ? ' (' + m.unit + ')' : ''}</div>
<input type="number" id="coord-monthly-${m.key}" value="${existing[m.key] ?? ''}" placeholder="Ist" min="0" step="any">`;
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 // INIT
// ============================================================ // ============================================================

View File

@@ -1147,6 +1147,13 @@
</div> </div>
</div> </div>
<!-- Mitgliederkoordinator-Bericht -->
<div id="display-coordinator-report" style="display:none;position:fixed;inset:0;padding:clamp(60px,8vh,100px) clamp(30px,5vw,80px) clamp(24px,4vh,50px);flex-direction:column;gap:clamp(10px,1.8vh,20px);overflow:hidden;z-index:20;">
<div style="font-size:clamp(0.9rem,2vw,1.4rem);font-weight:700;color:rgba(255,255,255,0.55);letter-spacing:2px;text-transform:uppercase;">Mitgliederkoordinator-Bericht</div>
<div id="coord-report-grid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,18px);flex:1;min-height:0;"></div>
<div style="font-size:0.7rem;color:rgba(255,255,255,0.25);text-align:right;" id="coord-report-updated"></div>
</div>
<!-- Startbild: außerhalb der Karte, damit es unabhängig angezeigt werden kann --> <!-- Startbild: außerhalb der Karte, damit es unabhängig angezeigt werden kann -->
<div id="display-empty" style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:10;"> <div id="display-empty" style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:10;">
<img id="display-logo-empty" src="logo.jpeg" alt="BNI" style="width:min(520px,72vw);height:auto;border-radius:24px;box-shadow:0 8px 60px rgba(0,0,0,0.5);"> <img id="display-logo-empty" src="logo.jpeg" alt="BNI" style="width:min(520px,72vw);height:auto;border-radius:24px;box-shadow:0 8px 60px rgba(0,0,0,0.5);">
@@ -1227,6 +1234,7 @@
<button class="btn btn-secondary btn-sm" onclick="openMemberSync()">🔄 Mitglieder aktualisieren</button> <button class="btn btn-secondary btn-sm" onclick="openMemberSync()">🔄 Mitglieder aktualisieren</button>
<button class="btn btn-secondary btn-sm" onclick="openImportExport()">⬇ Import / Export</button> <button class="btn btn-secondary btn-sm" onclick="openImportExport()">⬇ Import / Export</button>
<button class="btn btn-secondary btn-sm" onclick="window.open(location.href.split('#')[0] + '#display', '_blank', 'noopener')">🖥 Anzeige öffnen</button> <button class="btn btn-secondary btn-sm" onclick="window.open(location.href.split('#')[0] + '#display', '_blank', 'noopener')">🖥 Anzeige öffnen</button>
<button class="btn btn-secondary btn-sm" id="btn-coordinator-report" onclick="showCoordinatorReport()" style="display:none;">📊 Koordinatoren-Bericht</button>
<button class="btn btn-next" id="btn-next" onclick="goNext()" disabled> <button class="btn btn-next" id="btn-next" onclick="goNext()" disabled>
Weiter <span id="btn-next-name"></span> → Weiter <span id="btn-next-name"></span> →
</button> </button>
@@ -1757,10 +1765,11 @@ function defaultState() {
visitors: [], // { id, name, company, specialty, photoUrl } visitors: [], // { id, name, company, specialty, photoUrl }
visitorsOrder: [], // visitor IDs in order visitorsOrder: [], // visitor IDs in order
visitorsCurrentIndex: -1, visitorsCurrentIndex: -1,
mode: 'members', // 'members' | 'absent-summary' | 'visitors' mode: 'members', // 'members' | 'absent-summary' | 'visitors' | 'coordinator-report'
defaultTimer: 60, // seconds (standard speaking time) defaultTimer: 60, // seconds (standard speaking time)
memberTimers: {}, // { memberId: customSeconds } memberTimers: {}, // { memberId: customSeconds }
showTimer: true, // false = timer completely hidden showTimer: true, // false = timer completely hidden
coordinatorReportData: null,
}; };
} }
@@ -2176,6 +2185,28 @@ function renderDisplay() {
const mainCard = document.getElementById('main-card'); const mainCard = document.getElementById('main-card');
const displayHeader = document.querySelector('#view-display .display-header'); const displayHeader = document.querySelector('#view-display .display-header');
const absentSummaryEl = document.getElementById('display-absent-summary'); 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 // ABSENT SUMMARY MODE
if (state.mode === 'absent-summary') { if (state.mode === 'absent-summary') {
@@ -2188,6 +2219,9 @@ function renderDisplay() {
if (displayHeader) displayHeader.style.display = ''; if (displayHeader) displayHeader.style.display = '';
absentSummaryEl.style.display = 'flex'; absentSummaryEl.style.display = 'flex';
const qrWrap = document.getElementById('qr-wrap');
if (qrWrap) qrWrap.style.display = 'none';
const medicalIds = state.medicalIds || []; const medicalIds = state.medicalIds || [];
const medicalMembers = medicalIds.map(id => state.members.find(m => m.id === id)).filter(Boolean); 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); 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; const total = medicalMembers.length + absentMembers.length;
// Scale font size down as list grows // Scale font size down as list grows
const fontSize = total <= 8 ? '1.35rem' const fontSize = total <= 4 ? '2.2rem'
: total <= 14 ? '1.1rem' : total <= 8 ? '1.7rem'
: total <= 22 ? '0.95rem' : total <= 14 ? '1.35rem'
: total <= 32 ? '0.82rem' : total <= 22 ? '1.1rem'
: '0.7rem'; : total <= 32 ? '0.9rem'
: '0.75rem';
absentSummaryEl.style.setProperty('--absent-name-size', fontSize); absentSummaryEl.style.setProperty('--absent-name-size', fontSize);
// Auto-columns within each section // 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 still no photo, try matching by name in members list
if (!photoUrl) { if (!photoUrl) {
const match = state.members.find(m => const match = state.members.find(m =>
@@ -2750,8 +2792,9 @@ function renderVisitorQueue() {
else if (isNext) badge = `<span class="queue-badge badge-next">Nächster</span>`; else if (isNext) badge = `<span class="queue-badge badge-next">Nächster</span>`;
else if (isDone) badge = `<span class="queue-badge badge-done">✓</span>`; else if (isDone) badge = `<span class="queue-badge badge-done">✓</span>`;
const photoHtml = visitor.photoUrl const visitorDisplay = getDisplayData(visitor);
? `<img src="${escHtml(visitor.photoUrl)}" alt="" style="width:44px;height:44px;border-radius:50%;object-fit:cover;">` const photoHtml = visitorDisplay.photoUrl
? `<img src="${escHtml(visitorDisplay.photoUrl)}" alt="" style="width:44px;height:44px;border-radius:50%;object-fit:cover;">`
: `<div class="queue-initials" style="background:${getColor(visitor.name)}">${getInitials(visitor.name)}</div>`; : `<div class="queue-initials" style="background:${getColor(visitor.name)}">${getInitials(visitor.name)}</div>`;
const subTag = visitor.isSubstituteVisitor const subTag = visitor.isSubstituteVisitor
@@ -2892,6 +2935,12 @@ function updateNextButton() {
const nameEl = document.getElementById('btn-next-name'); const nameEl = document.getElementById('btn-next-name');
if (!btn) return; if (!btn) return;
if (state.mode === 'coordinator-report') {
btn.disabled = false;
nameEl.textContent = '↩ Schließen';
return;
}
if (state.mode === 'round-transition') { if (state.mode === 'round-transition') {
btn.disabled = false; btn.disabled = false;
nameEl.textContent = '2. Runde'; nameEl.textContent = '2. Runde';
@@ -2952,6 +3001,15 @@ function updateNextButton() {
// ============================================================ // ============================================================
// ACTIONS // ACTIONS
// ============================================================ // ============================================================
function _coordReportClose() {
state.mode = state._coordPrevMode || 'members';
delete state._coordPrevMode;
state.coordinatorReportData = null;
saveState();
renderControl();
renderDisplay();
}
function startRoundTransition() { function startRoundTransition() {
state.mode = 'round-transition'; state.mode = 'round-transition';
saveState(); saveState();
@@ -2960,6 +3018,10 @@ function startRoundTransition() {
} }
function goNext() { function goNext() {
if (state.mode === 'coordinator-report') {
_coordReportClose();
return;
}
if (state.mode === 'round-transition') { if (state.mode === 'round-transition') {
state.mode = 'visitors'; state.mode = 'visitors';
state.visitorsCurrentIndex = 0; state.visitorsCurrentIndex = 0;
@@ -4287,6 +4349,8 @@ function applyChapterUI(config) {
syncLink.href = config.syncUrl; syncLink.href = config.syncUrl;
syncLink.textContent = name + ' – Mitgliederliste'; syncLink.textContent = name + ' – Mitgliederliste';
} }
const coordBtn = document.getElementById('btn-coordinator-report');
if (coordBtn) coordBtn.style.display = config.memberCoordinatorReport ? '' : 'none';
} }
async function checkLicenseAndUpdates() { async function checkLicenseAndUpdates() {
@@ -4529,6 +4593,217 @@ function confirmSwitchChapter() {
renderCurrentView(); 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 `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:100%;" preserveAspectRatio="none">
<line x1="${todayX}" y1="0" x2="${todayX}" y2="${H}" stroke="rgba(255,255,255,0.1)" stroke-width="1" stroke-dasharray="3,3"/>
<text x="${W/2}" y="${H/2+4}" text-anchor="middle" fill="rgba(255,255,255,0.2)" font-size="9" font-family="sans-serif">Keine Daten</text></svg>`;
}
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 `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:100%;" preserveAspectRatio="none">
<path d="${areaPath}" fill="${col}" fill-opacity="0.12"/>
<path d="${targetPath}" stroke="rgba(255,255,255,0.28)" stroke-width="1.5" stroke-dasharray="4,3" fill="none"/>
<line x1="${todayX}" y1="0" x2="${todayX}" y2="${H}" stroke="rgba(255,255,255,0.12)" stroke-width="1" stroke-dasharray="3,3"/>
<path d="${linePath}" stroke="${col}" stroke-width="2.5" fill="none" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="${lp[0].toFixed(1)}" cy="${lp[1].toFixed(1)}" r="3.5" fill="${col}"/>
</svg>`;
}
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 = '<div style="color:rgba(255,255,255,0.4);grid-column:1/-1;display:flex;align-items:center;justify-content:center;font-size:1.1rem;">Keine Berichtsdaten vorhanden.</div>';
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
? `<div style="display:flex;flex-wrap:wrap;gap:clamp(4px,0.8vw,12px);font-size:clamp(0.55rem,0.85vw,0.78rem);color:rgba(255,255,255,0.7);justify-content:center;line-height:1.25;">
${sorted.map(e => `<span><span style="color:rgba(255,255,255,0.35);font-weight:600;">${_monthAbbr(e.month)}</span> <span style="font-weight:700;">${e.value}${m.unit || ''}</span></span>`).join('')}
</div>`
: '';
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 = `
<div style="font-size:clamp(0.5rem,0.8vw,0.7rem);color:rgba(255,255,255,0.45);font-weight:700;text-transform:uppercase;letter-spacing:0.5px;">${escHtml(m.label)}${m.unit ? ' (' + m.unit + ')' : ''}</div>
<div style="flex:1;min-height:0;">${makeSparklineSvg(entries, annualTarget, yearStr, isCumulative)}</div>
${monthlyHtml}
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:4px;">
<div style="font-size:clamp(0.95rem,2vw,1.7rem);font-weight:700;color:${valColor};">${latestVal !== null ? latestVal + (m.unit || '') : '—'}</div>
<div style="font-size:clamp(0.5rem,0.85vw,0.72rem);color:rgba(255,255,255,0.28);text-align:right;line-height:1.3;">
Ziel: ${annualTarget}${m.unit || ''}${progressPct !== null ? `<br><span style="color:${valColor};">${progressPct}%</span>` : ''}${monthlyNeeded !== null ? `<br><span style="color:rgba(255,255,255,0.45);">${monthlyNeeded >= 0 ? '+' : ''}${monthlyNeeded.toFixed(1)}/Mo</span>` : ''}
</div>
</div>`;
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 // INIT
// ============================================================ // ============================================================