Files
dogfather-universe/webdesign/verwaltung.html
T
DogFatherGitandClaude Opus 5 14ad108c87 Rechtliches: elektronische Widerrufsfunktion (§ 356a BGB), Impressum, AGB,
Widerrufsbelehrung und Datenschutz

RECHERCHIERT, NICHT AUS DEM GEDAECHTNIS

Der Webdesign-Bereich hatte KEINE eigene Rechtsseite -- obwohl dort
Vertraege geschlossen und Zahlungen ausgeloest werden. Die Fusszeile
verwies auf die Kontaktseite des Universe, die ein Content-Projekt
beschreibt, kein Dienstleistungsgeschaeft.

Die Web-Recherche hat eine Pflicht zutage gefoerdert, die seit zwei
Monaten gilt und die die Seite nicht erfuellt hat:

  § 356a BGB -- elektronische Widerrufsfunktion, in Kraft seit
  19.06.2026. Sie gilt fuer ALLE Fernabsatzvertraege, die ueber eine
  Online-Benutzeroberflaeche geschlossen werden, nicht nur fuer
  Finanzdienstleistungen.

WARUM DAS HIER GILT: Im Kundenportal nimmt der Kunde Zusatzangebote per
Klick VERBINDLICH an ("Damit nimmst du das Zusatzangebot verbindlich
an"). Das ist ein Vertragsschluss ueber eine Online-Benutzeroberflaeche.
Fuer die geplanten PayPal-Zahlungen gilt dasselbe.

Der Anbieter sitzt in Luxemburg. Fuer Verbraucher mit gewoehnlichem
Aufenthalt in Deutschland gilt nach Art. 6 Rom-I-VO trotzdem das
deutsche zwingende Verbraucherrecht, weil die Seite sich erkennbar an den
deutschsprachigen Markt richtet. Deshalb wurde der STRENGERE Standard
umgesetzt, nicht der bequemere.

WAS GEBAUT WURDE

webdesign/widerruf.html -- die Widerrufsfunktion, genau nach dem
Wortlaut der Vorschrift:
  * Beschriftung "Vertrag widerrufen" (gesetzlich vorgegeben)
  * ZWEITE Schaltflaeche "Widerruf bestaetigen" (ebenfalls vorgegeben --
    ein einstufiges Formular wuerde die Vorschrift nicht erfuellen)
  * unverzuegliche Eingangsbestaetigung mit Nummer, ZEITPUNKT (nicht nur
    Datum), Empfaenger und dem Wortlaut der Erklaerung
  * Link in der Fusszeile JEDER Seite -- "staendig verfuegbar,
    hervorgehoben platziert, leicht zugaenglich" heisst nicht "in den AGB
    versteckt"

Drei Entscheidungen, die unmittelbar aus dem Sinn der Vorschrift folgen:

  KEINE ANMELDUNG. Die Seite ist von der Zugangswand ausgenommen. Wer
  seinen Code verlegt hat, wuerde sonst seine Frist verlieren --
  Fristverlust durch eine selbstgebaute Huerde ist der schlimmste
  denkbare Fall.

  KEINE MENGENBEGRENZUNG. Ueberall sonst richtig, hier ein
  Rechtsverlust: Wer in der letzten Stunde seiner Frist wegen eines
  hakenden Netzes dreimal klickt, darf nicht abgewiesen werden.

  KEIN ZWISCHENSPEICHER. Beim Anfrageformular ein Segen, hier eine
  Falle: Auf einem geteilten Rechner laege der halb ausgefuellte Widerruf
  des einen im Browser des naechsten.

webdesign/rechtliches.html -- Impressum, AGB, Widerrufsbelehrung samt
Muster-Widerrufsformular, Datenschutzerklaerung. Alle Betreiberangaben
sind aus den bestehenden Seiten UEBERNOMMEN, nichts erfunden: Anschrift
in Mondercange, Kleinunternehmerregelung nach Art. 57 TVA-Gesetz LU und
Richtlinie (EU) 2020/285, netcup/Cloudflare, PayPal Luxemburg.

Migration 0014: wd_widerrufe (der dauerhafte Datentraeger und der
Nachweis, wird nie geleert) und wd_zustimmungen. Letztere speichert nicht
nur den Haken, sondern den WORTLAUT, den der Kunde gesehen hat -- die
Beweislast fuer die Zustimmung zum vorzeitigen Leistungsbeginn liegt beim
Unternehmer (§ 356 Abs. 4 BGB), und im Streit zaehlt, WAS bestaetigt
wurde. Bewusst OHNE Fremdschluessel: CASCADE wuerde den Nachweis mit dem
Kunden mitloeschen, RESTRICT wuerde eine DSGVO-Loeschung blockieren.

Druckansicht: Die Eingangsbestaetigung ist ein Rechtsnachweis. Auf Papier
schwarz auf weiss, ohne Navigation und Knoepfe -- ein Nachweis, den
niemand ausdruckt, weil er eine halbe Patrone kostet, erfuellt seinen
Zweck nicht.

ZWEI ECHTE FEHLER GEFUNDEN

1. Die Pflicht-Sternchen verschwanden nach der Uebersetzung. Ursache:
   data-i18n stand auf dem <label> selbst und ersetzte dessen ganzen
   Inhalt -- samt <span class="wd-pflicht">. Das Anfrageformular macht es
   laengst richtig (data-i18n auf einem INNEREN span); die neue Seite
   hatte das Muster nicht uebernommen. Aufgefallen ist es nur, weil der
   Test die Pflichtfelder GEZAEHLT hat statt sie vorauszusetzen.

2. window.WD.sprache() gibt es nicht, die Funktion heisst getSprache().
   Dadurch brach der Uebergang zur zweiten Stufe stumm ab.

GEPRUEFT: 58 Pruefungen, alle bestanden. Darunter die gesetzlich
vorgegebenen Beschriftungen in allen FUENF Sprachen, "kein Grund noetig",
genau drei Pflichtfelder, Datum UND Uhrzeit in der Bestaetigung, und die
Druckansicht.

WAS FILIPE NOCH PRUEFEN LASSEN MUSS: Diese Texte sind sorgfaeltig
recherchiert, aber ich bin keine Rechtsanwaeltin. Vor dem oeffentlichen
Start gehoert das Ganze einmal zu einer im luxemburgischen Recht
qualifizierten Fachperson -- besonders die grenzueberschreitende Lage
(Sitz LU, Kunden in DE/CH/FR/PT) und die Frage, ob die Seite in
Franzoesisch und Portugiesisch aktiv verkaufen soll. Dann muessen die
Rechtstexte auch dorthin uebersetzt werden, und zwar fachlich.

Versionsstempel und Cache-Name auf v7.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 12:29:09 +02:00

2450 lines
115 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Verwaltung — Dogfather Webdesign</title>
<meta name="robots" content="noindex, nofollow, noarchive" />
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
<meta name="theme-color" content="#05070b" />
<link rel="manifest" href="/webdesign/app.webmanifest" />
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="DF Verwaltung" />
<link rel="stylesheet" href="/assets/css/main.css?v=20260823wd7" />
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260823wd7" />
<style>
/* =====================================================================
Verwaltungsbereich — bewusst NUR AUF DEUTSCH.
Der öffentliche Teil läuft in fünf Sprachen, weil dort Kunden landen.
Hier landen ausschließlich Dogfather und VanVan. Fünf Sprachen wären
fünffache Pflege und fünffache Fehlerfläche ohne einen einzigen
Nutzer, der sie braucht — genau wie im bestehenden internen Bereich
des Universe, der ebenfalls einsprachig ist.
===================================================================== */
/* ---------- Anmeldung ---------- */
.vw-login { max-width: 420px; margin: clamp(2rem,8vh,5rem) auto; }
/* ---------- Kopfleiste ---------- */
.vw-leiste {
display: flex; flex-wrap: wrap; align-items: center; gap: .8rem;
margin-bottom: 1.2rem;
}
.vw-leiste h1 { margin: 0; font-size: clamp(1.4rem, 3.4vw, 2rem); }
.vw-wer { margin-left: auto; display: flex; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); }
/* ---------- Kennzahlen, gehobene Fassung ----------
Die erste Fassung war eine Reihe stiller Zahlen. Zahlen, die man
nur anschauen kann, sind Dekoration. Diese hier sind Werkzeuge:
jede Kachel filtert die Liste darunter, sobald man sie antippt.
Eine Null wird bewusst gedaempft. Sonst konkurriert "0 abgelehnt"
optisch mit "3 neu" -- und genau die Drei ist die, auf die man
schauen soll. Was nichts zu tun gibt, soll auch nicht leuchten. */
.vw-zahlen {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: .7rem; margin-bottom: 1.4rem;
}
.vw-zahl {
position: relative; overflow: hidden;
display: block; width: 100%; text-align: left; cursor: pointer;
padding: .95rem 1.05rem;
border-radius: 16px;
background: linear-gradient(180deg, rgba(20,32,50,.62), rgba(9,15,26,.8));
border: 1px solid var(--wd-rand);
transition: border-color var(--wd-zeit-schnell) ease,
transform var(--wd-zeit-schnell) ease,
box-shadow var(--wd-zeit) ease;
}
.vw-zahl:hover { border-color: var(--wd-rand-stark); transform: translateY(-2px); }
.vw-zahl.vw-an {
border-color: var(--wd-blau);
box-shadow: 0 0 0 1px var(--wd-blau) inset, 0 14px 34px -20px rgba(127,208,232,.7);
}
.vw-zahl-kopf { display: flex; align-items: center; gap: .45rem; margin-bottom: .35rem; }
.vw-zahl-symbol { font-size: 1rem; line-height: 1; opacity: .85; }
.vw-zahl-wert {
font-family: var(--font-head); font-weight: 800;
font-size: clamp(1.5rem, 3.4vw, 1.9rem); line-height: 1;
color: var(--wd-blau); font-variant-numeric: tabular-nums;
}
.vw-zahl-name { font-size: .78rem; letter-spacing: .03em; color: var(--wd-text-soft); }
.vw-zahl-zusatz { font-size: .74rem; color: var(--wd-text-soft); margin-top: .3rem; display: block; }
/* Null: alles gedaempft, damit es nicht um Aufmerksamkeit buhlt. */
.vw-zahl--null { opacity: .5; }
.vw-zahl--null .vw-zahl-wert { color: var(--wd-text-soft); }
/* Handlungsbedarf: Gold, und nur hier. */
.vw-zahl--achtung { border-color: rgba(232,200,119,.34); background: linear-gradient(180deg, rgba(232,200,119,.07), rgba(9,15,26,.8)); }
.vw-zahl--achtung .vw-zahl-wert { color: var(--wd-gold); }
.vw-zahl--achtung .vw-zahl-zusatz { color: var(--wd-gold); }
/* Dringend: etwas liegt zu lange. */
.vw-zahl--dringend { border-color: rgba(255,157,157,.4); background: linear-gradient(180deg, rgba(255,157,157,.07), rgba(9,15,26,.8)); }
.vw-zahl--dringend .vw-zahl-wert,
.vw-zahl--dringend .vw-zahl-zusatz { color: var(--wd-fehler); }
/* ---------- Filter ---------- */
.vw-filter { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; align-items: center; }
.vw-filter-knopf {
min-height: 44px; padding: .5rem .95rem; border-radius: 11px;
border: 1px solid var(--wd-rand); background: transparent;
color: var(--wd-text-mid); font-weight: 600; font-size: .9rem; cursor: pointer;
display: inline-flex; align-items: center; gap: .4rem;
}
.vw-filter-knopf:hover { border-color: var(--wd-rand-stark); color: var(--wd-text); }
.vw-filter-knopf.vw-an { background: rgba(127,208,232,.13); border-color: var(--wd-blau); color: var(--wd-blau); }
.vw-filter-zahl {
font-size: .74rem; padding: .1rem .4rem; border-radius: 6px;
background: rgba(127,208,232,.14); color: var(--wd-blau-hell);
}
.vw-suche { margin-left: auto; min-width: 200px; flex: 0 1 260px; }
/* ---------- Liste ---------- */
.vw-karte {
display: grid; grid-template-columns: auto 1fr auto; gap: .5rem 1rem;
align-items: center;
padding: 1rem 1.1rem; margin-bottom: .6rem;
border-radius: 14px; border: 1px solid var(--wd-rand);
background: rgba(16,26,41,.5);
cursor: pointer; width: 100%; text-align: left;
transition: border-color var(--wd-zeit-schnell) ease, background var(--wd-zeit-schnell) ease;
}
.vw-karte:hover { border-color: var(--wd-rand-stark); background: rgba(127,208,232,.05); }
.vw-karte-nr { font-family: var(--font-head); font-weight: 800; font-size: .82rem; color: var(--wd-blau); white-space: nowrap; }
.vw-karte-mitte { min-width: 0; display: flex; flex-direction: column; gap: .1rem; }
.vw-karte-name { font-weight: 700; color: var(--wd-text); }
.vw-karte-info { font-size: .87rem; color: var(--wd-text-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vw-karte-rechts { text-align: right; white-space: nowrap; }
.vw-karte-zeit { font-size: .78rem; color: var(--wd-text-soft); margin-top: .25rem; }
@media (max-width: 600px) {
.vw-karte { grid-template-columns: 1fr auto; }
.vw-karte-nr { grid-column: 1 / -1; }
}
/* Statusfarben — Gold nur da, wo wirklich etwas zu tun ist. */
.vw-status { display: inline-block; padding: .22rem .6rem; border-radius: 999px; font-size: .74rem; font-weight: 700; }
.vw-status--neu { background: rgba(232,200,119,.16); color: var(--wd-gold); }
.vw-status--pruefung { background: rgba(127,208,232,.14); color: var(--wd-blau); }
.vw-status--rueckfrage { background: rgba(183,157,255,.16); color: var(--wd-lila); }
.vw-status--angebot { background: rgba(183,157,255,.16); color: var(--wd-lila); }
.vw-status--angenommen { background: rgba(111,214,163,.16); color: var(--wd-ok); }
.vw-status--abgeschlossen { background: rgba(111,214,163,.1); color: var(--wd-ok); }
.vw-status--abgelehnt { background: rgba(255,157,157,.12); color: var(--wd-fehler); }
/* ---------- Detailansicht ---------- */
.vw-detail { position: fixed; inset: 0; z-index: 100; display: none; }
.vw-detail.vw-auf { display: block; }
.vw-detail-schatten { position: absolute; inset: 0; background: rgba(2,4,8,.78); backdrop-filter: blur(3px); }
/* ---------- Detailansicht: zentriertes Fenster ----------
Vorher eine seitlich eingeschobene Leiste. Fuer so viel Inhalt ist
das das falsche Format: Der Blick springt beim Oeffnen nach rechts,
die Liste dahinter bleibt halb sichtbar und lenkt ab, und alles muss
sich in eine schmale Spalte quetschen -- "keine Angabe" stand
dadurch achtmal untereinander.
Jetzt mittig, breiter und zweispaltig. Der Blick bleibt, wo er ist,
und zusammengehoerende Angaben stehen nebeneinander statt
hintereinander. */
.vw-detail-blatt {
position: absolute;
top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(940px, calc(100vw - 2rem));
max-height: min(88vh, 900px);
background: linear-gradient(180deg, #0b1523, #070c15);
border: 1px solid var(--wd-rand-stark);
border-radius: 20px;
box-shadow: 0 50px 120px -40px rgba(0,0,0,.95), 0 0 0 1px rgba(127,208,232,.06) inset;
overflow-y: auto;
padding: clamp(1.2rem, 3vw, 1.9rem) clamp(1rem, 3vw, 2rem) clamp(1.4rem, 3vw, 2rem);
animation: vw-rein .26s cubic-bezier(.22,1,.36,1) both;
}
@keyframes vw-rein {
from { transform: translate(-50%, -46%) scale(.97); opacity: 0; }
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.vw-detail-blatt { animation: none; }
}
/* Kopfzeile klebt oben fest — bei langen Anfragen weiss man sonst nach
dem Scrollen nicht mehr, wessen Daten man gerade liest. */
.vw-detail-kopf {
position: sticky; top: calc(clamp(1.2rem, 3vw, 1.9rem) * -1);
margin: calc(clamp(1.2rem, 3vw, 1.9rem) * -1) calc(clamp(1rem, 3vw, 2rem) * -1) 1.2rem;
padding: 1.1rem clamp(1rem, 3vw, 2rem);
background: rgba(11, 21, 35, .96);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--wd-rand);
border-radius: 20px 20px 0 0;
z-index: 2;
}
/* ---------- Zwei Spalten MIT BEDEUTUNG ----------
Vorher "columns: 2" — also Zeitungsspalten. Die füllen sich von
selbst: erst Spalte eins bis oben voll, dann Spalte zwei. Welcher
Kasten wo landet, hängt allein davon ab, wie lang die davor sind.
Bei einer Anfrage stand "Interne Notizen" oben rechts, bei der
nächsten unten links. Genau das war das Durcheinander
(Rückmeldung 22.08.2026) — es gab schlicht keine Ordnung, der man
hätte folgen können.
Jetzt ein festes Raster mit einer Aussage:
LINKS = was der Kunde geschickt hat (lesen)
RECHTS = was du damit machst (handeln)
Diese Trennung ist immer gleich. Man weiss nach der zweiten Anfrage,
wo man hinschauen muss, ohne zu suchen.
Die rechte Spalte ist schmaler: Sie enthaelt Knoepfe und kurze Texte,
die linke laufende Angaben. Gleich breit wuerde rechts Luft und
links Enge erzeugen.
Und sie klebt beim Scrollen mit. Die Handlungen sind der Grund, warum
man diese Ansicht ueberhaupt oeffnet — sie duerfen nicht nach unten
aus dem Bild wandern, waehrend man oben die Angaben liest. */
@media (min-width: 900px) {
.vw-detail-spalten {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 0 1.3rem;
align-items: start;
}
.vw-dt-links, .vw-dt-rechts { min-width: 0; }
.vw-dt-rechts {
position: sticky;
/* Abstand zur oben klebenden Kopfzeile, sonst schiebt sich der
erste Kasten darunter. */
top: 6.2rem;
}
}
/* Unter 900px untereinander — in der Reihenfolge lesen, dann handeln. */
.vw-dt-links, .vw-dt-rechts { display: flex; flex-direction: column; }
/* ---------- Auf einen Blick ----------
Die vier Fragen, die man IMMER zuerst hat: Was will der? Wie viel
Geld? Bis wann? Wie alt ist die Anfrage? Sie standen bisher verteilt
in drei verschiedenen Kaesten weiter unten. Jetzt einmal oben,
gross, ohne Suchen. */
.vw-blick {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(115px, 1fr));
gap: .5rem;
margin-bottom: 1.1rem;
}
.vw-blick-feld {
padding: .7rem .8rem;
border-radius: 12px;
background: rgba(127,208,232,.055);
border: 1px solid var(--wd-rand);
min-width: 0;
}
.vw-blick-was {
display: block; font-size: .68rem; letter-spacing: .11em;
text-transform: uppercase; color: var(--wd-text-soft); margin-bottom: .22rem;
}
.vw-blick-wert {
display: block; font-family: var(--font-head); font-weight: 700;
font-size: .98rem; color: var(--wd-text); line-height: 1.25;
overflow-wrap: break-word;
}
.vw-blick-wert.vw-leer { font-family: var(--font-body); font-weight: 400; opacity: .5; font-size: .88rem; }
/* ---------- Nicht Angegebenes ----------
Vorher bekam jede fehlende Angabe eine eigene Zeile mit "keine
Angabe". Bei einer knappen Anfrage bestand die halbe Ansicht daraus —
der Kasten "Umfang" enthielt nichts als zweimal diesen Text und war
trotzdem so gross wie ein Kasten mit Inhalt.
Leere Felder sind aber keine Information, sondern deren Fehlen. Sie
stehen jetzt als EINE zurueckhaltende Zeile am Fuss des Kastens.
Weggelassen werden sie nicht: Man muss sehen koennen, wonach gefragt
wurde und was unbeantwortet blieb — genau daraus entstehen die
Rueckfragen. */
.vw-ohne {
margin-top: .55rem; padding-top: .5rem;
border-top: 1px dashed var(--wd-rand);
font-size: .8rem; color: var(--wd-text-soft); line-height: 1.5;
}
.vw-ohne b { font-weight: 600; color: var(--wd-text-soft); }
/* Ein Kasten, in dem GAR NICHTS ausgefuellt wurde, tritt als Ganzes
zurueck — er soll nicht dieselbe Aufmerksamkeit bekommen wie einer
mit Inhalt. */
.vw-abschnitt--leer { opacity: .62; }
.vw-abschnitt--leer:hover { opacity: 1; }
/* Jeder Abschnitt wird eine eigene Flaeche statt nur einer Linie. */
.vw-detail-spalten .vw-abschnitt {
padding: .95rem 1.1rem 1rem;
border-radius: 14px;
background: rgba(16, 26, 41, .5);
border: 1px solid var(--wd-rand);
margin-bottom: 1.1rem;
}
.vw-detail-spalten .vw-abschnitt > h3 { border-bottom: none; padding-bottom: 0; margin-bottom: .7rem; }
/* "keine Angabe" tritt zurueck: Es ist die Abwesenheit einer Information
und darf nicht so aussehen wie eine. */
.vw-paar dd.vw-leer { font-size: .88rem; opacity: .55; }
@media (prefers-reduced-motion: reduce) { .vw-detail-blatt { animation: none; } }
.vw-detail-kopf { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.2rem; }
.vw-zu {
margin-left: auto; min-width: 44px; min-height: 44px;
border-radius: 10px; border: 1px solid var(--wd-rand);
background: transparent; color: var(--wd-text-mid); cursor: pointer; font-size: 1.1rem;
}
.vw-zu:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); }
.vw-abschnitt { margin-bottom: 1.4rem; }
.vw-abschnitt > h3 {
font-size: .74rem; letter-spacing: .13em; text-transform: uppercase;
color: var(--wd-blau); margin-bottom: .6rem;
padding-bottom: .4rem; border-bottom: 1px solid var(--wd-rand);
}
.vw-paar { display: grid; grid-template-columns: minmax(0,.4fr) minmax(0,1fr); gap: .2rem .9rem; margin-bottom: .45rem; }
@media (max-width: 520px) { .vw-paar { grid-template-columns: 1fr; margin-bottom: .8rem; } }
.vw-paar dt { color: var(--wd-text-soft); font-size: .88rem; }
.vw-paar dd { margin: 0; color: var(--wd-text); font-size: .95rem; overflow-wrap: break-word; white-space: pre-wrap; }
.vw-paar dd.vw-leer { color: var(--wd-text-soft); font-style: italic; }
.vw-status-wahl { display: flex; flex-wrap: wrap; gap: .4rem; }
.vw-notiz {
padding: .8rem .95rem; border-radius: 12px; margin-bottom: .5rem;
background: rgba(127,208,232,.05); border: 1px solid var(--wd-rand);
}
.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;
}
/* ---------- Anfragekarte, gehobene Fassung ----------
Die erste Fassung zeigte Nummer, Name, Paket, Status, Datum. Das ist
korrekt, aber es beantwortet nicht die Fragen, die man beim
Draufschauen WIRKLICH hat: Wie dringend ist das? Wie viel Geld steckt
drin? Wie lange liegt es schon? Genau die stehen jetzt drin.
Ein farbiger Streifen links codiert den Status zusaetzlich zur
Textmarke — Farbe ALLEIN waere fuer farbfehlsichtige Menschen keine
Information, zusammen mit dem Text ist sie ein schneller Anker. */
.vw-karte {
position: relative;
overflow: hidden;
padding-left: 1.5rem;
}
.vw-karte::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--wd-rand-stark);
}
.vw-karte--neu::before { background: var(--wd-gold); }
.vw-karte--pruefung::before,
.vw-karte--angebot::before { background: var(--wd-blau); }
.vw-karte--rueckfrage::before { background: var(--wd-lila); }
.vw-karte--angenommen::before,
.vw-karte--abgeschlossen::before { background: var(--wd-ok); }
.vw-karte--abgelehnt::before { background: var(--wd-fehler); }
.vw-karte-marken { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .4rem; }
.vw-mini {
font-size: .74rem; font-weight: 600; padding: .15rem .5rem;
border-radius: 6px; background: rgba(127,208,232,.09);
color: var(--wd-text-mid); border: 1px solid var(--wd-rand);
white-space: nowrap;
}
.vw-mini--geld { color: var(--wd-gold); border-color: rgba(232,200,119,.3); background: rgba(232,200,119,.07); }
.vw-mini--alt { color: var(--wd-fehler); border-color: rgba(255,157,157,.3); background: rgba(255,157,157,.06); }
/* Leerer Zustand — auch hier fuehren statt nur feststellen. */
.vw-leer-gross { text-align: center; padding: clamp(2rem,6vw,3.5rem) 1.5rem; }
.vw-leer-gross-symbol { font-size: 2.6rem; opacity: .45; margin-bottom: .6rem; }
.vw-leer-gross h3 { margin-bottom: .3em; }
.vw-leer-gross p { max-width: 46ch; margin-left: auto; margin-right: auto; }
/* ---------- Übernahme: Anfrage wird Kunde ----------
Der wichtigste Handgriff im ganzen Bereich. Bisher hätte man den
Namen, die E-Mail, das Paket und den Budgetrahmen von Hand in die
Kundenmaske abgetippt — vier Gelegenheiten für einen Tippfehler, und
einer davon (die E-Mail) ist später nicht mehr korrigierbar, weil sie
der Anmeldename ist. */
.vw-uebernahme {
border-radius: var(--wd-r);
padding: 1.1rem 1.2rem;
background: linear-gradient(180deg, rgba(127,208,232,.09), rgba(9,15,26,.6));
border: 1px solid var(--wd-rand-stark);
}
.vw-uebernahme h4 { margin: 0 0 .3rem; font-size: 1rem; color: var(--wd-text); }
.vw-uebernahme p { margin: 0 0 .8rem; font-size: .9rem; color: var(--wd-text-mid); }
.vw-uebernahme-vorschau {
display: grid; grid-template-columns: auto 1fr; gap: .25rem .8rem;
font-size: .88rem; margin-bottom: .9rem;
padding: .7rem .9rem; border-radius: 10px;
background: rgba(5,7,11,.5); border: 1px solid var(--wd-rand);
}
.vw-uebernahme-vorschau dt { color: var(--wd-text-soft); }
.vw-uebernahme-vorschau dd { margin: 0; color: var(--wd-text); overflow-wrap: anywhere; }
.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); }
/* =====================================================================
PROJEKTE UND AUFGABEN
Die vier Farben sind DIESELBEN wie im Portal und auf der Ablaufseite
(--wd-p1 bis --wd-p4). Das ist der eigentliche Punkt: Eine Farbe
bedeutet ueberall im Bereich dasselbe. Sonst waere sie Deko.
===================================================================== */
/* Ein Abzeichen an der Reiterleiste. Zeigt es nichts an, ist es GANZ
weg statt eine 0 zu zeigen -- eine Null, die man taeglich sieht,
wird zu Rauschen und man uebersieht irgendwann auch die 3. */
.vw-abzeichen {
display: inline-block; min-width: 19px; padding: 0 .32rem;
margin-left: .38rem; border-radius: 999px;
background: var(--wd-gold); color: #17120a;
font-size: .7rem; font-weight: 800; line-height: 19px; text-align: center;
vertical-align: text-top;
}
/* ---------- Fortschrittsbalken in vier Abschnitten ----------
Ein einzelner Balken sagt "60 %". Vier Abschnitte sagen zusaetzlich
WO die 60 % liegen -- ob die Gestaltung durch ist oder ob ueberall
ein bisschen angefangen wurde. Das ist beim Draufschauen die
nuetzlichere Information. */
.vw-fortschritt { display: flex; gap: 3px; height: 7px; margin: .55rem 0 .3rem; }
.vw-fs-teil {
flex: 1; border-radius: 999px; overflow: hidden;
background: rgba(255,255,255,.07);
position: relative;
}
.vw-fs-teil > i {
position: absolute; inset: 0 auto 0 0; display: block;
border-radius: 999px;
transition: width var(--wd-zeit) ease;
}
.vw-fs-teil[data-k="start"] > i { background: var(--wd-p1); }
.vw-fs-teil[data-k="gestaltung"] > i { background: var(--wd-p2); }
.vw-fs-teil[data-k="umsetzung"] > i { background: var(--wd-p3); }
.vw-fs-teil[data-k="abschluss"] > i { background: var(--wd-p4); }
.vw-fs-text { font-size: .78rem; color: var(--wd-text-soft); }
.vw-fs-text b { color: var(--wd-text); font-weight: 700; }
/* "Wartet auf dich"-Hinweis. Gold ist im ganzen Bereich fuer Hinweise
und Zahlung reserviert -- hier passt es: Es ist ein Hinweis, kein
Fehler. Rot waere zu laut fuer "der Kunde muss noch Texte schicken". */
.vw-wartet {
display: inline-flex; align-items: center; gap: .3rem;
padding: .1rem .5rem; border-radius: 999px;
background: rgba(232,200,119,.13); border: 1px solid rgba(232,200,119,.3);
color: var(--wd-gold); font-size: .76rem; font-weight: 600;
}
/* ---------- Aufgabenliste ---------- */
.vw-aufg-gruppe { margin-bottom: 1.1rem; }
.vw-aufg-kopf {
display: flex; align-items: baseline; gap: .5rem;
font-size: .74rem; letter-spacing: .13em; text-transform: uppercase;
margin-bottom: .5rem; padding-bottom: .35rem;
border-bottom: 1px solid var(--wd-rand);
}
.vw-aufg-kopf .vw-aufg-zahl { margin-left: auto; letter-spacing: 0; text-transform: none; font-size: .8rem; color: var(--wd-text-soft); }
.vw-aufg-gruppe[data-k="start"] .vw-aufg-kopf { color: var(--wd-p1); }
.vw-aufg-gruppe[data-k="gestaltung"] .vw-aufg-kopf { color: var(--wd-p2); }
.vw-aufg-gruppe[data-k="umsetzung"] .vw-aufg-kopf { color: var(--wd-p3); }
.vw-aufg-gruppe[data-k="abschluss"] .vw-aufg-kopf { color: var(--wd-p4); }
.vw-aufg {
display: grid; grid-template-columns: auto 1fr auto;
gap: .1rem .7rem; align-items: start;
padding: .55rem .7rem; margin-bottom: .3rem;
border-radius: 10px; border: 1px solid transparent;
}
.vw-aufg:hover { background: rgba(127,208,232,.045); border-color: var(--wd-rand); }
/* Das Kaestchen ist ein echter Knopf, kein Symbol mit Klickfläche
drumherum: Es muss ueber die Tastatur erreichbar sein und beim
Anspringen sichtbar umrandet werden. */
/* JEDES Kind bekommt Zeile UND Spalte ausdruecklich zugewiesen.
Ohne die Spaltenangabe geht es schief, und zwar auf eine Art, die man
nicht erraet: Das Raster platziert zuerst alle Elemente mit
FESTGELEGTER Zeile und erst danach die freien. Kaestchen und
Knopfgruppe haben beide "grid-row: 1 / span 2" -- sie wurden deshalb
zusammen nach vorne gesetzt (Spalte 1 und 2), und der Titel landete
als letzter in Spalte 3, ganz rechts. Im Bild sah das aus wie ein
falsch herum gebautes Formular. */
.vw-aufg-haken {
grid-row: 1 / span 2; grid-column: 1;
width: 24px; height: 24px; margin-top: .1rem; padding: 0;
border-radius: 7px; border: 2px solid var(--wd-rand-stark);
background: transparent; color: transparent;
cursor: pointer; font-size: .9rem; line-height: 1;
display: grid; place-items: center;
transition: all var(--wd-zeit-schnell) ease;
}
.vw-aufg-haken:hover { border-color: var(--wd-blau); }
.vw-aufg--erledigt .vw-aufg-haken { background: var(--wd-ok); border-color: var(--wd-ok); color: #06120c; }
.vw-aufg--arbeit .vw-aufg-haken { border-color: var(--wd-p2); color: var(--wd-p2); }
.vw-aufg--weg .vw-aufg-haken { border-style: dashed; color: var(--wd-text-soft); }
.vw-aufg-titel { grid-row: 1; grid-column: 2; font-size: .93rem; color: var(--wd-text); overflow-wrap: anywhere; }
.vw-aufg--erledigt .vw-aufg-titel { color: var(--wd-text-soft); text-decoration: line-through; text-decoration-thickness: 1px; }
.vw-aufg--weg .vw-aufg-titel { color: var(--wd-text-soft); text-decoration: line-through; opacity: .6; }
.vw-aufg-text { grid-row: 2; grid-column: 2; font-size: .82rem; color: var(--wd-text-soft); line-height: 1.5; }
.vw-aufg-rechts { grid-row: 1 / span 2; grid-column: 3; display: flex; align-items: center; gap: .3rem; }
/* Punkte, die auf den KUNDEN warten, muessen sich unterscheiden --
sonst liest man die Liste als reine Fortschrittsanzeige und
uebersieht, dass man selbst gar nicht am Zug ist. */
.vw-aufg--kunde { background: rgba(232,200,119,.055); }
.vw-aufg--kunde:hover { background: rgba(232,200,119,.1); }
.vw-aufg-wer {
font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 999px; white-space: nowrap;
background: rgba(232,200,119,.15); color: var(--wd-gold);
}
.vw-aufg-nicht {
display: inline-block; margin-left: .35rem;
font-size: .68rem; letter-spacing: .05em; text-transform: uppercase;
padding: .12rem .4rem; border-radius: 999px;
background: rgba(255,157,157,.12); color: var(--wd-fehler);
}
.vw-mini {
padding: .2rem .45rem; border-radius: 7px; font-size: .82rem;
border: 1px solid var(--wd-rand); background: transparent;
color: var(--wd-text-soft); cursor: pointer; line-height: 1;
min-width: 30px; min-height: 30px;
}
.vw-mini:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); }
/* =====================================================================
POSTFACH
===================================================================== */
.vw-pf { display: grid; gap: 1rem; }
@media (min-width: 900px) { .vw-pf { grid-template-columns: minmax(0,320px) minmax(0,1fr); align-items: start; } }
.vw-pf-eintrag {
display: block; width: 100%; text-align: left; cursor: pointer;
padding: .8rem .9rem; margin-bottom: .4rem;
border-radius: 12px; border: 1px solid var(--wd-rand);
background: var(--wd-glas); color: inherit;
}
.vw-pf-eintrag:hover { border-color: var(--wd-rand-stark); background: rgba(127,208,232,.05); }
.vw-pf-eintrag.vw-an { border-color: var(--wd-blau); background: rgba(127,208,232,.09); }
.vw-pf-eintrag--neu { border-left: 3px solid var(--wd-gold); }
.vw-pf-name { font-weight: 700; display: flex; align-items: center; gap: .4rem; }
.vw-pf-vorschau {
font-size: .84rem; color: var(--wd-text-soft); margin-top: .2rem;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.vw-pf-zeit { font-size: .74rem; color: var(--wd-text-soft); margin-top: .3rem; }
/* Verlauf: Der Kunde links, wir rechts -- die im Nachrichtenverkehr
gewohnte Anordnung. Man erkennt den Absender an der Seite, noch
bevor man den Namen liest. */
.vw-pf-verlauf {
max-height: 60vh; overflow-y: auto;
padding: .3rem .5rem .3rem .3rem;
display: flex; flex-direction: column; gap: .6rem;
}
.vw-blase {
max-width: 82%; padding: .65rem .85rem; border-radius: 14px;
font-size: .92rem; line-height: 1.55; white-space: pre-wrap;
overflow-wrap: anywhere;
}
.vw-blase--kunde {
align-self: flex-start;
background: rgba(255,255,255,.055); border: 1px solid var(--wd-rand);
border-bottom-left-radius: 5px;
}
.vw-blase--wir {
align-self: flex-end;
background: rgba(127,208,232,.12); border: 1px solid var(--wd-rand-stark);
border-bottom-right-radius: 5px;
}
/* Interne Notiz: deutlich anders. Sie darf keinesfalls wie eine
gesendete Nachricht aussehen -- sonst haelt man sie fuer verschickt
oder, schlimmer, tippt Vertrauliches ins falsche Feld. */
.vw-blase--intern {
align-self: flex-end;
background: rgba(232,200,119,.08);
border: 1px dashed rgba(232,200,119,.45);
border-bottom-right-radius: 5px;
}
.vw-blase-kopf { font-size: .7rem; color: var(--wd-text-soft); margin-bottom: .25rem; }
.vw-blase--intern .vw-blase-kopf { color: var(--wd-gold); font-weight: 700; }
/* Der Schalter "interne Notiz" veraendert die Farbe des Schreibfeldes.
Ein Haekchen allein uebersieht man -- die Umgebung muss sich sichtbar
aendern, sonst landet irgendwann eine interne Bemerkung beim Kunden. */
.vw-pf-schreiben { margin-top: .8rem; padding: .8rem; border-radius: 14px; border: 1px solid var(--wd-rand); }
.vw-pf-schreiben--intern { border-color: rgba(232,200,119,.45); background: rgba(232,200,119,.05); }
.vw-pf-schalter { display: flex; align-items: center; gap: .45rem; font-size: .85rem; color: var(--wd-text-soft); cursor: pointer; }
.vw-pf-schalter input { width: 18px; height: 18px; accent-color: var(--wd-gold); }
</style>
</head>
<body class="wd">
<div id="wd-kopf" data-wd-kopf="minimal"></div>
<main id="wd-inhalt">
<section class="wd-abschnitt" style="padding-top:1.6rem">
<div class="wd-container">
<!-- ============ ANMELDUNG ============ -->
<div id="vw-anmeldung" class="wd-karte vw-login" hidden>
<h1 style="font-size:1.4rem">Verwaltung</h1>
<p class="wd-klein wd-leise">
Normalerweise landest du hier gar nicht: Wer durch die Zugangswand
gekommen ist, wird direkt hereingelassen. Diese Maske erscheint nur,
wenn das nicht möglich war — dann brauchst du deinen persönlichen
Zugangscode aus dem internen Bereich (derselbe wie fürs Postfach).
</p>
<form id="vw-login-form" novalidate>
<div class="wd-feld" id="vw-feld-code">
<label for="vw-code">Zugangscode</label>
<input type="password" id="vw-code" autocomplete="current-password"
autocapitalize="off" spellcheck="false" />
<p class="wd-feld-fehler"></p>
</div>
<button type="submit" class="wd-btn wd-btn--haupt wd-btn--breit">Anmelden</button>
</form>
</div>
<!-- ============ ARBEITSBEREICH ============ -->
<div id="vw-bereich" hidden>
<div class="vw-leiste">
<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="projekte">Projekte</button>
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</button>
<button type="button" class="vw-filter-knopf" data-tab="postfach">Postfach<span
class="vw-abzeichen" id="vw-pf-zaehler" hidden>0</span></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>
</div>
</div>
<div class="vw-zahlen" id="vw-zahlen"></div>
<div class="vw-filter" id="vw-filter"></div>
<div class="wd-feld" style="margin-bottom:1rem">
<input type="search" id="vw-suche" class="vw-suche" placeholder="Nach Name, E-Mail oder Nummer suchen…"
style="width:100%" aria-label="Anfragen durchsuchen" />
</div>
<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>
<!-- ============ PROJEKTE ============ -->
<div id="vw-projekte-bereich" hidden>
<p class="wd-klein wd-leise" style="margin-bottom:1rem">
Jedes Projekt hat eine Aufgabenliste in den vier Abschnitten des
Ablaufs. Abgehakte Punkte sieht der Kunde sofort in seinem Portal —
das ersetzt die meisten „Wie weit seid ihr?“-Nachrichten.
</p>
<div id="vw-projekte-liste"><div class="vw-laedt">Wird geladen…</div></div>
</div>
<!-- ============ POSTFACH ============ -->
<div id="vw-postfach-bereich" hidden>
<p class="wd-klein wd-leise" style="margin-bottom:1rem">
Nachrichten, die zu keinem bestimmten Projekt gehören — Fragen,
Kleinigkeiten, alles Kurze. Auch Kunden ohne laufendes Projekt
können hier schreiben.
</p>
<div class="vw-pf">
<div id="vw-pf-liste"><div class="vw-laedt">Wird geladen…</div></div>
<div id="vw-pf-verlauf" class="wd-karte">
<p class="wd-leise" style="text-align:center;padding:2rem 0">
Wähle links einen Verlauf aus.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- ============ DETAILANSICHT ============ -->
<div class="vw-detail" id="vw-detail" role="dialog" aria-modal="true" aria-label="Anfrage im Detail">
<div class="vw-detail-schatten" data-schliessen></div>
<div class="vw-detail-blatt" id="vw-detail-blatt"></div>
</div>
<script src="/assets/js/wd-core.js?v=20260823wd7"></script>
<script>
/* =====================================================================
Verwaltung: Anfragen ansehen, Status ändern, Notizen, Archivieren.
Anmeldung: bewusst über die BESTEHENDE Team-Anmeldung des internen
Bereichs (/auth/login) statt einer zweiten eigenen. Zwei getrennte
Anmeldungen für dieselben zwei Personen wären doppelte Pflege und ein
zweiter Ort, an dem ein Zugang vergessen werden kann.
Das Sitzungstoken liegt in sessionStorage, NICHT in localStorage:
Es soll beim Schließen verschwinden — dieselbe Regel wie an der
Zugangswand ("jedes Mal wieder fragen").
===================================================================== */
(function () {
"use strict";
var TOKEN_SCHLUESSEL = "wd-vw-token";
var token = null;
var alleAnfragen = [];
var filterStatus = "";
var filterArchiv = false;
var suchText = "";
var STATUS_TEXT = {
neu: "Neu", pruefung: "In Prüfung", rueckfrage: "Rückfrage",
angebot: "Angebot raus", angenommen: "Angenommen",
abgeschlossen: "Abgeschlossen", abgelehnt: "Abgelehnt",
};
var PAKET_TEXT = {
onepager: "Onepager", website: "Professionelle Website",
shop: "Shop & umfangreiche Website", verwaltung: "Buchhaltungs- & Steuerverwaltung",
betreuung: "Laufende Betreuung", unklar: "Noch unklar",
};
var VORHANDEN_TEXT = { ja: "vorhanden", teilweise: "teilweise", nein: "fehlt noch" };
var BUDGET_TEXT = {
"bis-500": "bis 500 €", "500-1000": "500 – 1.000 €",
"1000-2500": "1.000 – 2.500 €", "ueber-2500": "über 2.500 €", unklar: "noch unklar",
};
var s = window.WD.sicher;
var meldung = document.getElementById("vw-meldung");
function tokenLesen() {
try { return sessionStorage.getItem(TOKEN_SCHLUESSEL); } catch (e) { return null; }
}
function tokenSchreiben(t) {
try { if (t) sessionStorage.setItem(TOKEN_SCHLUESSEL, t); else sessionStorage.removeItem(TOKEN_SCHLUESSEL); } catch (e) { /* egal */ }
}
function api(pfad, optionen) {
var opt = optionen || {};
opt.headers = Object.assign({ Authorization: "Bearer " + token }, opt.headers || {});
return window.WD.api(pfad, opt);
}
/* ---------------- Anmeldung ---------------- */
function zeigeAnmeldung() {
document.getElementById("vw-anmeldung").hidden = false;
document.getElementById("vw-bereich").hidden = true;
var feld = document.getElementById("vw-code");
if (feld) setTimeout(function () { feld.focus(); }, 100);
}
function zeigeBereich() {
document.getElementById("vw-anmeldung").hidden = true;
document.getElementById("vw-bereich").hidden = false;
}
document.getElementById("vw-login-form").addEventListener("submit", async function (e) {
e.preventDefault();
var feld = document.getElementById("vw-feld-code");
var eingabe = document.getElementById("vw-code");
var knopf = e.target.querySelector("button");
feld.classList.remove("wd-feld--fehler");
var code = eingabe.value.trim();
if (!code) {
feld.classList.add("wd-feld--fehler");
feld.querySelector(".wd-feld-fehler").textContent = "Bitte den Zugangscode eingeben.";
return;
}
window.WD.knopfSperren(knopf, true, "Wird geprüft…");
try {
var antwort = await window.WD.api("/auth/login", { method: "POST", body: { code: code } });
token = antwort.token;
tokenSchreiben(token);
document.getElementById("vw-name").textContent = antwort.name || antwort.actor || "angemeldet";
zeigeBereich();
ladeAnfragen();
/* Der Postfach-Zaehler direkt nach der Anmeldung. Ohne das sieht man
neue Nachrichten erst, wenn man den Reiter zufaellig anklickt --
also womoeglich tagelang nicht. */
zaehlerHolen();
} catch (fehler) {
feld.classList.add("wd-feld--fehler");
feld.querySelector(".wd-feld-fehler").textContent = fehler.message || "Anmeldung fehlgeschlagen.";
eingabe.select();
} finally {
window.WD.knopfSperren(knopf, false);
}
});
document.getElementById("vw-abmelden").addEventListener("click", async function () {
try { await api("/auth/logout", { method: "POST", body: {} }); } catch (e) { /* egal */ }
token = null; tokenSchreiben(null);
location.reload();
});
/* ---------------- Liste ---------------- */
async function ladeAnfragen() {
var liste = document.getElementById("vw-liste");
liste.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var antwort = await api("/webdesign/admin/anfragen?archiviert=" + (filterArchiv ? "1" : "0"));
alleAnfragen = antwort.anfragen || [];
zeichneZahlen(antwort.zaehler || {});
zeichneFilter(antwort.zaehler || {});
zeichneListe();
window.WD.meldungAus(meldung);
} catch (fehler) {
if (fehler.status === 401) {
/* Der Ausweis der Zugangswand laeuft nach 15 Minuten ab. Statt
jemanden mitten im Arbeiten rauszuwerfen, EINMAL still einen
neuen holen und es nochmal versuchen. Klappt auch das nicht,
ist die Zugangssitzung selbst weg -- dann zurueck zur
Anmeldung, nicht zu einer leeren Liste. */
if (!ausweisVersucht) {
ausweisVersucht = true;
var frisch = await ausweisHolen();
if (frisch) {
token = frisch; tokenSchreiben(token);
ausweisVersucht = false;
return ladeAnfragen();
}
}
token = null; tokenSchreiben(null); zeigeAnmeldung();
return;
}
liste.innerHTML = "";
window.WD.meldung(meldung, fehler.message || "Die Anfragen konnten nicht geladen werden.", "fehler");
}
}
function zeichneZahlen(zaehler) {
var gesamt = Object.keys(zaehler).reduce(function (n, k) { return n + zaehler[k]; }, 0);
/* Der aelteste unbearbeitete Vorgang ist die ehrlichste Kennzahl
ueberhaupt: Er sagt nicht, wie viel man geschafft hat, sondern wie
lange jemand schon auf eine Antwort wartet. Genau daran misst ein
Kunde, ob man zuverlaessig ist. */
var wartend = alleAnfragen.filter(function (a) {
return a.status === "neu" || a.status === "rueckfrage";
});
var aeltesteTage = wartend.reduce(function (max, a) {
return Math.max(max, tageSeit(a.erstellt_am));
}, 0);
var kacheln = [
{
wert: zaehler.neu || 0, name: "neu, ungelesen", symbol: "📬", filter: "neu",
achtung: (zaehler.neu || 0) > 0,
zusatz: (zaehler.neu || 0) > 0 ? "warten auf dich" : "alles gesichtet",
},
{
wert: zaehler.pruefung || 0, name: "in Prüfung", symbol: "🔍", filter: "pruefung",
zusatz: "du bist dran",
},
{
wert: zaehler.angebot || 0, name: "Angebot raus", symbol: "📄", filter: "angebot",
zusatz: "Kunde ist dran",
},
{
wert: zaehler.angenommen || 0, name: "angenommen", symbol: "✅", filter: "angenommen",
zusatz: "Projekt anlegen",
},
{
/* Diese Kachel filtert bewusst NICHT -- sie zeigt einen Zustand,
keinen Status. Deshalb ist sie auch nicht anklickbar. */
wert: aeltesteTage, name: aeltesteTage === 1 ? "Tag wartet der Älteste" : "Tage wartet der Älteste",
symbol: "⏳", filter: null,
dringend: aeltesteTage >= 7,
zusatz: aeltesteTage === 0 ? "nichts liegen geblieben"
: aeltesteTage >= 7 ? "das ist zu lang" : "noch im Rahmen",
},
{
wert: gesamt, name: "insgesamt offen", symbol: "📊", filter: "",
zusatz: "alle anzeigen",
},
];
document.getElementById("vw-zahlen").innerHTML = kacheln.map(function (k) {
var klassen = "vw-zahl";
if (k.dringend) klassen += " vw-zahl--dringend";
else if (k.achtung) klassen += " vw-zahl--achtung";
else if (!k.wert) klassen += " vw-zahl--null";
if (k.filter !== null && filterStatus === k.filter && !filterArchiv) klassen += " vw-an";
var innen =
'<span class="vw-zahl-kopf">' +
'<span class="vw-zahl-symbol" aria-hidden="true">' + k.symbol + "</span>" +
'<span class="vw-zahl-wert">' + k.wert + "</span>" +
"</span>" +
'<span class="vw-zahl-name">' + s(k.name) + "</span>" +
'<span class="vw-zahl-zusatz">' + s(k.zusatz) + "</span>";
/* Anklickbares wird ein echter Knopf, nicht ein div mit
Klickzuhoerer -- sonst ist es mit der Tastatur nicht erreichbar
und ein Screenreader kuendigt es nicht als Bedienelement an. */
return k.filter === null
? '<div class="' + klassen + '" style="cursor:default">' + innen + "</div>"
: '<button type="button" class="' + klassen + '" data-kachel="' + k.filter +
'" aria-label="' + s(k.wert + " " + k.name + " anzeigen") + '">' + innen + "</button>";
}).join("");
document.querySelectorAll("[data-kachel]").forEach(function (b) {
b.addEventListener("click", function () {
filterStatus = b.dataset.kachel;
filterArchiv = false;
ladeAnfragen();
});
});
}
function zeichneFilter(zaehler) {
var knoepfe = [{ wert: "", name: "Alle" }].concat(
Object.keys(STATUS_TEXT).map(function (k) { return { wert: k, name: STATUS_TEXT[k] }; })
);
var html = knoepfe.map(function (k) {
var n = k.wert ? (zaehler[k.wert] || 0) : "";
return '<button type="button" class="vw-filter-knopf' + (filterStatus === k.wert ? " vw-an" : "") +
'" data-status="' + k.wert + '">' + s(k.name) +
(n !== "" ? '<span class="vw-filter-zahl">' + n + "</span>" : "") + "</button>";
}).join("");
html += '<button type="button" class="vw-filter-knopf' + (filterArchiv ? " vw-an" : "") +
'" data-archiv="1" style="margin-left:.6rem">Archiv</button>';
var box = document.getElementById("vw-filter");
box.innerHTML = html;
box.querySelectorAll("[data-status]").forEach(function (b) {
b.addEventListener("click", function () { filterStatus = b.dataset.status; zeichneFilter({}); ladeAnfragen(); });
});
box.querySelector("[data-archiv]").addEventListener("click", function () {
filterArchiv = !filterArchiv; filterStatus = ""; ladeAnfragen();
});
}
/* Ganze Tage seit einem Zeitpunkt. Bewusst ueber den Kalendertag
gerechnet, nicht ueber 24-Stunden-Bloecke: eine Anfrage von gestern
23:50 Uhr ist umgangssprachlich "gestern", nicht "vor 0 Tagen". */
function tageSeit(zeit) {
if (!zeit) return 0;
var dann = new Date(zeit);
if (isNaN(dann.getTime())) return 0;
var a = new Date(dann.getFullYear(), dann.getMonth(), dann.getDate());
var heute = new Date();
var b = new Date(heute.getFullYear(), heute.getMonth(), heute.getDate());
return Math.round((b - a) / 86400000);
}
function zeitText(tage) {
if (tage <= 0) return "heute";
if (tage === 1) return "gestern";
if (tage < 7) return "vor " + tage + " Tagen";
if (tage < 14) return "vor einer Woche";
if (tage < 31) return "vor " + Math.floor(tage / 7) + " Wochen";
return "vor " + Math.floor(tage / 30) + " Monaten";
}
function zeichneListe() {
var liste = document.getElementById("vw-liste");
var gefiltert = alleAnfragen.filter(function (a) {
if (filterStatus && a.status !== filterStatus) return false;
if (!suchText) return true;
var t = suchText.toLowerCase();
return (a.name || "").toLowerCase().indexOf(t) >= 0 ||
(a.email || "").toLowerCase().indexOf(t) >= 0 ||
(a.nummer || "").toLowerCase().indexOf(t) >= 0;
});
if (!gefiltert.length) {
/* Auch hier fuehren statt nur feststellen: Wer keine Anfragen hat,
soll wissen, WO sie herkommen -- und den Link gleich griffbereit
haben. */
liste.innerHTML = alleAnfragen.length
? '<div class="vw-leer-hinweis">Keine Anfrage passt zu diesem Filter.</div>'
: filterArchiv
? '<div class="vw-leer-hinweis">Im Archiv liegt noch nichts. Archivierte Anfragen bleiben vollstaendig erhalten und lassen sich jederzeit zurueckholen.</div>'
: '<div class="wd-karte vw-leer-gross" style="cursor:default;display:block">' +
'<div class="vw-leer-gross-symbol" aria-hidden="true">📬</div>' +
"<h3>Noch keine Anfragen</h3>" +
'<p class="wd-leise">Sobald jemand das Formular ausfuellt, landet die Anfrage hier — mit allem, was er angegeben hat, und du bekommst eine Benachrichtigung.</p>' +
'<div class="wd-btn-reihe" style="justify-content:center;margin-top:1rem">' +
'<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/anfrage.html" target="_blank" rel="noopener">Formular ansehen</a>' +
'<a class="wd-btn wd-btn--still wd-btn--klein" href="/webdesign/" target="_blank" rel="noopener">Zur Seite</a>' +
"</div></div>";
return;
}
liste.innerHTML = gefiltert.map(function (a) {
var tage = tageSeit(a.erstellt_am);
/* "vor 3 Tagen" sagt mehr als ein Datum. Beim Datum muss man selbst
rechnen, wie lange jemand schon wartet -- und genau das ist die
Frage, die zaehlt. Ab sieben Tagen ohne Bearbeitung wird es rot:
eine Anfrage, die eine Woche liegt, ist ein verlorener Kunde. */
var liegtLange = tage >= 7 && (a.status === "neu" || a.status === "rueckfrage");
return '<button type="button" class="vw-karte vw-karte--' + s(a.status) + '" data-id="' + s(a.id) + '">' +
'<span class="vw-karte-nr">' + s(a.nummer) + "</span>" +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(a.name) + "</span>" +
'<span class="vw-karte-info">' + s(a.email) + "</span>" +
'<span class="vw-karte-marken">' +
'<span class="vw-mini">' + s(PAKET_TEXT[a.paket] || a.paket) + "</span>" +
(a.budget ? '<span class="vw-mini vw-mini--geld">' + s(BUDGET_TEXT[a.budget] || a.budget) + "</span>" : "") +
(a.termin ? '<span class="vw-mini">Wunsch: ' + s(a.termin) + "</span>" : "") +
(a.sprache && a.sprache !== "de" ? '<span class="vw-mini">' + s(a.sprache.toUpperCase()) + "</span>" : "") +
(a.kunde_id ? '<span class="vw-mini">ist Kunde</span>' : "") +
"</span>" +
"</span>" +
'<span class="vw-karte-rechts">' +
'<span class="vw-status vw-status--' + s(a.status) + '">' + s(STATUS_TEXT[a.status] || a.status) + "</span>" +
'<span class="vw-karte-zeit' + (liegtLange ? " vw-mini--alt" : "") + '">' +
s(zeitText(tage)) + "</span>" +
"</span></button>";
}).join("");
liste.querySelectorAll("[data-id]").forEach(function (k) {
k.addEventListener("click", function () { oeffneDetail(k.dataset.id); });
});
}
document.getElementById("vw-suche").addEventListener("input", function (e) {
suchText = e.target.value.trim();
zeichneListe();
});
/* ---------------- Detail ---------------- */
var detail = document.getElementById("vw-detail");
var blatt = document.getElementById("vw-detail-blatt");
var offeneId = null;
function schliesseDetail() {
detail.classList.remove("vw-auf");
offeneId = null;
/* Auch das offene Projekt zuruecksetzen. Bliebe es stehen, wuerde ein
spaeterer Klick auf "+ Punkt" ihn im zuletzt geoeffneten Projekt
anlegen -- also im falschen. */
offenesProjekt = null;
document.body.style.overflow = "";
}
detail.querySelector("[data-schliessen]").addEventListener("click", schliesseDetail);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && detail.classList.contains("vw-auf")) schliesseDetail();
});
function paar(was, wert, roh) {
var leer = !wert;
return '<div class="vw-paar"><dt>' + s(was) + "</dt>" +
'<dd' + (leer ? ' class="vw-leer"' : "") + ">" +
(leer ? "keine Angabe" : s(roh ? roh : wert)) + "</dd></div>";
}
/* Baut einen Kasten aus Paaren [Bezeichnung, Wert] und trennt dabei
Ausgefuelltes von Leerem: gefuellte Angaben als vollwertige Zeilen,
alle fehlenden zusammen als eine leise Zeile darunter.
Der Unterschied in der Praxis: Aus sechs Zeilen "keine Angabe"
untereinander wird "Ohne Angabe: Telefon, Zielgruppe, Termin" — eine
Zeile statt sechs, und man liest auf einen Blick, was noch fehlt,
statt es sich aus Luecken zusammenzusuchen. */
function abschnitt(titel, paare, anhang) {
var gefuellt = paare.filter(function (p) { return p[1]; });
var fehlend = paare.filter(function (p) { return !p[1]; }).map(function (p) { return p[0]; });
var innen = gefuellt.map(function (p) { return paar(p[0], p[1]); }).join("");
/* Anhang (Knoepfe, Zusatzfragen) VOR die Fehlt-Zeile. Die gehoert an
den Fuss des Kastens — ein Knopf darunter sah aus, als beziehe er
sich auf die fehlenden Angaben. */
innen += anhang || "";
if (fehlend.length) {
innen += '<div class="vw-ohne"><b>Ohne Angabe:</b> ' + s(fehlend.join(", ")) + "</div>";
}
return '<div class="vw-abschnitt' + (gefuellt.length ? "" : " vw-abschnitt--leer") + '">' +
"<h3>" + s(titel) + "</h3>" + innen + "</div>";
}
function blickFeld(was, wert) {
var leer = !wert;
return '<div class="vw-blick-feld"><span class="vw-blick-was">' + s(was) + "</span>" +
'<span class="vw-blick-wert' + (leer ? " vw-leer" : "") + '">' +
(leer ? "offen" : s(wert)) + "</span></div>";
}
/* "vor 3 Tagen" statt eines Datums. Beim Sichten der Anfragen ist die
Frage nie "welcher Tag war das", sondern "wie lange liegt das schon
hier" — und die beantwortet ein Datum erst nach Kopfrechnen. */
function alter(iso) {
var ms = Date.now() - new Date(iso).getTime();
var tage = Math.floor(ms / 86400000);
if (isNaN(tage)) return "";
if (tage <= 0) return "heute";
if (tage === 1) return "gestern";
if (tage < 7) return "vor " + tage + " Tagen";
if (tage < 14) return "vor 1 Woche";
if (tage < 60) return "vor " + Math.floor(tage / 7) + " Wochen";
return "vor " + Math.floor(tage / 30) + " Monaten";
}
async function oeffneDetail(id) {
offeneId = id;
detail.classList.add("vw-auf");
document.body.style.overflow = "hidden";
blatt.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var antwort = await api("/webdesign/admin/anfragen/" + encodeURIComponent(id));
var a = antwort.anfrage;
var notizen = antwort.notizen || [];
var funktionen = (a.funktionen || []).join(", ");
var details = Object.keys(a.details || {}).map(function (k) {
return paar(k.replace(/_/g, " "), a.details[k]);
}).join("");
blatt.innerHTML =
'<div class="vw-detail-kopf">' +
"<div><div class=\"vw-karte-nr\" style=\"font-size:.9rem\">" + s(a.nummer) + "</div>" +
'<h2 style="margin:.2rem 0 .3rem;font-size:1.4rem">' + s(a.name) + "</h2>" +
'<span class="vw-status vw-status--' + s(a.status) + '">' + s(STATUS_TEXT[a.status] || a.status) + "</span></div>" +
'<button class="vw-zu" type="button" aria-label="Schließen">✕</button>' +
"</div>" +
/* Auf einen Blick — die vier Fragen, die man immer zuerst hat. */
'<div class="vw-blick">' +
blickFeld("Paket", PAKET_TEXT[a.paket] || a.paket) +
blickFeld("Budget", BUDGET_TEXT[a.budget] || a.budget) +
blickFeld("Wunschtermin", a.termin) +
blickFeld("Eingegangen", alter(a.erstellt_am)) +
"</div>" +
'<div class="vw-detail-spalten">' +
/* ----- LINKS: was der Kunde geschickt hat (lesen) ----- */
'<div class="vw-dt-links">' +
abschnitt("Kontakt", [
["E-Mail", a.email],
["Telefon", a.telefon],
["Bevorzugt", a.kontaktweg === "telefon" ? "Telefon" : "E-Mail"],
["Sprache", (a.sprache || "").toUpperCase()],
],
'<div class="wd-btn-reihe" style="margin-top:.7rem">' +
'<a class="wd-btn wd-btn--zweit wd-btn--klein" href="mailto:' + s(a.email) +
"?subject=" + encodeURIComponent("Deine Anfrage " + a.nummer + " bei Dogfather Webdesign") +
'">Antwort schreiben</a></div>') +
abschnitt("Vorhaben", [
["Ziel", a.ziel],
["Zielgruppe", a.zielgruppe],
]) +
/* "details" sind die Zusatzfragen aus dem Formular — sie kommen
schon fertig gebaut und immer gefuellt, deshalb als Anhang. */
abschnitt("Umfang", [
["Bereiche", a.seiten],
["Funktionen", funktionen],
], details) +
abschnitt("Vorhandene Inhalte", [
["Texte", VORHANDEN_TEXT[a.hat_texte] || a.hat_texte],
["Bilder", VORHANDEN_TEXT[a.hat_bilder] || a.hat_bilder],
["Logo", VORHANDEN_TEXT[a.hat_logo] || a.hat_logo],
["Domain", VORHANDEN_TEXT[a.hat_domain] || a.hat_domain],
]) +
(a.bemerkung ? '<div class="vw-abschnitt"><h3>Bemerkung</h3>' + paar("", a.bemerkung) + "</div>" : "") +
"</div>" +
/* ----- RECHTS: was du damit machst (handeln) ----- */
'<div class="vw-dt-rechts">' +
/* Der wichtigste Knopf steht oben. Bei einer neuen Anfrage ist die
Uebernahme das, was man tun WILL — sie stand vorher unter den
Notizen und damit ausserhalb des Bildes. */
(a.kunde_id
? '<div class="vw-abschnitt"><h3>Kunde</h3>' +
'<p class="wd-klein wd-leise">Aus dieser Anfrage wurde bereits ein Kundenzugang erstellt. Du findest ihn oben unter „Kunden“.</p></div>'
: '<div class="vw-abschnitt"><h3>Weiter geht es</h3>' +
'<div class="vw-uebernahme">' +
"<h4>Anfrage übernehmen</h4>" +
"<p>Legt in einem Schritt den Kundenzugang UND das Projekt an — mit den Angaben aus dieser Anfrage. Du bekommst danach sofort den Einladungslink.</p>" +
'<dl class="vw-uebernahme-vorschau">' +
"<dt>Kunde</dt><dd>" + s(a.name) + "</dd>" +
"<dt>E-Mail</dt><dd>" + s(a.email) + "</dd>" +
"<dt>Projekt</dt><dd>" + s(PAKET_TEXT[a.paket] || a.paket) + " für " + s(a.name) + "</dd>" +
"<dt>Sprache</dt><dd>" + s((a.sprache || "de").toUpperCase()) + "</dd>" +
"</dl>" +
'<div class="wd-feld" style="margin-bottom:.8rem">' +
'<label for="vw-ue-preis">Gesamtpreis in Euro <span class="wd-leise">optional, später änderbar</span></label>' +
'<input type="number" id="vw-ue-preis" min="0" step="1" inputmode="numeric" placeholder="z. B. 1500" />' +
'<span class="wd-feld-hilfe" id="vw-ue-anzahlung">Die 30 % Anzahlung wird daraus berechnet.</span>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-uebernehmen">Kunde und Projekt anlegen</button>' +
"</div></div>") +
'<div class="vw-abschnitt"><h3>Status ändern</h3>' +
'<div class="vw-status-wahl">' +
Object.keys(STATUS_TEXT).map(function (k) {
return '<button type="button" class="vw-filter-knopf' + (a.status === k ? " vw-an" : "") +
'" data-neuer-status="' + k + '">' + s(STATUS_TEXT[k]) + "</button>";
}).join("") + "</div>" +
"</div>" +
'<div class="vw-abschnitt"><h3>Interne Notizen (' + notizen.length + ")</h3>" +
'<div id="vw-notizen">' +
(notizen.length ? notizen.map(function (n) {
return '<div class="vw-notiz"><div class="vw-notiz-kopf">' + s(n.autor) + " · " +
window.WD.datum(n.erstellt_am) + '</div><p>' + s(n.text) + "</p></div>";
}).join("") : '<p class="wd-klein wd-leise">Noch keine Notiz.</p>') +
"</div>" +
'<div class="wd-feld" style="margin-top:.7rem;margin-bottom:.5rem">' +
'<textarea id="vw-neue-notiz" rows="2" placeholder="Notiz hinzufügen (nur intern sichtbar)"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-notiz-speichern">Notiz speichern</button>' +
"</div>" +
'<div class="vw-abschnitt"><h3>Aufräumen</h3>' +
'<p class="wd-klein wd-leise">Archivieren löscht nichts — die Anfrage bleibt vollständig erhalten und lässt sich jederzeit zurückholen.</p>' +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-archivieren">' +
(a.archiviert ? "Aus dem Archiv zurückholen" : "Archivieren") + "</button>" +
"</div>" +
"</div>" + /* Ende rechte Spalte */
"</div>"; /* Ende Spaltenraster */
blatt.querySelector(".vw-zu").addEventListener("click", schliesseDetail);
blatt.querySelectorAll("[data-neuer-status]").forEach(function (b) {
b.addEventListener("click", async function () {
window.WD.knopfSperren(b, true, "…");
try {
await api("/webdesign/admin/anfragen/" + encodeURIComponent(id) + "/status",
{ method: "POST", body: { status: b.dataset.neuerStatus } });
await ladeAnfragen();
oeffneDetail(id);
} catch (f) {
window.WD.knopfSperren(b, false);
alert(f.message || "Status konnte nicht geändert werden.");
}
});
});
blatt.querySelector("#vw-notiz-speichern").addEventListener("click", async function (e) {
var feld = blatt.querySelector("#vw-neue-notiz");
var text = feld.value.trim();
if (!text) { feld.focus(); return; }
window.WD.knopfSperren(e.target, true, "Wird gespeichert…");
try {
await api("/webdesign/admin/anfragen/" + encodeURIComponent(id) + "/notiz",
{ method: "POST", body: { text: text } });
oeffneDetail(id);
} catch (f) {
window.WD.knopfSperren(e.target, false);
alert(f.message || "Notiz konnte nicht gespeichert werden.");
}
});
var preisFeld = blatt.querySelector("#vw-ue-preis");
if (preisFeld) {
preisFeld.addEventListener("input", function () {
var wert = Number(preisFeld.value);
blatt.querySelector("#vw-ue-anzahlung").textContent =
Number.isFinite(wert) && wert > 0
? "30 % Anzahlung: " + (Math.round(wert * 100 * 0.3) / 100).toFixed(2).replace(".", ",") + " €"
: "Die 30 % Anzahlung wird daraus berechnet.";
});
}
var ueKnopf = blatt.querySelector("#vw-uebernehmen");
if (ueKnopf) {
ueKnopf.addEventListener("click", async function (e) {
window.WD.knopfSperren(e.target, true, "Wird angelegt…");
try {
/* Zwei Schritte, bewusst nacheinander und nicht parallel: das
Projekt braucht die Kunden-Kennung. Schlaegt der zweite
fehl, existiert der Kunde trotzdem schon -- deshalb sagt die
Fehlermeldung unten ausdruecklich, dass er angelegt wurde,
damit niemand es blind noch einmal versucht und dann an der
doppelten E-Mail scheitert. */
var kunde = await api("/webdesign/admin/kunden", {
method: "POST",
body: {
name: a.name, email: a.email, telefon: a.telefon || "",
sprache: a.sprache || "de", freischalten: true,
},
});
var preis = Number(preisFeld && preisFeld.value);
try {
await api("/webdesign/admin/projekte", {
method: "POST",
body: {
kundeId: kunde.id,
anfrageId: a.id,
titel: (PAKET_TEXT[a.paket] || a.paket) + " für " + a.name,
paket: a.paket === "unklar" ? "website" : a.paket,
preisEuro: Number.isFinite(preis) && preis > 0 ? preis : undefined,
richttermin: a.termin || "",
naechsterSchritt: "Briefing-Termin vereinbaren",
},
});
} catch (projektFehler) {
window.WD.meldung(meldung,
"Der Kundenzugang wurde angelegt, das Projekt aber nicht: " +
projektFehler.message + " Du kannst das Projekt unter „Kunden“ nachtragen.", "fehler");
}
schliesseDetail();
aktiverTab = "kunden";
document.querySelector('[data-tab="kunden"]').click();
zeigeLink(kunde.einladungsLink);
} catch (fehler) {
window.WD.knopfSperren(e.target, false);
window.WD.meldung(meldung, fehler.message, "fehler");
}
});
}
blatt.querySelector("#vw-archivieren").addEventListener("click", async function (e) {
window.WD.knopfSperren(e.target, true, "…");
try {
await api("/webdesign/admin/anfragen/" + encodeURIComponent(id) + "/archivieren",
{ method: "POST", body: { zurueck: !!a.archiviert } });
schliesseDetail();
ladeAnfragen();
} catch (f) {
window.WD.knopfSperren(e.target, false);
alert(f.message || "Konnte nicht archiviert werden.");
}
});
} catch (fehler) {
blatt.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message || "Konnte nicht geladen werden.") +
'<br /><br /><button class="wd-btn wd-btn--still wd-btn--klein" type="button" onclick="this.closest(\'.vw-detail\').classList.remove(\'vw-auf\')">Schließen</button></div>';
}
}
/* =================================================================
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);
});
/* Vorher war das eine Ja/Nein-Umschaltung zwischen zwei Bereichen.
Mit vier Reitern geht das nicht mehr: Jede neue Ansicht haette
eine weitere Zeile "hidden = ..." gebraucht, und beim naechsten
Reiter vergisst man eine — dann liegen zwei Ansichten
uebereinander. Deshalb jetzt eine Zuordnung: Reiter -> Titel und
zugehoeriger Kasten. Alles andere wird ausgeblendet, ohne dass
man es einzeln aufzaehlen muss. */
var ANSICHTEN = {
anfragen: { titel: "Projektanfragen", kasten: null },
projekte: { titel: "Projekte", kasten: "vw-projekte-bereich" },
kunden: { titel: "Kunden", kasten: "vw-kunden-bereich" },
postfach: { titel: "Postfach", kasten: "vw-postfach-bereich" },
};
var istAnfragen = aktiverTab === "anfragen";
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
/* Die Werkzeuge oben (Zahlen, Filter, Suche) gehoeren zur
Anfragenliste und zu keiner anderen Ansicht. */
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;
Object.keys(ANSICHTEN).forEach(function (name) {
var id = ANSICHTEN[name].kasten;
if (id) document.getElementById(id).hidden = name !== aktiverTab;
});
if (aktiverTab === "kunden") ladeKunden();
if (aktiverTab === "projekte") ladeProjekte();
if (aktiverTab === "postfach") ladePostfach();
});
});
/* =================================================================
PROJEKTE UND AUFGABEN
Der Kern des Wunsches vom 22.08.2026: "wenn sie drauf drücken sollen
sie auch sehen was ich schon gemacht habe von dem was im plan war."
Hier ist die Seite, auf der ich abhake — was hier passiert, sieht der
Kunde unmittelbar in seinem Portal.
================================================================= */
var KATEGORIEN = ["start", "gestaltung", "umsetzung", "abschluss"];
var KAT_TEXT = {
start: "Start", gestaltung: "Gestaltung",
umsetzung: "Umsetzung", abschluss: "Abschluss",
};
var offenesProjekt = null;
/* Die Phasen des Projekts — dieselbe Reihenfolge wie die Leiste, die
der Kunde in seinem Portal sieht. "pausiert" und "abgebrochen"
stehen bewusst am Ende und ausserhalb der Reihe: Sie sind keine
Phase, sondern ein Zustand daneben. */
var PHASEN = ["briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben"];
var PHASEN_SONDER = ["pausiert", "abgebrochen"];
var PHASE_TEXT = {
briefing: "Briefing", angebot: "Angebot", design: "Design",
entwicklung: "Entwicklung", tests: "Tests", abnahme: "Abnahme",
uebergeben: "Übergeben", pausiert: "Pausiert", abgebrochen: "Abgebrochen",
};
var AE_TEXT = {
eingereicht: "Eingereicht", geprueft: "Wird geprüft", angeboten: "Angebot liegt vor",
angenommen: "Angenommen", abgelehnt: "Abgelehnt", umgesetzt: "Umgesetzt",
};
/* Der Balken in vier Abschnitten. Ein einzelner Balken sagt "60 %",
vier Abschnitte sagen zusaetzlich WO diese 60 % liegen. */
function balken(f) {
return '<div class="vw-fortschritt">' + KATEGORIEN.map(function (k) {
var d = (f.jeKategorie && f.jeKategorie[k]) || { gesamt: 0, erledigt: 0 };
var breite = d.gesamt ? Math.round((d.erledigt / d.gesamt) * 100) : 0;
return '<span class="vw-fs-teil" data-k="' + k + '" title="' + KAT_TEXT[k] + ": " +
d.erledigt + " von " + d.gesamt + '"><i style="width:' + breite + '%"></i></span>';
}).join("") + "</div>";
}
async function ladeProjekte() {
var ziel = document.getElementById("vw-projekte-liste");
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/projekte");
if (!d.projekte.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis">Noch keine Projekte. ' +
"Sie entstehen aus einer Anfrage über „Anfrage übernehmen“ oder " +
"unter „Kunden“ über „+ Projekt“.</div>";
return;
}
ziel.innerHTML = d.projekte.map(function (p) {
var gesamt = p.aufg_gesamt || 0;
var fertig = p.aufg_erledigt || 0;
var prozent = gesamt ? Math.round((fertig / gesamt) * 100) : 0;
/* Der Balken braucht die Aufteilung je Kategorie, die die Liste
aus Sparsamkeit nicht mitliefert. Deshalb hier ein einzelner
Balken über alles — die genaue Aufteilung erscheint beim
Oeffnen. Ein falscher Vierfarbbalken waere schlimmer als ein
ehrlicher einfarbiger. */
return '<button type="button" class="vw-karte" data-projekt="' + s(p.id) + '">' +
'<span class="vw-karte-nr">' + s(p.nummer) + "</span>" +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(p.titel) + "</span>" +
'<span class="vw-karte-info">' + s(p.kunde_name) +
(p.kunde_firma ? " · " + s(p.kunde_firma) : "") + "</span>" +
'<span class="vw-fortschritt"><span class="vw-fs-teil" data-k="umsetzung" ' +
'style="flex:1"><i style="width:' + prozent + '%"></i></span></span>' +
'<span class="vw-fs-text"><b>' + fertig + " von " + gesamt + "</b> erledigt" +
(gesamt ? " · " + prozent + " %" : " — noch keine Aufgabenliste") + "</span>" +
"</span>" +
'<span class="vw-karte-rechts">' +
(p.aufg_kunde
? '<span class="vw-wartet">wartet auf Kunde: ' + p.aufg_kunde + "</span>"
: '<span class="vw-status vw-status--angenommen">alles bei dir</span>') +
(p.ungelesen ? '<span class="vw-abzeichen">' + p.ungelesen + "</span>" : "") +
'<span class="vw-karte-zeit">' + s(PAKET_TEXT[p.paket] || p.paket) + "</span>" +
"</span></button>";
}).join("");
ziel.querySelectorAll("[data-projekt]").forEach(function (b) {
b.addEventListener("click", function () { oeffneAufgaben(b.dataset.projekt); });
});
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ziel.innerHTML = "";
window.WD.meldung(meldung, fehler.message, "fehler");
}
}
/* Eine einzelne Aufgabenzeile. */
function aufgabeZeile(a) {
var klasse = "vw-aufg";
if (a.status === "erledigt") klasse += " vw-aufg--erledigt";
else if (a.status === "in_arbeit") klasse += " vw-aufg--arbeit";
else if (a.status === "entfaellt") klasse += " vw-aufg--weg";
/* Der goldene Hintergrund nur, wenn der Punkt NOCH offen ist. Ein
erledigter Kundenpunkt braucht keine Aufmerksamkeit mehr. */
if (a.wer_dran === "kunde" && a.status !== "erledigt" && a.status !== "entfaellt") {
klasse += " vw-aufg--kunde";
}
var zeichen = a.status === "erledigt" ? "✓" : a.status === "in_arbeit" ? "•" : a.status === "entfaellt" ? "–" : "";
var naechster = a.status === "offen" ? "in_arbeit" : a.status === "in_arbeit" ? "erledigt" : "offen";
return '<div class="' + klasse + '" data-aufgabe="' + s(a.id) + '">' +
'<button type="button" class="vw-aufg-haken" data-weiter="' + naechster + '" ' +
'title="' + (a.status === "erledigt" ? "Wieder öffnen" : a.status === "in_arbeit" ? "Als erledigt setzen" : "In Arbeit setzen") + '" ' +
'aria-label="Zustand ändern: ' + s(a.titel) + '">' + zeichen + "</button>" +
'<span class="vw-aufg-titel">' + s(a.titel) +
(a.nicht_enthalten ? '<span class="vw-aufg-nicht">nicht enthalten</span>' : "") + "</span>" +
'<span class="vw-aufg-rechts">' +
(a.wer_dran === "kunde" && a.status !== "erledigt" ? '<span class="vw-aufg-wer">Kunde</span>' : "") +
'<button type="button" class="vw-mini" data-entfaellt="' + (a.status === "entfaellt" ? "0" : "1") +
'" title="' + (a.status === "entfaellt" ? "Doch wieder aufnehmen" : "Entfällt — zählt dann nicht mehr mit") + '">' +
(a.status === "entfaellt" ? "↺" : "∅") + "</button>" +
'<button type="button" class="vw-mini" data-loeschen="1" title="Punkt löschen">✕</button>' +
"</span>" +
(a.beschreibung || a.hinweis
? '<span class="vw-aufg-text">' + s(a.beschreibung || "") +
(a.hinweis ? " " + s(a.hinweis) : "") + "</span>"
: "") +
"</div>";
}
async function oeffneAufgaben(projektId) {
offenesProjekt = projektId;
detail.classList.add("vw-auf");
document.body.style.overflow = "hidden";
blatt.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/projekte/" + encodeURIComponent(projektId) + "/alles");
zeichneAufgaben(d);
} catch (fehler) {
if (fehler.status === 401) { schliesseDetail(); token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
blatt.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message) + "</div>";
}
}
function zeichneAufgaben(d) {
var p = d.projekt, f = d.fortschritt;
var gruppen = KATEGORIEN.map(function (k) {
var drin = d.aufgaben.filter(function (a) { return a.kategorie === k; });
if (!drin.length) return "";
var z = f.jeKategorie[k] || { gesamt: 0, erledigt: 0 };
return '<div class="vw-aufg-gruppe" data-k="' + k + '">' +
'<div class="vw-aufg-kopf">' + KAT_TEXT[k] +
'<span class="vw-aufg-zahl">' + z.erledigt + " / " + z.gesamt + "</span></div>" +
drin.map(aufgabeZeile).join("") +
'<button type="button" class="vw-mini" data-neu="' + k + '" ' +
'style="margin-top:.2rem;min-width:auto;padding:.25rem .6rem">+ Punkt in „' +
KAT_TEXT[k] + '“</button>' +
"</div>";
}).join("");
blatt.innerHTML =
'<div class="vw-detail-kopf">' +
'<div><div class="vw-karte-nr" style="font-size:.9rem">' + s(p.nummer) + "</div>" +
'<h2 style="margin:.2rem 0 .3rem;font-size:1.4rem">' + s(p.titel) + "</h2>" +
'<span class="wd-klein wd-leise">' + s(p.kunde_name) + " · " +
s(PAKET_TEXT[p.paket] || p.paket) + "</span></div>" +
'<button class="vw-zu" type="button" aria-label="Schließen">✕</button>' +
"</div>" +
/* Die Zusammenfassung steht oben, weil sie die Frage beantwortet,
mit der man diese Ansicht oeffnet. */
'<div class="vw-blick">' +
'<div class="vw-blick-feld"><span class="vw-blick-was">Erledigt</span>' +
'<span class="vw-blick-wert">' + f.erledigt + " von " + f.gesamt + "</span></div>" +
'<div class="vw-blick-feld"><span class="vw-blick-was">Fortschritt</span>' +
'<span class="vw-blick-wert">' + f.prozent + " %</span></div>" +
'<div class="vw-blick-feld"><span class="vw-blick-was">In Arbeit</span>' +
'<span class="vw-blick-wert' + (f.inArbeit ? "" : " vw-leer") + '">' +
(f.inArbeit || "nichts") + "</span></div>" +
'<div class="vw-blick-feld"><span class="vw-blick-was">Wartet auf Kunde</span>' +
'<span class="vw-blick-wert' + (f.beimKunden ? "" : " vw-leer") + '">' +
(f.beimKunden || "nichts") + "</span></div>" +
"</div>" +
balken(f) +
/* Gleiche Aufteilung wie in der Anfrageansicht: links die Arbeit,
rechts das Steuern. Wer zwischen beiden Ansichten wechselt, muss
nicht umdenken. */
'<div class="vw-detail-spalten">' +
'<div class="vw-dt-links">' +
(d.aufgaben.length ? gruppen :
'<div class="vw-leer-hinweis">Für dieses Projekt gibt es noch keine Aufgabenliste.' +
'<div style="margin-top:1rem"><button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" ' +
'id="vw-vorlage">Standardliste für „' + s(PAKET_TEXT[p.paket] || p.paket) + '“ einsetzen</button></div></div>') +
(d.aufgaben.length
? '<p class="wd-klein wd-leise" style="margin-top:1rem;border-top:1px solid var(--wd-rand);padding-top:.8rem">' +
"Klick auf das Kästchen schaltet weiter: offen → in Arbeit → erledigt. " +
"Der Kunde sieht jede Änderung sofort in seinem Portal. " +
'<button type="button" class="vw-mini" id="vw-vorlage" style="min-width:auto;padding:.25rem .6rem">' +
"Fehlende Standardpunkte ergänzen</button></p>"
: "") +
"</div>" +
'<div class="vw-dt-rechts">' +
/* ----- STAND -----
Das Wichtigste an dieser ganzen Erweiterung. Phase und "nächster
Schritt" stehen im Portal des Kunden GANZ OBEN — und liessen
sich bis hierher nirgends ändern. Ein Projekt blieb dadurch für
immer im Briefing stehen, egal wie weit es wirklich war. */
'<div class="vw-abschnitt"><h3>Stand</h3>' +
'<p class="wd-klein wd-leise" style="margin-top:-.2rem">' +
"Das sieht der Kunde ganz oben in seinem Portal." +
"</p>" +
'<div class="vw-status-wahl" style="margin-bottom:.7rem">' +
PHASEN.map(function (k, i) {
return '<button type="button" class="vw-filter-knopf' + (p.status === k ? " vw-an" : "") +
'" data-phase="' + k + '">' + (i + 1) + ". " + s(PHASE_TEXT[k]) + "</button>";
}).join("") +
"</div>" +
'<div class="vw-status-wahl" style="margin-bottom:.9rem">' +
PHASEN_SONDER.map(function (k) {
return '<button type="button" class="vw-filter-knopf' + (p.status === k ? " vw-an" : "") +
'" data-phase="' + k + '">' + s(PHASE_TEXT[k]) + "</button>";
}).join("") +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
'<label for="vw-p-schritt">Nächster Schritt</label>' +
'<input type="text" id="vw-p-schritt" maxlength="300" value="' + s(p.naechster_schritt || "") +
'" placeholder="z. B. Entwurf ansehen und Rückmeldung geben" />' +
'<span class="wd-feld-hilfe">Ein Satz in der Sprache des Kunden — nicht „QA-Runde 2“.</span>' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
"<label>Wer ist am Zug?</label>" +
'<div class="vw-status-wahl">' +
'<button type="button" class="vw-filter-knopf' + (p.wartet_auf === "dogfather" ? " vw-an" : "") +
'" data-wartet="dogfather">Ich</button>' +
'<button type="button" class="vw-filter-knopf' + (p.wartet_auf === "kunde" ? " vw-an" : "") +
'" data-wartet="kunde">Der Kunde</button>' +
"</div>" +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.8rem">' +
'<label for="vw-p-termin">Richttermin</label>' +
'<input type="text" id="vw-p-termin" maxlength="40" value="' + s(p.richttermin || "") +
'" placeholder="z. B. Mitte Oktober" />' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-p-speichern">Stand speichern</button>' +
"</div>" +
/* ----- ZAHLUNGSSTAND ----- */
'<div class="vw-abschnitt"><h3>Zahlungsstand</h3>' +
'<label class="vw-pf-schalter" style="margin-bottom:.5rem">' +
'<input type="checkbox" id="vw-p-anz"' + (p.anzahlung_bezahlt ? " checked" : "") + " />" +
"<span>Anzahlung ist eingegangen</span></label>" +
'<label class="vw-pf-schalter" style="margin-bottom:.5rem">' +
'<input type="checkbox" id="vw-p-rest"' + (p.rest_bezahlt ? " checked" : "") + " />" +
"<span>Restbetrag ist eingegangen</span></label>" +
'<label class="vw-pf-schalter">' +
'<input type="checkbox" id="vw-p-portfolio"' + (p.portfolio_freigabe ? " checked" : "") + " />" +
"<span>Darf ins Portfolio</span></label>" +
'<p class="wd-klein wd-leise" style="margin:.5rem 0 0">Wird sofort gespeichert.</p>' +
"</div>" +
/* ----- ÄNDERUNGSWÜNSCHE -----
Der Kunde konnte Ideen einreichen, seit es das Portal gibt. Sie
zu beziffern ging serverseitig auch — nur gab es keine
Oberfläche. Der Kreis war also offen: Er schickt etwas los und
hört nie wieder davon. */
'<div class="vw-abschnitt"><h3>Änderungswünsche (' + d.aenderungen.length + ")</h3>" +
(d.aenderungen.length
? d.aenderungen.map(function (a) {
var offen = a.status === "eingereicht" || a.status === "geprueft";
return '<div class="vw-notiz" data-ae="' + s(a.id) + '">' +
'<div class="vw-notiz-kopf">' + window.WD.datum(a.erstellt_am) +
' · <span class="wd-tag' + (offen ? " wd-tag--gold" : a.status === "angenommen" ? " wd-tag--ok" : "") +
'">' + s(AE_TEXT[a.status] || a.status) + "</span></div>" +
"<p><strong>" + s(a.titel) + "</strong></p>" +
"<p>" + s(a.beschreibung || "") + "</p>" +
(a.zusatzpreis_cent != null
? '<p class="wd-klein wd-leise">Beziffert: ' +
(a.zusatzpreis_cent / 100).toFixed(2).replace(".", ",") + " €" +
(a.zusatzdauer_text ? " · " + s(a.zusatzdauer_text) : "") + "</p>"
: "") +
(offen
? '<div style="margin-top:.6rem">' +
'<div class="wd-feld" style="margin-bottom:.4rem">' +
'<input type="number" class="vw-ae-preis" min="0" step="1" inputmode="numeric" ' +
'placeholder="Preis in Euro" />' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.4rem">' +
'<input type="text" class="vw-ae-dauer" maxlength="200" placeholder="Zusätzliche Dauer, z. B. 3 Tage" />' +
"</div>" +
'<div class="wd-feld" style="margin-bottom:.5rem">' +
'<textarea class="vw-ae-antwort" rows="2" placeholder="Kurze Erklärung für den Kunden"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" data-beziffern="' +
s(a.id) + '">Angebot an den Kunden schicken</button></div>'
: "") +
"</div>";
}).join("")
: '<p class="wd-klein wd-leise">Noch keine Wünsche eingereicht.</p>') +
"</div>" +
/* ----- NACHRICHTEN ZUM PROJEKT -----
Getrennt vom allgemeinen Postfach, weil sie hier zum Projekt
gehören und im Portal auch dort erscheinen. Sie hier NICHT zu
haben hiess: Der Kunde schreibt im Projekt, und ich kann ihm nur
woanders antworten. */
'<div class="vw-abschnitt"><h3>Nachrichten zum Projekt (' + d.nachrichten.length + ")</h3>" +
(d.nachrichten.length
? '<div class="vw-pf-verlauf" style="max-height:34vh" id="vw-p-blasen">' +
d.nachrichten.map(function (n) {
var vomKunden = n.autor === "kunde";
return '<div class="vw-blase vw-blase--' + (vomKunden ? "kunde" : "wir") + '">' +
'<div class="vw-blase-kopf">' +
(vomKunden ? s(p.kunde_name) : "Du") + " · " + window.WD.datum(n.erstellt_am) +
"</div>" + s(n.text) + "</div>";
}).join("") + "</div>"
: '<p class="wd-klein wd-leise">Noch keine Nachrichten.</p>') +
'<div class="wd-feld" style="margin:.7rem 0 .5rem">' +
'<textarea id="vw-p-nachricht" rows="2" placeholder="Antwort zum Projekt…"></textarea>' +
"</div>" +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-p-senden">Senden</button>' +
"</div>" +
"</div></div>";
blatt.querySelector(".vw-zu").addEventListener("click", schliesseDetail);
/* Zustand weiterschalten. */
blatt.querySelectorAll("[data-weiter]").forEach(function (b) {
b.addEventListener("click", function () {
aufgabeAendern(b.closest("[data-aufgabe]").dataset.aufgabe, { status: b.dataset.weiter });
});
});
blatt.querySelectorAll("[data-entfaellt]").forEach(function (b) {
b.addEventListener("click", function () {
aufgabeAendern(b.closest("[data-aufgabe]").dataset.aufgabe,
{ status: b.dataset.entfaellt === "1" ? "entfaellt" : "offen" });
});
});
blatt.querySelectorAll("[data-loeschen]").forEach(function (b) {
b.addEventListener("click", async function () {
var id = b.closest("[data-aufgabe]").dataset.aufgabe;
/* Nachfragen, weil Loeschen hier wirklich loescht (kein Archiv).
Begruendung dafuer steht im Server-Code. */
if (!confirm("Diesen Punkt wirklich löschen? Das lässt sich nicht rückgängig machen.")) return;
try {
await api("/webdesign/admin/aufgaben/" + encodeURIComponent(id) + "/loeschen", { method: "POST", body: {} });
oeffneAufgaben(offenesProjekt);
} catch (f2) { window.WD.meldung(meldung, f2.message, "fehler"); }
});
});
blatt.querySelectorAll("[data-neu]").forEach(function (b) {
b.addEventListener("click", async function () {
var titel = prompt("Was soll als Punkt in „" + KAT_TEXT[b.dataset.neu] + "“ stehen?");
if (!titel || !titel.trim()) return;
var wer = confirm("Muss der KUNDE das erledigen?\n\nOK = Kunde · Abbrechen = du") ? "kunde" : "dogfather";
try {
await api("/webdesign/admin/projekte/" + encodeURIComponent(offenesProjekt) + "/aufgaben",
{ method: "POST", body: { titel: titel.trim(), kategorie: b.dataset.neu, werDran: wer } });
oeffneAufgaben(offenesProjekt);
} catch (f2) { window.WD.meldung(meldung, f2.message, "fehler"); }
});
});
/* ---- Stand ändern ----
Phase und "wer ist am Zug" werden SOFORT gespeichert, ohne
Speichern-Knopf: Es ist ein Klick auf genau einen Wert, da wäre
ein zweiter Klick reine Zeremonie. Die Textfelder brauchen den
Knopf, weil man beim Tippen zwischendurch nicht speichern will. */
function standAendern(felder, danach) {
return api("/webdesign/admin/projekte/" + encodeURIComponent(offenesProjekt),
{ method: "POST", body: felder })
.then(function () { if (danach) danach(); })
.catch(function (f) { window.WD.meldung(meldung, f.message, "fehler"); });
}
blatt.querySelectorAll("[data-phase]").forEach(function (b) {
b.addEventListener("click", function () {
blatt.querySelectorAll("[data-phase]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
standAendern({ status: b.dataset.phase });
});
});
blatt.querySelectorAll("[data-wartet]").forEach(function (b) {
b.addEventListener("click", function () {
blatt.querySelectorAll("[data-wartet]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
standAendern({ wartetAuf: b.dataset.wartet });
});
});
var speichern = blatt.querySelector("#vw-p-speichern");
speichern.addEventListener("click", function () {
window.WD.knopfSperren(speichern, true, "…");
standAendern({
naechsterSchritt: blatt.querySelector("#vw-p-schritt").value,
richttermin: blatt.querySelector("#vw-p-termin").value,
}).then(function () {
window.WD.knopfSperren(speichern, false);
window.WD.meldung(meldung, "Gespeichert — der Kunde sieht es sofort.", "ok");
});
});
[["#vw-p-anz", "anzahlungBezahlt"], ["#vw-p-rest", "restBezahlt"],
["#vw-p-portfolio", "portfolioFreigabe"]].forEach(function (paar2) {
var el = blatt.querySelector(paar2[0]);
el.addEventListener("change", function () {
var felder = {};
felder[paar2[1]] = el.checked;
standAendern(felder);
});
});
/* ---- Änderungswunsch beziffern ---- */
blatt.querySelectorAll("[data-beziffern]").forEach(function (b) {
b.addEventListener("click", async function () {
var kasten = b.closest("[data-ae]");
var preis = kasten.querySelector(".vw-ae-preis").value;
if (!preis || Number(preis) < 0) {
window.WD.meldung(meldung,
"Bitte einen Preis angeben — ohne Preis kann der Kunde nicht entscheiden.", "fehler");
kasten.querySelector(".vw-ae-preis").focus();
return;
}
window.WD.knopfSperren(b, true, "…");
try {
await api("/webdesign/admin/aenderungen/" + encodeURIComponent(b.dataset.beziffern) + "/beziffern",
{ method: "POST", body: {
preisEuro: Number(preis),
dauer: kasten.querySelector(".vw-ae-dauer").value,
antwort: kasten.querySelector(".vw-ae-antwort").value,
} });
window.WD.meldung(meldung, "Angebot ist beim Kunden. Er entscheidet in seinem Portal.", "ok");
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(b, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
});
/* ---- Nachricht zum Projekt ---- */
var blasen = blatt.querySelector("#vw-p-blasen");
if (blasen) blasen.scrollTop = blasen.scrollHeight;
var pSenden = blatt.querySelector("#vw-p-senden");
pSenden.addEventListener("click", async function () {
var feld = blatt.querySelector("#vw-p-nachricht");
var inhalt = feld.value.trim();
if (!inhalt) { feld.focus(); return; }
window.WD.knopfSperren(pSenden, true, "…");
try {
await api("/webdesign/admin/projekt-nachricht",
{ method: "POST", body: { projektId: offenesProjekt, text: inhalt } });
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(pSenden, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
var vorlage = blatt.querySelector("#vw-vorlage");
if (vorlage) {
vorlage.addEventListener("click", async function () {
window.WD.knopfSperren(vorlage, true, "…");
try {
var r = await api("/webdesign/admin/projekte/" + encodeURIComponent(offenesProjekt) + "/aufgaben/vorlage",
{ method: "POST", body: {} });
window.WD.meldung(meldung,
r.neu + " Punkte eingesetzt" + (r.uebersprungen ? ", " + r.uebersprungen + " waren schon da." : "."), "ok");
oeffneAufgaben(offenesProjekt);
} catch (f2) {
window.WD.knopfSperren(vorlage, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
}
}
async function aufgabeAendern(id, felder) {
/* Die Zeile wird sofort umgestellt, ohne auf den Server zu warten.
Bei einer Liste, in der man zehn Punkte hintereinander abhakt,
waere ein Neuaufbau nach jedem Klick zaeh — und man verliert die
Stelle, an der man gerade war. Geht es schief, wird die ganze
Liste neu geholt und der Zustand ist wieder ehrlich. */
var zeile = blatt.querySelector('[data-aufgabe="' + id + '"]');
if (zeile) zeile.style.opacity = ".5";
try {
await api("/webdesign/admin/aufgaben/" + encodeURIComponent(id), { method: "POST", body: felder });
oeffneAufgaben(offenesProjekt);
} catch (fehler) {
if (zeile) zeile.style.opacity = "";
window.WD.meldung(meldung, fehler.message, "fehler");
oeffneAufgaben(offenesProjekt);
}
}
/* =================================================================
POSTFACH
"auf dieser seite sollen die kunden mir auch nachrichten hinterlassen
können, für noch kleinigkeiten oder sachen die ich brauch."
(22.08.2026)
Aufbau links Verläufe, rechts das Gespräch — wie in jedem
Nachrichtenprogramm. Nicht aus Nachahmung, sondern weil man ein
Gespräch abarbeitet und nicht eine Zeile: Man will sehen, was vorher
besprochen wurde, während man antwortet.
================================================================= */
var offenerVerlauf = null;
async function ladePostfach() {
var ziel = document.getElementById("vw-pf-liste");
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/postfach");
zaehlerSetzen(d.ungelesen);
if (!d.verlaeufe.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis" style="padding:2rem 1rem">' +
"Noch keine Nachrichten. Sobald ein Kunde schreibt, steht der Verlauf hier.</div>";
return;
}
ziel.innerHTML = d.verlaeufe.map(function (v) {
return '<button type="button" class="vw-pf-eintrag' +
(v.ungelesen ? " vw-pf-eintrag--neu" : "") +
(v.kunde_id === offenerVerlauf ? " vw-an" : "") +
'" data-verlauf="' + s(v.kunde_id) + '">' +
'<span class="vw-pf-name">' + s(v.name) +
(v.ungelesen ? '<span class="vw-abzeichen">' + v.ungelesen + "</span>" : "") + "</span>" +
'<span class="vw-pf-vorschau">' +
(v.letzter_autor === "kunde" ? "" : "Du: ") + s(v.letzter_text || "") + "</span>" +
'<span class="vw-pf-zeit">' + alter(v.letzte) + "</span>" +
"</button>";
}).join("");
ziel.querySelectorAll("[data-verlauf]").forEach(function (b) {
b.addEventListener("click", function () { oeffneVerlauf(b.dataset.verlauf); });
});
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ziel.innerHTML = "";
window.WD.meldung(meldung, fehler.message, "fehler");
}
}
async function oeffneVerlauf(kundeId) {
offenerVerlauf = kundeId;
var kasten = document.getElementById("vw-pf-verlauf");
kasten.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
document.querySelectorAll("[data-verlauf]").forEach(function (b) {
b.classList.toggle("vw-an", b.dataset.verlauf === kundeId);
});
try {
var d = await api("/webdesign/admin/postfach/" + encodeURIComponent(kundeId));
var blasen = d.nachrichten.map(function (n) {
var art = n.intern ? "intern" : n.autor === "kunde" ? "kunde" : "wir";
var wer = n.intern ? "Interne Notiz — der Kunde sieht das nicht"
: n.autor === "kunde" ? s(d.kunde.name) : (n.autor === "vanvan" ? "VanVan" : "Dogfather");
return '<div class="vw-blase vw-blase--' + art + '">' +
'<div class="vw-blase-kopf">' + wer + " · " + window.WD.datum(n.erstellt_am) +
(n.projekt_titel ? " · zu „" + s(n.projekt_titel) + "“" : "") + "</div>" +
s(n.text) + "</div>";
}).join("");
kasten.innerHTML =
'<div style="display:flex;align-items:baseline;gap:.6rem;margin-bottom:.7rem">' +
'<h2 style="font-size:1.1rem;margin:0">' + s(d.kunde.name) + "</h2>" +
'<span class="wd-klein wd-leise">' + s(d.kunde.email) + "</span></div>" +
'<div class="vw-pf-verlauf" id="vw-blasen">' +
(blasen || '<p class="wd-leise" style="text-align:center">Noch nichts geschrieben.</p>') +
"</div>" +
'<div class="vw-pf-schreiben" id="vw-schreibfeld">' +
'<div class="wd-feld" style="margin-bottom:.6rem">' +
'<textarea id="vw-pf-text" rows="3" placeholder="Antwort schreiben…"></textarea>' +
"</div>" +
(d.projekte.length
? '<div class="wd-feld" style="margin-bottom:.6rem">' +
'<label for="vw-pf-projekt" class="wd-klein">Bezug (optional)</label>' +
'<select id="vw-pf-projekt"><option value="">— kein bestimmtes Projekt —</option>' +
d.projekte.map(function (p) {
return '<option value="' + s(p.id) + '">' + s(p.titel) + "</option>";
}).join("") + "</select></div>"
: "") +
'<div class="wd-btn-reihe" style="align-items:center;justify-content:space-between">' +
'<label class="vw-pf-schalter"><input type="checkbox" id="vw-pf-intern" />' +
"<span>Nur interne Notiz</span></label>" +
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-pf-senden">Senden</button>' +
"</div>" +
"</div>";
/* Ans Ende springen — beim Oeffnen will man das Neueste sehen,
nicht den Anfang von vor drei Monaten. */
var lauf = document.getElementById("vw-blasen");
lauf.scrollTop = lauf.scrollHeight;
/* Der Schalter faerbt das Schreibfeld um. Ein Haekchen allein
uebersieht man — und eine interne Bemerkung, die beim Kunden
landet, ist der peinlichste Fehler, den dieses System machen
kann. Deshalb muss sich die Umgebung sichtbar aendern. */
var schalter = document.getElementById("vw-pf-intern");
var feld = document.getElementById("vw-schreibfeld");
var eingabe = document.getElementById("vw-pf-text");
schalter.addEventListener("change", function () {
feld.classList.toggle("vw-pf-schreiben--intern", schalter.checked);
eingabe.placeholder = schalter.checked
? "Interne Notiz — nur für dich und VanVan sichtbar…"
: "Antwort schreiben…";
});
var senden = document.getElementById("vw-pf-senden");
async function abschicken() {
var text = eingabe.value.trim();
if (!text) return;
var wahl = document.getElementById("vw-pf-projekt");
window.WD.knopfSperren(senden, true, "…");
try {
await api("/webdesign/admin/postfach/" + encodeURIComponent(kundeId), {
method: "POST",
body: {
text: text,
intern: schalter.checked === true,
projektId: wahl ? wahl.value : null,
},
});
eingabe.value = "";
schalter.checked = false;
feld.classList.remove("vw-pf-schreiben--intern");
await oeffneVerlauf(kundeId);
await ladePostfach();
} catch (f2) {
window.WD.meldung(meldung, f2.message, "fehler");
} finally { window.WD.knopfSperren(senden, false); }
}
senden.addEventListener("click", abschicken);
/* Strg+Enter sendet. Enter allein NICHT — in einem mehrzeiligen
Feld will man Absaetze machen koennen, ohne dass die halbe
Nachricht rausgeht. */
eingabe.addEventListener("keydown", function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); abschicken(); }
});
/* Das Oeffnen hat serverseitig als gelesen markiert — der Zaehler
oben muss das sofort nachvollziehen, sonst zeigt er eine Zahl,
die nicht mehr stimmt. */
zaehlerHolen();
} catch (fehler) {
kasten.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message) + "</div>";
}
}
function zaehlerSetzen(n) {
var z = document.getElementById("vw-pf-zaehler");
z.hidden = !n;
z.textContent = n || "";
}
async function zaehlerHolen() {
try {
var d = await api("/webdesign/admin/postfach-zaehler");
zaehlerSetzen(d.ungelesen);
} catch (e) { /* Ein fehlender Zaehler ist kein Grund, etwas zu melden. */ }
}
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--zweit wd-btn--klein" data-projekt-fuer="' + s(k.id) +
'" data-projekt-name="' + s(k.name) + '">+ Projekt</button> ' +
'<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-projekt-fuer]").forEach(function (b) {
b.addEventListener("click", function () {
zeigeProjektFormular(b.dataset.projektFuer, b.dataset.projektName);
});
});
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);
}
}
/* -----------------------------------------------------------------
PROJEKT ANLEGEN
Bewusst als Einblendung direkt bei der Kundenzeile statt als eigene
Seite: man legt ein Projekt IMMER fuer einen bestimmten Kunden an,
nie im luftleeren Raum. Der Kundenname steht deshalb gross im
Formular -- so kann man es nicht versehentlich dem Falschen anhaengen.
----------------------------------------------------------------- */
function zeigeProjektFormular(kundeId, kundeName) {
var box = document.getElementById("vw-link-box");
box.hidden = false;
box.innerHTML =
'<div class="wd-karte" style="cursor:default;display:block">' +
'<h3 style="margin:0 0 .2rem">Neues Projekt</h3>' +
'<p class="wd-klein" style="color:var(--wd-blau);margin:0 0 1rem">für ' + s(kundeName) + '</p>' +
'<div class="wd-grid wd-grid--2">' +
'<div class="wd-feld" id="vw-feld-ptitel">' +
'<label for="vw-ptitel">Projekttitel<span class="wd-pflicht">*</span></label>' +
'<input type="text" id="vw-ptitel" placeholder="z. B. Website Handwerksbetrieb Meier" />' +
'<p class="wd-feld-fehler"></p>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ppaket">Paket</label>' +
'<select id="vw-ppaket">' +
'<option value="onepager">Onepager — ab 300 €</option>' +
'<option value="website" selected>Professionelle Website — ab 500 €</option>' +
'<option value="shop">Shop &amp; umfangreiche Website — ab 1.000 €</option>' +
'<option value="verwaltung">Buchhaltungs- &amp; Steuerverwaltung — ab 1.500 €</option>' +
'<option value="betreuung">Laufende Betreuung — ab 49 €/Monat</option>' +
'</select>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ppreis">Gesamtpreis in Euro</label>' +
'<input type="number" id="vw-ppreis" min="0" step="1" placeholder="1500" inputmode="numeric" />' +
'<span class="wd-feld-hilfe" id="vw-panzahlung">Die 30 % Anzahlung wird daraus automatisch berechnet.</span>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ptermin">Richttermin <span class="wd-leise">optional</span></label>' +
'<input type="text" id="vw-ptermin" placeholder="z. B. Mitte Oktober" />' +
'</div>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-pschritt">Nächster Schritt <span class="wd-leise">sieht der Kunde sofort</span></label>' +
'<input type="text" id="vw-pschritt" placeholder="z. B. Briefing-Termin vereinbaren" />' +
'</div>' +
'<div class="wd-btn-reihe">' +
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-projekt-speichern">Projekt anlegen</button>' +
'<button type="button" class="wd-btn wd-btn--still" id="vw-projekt-abbrechen">Abbrechen</button>' +
'</div>' +
'</div>';
box.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(function () { document.getElementById("vw-ptitel").focus(); }, 100);
/* Die Anzahlung live mitrechnen. Sie wird zwar serverseitig berechnet,
aber wer sie beim Eintippen sieht, merkt eine falsche Null sofort --
und nicht erst, wenn der Kunde den Betrag ueberweisen soll. */
var preisFeld = document.getElementById("vw-ppreis");
preisFeld.addEventListener("input", function () {
var wert = Number(preisFeld.value);
document.getElementById("vw-panzahlung").textContent =
Number.isFinite(wert) && wert > 0
? "30 % Anzahlung: " + (Math.round(wert * 100 * 0.3) / 100).toFixed(2).replace(".", ",") + " €"
: "Die 30 % Anzahlung wird daraus automatisch berechnet.";
});
document.getElementById("vw-projekt-abbrechen").addEventListener("click", function () {
box.hidden = true; box.innerHTML = "";
});
document.getElementById("vw-projekt-speichern").addEventListener("click", async function (e) {
var titel = document.getElementById("vw-ptitel").value.trim();
var feld = document.getElementById("vw-feld-ptitel");
feld.classList.remove("wd-feld--fehler");
if (!titel) {
feld.classList.add("wd-feld--fehler");
feld.querySelector(".wd-feld-fehler").textContent = "Bitte einen Projekttitel angeben.";
return;
}
window.WD.knopfSperren(e.target, true, "Wird angelegt…");
try {
var r = await api("/webdesign/admin/projekte", {
method: "POST",
body: {
kundeId: kundeId,
titel: titel,
paket: document.getElementById("vw-ppaket").value,
preisEuro: Number(document.getElementById("vw-ppreis").value) || undefined,
richttermin: document.getElementById("vw-ptermin").value.trim(),
naechsterSchritt: document.getElementById("vw-pschritt").value.trim(),
},
});
box.innerHTML = '<div class="vw-link-anzeige">' +
'<p class="wd-hinweis-titel">Projekt ' + s(r.nummer) + ' angelegt</p>' +
'<p class="wd-klein" style="margin:0">' +
(r.anzahlungText ? "Anzahlung 30 %: <strong>" + s(r.anzahlungText) + "</strong>. " : "") +
'Der Kunde sieht es ab sofort in seinem Projektraum.</p></div>';
ladeKunden();
} catch (f) {
window.WD.knopfSperren(e.target, false);
window.WD.meldung(meldung, f.message, "fehler");
}
});
}
/* ---------------- Start ----------------
Reihenfolge bewusst so:
1. Ein bereits geholter Ausweis im Sitzungsspeicher.
2. Sonst: einen neuen bei der Zugangswand holen. Die hat serverseitig
laengst geprueft, wer hier ist -- ein zweites Passwort danach
bringt keinen zusaetzlichen Schutz und kostet nur Zeit
(Rueckmeldung 22.08.2026).
3. Erst wenn das nicht klappt (Ausweis nicht eingerichtet, Sitzung
abgelaufen), die eigene Anmeldemaske.
Der Ausweis laeuft nach 15 Minuten ab. Deshalb wird bei jedem 401
einmal stillschweigend ein neuer geholt und die Anfrage wiederholt --
sonst fliegt man mitten im Arbeiten raus. */
var ausweisVersucht = false;
async function ausweisHolen() {
try {
var antwort = await fetch("/webdesign/api-ausweis", { cache: "no-store" });
if (!antwort.ok) return null;
var daten = await antwort.json();
return daten && daten.ausweis ? daten.ausweis : null;
} catch (e) {
return null;
}
}
async function start() {
token = tokenLesen();
if (token) { zeigeBereich(); ladeAnfragen(); zaehlerHolen(); return; }
var ausweis = await ausweisHolen();
if (ausweis) {
token = ausweis;
tokenSchreiben(token);
document.getElementById("vw-name").textContent =
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
zeigeBereich();
ladeAnfragen();
/* Auch auf diesem Weg. Der Ausweis ist der ÜBLICHE Einstieg (man
kommt von der Zugangswand und ist sofort drin) -- ohne diese
Zeile bliebe der Zähler ausgerechnet im Normalfall leer. */
zaehlerHolen();
return;
}
zeigeAnmeldung();
}
if (window.WD) start();
else document.addEventListener("wd-bereit", start);
})();
</script>
</body>
</html>