Neue Funktion: Supporter-Abstimmungen (Verwaltung + DogiCrew-Bereich)
Naechste "Ausbaustufe" aus Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20 (siehe Supporter-Abo-System.md), auf Nutzerwunsch "perfektioniere meine Verwaltungsseite": Dogi/VanVan koennen in verwaltung.html eine Frage mit 2-6 Antwortoptionen auf Deutsch erstellen, automatische Uebersetzung beim Speichern (gleiches Muster wie "Event des Jahres"). Jede aktive DogiCrew- Person sieht die Abstimmung in ihrem Supporter-Bereich, stimmt genau einmal ab (UNIQUE-Constraint in der DB, nicht nur Anwendungslogik), sieht danach die Live-Ergebnisse. Admin-Seite zeigt Ergebnisbalken live, kann schliessen/ wiedereroeffnen/loeschen. - Neue Migration 0005_supporter_polls.sql (supporter_polls, supporter_poll_votes), neue Berechtigung POLLS_MANAGE. - server-internal/routes/polls.js: 30 End-to-End-Tests gegen eine Fake-DB bestanden (better-sqlite3 laesst sich lokal nicht kompilieren). - verwaltung.html: neue "Abstimmungen"-Kiste im bestehenden vw-overview-box-Stil (violett/pink Ergebnisbalken). - supporter.html: neue "Aktuelle Abstimmung"-Karte im bestehenden Gold-Look, Optionen -> Stimme -> Ergebnisbalken, alle 5 Sprachen. Backend-Teil (server-internal/, cloudflare-worker/migrations/) noch ohne Deploy-Zugriff -- Dogi muss ihn manuell auf dogiintern ausrollen. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+192
@@ -529,6 +529,12 @@
|
||||
transition: width .5s ease;
|
||||
}
|
||||
@keyframes abo-progress-shine { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
|
||||
/* Supporter-Abstimmungen (20.08.2026) */
|
||||
.vw-poll-option { margin-bottom: .8rem; }
|
||||
.vw-poll-option:last-child { margin-bottom: 0; }
|
||||
.vw-poll-option-head { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; margin-bottom: .3rem; }
|
||||
.vw-poll-bar-track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; border: 1px solid var(--border); }
|
||||
.vw-poll-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #8b5cf6, #ff6fa5); box-shadow: 0 0 10px rgba(139,92,246,.4); transition: width .4s ease; }
|
||||
.vw-progress-celebrate {
|
||||
margin-top: .8rem; padding: .7rem 1rem; border-radius: 12px; font-size: .87rem; font-weight: 700;
|
||||
background: rgba(56,189,248,.1); border: 1px solid rgba(56,189,248,.35); color: #7dd3fc;
|
||||
@@ -790,6 +796,41 @@
|
||||
<div class="vw-overview-body" id="zahlungen-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- Supporter-Abstimmungen (20.08.2026) — "nächste Ausbaustufe" aus
|
||||
Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20, eigenes
|
||||
POLLS_MANAGE-Recht, siehe DogFather Website - Offene Punkte /
|
||||
Supporter-Abo-System.md. Gleiches Kisten-Muster wie die 3 Boxen
|
||||
oben, daher automatisch von der bestehenden
|
||||
.vw-overview-head-Klick-Logik weiter unten miterfasst. -->
|
||||
<div class="card vw-card vw-shine-card vw-noise vw-overview-box" id="box-abstimmungen">
|
||||
<div class="vw-overview-head" data-toggle="box-abstimmungen">
|
||||
<span class="vw-overview-title">🗳️ Abstimmungen</span>
|
||||
<div class="vw-overview-summary" id="abstimmungen-summary"></div>
|
||||
<span class="vw-overview-toggle">Anzeigen <span class="pfeil">▾</span></span>
|
||||
</div>
|
||||
<div class="vw-overview-body" id="abstimmungen-body">
|
||||
<div class="card" style="padding:1.2rem;margin-bottom:1.2rem;background:rgba(255,255,255,.03);">
|
||||
<h3 class="vw-subh" style="margin-top:0;">Neue Abstimmung erstellen</h3>
|
||||
<p class="small muted" style="margin:0 0 1rem;">
|
||||
Frage und Optionen bitte auf Deutsch eintippen — die anderen Sprachen werden beim
|
||||
Speichern automatisch übersetzt. Erscheint für jede aktive DogiCrew-Person in ihrem
|
||||
Supporter-Bereich, bis sie abgestimmt hat oder du sie schließt.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="poll-frage">Frage</label>
|
||||
<input type="text" id="poll-frage" maxlength="200" placeholder="z. B. Welches Format als nächstes?" />
|
||||
</div>
|
||||
<div id="poll-optionen-liste"></div>
|
||||
<button type="button" class="vw-refresh-btn" id="poll-option-hinzufuegen" style="margin-top:.6rem;">+ Option hinzufügen</button>
|
||||
<div style="margin-top:1.2rem;">
|
||||
<button type="button" class="btn btn-primary" id="poll-erstellen-btn">Abstimmung starten</button>
|
||||
</div>
|
||||
<p class="small" id="poll-erstellen-status" style="margin-top:.6rem;"></p>
|
||||
</div>
|
||||
<div id="poll-liste"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutzer-Wunsch 04.08.2026 (8. Runde): "dieses Fenster soll nicht
|
||||
einfach kacke weiß aussehen, soll der ganzen Seite angepasst
|
||||
sein" — das aufgeklappte Dropdown eines nativen <select> wird
|
||||
@@ -972,6 +1013,13 @@
|
||||
document.getElementById("events-root").style.display = "";
|
||||
await ladeUndRenderEvents(session);
|
||||
}
|
||||
const pollsBox = document.getElementById("box-abstimmungen");
|
||||
if (window.AdminAuth.sessionHasPermission(session, "POLLS_MANAGE")) {
|
||||
pollsBox.style.display = "";
|
||||
await ladeUndRenderPolls();
|
||||
} else {
|
||||
pollsBox.style.display = "none";
|
||||
}
|
||||
await ladeAlleSupporter();
|
||||
}
|
||||
|
||||
@@ -982,6 +1030,7 @@
|
||||
document.getElementById("vw-stats-root").style.display = "none";
|
||||
document.getElementById("supp-root").style.display = "none";
|
||||
document.getElementById("events-root").style.display = "none";
|
||||
document.getElementById("box-abstimmungen").style.display = "none";
|
||||
}
|
||||
|
||||
/* ===== "Event des Jahres" — Verwaltung (20.08.2026) =====
|
||||
@@ -1118,6 +1167,149 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ===== Supporter-Abstimmungen (20.08.2026) =====
|
||||
Formular mit dynamischer Optionsliste (mind. 2, max. 6), Übersetzung
|
||||
passiert serverseitig beim Speichern (wie "Event des Jahres"). Liste
|
||||
zeigt jede Abstimmung mit Live-Ergebnisbalken + Schließen/Wiedereröffnen/
|
||||
Löschen — dieselbe Kisten-Optik wie die 3 Übersichts-Boxen oben. */
|
||||
const POLL_STATUS_LABELS = { active: "Aktiv", closed: "Geschlossen" };
|
||||
|
||||
function pollOptionsHtml(anzahl) {
|
||||
let html = "";
|
||||
for (let i = 0; i < anzahl; i++) {
|
||||
html += `<div class="field" style="margin-top:.6rem;" data-poll-option-feld>
|
||||
<label>Option ${i + 1}</label>
|
||||
<input type="text" class="poll-option-input" maxlength="100" />
|
||||
</div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function baueOptionsListe() {
|
||||
document.getElementById("poll-optionen-liste").innerHTML = pollOptionsHtml(2);
|
||||
}
|
||||
baueOptionsListe();
|
||||
|
||||
document.getElementById("poll-option-hinzufuegen").addEventListener("click", () => {
|
||||
const liste = document.getElementById("poll-optionen-liste");
|
||||
const anzahl = liste.querySelectorAll("[data-poll-option-feld]").length;
|
||||
if (anzahl >= 6) return;
|
||||
const feld = document.createElement("div");
|
||||
feld.className = "field";
|
||||
feld.style.marginTop = ".6rem";
|
||||
feld.setAttribute("data-poll-option-feld", "");
|
||||
feld.innerHTML = `<label>Option ${anzahl + 1}</label><input type="text" class="poll-option-input" maxlength="100" />`;
|
||||
liste.appendChild(feld);
|
||||
});
|
||||
|
||||
document.getElementById("poll-erstellen-btn").addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("poll-erstellen-status");
|
||||
const frage = document.getElementById("poll-frage").value.trim();
|
||||
const optionen = [...document.querySelectorAll(".poll-option-input")]
|
||||
.map((el) => el.value.trim())
|
||||
.filter(Boolean);
|
||||
if (!frage) {
|
||||
statusEl.textContent = "Bitte eine Frage eingeben.";
|
||||
statusEl.style.color = "#ff6b6b";
|
||||
return;
|
||||
}
|
||||
if (optionen.length < 2) {
|
||||
statusEl.textContent = "Mindestens 2 Antwortoptionen eintragen.";
|
||||
statusEl.style.color = "#ff6b6b";
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = "Wird erstellt und übersetzt …";
|
||||
statusEl.style.color = "";
|
||||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/create", { frage, optionen });
|
||||
if (!res.ok) {
|
||||
statusEl.textContent = res.error || "Erstellen fehlgeschlagen.";
|
||||
statusEl.style.color = "#ff6b6b";
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = "✅ Abstimmung ist live im Supporter-Bereich.";
|
||||
statusEl.style.color = "#baf7d6";
|
||||
document.getElementById("poll-frage").value = "";
|
||||
baueOptionsListe();
|
||||
await ladeUndRenderPolls();
|
||||
});
|
||||
|
||||
function pollKarteHtml(poll) {
|
||||
const gesamt = poll.totalVotes || 0;
|
||||
const balken = poll.options
|
||||
.map((opt, i) => {
|
||||
const n = poll.counts?.[i] || 0;
|
||||
const proz = gesamt ? Math.round((n / gesamt) * 100) : 0;
|
||||
return `<div class="vw-poll-option">
|
||||
<div class="vw-poll-option-head">
|
||||
<span>${escapeHtml(opt.de)}</span>
|
||||
<span class="small" style="color:var(--text-muted);">${n} × (${proz}%)</span>
|
||||
</div>
|
||||
<div class="vw-poll-bar-track"><div class="vw-poll-bar-fill" style="width:${proz}%;"></div></div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
const istAktiv = poll.status === "active";
|
||||
return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.3rem 1.4rem;" data-poll-id="${poll.id}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong>${escapeHtml(poll.question.de)}</strong>
|
||||
<div class="small" style="color:var(--text-muted);">${POLL_STATUS_LABELS[poll.status] || poll.status} · ${gesamt} Stimme${gesamt === 1 ? "" : "n"} · erstellt am ${fmtDatum(poll.createdAt)}</div>
|
||||
</div>
|
||||
<div class="btn-row" style="gap:.5rem;">
|
||||
${istAktiv
|
||||
? `<button type="button" class="vw-refresh-btn poll-schliessen-btn">Schließen</button>`
|
||||
: `<button type="button" class="vw-refresh-btn poll-wiedereroeffnen-btn">Wiedereröffnen</button>`}
|
||||
<button type="button" class="vw-refresh-btn poll-loeschen-btn" style="color:#ff8a8a;">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:1rem;">${balken}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function ladeUndRenderPolls() {
|
||||
const summaryEl = document.getElementById("abstimmungen-summary");
|
||||
const listeEl = document.getElementById("poll-liste");
|
||||
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/list", {});
|
||||
if (!res.ok) {
|
||||
listeEl.innerHTML = `<p class="vw-leer">Abstimmungen konnten nicht geladen werden.</p>`;
|
||||
return;
|
||||
}
|
||||
const polls = res.polls || [];
|
||||
const aktiveAnzahl = polls.filter((p) => p.status === "active").length;
|
||||
summaryEl.innerHTML = polls.length
|
||||
? `<span class="vw-overview-chip">${aktiveAnzahl} aktiv</span><span class="vw-overview-chip">${polls.length} gesamt</span>`
|
||||
: "";
|
||||
listeEl.innerHTML = polls.length
|
||||
? polls.map(pollKarteHtml).join("")
|
||||
: `<p class="vw-leer">Noch keine Abstimmung erstellt.</p>`;
|
||||
|
||||
listeEl.querySelectorAll(".poll-schliessen-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
|
||||
btn.disabled = true;
|
||||
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/close", { pollId });
|
||||
await ladeUndRenderPolls();
|
||||
});
|
||||
});
|
||||
listeEl.querySelectorAll(".poll-wiedereroeffnen-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
|
||||
btn.disabled = true;
|
||||
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/reopen", { pollId });
|
||||
await ladeUndRenderPolls();
|
||||
});
|
||||
});
|
||||
listeEl.querySelectorAll(".poll-loeschen-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!confirm("Diese Abstimmung inkl. aller Stimmen unwiderruflich löschen?")) return;
|
||||
const pollId = btn.closest("[data-poll-id]").dataset.pollId;
|
||||
btn.disabled = true;
|
||||
await window.AdminAuth.apiCall(API_BASE_URL, "/admin/polls/delete", { pollId });
|
||||
await ladeUndRenderPolls();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare
|
||||
// (Dogi + VanVan) statt einem gemeinsamen Feld — beide rufen denselben
|
||||
// Login-Endpunkt auf, die Fehlermeldung erscheint jeweils nur in der
|
||||
|
||||
Reference in New Issue
Block a user