Feature: Stammvertreter-Datenbank + admin.html + Multi-File-Architektur

- Neues admin.html: eigenständiges Admin-Panel mit Tabs für Stammvertreter, Mitglieder, Superadmin, Einstellungen
- Stammvertreter-Feature in index.html: Profilvorrat für schnelle Vertreterauswahl, localStorage-basiert, chapter-spezifisch
- Stammvertreter-Picker im Vertretungs-Modal: Felder werden automatisch befüllt
- Superadmin: alle Chapter einsehen, version.json für Rollout pushen
- Import aus anderen Chaptern: Mitglied aus fremdem Chapter als Stammvertreter übernehmen
- Admin-Tab in index.html vereinfacht (Link zu admin.html)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Heubaum
2026-04-18 16:56:00 +02:00
parent 5deda366f3
commit b9fee3ae7c
2 changed files with 940 additions and 19 deletions

743
admin.html Normal file
View File

@@ -0,0 +1,743 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BNI Admin-Panel</title>
<style>
:root {
--red: #cc0000; --red-dark: #990000;
--navy: #1a2744; --bg: #f0f2f5;
--card: #ffffff; --text: #1a1a2e;
--muted: #6b7280; --border: #e5e7eb;
--success: #16a34a; --warning: #d97706;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
.header { background:var(--navy); color:white; padding:14px 24px; display:flex; align-items:center; gap:16px; }
.header img { height:40px; border-radius:6px; object-fit:contain; }
.header-title { font-size:1.1rem; font-weight:700; }
.header-chapter { font-size:0.82rem; color:rgba(255,255,255,0.6); }
.header-back { margin-left:auto; color:rgba(255,255,255,0.7); text-decoration:none; font-size:0.85rem; }
.header-back:hover { color:white; }
.tabs { background:white; border-bottom:1px solid var(--border); display:flex; padding:0 24px; }
.tab { padding:12px 20px; font-size:0.85rem; font-weight:600; color:var(--muted); cursor:pointer; border-bottom:2px solid transparent; }
.tab.active { color:var(--red); border-bottom-color:var(--red); }
.content { padding:24px; max-width:900px; }
.card { background:white; border-radius:12px; border:1px solid var(--border); padding:20px; margin-bottom:16px; }
.card-title { font-weight:700; font-size:0.95rem; margin-bottom:14px; }
.card-subtitle { font-size:0.78rem; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:1px; margin-bottom:10px; }
.btn { padding:8px 16px; border-radius:8px; font-size:0.85rem; font-weight:600; cursor:pointer; border:none; display:inline-flex; align-items:center; gap:6px; }
.btn-primary { background:var(--red); color:white; }
.btn-primary:hover { background:var(--red-dark); }
.btn-secondary { background:#f3f4f6; color:var(--text); border:1px solid var(--border); }
.btn-secondary:hover { background:#e5e7eb; }
.btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
.btn-danger { background:#fee2e2; color:#dc2626; }
.btn-sm { padding:5px 10px; font-size:0.78rem; }
.btn:disabled { opacity:0.5; cursor:not-allowed; }
.w-full { width:100%; justify-content:center; }
label { display:block; font-size:0.78rem; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; margin-top:10px; }
input[type=text], input[type=password], select, textarea {
width:100%; padding:9px 12px; border:1px solid var(--border); border-radius:8px; font-size:0.88rem; outline:none;
}
input:focus, select:focus { border-color:var(--red); }
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.sv-item { display:flex; align-items:center; gap:10px; padding:10px 0; border-bottom:1px solid var(--border); }
.sv-photo { width:44px; height:44px; border-radius:50%; object-fit:cover; flex-shrink:0; }
.sv-initials { width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; color:white; font-size:0.8rem; font-weight:700; flex-shrink:0; }
.sv-info { flex:1; min-width:0; }
.sv-name { font-weight:600; font-size:0.9rem; }
.sv-meta { font-size:0.78rem; color:var(--muted); }
.member-item { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--border); }
.chapter-item { padding:10px 14px; background:#f8f9fa; border:1px solid var(--border); border-radius:8px; margin-bottom:8px; display:flex; align-items:center; gap:12px; }
.status-msg { font-size:0.83rem; min-height:20px; margin-top:6px; }
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal { background:white; border-radius:16px; padding:24px; width:min(520px,92vw); max-height:90vh; overflow-y:auto; }
.modal-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
.modal-title { font-weight:700; font-size:1.05rem; }
.modal-close { background:none; border:none; font-size:1.3rem; cursor:pointer; color:var(--muted); }
.modal-footer { display:flex; gap:8px; justify-content:flex-end; margin-top:16px; padding-top:12px; border-top:1px solid var(--border); }
.photo-thumb { width:64px; height:64px; border-radius:50%; object-fit:cover; border:2px solid var(--border); }
.photo-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; }
.btn-file-label { display:inline-flex; align-items:center; gap:6px; padding:5px 10px; background:#f3f4f6; border:1px solid var(--border); border-radius:8px; font-size:0.78rem; font-weight:600; cursor:pointer; }
.tab-content { display:none; }
.tab-content.active { display:block; }
.import-chapter-list { max-height:280px; overflow-y:auto; border:1px solid var(--border); border-radius:8px; }
.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; }
</style>
</head>
<body>
<div class="header">
<img id="admin-logo" src="logo.jpeg" alt="BNI">
<div>
<div class="header-title">BNI Admin-Panel</div>
<div class="header-chapter" id="admin-chapter-name">—</div>
</div>
<a href="index.html" class="header-back">← Zurück zur Steuerung</a>
</div>
<div class="tabs">
<div class="tab active" onclick="switchAdminTab('stammvertreter')">Stammvertreter</div>
<div class="tab" onclick="switchAdminTab('mitglieder')">Mitglieder</div>
<div class="tab" id="superadmin-tab" onclick="switchAdminTab('superadmin')" style="display:none;">Superadmin</div>
<div class="tab" onclick="switchAdminTab('einstellungen')">Einstellungen</div>
</div>
<div class="content">
<!-- ============================================================ STAMMVERTRETER -->
<div id="tab-stammvertreter" class="tab-content active">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<div class="card-title" style="margin:0;">Stammvertreter-Vorrat</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-secondary btn-sm" onclick="openImportFromChapter()">⬇ Aus anderem Chapter</button>
<button class="btn btn-primary btn-sm" onclick="openAddSvAdmin()">+ Hinzufügen</button>
</div>
</div>
<div id="admin-sv-list"><div style="color:var(--muted);font-size:0.88rem;">Lade…</div></div>
</div>
<div class="card">
<div class="card-title">Gitea-Synchronisation</div>
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">Stammvertreter-Liste auf dem Server sichern, damit sie nach einem Browser-Reset wiederherstellbar ist.</p>
<div style="display:flex;gap:8px;">
<button class="btn btn-secondary btn-sm" onclick="downloadSvFromGitea()">🔄 Vom Server laden</button>
<button class="btn btn-primary btn-sm" onclick="uploadSvToGitea()">📤 Auf Server sichern</button>
</div>
<div class="status-msg" id="sv-sync-status"></div>
</div>
</div>
<!-- ============================================================ MITGLIEDER -->
<div id="tab-mitglieder" class="tab-content">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<div class="card-title" style="margin:0;">Mitglieder</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-secondary btn-sm" onclick="fetchMembersAdmin()">🔄 Vom Server laden</button>
<button class="btn btn-primary btn-sm" onclick="uploadMembersAdmin()">📤 Auf Server hochladen</button>
</div>
</div>
<div class="status-msg" id="members-status"></div>
<div id="admin-members-list" style="margin-top:10px;"><div style="color:var(--muted);font-size:0.88rem;">Lade…</div></div>
</div>
</div>
<!-- ============================================================ SUPERADMIN -->
<div id="tab-superadmin" class="tab-content">
<div class="card">
<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>
<div class="card">
<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>
<label>Aktuelle Version auf Gitea</label>
<div id="current-version-display" style="font-size:0.88rem;padding:8px 0;color:var(--muted);">—</div>
<label>Neue Version</label>
<div style="display:flex;gap:8px;">
<input type="text" id="new-version-input" placeholder="z.B. 1.2.0" style="flex:1;">
<input type="text" id="new-version-notes" placeholder="Hinweis (optional)" style="flex:2;">
<button class="btn btn-primary btn-sm" onclick="pushVersionUpdate()">Pushen</button>
</div>
<div class="status-msg" id="version-status"></div>
</div>
</div>
<!-- ============================================================ EINSTELLUNGEN -->
<div id="tab-einstellungen" class="tab-content">
<div class="card">
<div class="card-title">Admin-Token (Gitea API)</div>
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:10px;">Wird für alle Uploads auf den Gitea-Server benötigt. Wird nur lokal im Browser gespeichert.</p>
<div style="display:flex;gap:8px;">
<input type="password" id="settings-token" placeholder="Gitea API Token" style="flex:1;">
<button class="btn btn-primary btn-sm" onclick="saveSettingsToken()">Speichern</button>
</div>
<div class="status-msg" id="token-status"></div>
</div>
<div class="card">
<div class="card-title">Chapter-Informationen</div>
<div id="chapter-info-display" style="font-size:0.88rem;color:var(--muted);">Lade…</div>
</div>
</div>
</div><!-- .content -->
<!-- ============================================================ MODALS -->
<!-- SV Add/Edit Modal -->
<div class="modal-overlay" id="modal-sv-admin">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="sv-admin-modal-title">Stammvertreter hinzufügen</div>
<button class="modal-close" onclick="closeAdminModal('modal-sv-admin')">✕</button>
</div>
<input type="hidden" id="sv-admin-id">
<label>Name *</label>
<input type="text" id="sv-admin-name" placeholder="Vor- und Nachname">
<div class="form-row">
<div><label>Firma</label><input type="text" id="sv-admin-company" placeholder="Firma GmbH"></div>
<div><label>Fachgebiet</label><input type="text" id="sv-admin-specialty" placeholder="Fachgebiet"></div>
</div>
<label>Foto</label>
<div style="display:flex;align-items:center;gap:12px;margin-top:8px;">
<img id="sv-admin-photo-thumb" class="photo-thumb" src="" alt="" style="display:none;">
<div class="photo-actions">
<label class="btn-file-label">📁 Datei wählen<input type="file" id="sv-admin-photo-file" accept="image/*" style="display:none" onchange="handleSvAdminPhotoFile(this)"></label>
<button class="btn btn-danger btn-sm" id="sv-admin-remove-photo" onclick="removeSvAdminPhoto()" style="display:none;">✕ Entfernen</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger btn-sm" id="sv-admin-delete-btn" onclick="deleteSvAdmin()" style="display:none;">Löschen</button>
<button class="btn btn-ghost" onclick="closeAdminModal('modal-sv-admin')">Abbrechen</button>
<button class="btn btn-primary" onclick="saveSvAdminModal()">Speichern</button>
</div>
</div>
</div>
<!-- Import from Chapter Modal -->
<div class="modal-overlay" id="modal-import-chapter">
<div class="modal">
<div class="modal-header">
<div class="modal-title">Aus anderem Chapter importieren</div>
<button class="modal-close" onclick="closeAdminModal('modal-import-chapter')">✕</button>
</div>
<div id="import-chapter-step1">
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">Chapter auswählen, aus dem du ein Profil übernehmen möchtest:</p>
<div id="import-chapter-select-list"><div style="color:var(--muted);">Lade Chapter…</div></div>
</div>
<div id="import-chapter-step2" style="display:none;">
<button class="btn btn-ghost btn-sm" onclick="showImportStep1()" style="margin-bottom:12px;">← Zurück</button>
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:8px;">Mitglied auswählen:</p>
<div id="import-member-list" class="import-chapter-list"></div>
</div>
<div class="status-msg" id="import-status"></div>
</div>
</div>
<script>
// ============================================================
// SHARED CONSTANTS (copy from index.html)
// ============================================================
const APP_VERSION = '1.1.0';
const GITEA_RAW = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Sonne/raw';
const GITEA_API = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Sonne';
const CHAPTER_CONFIG_KEY = 'bni-active-chapter';
function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=main`; }
// ============================================================
// CHAPTER CONFIG
// ============================================================
function loadChapterConfig() {
try { return JSON.parse(localStorage.getItem(CHAPTER_CONFIG_KEY) || 'null'); } catch(e) { return null; }
}
const activeChapter = loadChapterConfig();
// ============================================================
// STORAGE KEYS
// ============================================================
function storageKey() { return activeChapter ? `bni-chapter-${activeChapter.code}-v2` : 'bni-chapter-default-v2'; }
function extrasKey(name) { return (name||'').toLowerCase().trim(); }
function extrasStorageKey() { return activeChapter ? `bni-extras-${activeChapter.code}` : 'bni-profile-extras'; }
function svStorageKey() { return activeChapter ? `bni-stammvertreter-${activeChapter.code}` : 'bni-stammvertreter'; }
function loadState() {
try { return JSON.parse(localStorage.getItem(storageKey()) || 'null') || defaultState(); } catch(e) { return defaultState(); }
}
function saveState(s) { localStorage.setItem(storageKey(), JSON.stringify(s)); }
function defaultState() { return { members:[], meetingOrder:[], currentIndex:-1, substitutes:{}, absentIds:[], medicalIds:[], visitors:[], visitorsOrder:[], visitorsCurrentIndex:-1, mode:'members', defaultTimer:60, memberTimers:{} }; }
function loadExtras() { try { return JSON.parse(localStorage.getItem(extrasStorageKey())||'{}'); } catch(e) { return {}; } }
function saveExtras(e) { localStorage.setItem(extrasStorageKey(), JSON.stringify(e)); }
function loadStammvertreter() { try { return JSON.parse(localStorage.getItem(svStorageKey())||'[]'); } catch(e) { return []; } }
function saveStammvertreter(list) { localStorage.setItem(svStorageKey(), JSON.stringify(list)); }
function getAdminToken() { return localStorage.getItem('bni-gitea-admin-token'); }
// ============================================================
// UTILS
// ============================================================
function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function getInitials(n) { if(!n)return'?'; return n.trim().split(/\s+/).map(w=>w[0]).join('').toUpperCase().slice(0,2); }
function getColor(n) { const c=['#1a2744','#cc0000','#0f3460','#1e3a5f','#4a1942']; let h=0; for(let ch of(n||''))h=(h*31+ch.charCodeAt(0))&0xffffffff; return c[Math.abs(h)%c.length]; }
function resizePhotoToBase64(file, maxPx=400) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
let w=img.width, h=img.height;
if(w>maxPx||h>maxPx){ if(w>h){h=Math.round(h*maxPx/w);w=maxPx;}else{w=Math.round(w*maxPx/h);h=maxPx;} }
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
canvas.getContext('2d').drawImage(img,0,0,w,h);
resolve(canvas.toDataURL('image/jpeg',0.82));
};
img.onerror=reject; img.src=e.target.result;
};
reader.onerror=reject; reader.readAsDataURL(file);
});
}
function avatarHtml(name, photoB64, photoUrl, size=44) {
const src = photoB64 || photoUrl || '';
if (src) return `<img src="${escHtml(src)}" style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;flex-shrink:0;" onerror="this.replaceWith(makeInitialsEl('${escHtml(name)}',${size}))">`;
const el = document.createElement('div');
el.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;background:${getColor(name)};display:flex;align-items:center;justify-content:center;color:white;font-size:${Math.round(size*0.32)}px;font-weight:700;flex-shrink:0;`;
el.textContent = getInitials(name);
return el.outerHTML;
}
function openAdminModal(id) { document.getElementById(id).classList.add('open'); }
function closeAdminModal(id) { document.getElementById(id).classList.remove('open'); }
// ============================================================
// TABS
// ============================================================
function switchAdminTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelector(`[onclick="switchAdminTab('${tab}')"]`).classList.add('active');
document.getElementById(`tab-${tab}`).classList.add('active');
if (tab === 'stammvertreter') renderSvList();
if (tab === 'mitglieder') renderMembersList();
if (tab === 'superadmin') { loadAllChapters(); loadCurrentVersion(); }
if (tab === 'einstellungen') renderSettings();
}
// ============================================================
// STAMMVERTRETER
// ============================================================
let _svAdminPhotoB64 = null;
function renderSvList() {
const list = loadStammvertreter();
const container = document.getElementById('admin-sv-list');
if (!list.length) {
container.innerHTML = `<div style="color:var(--muted);font-size:0.88rem;padding:8px 0;">Noch keine Stammvertreter gespeichert.</div>`;
return;
}
container.innerHTML = '';
list.forEach(sv => {
const extras = loadExtras();
const photoB64 = sv.photoB64 || (extras[extrasKey(sv.name)] && extras[extrasKey(sv.name)].photoB64) || null;
const div = document.createElement('div');
div.className = 'sv-item';
div.innerHTML = `${avatarHtml(sv.name, photoB64, null, 44)}
<div class="sv-info">
<div class="sv-name">${escHtml(sv.name)}</div>
<div class="sv-meta">${escHtml(sv.company||'')}${sv.specialty?' · '+escHtml(sv.specialty):''}</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="openEditSvAdmin('${sv.id}')">✏ Bearbeiten</button>`;
container.appendChild(div);
});
}
function openAddSvAdmin() {
document.getElementById('sv-admin-id').value = '';
document.getElementById('sv-admin-modal-title').textContent = 'Stammvertreter hinzufügen';
document.getElementById('sv-admin-name').value = '';
document.getElementById('sv-admin-company').value = '';
document.getElementById('sv-admin-specialty').value = '';
document.getElementById('sv-admin-delete-btn').style.display = 'none';
_svAdminPhotoB64 = null;
updateSvAdminPhotoUI(null);
openAdminModal('modal-sv-admin');
}
function openEditSvAdmin(svId) {
const sv = loadStammvertreter().find(s => s.id === svId);
if (!sv) return;
document.getElementById('sv-admin-id').value = sv.id;
document.getElementById('sv-admin-modal-title').textContent = 'Stammvertreter bearbeiten';
document.getElementById('sv-admin-name').value = sv.name;
document.getElementById('sv-admin-company').value = sv.company || '';
document.getElementById('sv-admin-specialty').value = sv.specialty || '';
document.getElementById('sv-admin-delete-btn').style.display = '';
_svAdminPhotoB64 = sv.photoB64 || null;
updateSvAdminPhotoUI(_svAdminPhotoB64);
openAdminModal('modal-sv-admin');
}
function updateSvAdminPhotoUI(b64) {
_svAdminPhotoB64 = b64;
const thumb = document.getElementById('sv-admin-photo-thumb');
const btn = document.getElementById('sv-admin-remove-photo');
if (b64) { thumb.src = b64; thumb.style.display = 'block'; btn.style.display = 'inline-flex'; }
else { thumb.src = ''; thumb.style.display = 'none'; btn.style.display = 'none'; }
}
function handleSvAdminPhotoFile(input) {
const file = input.files && input.files[0];
if (!file) return;
resizePhotoToBase64(file).then(b64 => updateSvAdminPhotoUI(b64)).catch(e => alert(e.message));
input.value = '';
}
function removeSvAdminPhoto() { updateSvAdminPhotoUI(null); }
function saveSvAdminModal() {
const name = document.getElementById('sv-admin-name').value.trim();
if (!name) { alert('Bitte Namen eingeben.'); return; }
const list = loadStammvertreter();
const existingId = document.getElementById('sv-admin-id').value;
const sv = {
id: existingId || 'sv-' + Date.now(),
name,
company: document.getElementById('sv-admin-company').value.trim(),
specialty: document.getElementById('sv-admin-specialty').value.trim(),
photoB64: _svAdminPhotoB64 || null,
};
if (existingId) {
const idx = list.findIndex(s => s.id === existingId);
if (idx >= 0) list[idx] = sv; else list.push(sv);
} else {
list.push(sv);
}
saveStammvertreter(list);
closeAdminModal('modal-sv-admin');
renderSvList();
}
function deleteSvAdmin() {
const svId = document.getElementById('sv-admin-id').value;
if (!confirm('Stammvertreter löschen?')) return;
saveStammvertreter(loadStammvertreter().filter(s => s.id !== svId));
closeAdminModal('modal-sv-admin');
renderSvList();
}
// ============================================================
// IMPORT FROM OTHER CHAPTER
// ============================================================
async function openImportFromChapter() {
document.getElementById('import-status').textContent = '';
showImportStep1();
openAdminModal('modal-import-chapter');
const listEl = document.getElementById('import-chapter-select-list');
listEl.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade Chapter…</div>';
try {
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') : [];
if (!chapters.length) { listEl.innerHTML = '<div style="color:var(--muted);">Keine anderen Chapter gefunden.</div>'; return; }
listEl.innerHTML = '';
for (const ch of chapters) {
if (activeChapter && ch.name === activeChapter.code) continue;
try {
const cfgRes = await fetch(giteaUrl(`chapters/${ch.name}/config.json`));
const cfg = cfgRes.ok ? await cfgRes.json() : {};
const btn = document.createElement('div');
btn.className = 'chapter-item';
btn.style.cursor = 'pointer';
btn.innerHTML = `<div><div style="font-weight:600;">${escHtml(cfg.chapterName||ch.name)}</div><div style="font-size:0.78rem;color:var(--muted);">${ch.name}</div></div><span style="margin-left:auto;color:var(--red);font-size:0.85rem;">Mitglieder laden →</span>`;
btn.onclick = () => loadChapterMembersForImport(ch.name, cfg.chapterName || ch.name);
listEl.appendChild(btn);
} catch(e) { /* skip */ }
}
} catch(e) {
listEl.innerHTML = '<div style="color:#dc2626;">Fehler beim Laden der Chapter.</div>';
}
}
function showImportStep1() {
document.getElementById('import-chapter-step1').style.display = '';
document.getElementById('import-chapter-step2').style.display = 'none';
}
async function loadChapterMembersForImport(code, chapterName) {
document.getElementById('import-chapter-step1').style.display = 'none';
document.getElementById('import-chapter-step2').style.display = '';
const listEl = document.getElementById('import-member-list');
listEl.innerHTML = '<div style="padding:12px;color:var(--muted);">Lade Mitglieder…</div>';
try {
const res = await fetch(giteaUrl(`chapters/${code}/members.json`));
if (!res.ok) { listEl.innerHTML = '<div style="padding:12px;color:#dc2626;">Keine Mitglieder gefunden.</div>'; return; }
const data = await res.json();
const members = data.members || [];
if (!members.length) { listEl.innerHTML = '<div style="padding:12px;color:var(--muted);">Keine Mitglieder vorhanden.</div>'; return; }
listEl.innerHTML = '';
members.forEach(m => {
const photoB64 = m.photoData || null;
const row = document.createElement('div');
row.className = 'import-member-row';
row.innerHTML = `${avatarHtml(m.name, photoB64, m.photoUrl, 36)}
<div>
<div style="font-weight:600;font-size:0.88rem;">${escHtml(m.name)}</div>
<div style="font-size:0.75rem;color:var(--muted);">${escHtml(m.company||'')}${m.specialty?' · '+escHtml(m.specialty):''}</div>
</div>
<span style="margin-left:auto;font-size:0.75rem;color:var(--muted);">${escHtml(chapterName)}</span>`;
row.onclick = () => importMemberAsStammvertreter(m, chapterName);
listEl.appendChild(row);
});
} catch(e) {
listEl.innerHTML = '<div style="padding:12px;color:#dc2626;">Fehler: ' + e.message + '</div>';
}
}
function importMemberAsStammvertreter(member, chapterName) {
const existing = loadStammvertreter();
if (existing.find(s => s.name.toLowerCase() === member.name.toLowerCase())) {
if (!confirm(`"${member.name}" ist bereits in deiner Liste. Trotzdem importieren?`)) return;
}
const sv = {
id: 'sv-' + Date.now(),
name: member.name,
company: member.company || '',
specialty: member.specialty || '',
photoB64: member.photoData || null,
fromChapter: chapterName,
};
existing.push(sv);
saveStammvertreter(existing);
document.getElementById('import-status').textContent = `✓ "${member.name}" übernommen.`;
document.getElementById('import-status').style.color = 'var(--success)';
renderSvList();
}
// ============================================================
// STAMMVERTRETER GITEA SYNC
// ============================================================
async function uploadSvToGitea() {
const token = getAdminToken();
if (!token) { alert('Bitte zuerst Admin-Token in den Einstellungen speichern.'); return; }
const statusEl = document.getElementById('sv-sync-status');
statusEl.textContent = 'Lädt hoch…'; statusEl.style.color = 'var(--muted)';
const list = loadStammvertreter();
const data = { lastUpdated: new Date().toISOString().slice(0,10), substitutes: list.map(s => ({ id:s.id, name:s.name, company:s.company, specialty:s.specialty, photoB64: s.photoB64||null, fromChapter:s.fromChapter||null })) };
const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2))));
const path = `chapters/${activeChapter.code}/stammvertreter.json`;
try {
let sha = null;
const shaRes = await fetch(`${GITEA_API}/contents/${path}`, { headers: { Authorization: `token ${token}` } });
if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message: 'Stammvertreter aktualisiert', content };
if (sha) body.sha = sha;
const res = await fetch(`${GITEA_API}/contents/${path}`, { method: sha?'PUT':'POST', headers: { Authorization:`token ${token}`, 'Content-Type':'application/json' }, body: JSON.stringify(body) });
if (res.ok) { statusEl.textContent = `✓ ${list.length} Stammvertreter gesichert`; statusEl.style.color = 'var(--success)'; }
else { statusEl.textContent = 'Fehler beim Upload.'; statusEl.style.color = '#dc2626'; }
} catch(e) { statusEl.textContent = 'Verbindungsfehler.'; statusEl.style.color = '#dc2626'; }
}
async function downloadSvFromGitea() {
const statusEl = document.getElementById('sv-sync-status');
statusEl.textContent = 'Lade…'; statusEl.style.color = 'var(--muted)';
try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/stammvertreter.json`), { cache: 'no-store' });
if (!res.ok) { statusEl.textContent = 'Keine Daten auf Server.'; statusEl.style.color = 'var(--muted)'; return; }
const data = await res.json();
if (data.substitutes) { saveStammvertreter(data.substitutes); renderSvList(); statusEl.textContent = `✓ ${data.substitutes.length} Stammvertreter geladen`; statusEl.style.color = 'var(--success)'; }
} catch(e) { statusEl.textContent = 'Verbindungsfehler.'; statusEl.style.color = '#dc2626'; }
}
// ============================================================
// MITGLIEDER
// ============================================================
function renderMembersList() {
const state = loadState();
const extras = loadExtras();
const container = document.getElementById('admin-members-list');
if (!state.members.length) {
container.innerHTML = `<div style="color:var(--muted);font-size:0.88rem;">Keine Mitglieder vorhanden. Erst Mitglieder über die Steuerung (BNI-Sync) importieren.</div>`;
return;
}
container.innerHTML = '';
state.members.forEach(m => {
const ex = extras[extrasKey(m.name)] || {};
const photoB64 = ex.photoB64 || ex.offlinePhotoB64 || null;
const div = document.createElement('div');
div.className = 'member-item';
div.innerHTML = `${avatarHtml(m.name, photoB64, m.photoUrl, 40)}
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.88rem;">${escHtml(m.name)}</div>
<div style="font-size:0.75rem;color:var(--muted);">${escHtml(m.company||'')}${m.specialty?' · '+escHtml(m.specialty):''}</div>
</div>
<span style="font-size:0.72rem;color:${photoB64?'var(--success)':'var(--muted)'};">${photoB64?'✓ Foto':'kein Foto'}</span>`;
container.appendChild(div);
});
}
async function fetchMembersAdmin() {
const statusEl = document.getElementById('members-status');
statusEl.textContent = 'Lade vom Server…'; statusEl.style.color = 'var(--muted)';
try {
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/members.json`), { cache: 'no-store' });
if (!res.ok) { statusEl.textContent = 'Keine Mitglieder auf Server.'; return; }
const data = await res.json();
if (!data.members || !data.members.length) { statusEl.textContent = 'Liste ist leer.'; return; }
const state = loadState();
const extras = loadExtras();
const map = new Map(state.members.map(m => [m.id, m]));
data.members.forEach(m => {
map.set(m.id, Object.assign({}, map.get(m.id)||{}, m));
if (m.photoData) { const key=extrasKey(m.name); if(!extras[key])extras[key]={}; extras[key].photoB64=m.photoData; }
});
state.members = Array.from(map.values());
const known = new Set(state.meetingOrder);
state.members.forEach(m => { if(!known.has(m.id)) state.meetingOrder.push(m.id); });
saveState(state); saveExtras(extras);
statusEl.textContent = `✓ ${data.members.length} Mitglieder geladen`; statusEl.style.color = 'var(--success)';
renderMembersList();
} catch(e) { statusEl.textContent = 'Verbindungsfehler.'; statusEl.style.color = '#dc2626'; }
}
async function uploadMembersAdmin() {
const token = getAdminToken();
if (!token) { alert('Bitte zuerst Admin-Token in den Einstellungen speichern.'); return; }
const statusEl = document.getElementById('members-status');
statusEl.textContent = 'Lädt hoch…'; statusEl.style.color = 'var(--muted)';
const state = loadState();
const extras = loadExtras();
const members = state.members.map(m => {
const ex = extras[extrasKey(m.name)] || {};
const entry = { id:m.id, name:m.name, company:m.company, specialty:m.specialty, photoUrl:m.photoUrl||'', profileUrl:m.profileUrl||'' };
if (ex.photoB64 || ex.offlinePhotoB64) entry.photoData = ex.photoB64 || ex.offlinePhotoB64;
return entry;
});
const data = { lastUpdated: new Date().toISOString().slice(0,10), members };
const content = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2))));
const path = `chapters/${activeChapter.code}/members.json`;
try {
let sha = null;
const shaRes = await fetch(`${GITEA_API}/contents/${path}`, { headers: { Authorization:`token ${token}` } });
if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message:`Mitglieder aktualisiert ${new Date().toLocaleDateString('de')}`, content };
if (sha) body.sha = sha;
const res = await fetch(`${GITEA_API}/contents/${path}`, { method:sha?'PUT':'POST', headers:{Authorization:`token ${token}`,'Content-Type':'application/json'}, body:JSON.stringify(body) });
if (res.ok) { statusEl.textContent = `✓ ${members.length} Mitglieder hochgeladen`; statusEl.style.color = 'var(--success)'; }
else { statusEl.textContent = 'Fehler beim Upload.'; statusEl.style.color = '#dc2626'; }
} catch(e) { statusEl.textContent = 'Verbindungsfehler.'; statusEl.style.color = '#dc2626'; }
}
// ============================================================
// SUPERADMIN
// ============================================================
async function loadAllChapters() {
const container = document.getElementById('all-chapters-list');
container.innerHTML = '<div style="color:var(--muted);font-size:0.85rem;">Lade…</div>';
try {
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') : [];
container.innerHTML = '';
for (const ch of chapters) {
try {
const cfgRes = await fetch(giteaUrl(`chapters/${ch.name}/config.json`));
const cfg = cfgRes.ok ? await cfgRes.json() : {};
const membersRes = await fetch(giteaUrl(`chapters/${ch.name}/members.json`));
const membersData = membersRes.ok ? await membersRes.json() : {};
const div = document.createElement('div');
div.className = 'chapter-item';
div.innerHTML = `<div style="flex:1;">
<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>`;
container.appendChild(div);
} catch(e) { /* skip */ }
}
if (!container.children.length) container.innerHTML = '<div style="color:var(--muted);">Keine Chapter gefunden.</div>';
} catch(e) {
container.innerHTML = '<div style="color:#dc2626;">Fehler beim Laden.</div>';
}
}
async function loadCurrentVersion() {
try {
const res = await fetch(giteaUrl('version.json'), { cache:'no-store' });
if (res.ok) {
const data = await res.json();
document.getElementById('current-version-display').textContent = data.version || '—';
document.getElementById('new-version-input').placeholder = data.version ? `z.B. ${data.version.split('.').map((n,i)=>i===2?+n+1:n).join('.')}` : 'z.B. 1.2.0';
}
} catch(e) { /* ignore */ }
}
async function pushVersionUpdate() {
const token = getAdminToken();
if (!token) { alert('Bitte Admin-Token in den Einstellungen eintragen.'); return; }
const version = document.getElementById('new-version-input').value.trim();
const notes = document.getElementById('new-version-notes').value.trim();
if (!version) { alert('Bitte Versionsnummer eingeben.'); return; }
const statusEl = document.getElementById('version-status');
statusEl.textContent = 'Pushe…'; statusEl.style.color = 'var(--muted)';
const content = btoa(unescape(encodeURIComponent(JSON.stringify({ version, notes }, null, 2))));
try {
let sha = null;
const shaRes = await fetch(`${GITEA_API}/contents/version.json`, { headers:{Authorization:`token ${token}`} });
if (shaRes.ok) sha = (await shaRes.json()).sha;
const body = { message:`Version ${version}`, content };
if (sha) body.sha = sha;
const res = await fetch(`${GITEA_API}/contents/version.json`, { method:sha?'PUT':'POST', headers:{Authorization:`token ${token}`,'Content-Type':'application/json'}, body:JSON.stringify(body) });
if (res.ok) {
statusEl.textContent = `✓ Version ${version} gepusht – alle Nutzer sehen das Update-Banner`;
statusEl.style.color = 'var(--success)';
document.getElementById('current-version-display').textContent = version;
} else { statusEl.textContent = 'Fehler beim Pushen.'; statusEl.style.color = '#dc2626'; }
} catch(e) { statusEl.textContent = 'Verbindungsfehler.'; statusEl.style.color = '#dc2626'; }
}
// ============================================================
// EINSTELLUNGEN
// ============================================================
function renderSettings() {
const token = getAdminToken();
document.getElementById('settings-token').value = token ? '••••••••' : '';
document.getElementById('settings-token').onfocus = function() { if (this.value==='••••••••') this.value=''; };
if (activeChapter) {
const cfg = activeChapter.config || {};
document.getElementById('chapter-info-display').innerHTML = `
<div><strong>Chapter:</strong> ${escHtml(cfg.chapterName||'—')}</div>
<div><strong>Code:</strong> <code>${activeChapter.code}</code></div>
<div><strong>Version:</strong> ${APP_VERSION}</div>
<div><strong>Aktiviert am:</strong> ${activeChapter.activatedAt ? new Date(activeChapter.activatedAt).toLocaleDateString('de') : '—'}</div>`;
}
}
function saveSettingsToken() {
const val = document.getElementById('settings-token').value;
const realVal = val === '••••••••' ? getAdminToken() : val.trim();
if (realVal) { localStorage.setItem('bni-gitea-admin-token', realVal); document.getElementById('token-status').textContent = '✓ Gespeichert'; document.getElementById('token-status').style.color = 'var(--success)'; }
else { localStorage.removeItem('bni-gitea-admin-token'); document.getElementById('token-status').textContent = 'Token entfernt.'; document.getElementById('token-status').style.color = 'var(--muted)'; }
if (getAdminToken()) document.getElementById('superadmin-tab').style.display = '';
setTimeout(() => { document.getElementById('token-status').textContent = ''; }, 3000);
}
// ============================================================
// INIT
// ============================================================
if (!activeChapter) {
document.body.innerHTML = `<div style="display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;gap:16px;color:var(--muted);">
<div style="font-size:1.2rem;font-weight:600;">Kein Chapter aktiviert</div>
<div>Bitte zuerst <a href="index.html" style="color:var(--red);">das Tool öffnen</a> und den Lizenzcode eingeben.</div>
</div>`;
} else {
const cfg = activeChapter.config || {};
document.getElementById('admin-chapter-name').textContent = cfg.chapterName || activeChapter.code;
if (cfg.logoUrl && cfg.logoUrl.startsWith('http')) document.getElementById('admin-logo').src = cfg.logoUrl;
if (getAdminToken()) document.getElementById('superadmin-tab').style.display = '';
renderSvList();
}
</script>
</body>
</html>