@@ -11,8 +11,8 @@
< 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=20260822wd3 " / >
< link rel = "stylesheet" href = "/assets/css/webdesign.css?v=20260822wd3 " / >
< link rel = "stylesheet" href = "/assets/css/main.css?v=20260822wd4 " / >
< link rel = "stylesheet" href = "/assets/css/webdesign.css?v=20260822wd4 " / >
< style >
/* =====================================================================
Verwaltungsbereich — bewusst NUR AUF DEUTSCH.
@@ -401,6 +401,205 @@
. vw-leer-hinweis { text-align : center ; padding : 3 rem 1 rem ; color : var ( - - wd - text - soft ) ; }
. vw-laedt { text-align : center ; padding : 2.5 rem 1 rem ; 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 : 19 px ; padding : 0 .32 rem ;
margin-left : .38 rem ; border-radius : 999 px ;
background : var ( - - wd - gold ) ; color : #17120a ;
font-size : .7 rem ; font-weight : 800 ; line-height : 19 px ; 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 : 3 px ; height : 7 px ; margin : .55 rem 0 .3 rem ; }
. vw-fs-teil {
flex : 1 ; border-radius : 999 px ; 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 : 999 px ;
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 : .78 rem ; 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 : .3 rem ;
padding : .1 rem .5 rem ; border-radius : 999 px ;
background : rgba ( 232 , 200 , 119 , .13 ) ; border : 1 px solid rgba ( 232 , 200 , 119 , .3 ) ;
color : var ( - - wd - gold ) ; font-size : .76 rem ; font-weight : 600 ;
}
/* ---------- Aufgabenliste ---------- */
. vw-aufg-gruppe { margin-bottom : 1.1 rem ; }
. vw-aufg-kopf {
display : flex ; align-items : baseline ; gap : .5 rem ;
font-size : .74 rem ; letter-spacing : .13 em ; text-transform : uppercase ;
margin-bottom : .5 rem ; padding-bottom : .35 rem ;
border-bottom : 1 px solid var ( - - wd - rand ) ;
}
. vw-aufg-kopf . vw-aufg-zahl { margin-left : auto ; letter-spacing : 0 ; text-transform : none ; font-size : .8 rem ; 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 1 fr auto ;
gap : .1 rem .7 rem ; align-items : start ;
padding : .55 rem .7 rem ; margin-bottom : .3 rem ;
border-radius : 10 px ; border : 1 px 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 : 24 px ; height : 24 px ; margin-top : .1 rem ; padding : 0 ;
border-radius : 7 px ; border : 2 px solid var ( - - wd - rand - stark ) ;
background : transparent ; color : transparent ;
cursor : pointer ; font-size : .9 rem ; 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 : .93 rem ; 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 : 1 px ; }
. 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 : .82 rem ; 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 : .3 rem ; }
/* 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 : .68 rem ; letter-spacing : .05 em ; text-transform : uppercase ;
padding : .12 rem .4 rem ; border-radius : 999 px ; white-space : nowrap ;
background : rgba ( 232 , 200 , 119 , .15 ) ; color : var ( - - wd - gold ) ;
}
. vw-aufg-nicht {
display : inline-block ; margin-left : .35 rem ;
font-size : .68 rem ; letter-spacing : .05 em ; text-transform : uppercase ;
padding : .12 rem .4 rem ; border-radius : 999 px ;
background : rgba ( 255 , 157 , 157 , .12 ) ; color : var ( - - wd - fehler ) ;
}
. vw-mini {
padding : .2 rem .45 rem ; border-radius : 7 px ; font-size : .82 rem ;
border : 1 px solid var ( - - wd - rand ) ; background : transparent ;
color : var ( - - wd - text - soft ) ; cursor : pointer ; line-height : 1 ;
min-width : 30 px ; min-height : 30 px ;
}
. vw-mini : hover { color : var ( - - wd - text ) ; border-color : var ( - - wd - rand - stark ) ; }
/* =====================================================================
POSTFACH
===================================================================== */
. vw-pf { display : grid ; gap : 1 rem ; }
@ media ( min-width : 900px ) { . vw-pf { grid-template-columns : minmax ( 0 , 320 px ) minmax ( 0 , 1 fr ) ; align-items : start ; } }
. vw-pf-eintrag {
display : block ; width : 100 % ; text-align : left ; cursor : pointer ;
padding : .8 rem .9 rem ; margin-bottom : .4 rem ;
border-radius : 12 px ; border : 1 px 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 : 3 px solid var ( - - wd - gold ) ; }
. vw-pf-name { font-weight : 700 ; display : flex ; align-items : center ; gap : .4 rem ; }
. vw-pf-vorschau {
font-size : .84 rem ; color : var ( - - wd - text - soft ) ; margin-top : .2 rem ;
display : -webkit- box ; -webkit- line-clamp : 2 ; -webkit- box-orient : vertical ;
overflow : hidden ;
}
. vw-pf-zeit { font-size : .74 rem ; color : var ( - - wd - text - soft ) ; margin-top : .3 rem ; }
/* 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 : 60 vh ; overflow-y : auto ;
padding : .3 rem .5 rem .3 rem .3 rem ;
display : flex ; flex-direction : column ; gap : .6 rem ;
}
. vw-blase {
max-width : 82 % ; padding : .65 rem .85 rem ; border-radius : 14 px ;
font-size : .92 rem ; 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 : 1 px solid var ( - - wd - rand ) ;
border-bottom-left-radius : 5 px ;
}
. vw-blase--wir {
align-self : flex-end ;
background : rgba ( 127 , 208 , 232 , .12 ) ; border : 1 px solid var ( - - wd - rand - stark ) ;
border-bottom-right-radius : 5 px ;
}
/* 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 : 1 px dashed rgba ( 232 , 200 , 119 , .45 ) ;
border-bottom-right-radius : 5 px ;
}
. vw-blase-kopf { font-size : .7 rem ; color : var ( - - wd - text - soft ) ; margin-bottom : .25 rem ; }
. 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 : .8 rem ; padding : .8 rem ; border-radius : 14 px ; border : 1 px 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 : .45 rem ; font-size : .85 rem ; color : var ( - - wd - text - soft ) ; cursor : pointer ; }
. vw-pf-schalter input { width : 18 px ; height : 18 px ; accent-color : var ( - - wd - gold ) ; }
< / style >
< / head >
< body class = "wd" >
@@ -437,7 +636,10 @@
< 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 >
@@ -512,6 +714,33 @@
< 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 >
@@ -524,7 +753,7 @@
< div class = "vw-detail-blatt" id = "vw-detail-blatt" > < / div >
< / div >
< script src = "/assets/js/wd-core.js?v=20260822wd3 " > < / script >
< script src = "/assets/js/wd-core.js?v=20260822wd4 " > < / script >
< script >
/* =====================================================================
Verwaltung: Anfragen ansehen, Status ändern, Notizen, Archivieren.
@@ -614,6 +843,10 @@
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." ;
@@ -864,6 +1097,10 @@
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 ) ;
@@ -1175,17 +1412,469 @@
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 = istAnfragen ? "Projektanfragen" : "Kunden" ;
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 ;
document . getElementById ( "vw-kunden-bereich" ) . hidden = istAnfragen ;
if ( ! istAnfragen ) ladeKunden ( ) ;
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 ;
/* 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 ) + "/aufgaben" ) ;
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 ) +
( 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>"
: "" ) ;
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" ) ; }
} ) ;
} ) ;
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>' ;
@@ -1482,7 +2171,7 @@
async function start ( ) {
token = tokenLesen ( ) ;
if ( token ) { zeigeBereich ( ) ; ladeAnfragen ( ) ; return ; }
if ( token ) { zeigeBereich ( ) ; ladeAnfragen ( ) ; zaehlerHolen ( ) ; return ; }
var ausweis = await ausweisHolen ( ) ;
if ( ausweis ) {
@@ -1492,6 +2181,10 @@
( 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 ( ) ;