Kunden und Projekte in der Verwaltung anlegen, inklusive Testzugang

Wunsch 22.08.2026: "ich will einen provisorischen zugang fuer mich".
Der richtige Weg dahin war ohnehin ueberfaellig -- Kundenzugaenge
entstehen ausschliesslich hier, es gibt bewusst keine Selbstregistrierung.

Neu:
* Kundenzugang anlegen (Name, E-Mail, Firma, Sprache, sofort freischalten
  ja/nein) mit Einladungslink
* "Testzugang fuer mich" -- ein Klick, legt einen als Test erkennbaren
  Zugang mit Datumsstempel an. Bewusst NICHT die echte Geschaeftsadresse:
  die E-Mail ist gleichzeitig der Anmeldename und laesst sich aus gutem
  Grund nicht mehr aendern, ein Test wuerde also spaeter mit einem echten
  Kundenkonto kollidieren.
* Zugaenge auflisten, freischalten, sperren, neuen Einladungslink erzeugen
* Projekte anlegen und aendern, Aenderungswuensche beziffern, Nachrichten

Entscheidungen:
* Der Einladungslink geht EINMAL im Klartext raus, direkt beim Anlegen.
  In der Datenbank liegt nur sein Hash. Wer ihn verliert, bekommt einen
  neuen -- das ist sicherer, als ihn dauerhaft abrufbar zu halten. Die
  Oberflaeche sagt das auch klar dazu, sonst klickt man ihn weg und
  wundert sich.
* Ein neuer Link entwertet alle offenen alten. Sonst sammeln sich mehrere
  gueltige Generalschluessel fuer dasselbe Konto an.
* "Sofort freischalten" ist eine bewusste Handlung. Ohne Haekchen wird der
  Zugang angelegt, kommt aber noch nicht hinein -- Masterplan S.12
  verlangt, dass Projektkauf oder Betreuung VOR der Freischaltung geprueft
  werden.
* Sperren beendet laufende Sitzungen sofort, nicht erst nach Ablauf.
* Die 30 % Anzahlung werden aus dem Preis BERECHNET, nicht eingetippt.
  Ein Tippfehler in der Anzahlung faellt sonst erst beim Geldeingang auf.
* Preise kommen als Euro herein und werden sofort in Cent umgerechnet
  (Math.round, damit 49.99 nicht zu 4998 wird). Ab da nie wieder Komma.
* Vier klar unterscheidbare Zustaende in der Liste. Wichtig vor allem
  "Einladung offen": freigeschaltet, aber noch kein Passwort gesetzt --
  der Kunde war also noch nie drin.
* Kopieren faellt auf Markieren zurueck, wenn die Zwischenablage
  blockiert ist. Eine Fehlermeldung waere dort nutzlos.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 21:23:15 +02:00
