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>
1175 lines
62 KiB
HTML
1175 lines
62 KiB
HTML
<!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; }
|
||
.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>
|
||
</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">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-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>
|
||
|
||
<!-- Member Photo Modal -->
|
||
<div class="modal-overlay" id="modal-member-photo">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="member-photo-modal-title">Foto bearbeiten</div>
|
||
<button class="modal-close" onclick="closeAdminModal('modal-member-photo')">✕</button>
|
||
</div>
|
||
<input type="hidden" id="member-photo-name">
|
||
<div style="display:flex;align-items:center;gap:16px;margin:16px 0;">
|
||
<img id="member-photo-thumb" class="photo-thumb" style="width:80px;height:80px;display:none;" src="" alt="">
|
||
<div id="member-photo-initials" style="width:80px;height:80px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:white;font-size:1.4rem;font-weight:700;flex-shrink:0;"></div>
|
||
<div class="photo-actions" style="flex-direction:column;align-items:flex-start;">
|
||
<label class="btn-file-label">📁 Foto wählen<input type="file" id="member-photo-file" accept="image/*" style="display:none" onchange="handleMemberPhotoFile(this)"></label>
|
||
<button class="btn btn-danger btn-sm" id="member-photo-remove" onclick="removeMemberPhoto()" style="display:none;margin-top:6px;">✕ Foto entfernen</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="closeAdminModal('modal-member-photo')">Abbrechen</button>
|
||
<button class="btn btn-primary" onclick="saveMemberPhoto()">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-Vorstellungsrunde/raw';
|
||
const GITEA_API = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Vorstellungsrunde';
|
||
const CHAPTER_CONFIG_KEY = 'bni-active-chapter';
|
||
function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=main`; }
|
||
function giteaAuthUrl(path, token) { return `${GITEA_API}/contents/${path}?token=${encodeURIComponent(token)}`; }
|
||
|
||
// ============================================================
|
||
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
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`), { cache: 'no-store' });
|
||
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(giteaAuthUrl(path, token));
|
||
if (shaRes.ok) sha = (await shaRes.json()).sha;
|
||
const body = { message: 'Stammvertreter aktualisiert', content };
|
||
if (sha) body.sha = sha;
|
||
const res = await fetch(giteaAuthUrl(path, token), { method: sha?'PUT':'POST', headers: { '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.style.cursor = 'pointer';
|
||
div.title = 'Foto bearbeiten';
|
||
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)':'#d97706'};">${photoB64?'✓ Foto':'+ Foto'}</span>`;
|
||
div.onclick = () => openMemberPhotoModal(m.name, photoB64);
|
||
container.appendChild(div);
|
||
});
|
||
}
|
||
|
||
let _memberPhotoB64 = null;
|
||
|
||
function openMemberPhotoModal(name, currentPhotoB64) {
|
||
_memberPhotoB64 = currentPhotoB64 || null;
|
||
document.getElementById('member-photo-name').value = name;
|
||
document.getElementById('member-photo-modal-title').textContent = name;
|
||
updateMemberPhotoUI(_memberPhotoB64, name);
|
||
openAdminModal('modal-member-photo');
|
||
}
|
||
|
||
function updateMemberPhotoUI(b64, name) {
|
||
_memberPhotoB64 = b64;
|
||
const thumb = document.getElementById('member-photo-thumb');
|
||
const initials = document.getElementById('member-photo-initials');
|
||
const removeBtn = document.getElementById('member-photo-remove');
|
||
if (b64) {
|
||
thumb.src = b64; thumb.style.display = 'block';
|
||
initials.style.display = 'none';
|
||
removeBtn.style.display = 'inline-flex';
|
||
} else {
|
||
thumb.src = ''; thumb.style.display = 'none';
|
||
initials.style.display = 'flex';
|
||
initials.style.background = getColor(name || '');
|
||
initials.textContent = getInitials(name || '');
|
||
removeBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function handleMemberPhotoFile(input) {
|
||
const file = input.files && input.files[0];
|
||
if (!file) return;
|
||
const name = document.getElementById('member-photo-name').value;
|
||
resizePhotoToBase64(file).then(b64 => updateMemberPhotoUI(b64, name)).catch(e => alert(e.message));
|
||
input.value = '';
|
||
}
|
||
|
||
function removeMemberPhoto() {
|
||
updateMemberPhotoUI(null, document.getElementById('member-photo-name').value);
|
||
}
|
||
|
||
function saveMemberPhoto() {
|
||
const name = document.getElementById('member-photo-name').value;
|
||
const extras = loadExtras();
|
||
const key = extrasKey(name);
|
||
if (!extras[key]) extras[key] = {};
|
||
if (_memberPhotoB64) {
|
||
extras[key].photoB64 = _memberPhotoB64;
|
||
} else {
|
||
delete extras[key].photoB64;
|
||
delete extras[key].offlinePhotoB64;
|
||
}
|
||
saveExtras(extras);
|
||
closeAdminModal('modal-member-photo');
|
||
renderMembersList();
|
||
}
|
||
|
||
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)';
|
||
try {
|
||
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`;
|
||
const url = giteaAuthUrl(path, token);
|
||
console.log('[Upload] URL:', url);
|
||
statusEl.textContent = 'Prüfe SHA…';
|
||
let sha = null;
|
||
const shaRes = await fetch(url);
|
||
console.log('[Upload] SHA-Status:', shaRes.status);
|
||
if (shaRes.ok) sha = (await shaRes.json()).sha;
|
||
const body = { message:`Mitglieder aktualisiert ${new Date().toLocaleDateString('de')}`, content };
|
||
if (sha) body.sha = sha;
|
||
statusEl.textContent = sha ? 'Aktualisiere…' : 'Erstelle…';
|
||
const res = await fetch(url, { method:sha?'PUT':'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
|
||
console.log('[Upload] Upload-Status:', res.status);
|
||
if (res.ok) { statusEl.textContent = `✓ ${members.length} Mitglieder hochgeladen`; statusEl.style.color = 'var(--success)'; }
|
||
else {
|
||
const errText = await res.text();
|
||
console.error('[Upload] Fehler:', errText);
|
||
statusEl.textContent = `Fehler ${res.status}: ${errText.slice(0,80)}`; statusEl.style.color = '#dc2626';
|
||
}
|
||
} catch(e) {
|
||
console.error('[Upload] Exception:', e);
|
||
statusEl.textContent = `Fehler: ${e.message}`; statusEl.style.color = '#dc2626';
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// SUPERADMIN
|
||
// ============================================================
|
||
let _allChaptersData = [];
|
||
|
||
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') : [];
|
||
_allChaptersData = [];
|
||
container.innerHTML = '';
|
||
for (const ch of chapters) {
|
||
try {
|
||
const cfgRes = await fetch(giteaUrl(`chapters/${ch.name}/config.json`), { cache: 'no-store' });
|
||
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 = `<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>
|
||
<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);
|
||
} catch(e) { /* skip */ }
|
||
}
|
||
if (!container.children.length) container.innerHTML = '<div style="color:var(--muted);">Keine Chapter gefunden.</div>';
|
||
_populateCoordChapterDropdown();
|
||
} 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(giteaAuthUrl('version.json', token));
|
||
if (shaRes.ok) sha = (await shaRes.json()).sha;
|
||
const body = { message:`Version ${version}`, content };
|
||
if (sha) body.sha = sha;
|
||
const res = await fetch(giteaAuthUrl('version.json', token), { method:sha?'PUT':'POST', headers:{'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);
|
||
}
|
||
|
||
// ============================================================
|
||
// 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
|
||
// ============================================================
|
||
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>
|