co-authored by Claude Opus 5
parent 1b432f456f
commit 2542a2598e
3 changed files with 609 additions and 1 deletions
+246 -1
View File
@@ -135,6 +135,15 @@
.vw-notiz-kopf { font-size: .76rem; color: var(--wd-text-soft); margin-bottom: .3rem; }
.vw-notiz p { margin: 0; font-size: .93rem; white-space: pre-wrap; }
.vw-tabs { display: flex; gap: .4rem; }
.vw-link-anzeige {
padding: 1rem; border-radius: 14px;
background: rgba(232,200,119,.08); border: 1px solid rgba(232,200,119,.35);
}
.vw-link-anzeige code {
display: block; word-break: break-all; margin: .6rem 0;
font-size: .84rem; color: var(--wd-gold); line-height: 1.5;
}
.vw-leer-hinweis { text-align: center; padding: 3rem 1rem; color: var(--wd-text-soft); }
.vw-laedt { text-align: center; padding: 2.5rem 1rem; color: var(--wd-text-soft); }
</style>
@@ -168,7 +177,11 @@
<!-- ============ ARBEITSBEREICH ============ -->
<div id="vw-bereich" hidden>
<div class="vw-leiste">
<h1>Projektanfragen</h1>
<h1 id="vw-titel">Projektanfragen</h1>
<div class="vw-tabs">
<button type="button" class="vw-filter-knopf vw-an" data-tab="anfragen">Anfragen</button>
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</button>
</div>
<div class="vw-wer">
<span id="vw-name">—</span>
<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-abmelden">Abmelden</button>
@@ -186,6 +199,62 @@
<div class="wd-meldung" id="vw-meldung" hidden></div>
<div id="vw-liste"><div class="vw-laedt">Wird geladen…</div></div>
<!-- ============ KUNDEN ============ -->
<div id="vw-kunden-bereich" hidden>
<div class="wd-karte" style="margin-bottom:1.2rem">
<h2 style="font-size:1.15rem">Neuen Kundenzugang anlegen</h2>
<p class="wd-klein wd-leise">
Der Kunde bekommt einen Einladungslink und setzt sein Passwort selbst.
Den Link siehst du <strong>nur einmal</strong> — danach steht in der
Datenbank nur noch sein verschlüsselter Abdruck. Verloren? Einfach
einen neuen erzeugen.
</p>
<form id="vw-kunde-form" novalidate>
<div class="wd-grid wd-grid--2">
<div class="wd-feld" id="vw-feld-kname">
<label for="vw-kname">Name<span class="wd-pflicht">*</span></label>
<input type="text" id="vw-kname" autocomplete="off" />
<p class="wd-feld-fehler"></p>
</div>
<div class="wd-feld" id="vw-feld-kmail">
<label for="vw-kmail">E-Mail<span class="wd-pflicht">*</span></label>
<input type="email" id="vw-kmail" autocomplete="off" inputmode="email" />
<p class="wd-feld-fehler"></p>
</div>
<div class="wd-feld">
<label for="vw-kfirma">Firma <span class="wd-leise">optional</span></label>
<input type="text" id="vw-kfirma" autocomplete="off" />
</div>
<div class="wd-feld">
<label for="vw-ksprache">Sprache</label>
<select id="vw-ksprache">
<option value="de">Deutsch</option>
<option value="de-CH">Schwiizerdütsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="pt">Português</option>
</select>
</div>
</div>
<label class="wd-wahl-kachel" style="display:block;margin-bottom:.9rem">
<input type="checkbox" id="vw-kfrei" checked />
<span class="wd-wahl-inhalt">
<span class="wd-wahl-titel">Sofort freischalten</span>
<span class="wd-wahl-text">Ohne Häkchen wird der Zugang angelegt, kommt aber noch nicht hinein — für Kunden, deren Projekt erst später startet.</span>
</span>
</label>
<div class="wd-btn-reihe">
<button type="submit" class="wd-btn wd-btn--haupt">Zugang anlegen</button>
<button type="button" class="wd-btn wd-btn--zweit" id="vw-testzugang">Testzugang für mich</button>
</div>
</form>
<div id="vw-link-box" hidden style="margin-top:1.2rem"></div>
</div>
<h2 style="font-size:1.15rem">Bestehende Zugänge</h2>
<div id="vw-kunden-liste"><div class="vw-laedt">Wird geladen…</div></div>
</div>
</div>
</div>
@@ -569,6 +638,182 @@
}
}
/* =================================================================
KUNDEN
================================================================= */
var aktiverTab = "anfragen";
document.querySelectorAll("[data-tab]").forEach(function (b) {
b.addEventListener("click", function () {
aktiverTab = b.dataset.tab;
document.querySelectorAll("[data-tab]").forEach(function (x) {
x.classList.toggle("vw-an", x.dataset.tab === aktiverTab);
});
var istAnfragen = aktiverTab === "anfragen";
document.getElementById("vw-titel").textContent = istAnfragen ? "Projektanfragen" : "Kunden";
document.getElementById("vw-zahlen").hidden = !istAnfragen;
document.getElementById("vw-filter").hidden = !istAnfragen;
document.getElementById("vw-suche").closest(".wd-feld").hidden = !istAnfragen;
document.getElementById("vw-liste").hidden = !istAnfragen;
document.getElementById("vw-kunden-bereich").hidden = istAnfragen;
if (!istAnfragen) ladeKunden();
});
});
async function ladeKunden() {
var ziel = document.getElementById("vw-kunden-liste");
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/kunden");
if (!d.kunden.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis">Noch keine Kundenzugaenge angelegt.</div>';
return;
}
ziel.innerHTML = d.kunden.map(function (k) {
/* Der Zustand eines Zugangs hat vier Stufen, und alle muessen auf
einen Blick unterscheidbar sein. Besonders wichtig ist
"freigeschaltet, aber noch kein Passwort gesetzt": das heisst,
die Einladung liegt noch offen und der Kunde war noch nie drin. */
var marke = k.gesperrt
? '<span class="vw-status vw-status--abgelehnt">gesperrt</span>'
: !k.freigeschaltet
? '<span class="vw-status vw-status--neu">nicht freigeschaltet</span>'
: !k.hat_passwort
? '<span class="vw-status vw-status--rueckfrage">Einladung offen</span>'
: '<span class="vw-status vw-status--angenommen">aktiv</span>';
return '<div class="vw-karte" style="cursor:default">' +
'<span class="vw-karte-nr">' + (k.projekte || 0) + ' Proj.</span>' +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(k.name) + (k.firma ? " · " + s(k.firma) : "") + "</span><br />" +
'<span class="vw-karte-info">' + s(k.email) + "</span>" +
"</span>" +
'<span class="vw-karte-rechts">' + marke +
'<span class="vw-karte-zeit">' +
(k.letzter_login ? "zuletzt " + window.WD.datum(k.letzter_login) : "noch nie angemeldet") +
"</span>" +
'<span style="display:block;margin-top:.5rem">' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-neuer-link="' + s(k.id) + '">Neuer Link</button> ' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-schalten="' + s(k.id) +
'" data-frei="' + (k.freigeschaltet ? "0" : "1") + '">' +
(k.freigeschaltet ? "Sperren" : "Freischalten") + "</button>" +
"</span>" +
"</span></div>";
}).join("");
ziel.querySelectorAll("[data-neuer-link]").forEach(function (b) {
b.addEventListener("click", async function () {
window.WD.knopfSperren(b, true, "…");
try {
var r = await api("/webdesign/admin/kunden/" + encodeURIComponent(b.dataset.neuerLink) + "/einladung",
{ method: "POST", body: {} });
zeigeLink(r.einladungsLink);
} catch (f) { window.WD.meldung(meldung, f.message, "fehler"); }
finally { window.WD.knopfSperren(b, false); }
});
});
ziel.querySelectorAll("[data-schalten]").forEach(function (b) {
b.addEventListener("click", async function () {
var frei = b.dataset.frei === "1";
window.WD.knopfSperren(b, true, "…");
try {
await api("/webdesign/admin/kunden/" + encodeURIComponent(b.dataset.schalten) + "/zugang",
{ method: "POST", body: { freigeschaltet: frei } });
ladeKunden();
} catch (f) {
window.WD.knopfSperren(b, false);
window.WD.meldung(meldung, f.message, "fehler");
}
});
});
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ziel.innerHTML = "";
window.WD.meldung(meldung, fehler.message, "fehler");
}
}
/* Der Link wird EINMAL angezeigt. Deshalb gross, kopierbar und mit einer
klaren Ansage, dass er nicht wiederkommt -- sonst klickt man ihn weg
und wundert sich. */
function zeigeLink(link) {
var box = document.getElementById("vw-link-box");
box.hidden = false;
box.innerHTML = '<div class="vw-link-anzeige">' +
'<p class="wd-hinweis-titel">Einladungslink — wird nur dieses eine Mal angezeigt</p>' +
"<code>" + s(link) + "</code>" +
'<div class="wd-btn-reihe">' +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-link-kopieren">Link kopieren</button>' +
'<a class="wd-btn wd-btn--zweit wd-btn--klein" href="' + s(link) + '" target="_blank" rel="noopener">Selbst oeffnen</a>' +
"</div>" +
'<p class="wd-klein wd-leise" style="margin:.6rem 0 0">Gueltig 14 Tage. Danach oder bei Verlust einfach einen neuen erzeugen.</p>' +
"</div>";
box.scrollIntoView({ behavior: "smooth", block: "center" });
document.getElementById("vw-link-kopieren").addEventListener("click", async function (e) {
try {
await navigator.clipboard.writeText(link);
e.target.textContent = "Kopiert ✓";
} catch (f) {
/* Die Zwischenablage kann blockiert sein (aelterer Browser, kein
HTTPS, verweigerte Berechtigung). Dann den Text markieren statt
einer Fehlermeldung, mit der niemand etwas anfangen kann. */
var bereich = document.createRange();
bereich.selectNodeContents(box.querySelector("code"));
var auswahl = getSelection();
auswahl.removeAllRanges();
auswahl.addRange(bereich);
e.target.textContent = "markiert — jetzt kopieren";
}
});
}
document.getElementById("vw-kunde-form").addEventListener("submit", async function (e) {
e.preventDefault();
await legeKundeAn({
name: document.getElementById("vw-kname").value.trim(),
email: document.getElementById("vw-kmail").value.trim(),
firma: document.getElementById("vw-kfirma").value.trim(),
sprache: document.getElementById("vw-ksprache").value,
freischalten: document.getElementById("vw-kfrei").checked,
}, e.target.querySelector('button[type="submit"]'));
});
/* "Testzugang fuer mich": legt einen Zugang mit einer eindeutig als Test
erkennbaren Adresse an. Bewusst NICHT die echte Geschaeftsadresse --
sonst kollidiert der Test spaeter mit einem echten Kundenkonto, und
die E-Mail-Adresse laesst sich aus gutem Grund nicht mehr aendern. */
document.getElementById("vw-testzugang").addEventListener("click", async function (e) {
var stempel = new Date().toISOString().slice(0, 10).replace(/-/g, "");
await legeKundeAn({
name: "Testzugang (" + stempel + ")",
email: "test+" + stempel + "@dogfather-universe.com",
firma: "Eigener Test",
sprache: window.WD.getSprache(),
freischalten: true,
}, e.target);
});
async function legeKundeAn(daten, knopf) {
["vw-feld-kname", "vw-feld-kmail"].forEach(function (id) {
document.getElementById(id).classList.remove("wd-feld--fehler");
});
window.WD.meldungAus(meldung);
window.WD.knopfSperren(knopf, true, "Wird angelegt…");
try {
var r = await api("/webdesign/admin/kunden", { method: "POST", body: daten });
zeigeLink(r.einladungsLink);
document.getElementById("vw-kunde-form").reset();
document.getElementById("vw-kfrei").checked = true;
ladeKunden();
} catch (fehler) {
window.WD.meldung(meldung, fehler.message, "fehler");
} finally {
window.WD.knopfSperren(knopf, false);
}
}
/* ---------------- Start ---------------- */
function start() {
token = tokenLesen();