diff --git a/cloudflare-worker/migrations/0016_webdesign_automatik.sql b/cloudflare-worker/migrations/0016_webdesign_automatik.sql new file mode 100644 index 00000000..5fc6c029 --- /dev/null +++ b/cloudflare-worker/migrations/0016_webdesign_automatik.sql @@ -0,0 +1,80 @@ +-- ===================================================================== +-- Die Uhr läuft erst, wenn die Anzahlung da ist +-- +-- WARUM DAS EIN EIGENER ZUSTAND IST +-- +-- Bisher begann der Liefertermin mit der Annahme. Das ist unfair in +-- beide Richtungen: Wer zehn Tage bis zur Zahlung braucht, verbraucht +-- zehn Tage der zugesagten Zeit, ohne dass ein Handschlag Arbeit +-- passiert wäre — und ich stehe am Ende als der da, der seinen Termin +-- reisst. +-- +-- Deshalb hat ein Projekt jetzt drei Abschnitte statt zwei: +-- 1. angenommen, wartet auf Anzahlung → Uhr steht +-- 2. Anzahlung da → Uhr läuft, Termin ab HEUTE +-- 3. übergeben oder abgebrochen → Uhr steht wieder +-- +-- `termin_am` wird beim Zahlungseingang NEU berechnet. Es wäre bequemer, +-- den bei der Annahme errechneten Termin stehenzulassen — aber dann wäre +-- die Zusage falsch, sobald jemand eine Woche mit der Zahlung wartet. +-- Der bei der Annahme genannte Termin bleibt als `termin_geplant_am` +-- erhalten, damit man sieht, was ursprünglich in Aussicht stand. +-- ===================================================================== + +ALTER TABLE wd_projekte ADD COLUMN uhr_start_am TEXT; +ALTER TABLE wd_projekte ADD COLUMN termin_geplant_am TEXT; + +-- --------------------------------------------------------------------- +-- Abbruch +-- +-- Ein angenommener Auftrag muss abbrechbar bleiben: Der Kunde zahlt +-- nicht, meldet sich nicht mehr, oder springt selbst ab. Ohne diesen +-- Weg bliebe das Projekt für immer offen in der Liste stehen und +-- verfälschte jede Zahl im Cockpit. +-- +-- Der Erstattungsbetrag wird festgehalten, nicht nur die Entscheidung: +-- Bei einem Streit zählt, WAS erstattet wurde und WARUM — und der +-- Fortschritt zum Zeitpunkt des Abbruchs ist die Begründung dafür. +-- --------------------------------------------------------------------- +ALTER TABLE wd_projekte ADD COLUMN abbruch_am TEXT; +ALTER TABLE wd_projekte ADD COLUMN abbruch_grund TEXT; +ALTER TABLE wd_projekte ADD COLUMN abbruch_wer TEXT; -- kunde | dogfather +ALTER TABLE wd_projekte ADD COLUMN abbruch_erstattung_cent INTEGER; +ALTER TABLE wd_projekte ADD COLUMN abbruch_fortschritt INTEGER; -- Prozent bei Abbruch + +-- ===================================================================== +-- Benachrichtigungen für die Verwaltung +-- +-- WARUM EINE EIGENE TABELLE UND NICHT DER VERLAUF +-- +-- `wd_verlauf` gibt es schon, aber er hat einen anderen Zweck: Er hält +-- fest, WAS geschehen ist — vollständig, für später, zum Nachschlagen. +-- Eine Benachrichtigung ist etwas anderes: Sie ist ein Anstupsen, das +-- gelesen und dann weggelegt wird. +-- +-- Beides in eine Tabelle zu werfen hiesse: Entweder man markiert jeden +-- Verlaufseintrag als gelesen (dann ist der Verlauf voller Rauschen), +-- oder man kann Benachrichtigungen nicht wegklicken (dann sammeln sie +-- sich, bis niemand mehr hinsieht). Getrennt kann jedes für sich das +-- Richtige tun. +-- ===================================================================== +CREATE TABLE IF NOT EXISTS wd_meldungen ( + id TEXT PRIMARY KEY, + art TEXT NOT NULL, -- anzahlung_da | restzahlung_da | zusage | widerruf | zahlung_faellig + titel TEXT NOT NULL, + text TEXT, + -- Wohin man springt, wenn man draufdrückt. Eine Benachrichtigung, die + -- nur meldet und nicht hinführt, erzeugt Arbeit statt sie abzunehmen. + ziel TEXT, -- projekte | zahlungen | anfragen | postfach + ziel_id TEXT, + dringend INTEGER NOT NULL DEFAULT 0, + gelesen INTEGER NOT NULL DEFAULT 0, + gelesen_am TEXT, + erstellt_am TEXT NOT NULL +); + +-- Ungelesene zuerst, neueste oben: genau die Reihenfolge, in der sie +-- gelesen werden. Ohne Index wird das bei jedem Laden der Übersicht neu +-- sortiert. +CREATE INDEX IF NOT EXISTS idx_wd_meldungen_offen + ON wd_meldungen (gelesen, erstellt_am DESC); diff --git a/server-internal/db.js b/server-internal/db.js index 2cece8eb..f859988c 100644 --- a/server-internal/db.js +++ b/server-internal/db.js @@ -57,6 +57,9 @@ const MIGRATION_FILES = [ // Annehmen und Ablehnen einer Anfrage: echter Liefertermin (termin_am) // fuer den Countdown, Zeitpunkt der Annahme, Ablehnungsgrund. "0015_webdesign_annahme.sql", + // Die Uhr laeuft erst bei Anzahlung; Abbruch mit Erstattung; + // Benachrichtigungen fuer die Verwaltung. + "0016_webdesign_automatik.sql", ]; function alreadyApplied(name) { diff --git a/server-internal/index.js b/server-internal/index.js index 1b0133a7..e1801786 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -124,10 +124,11 @@ import { import { zahlungAnsehen, zahlungStarten, zahlungAbschliessen, paypalWebhook, zahlungAnlegen, zahlungenListe, - einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht, + einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht, meldungGelesen, } from "./routes/webdesign-zahlungen.js"; import { annahmeVorschau, anfrageAnnehmen, absageVorschau, anfrageAblehnen, + abbruchVorschau, projektAbbrechen, } from "./routes/webdesign-annahme.js"; import { suche } from "./routes/webdesign-suche.js"; /* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen, @@ -363,6 +364,9 @@ app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe)); app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen)); app.get("/webdesign/admin/uebersicht", wrap(uebersicht)); app.get("/webdesign/admin/suche", wrap(suche)); +app.post("/webdesign/admin/meldungen/:id/gelesen", wrap(meldungGelesen)); +app.get("/webdesign/admin/projekte/:id/abbruch-vorschau", wrap(abbruchVorschau)); +app.post("/webdesign/admin/projekte/:id/abbrechen", wrap(projektAbbrechen)); app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen)); app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben)); app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen)); diff --git a/server-internal/lib/webdesign-automatik.js b/server-internal/lib/webdesign-automatik.js new file mode 100644 index 00000000..59c4e6a9 --- /dev/null +++ b/server-internal/lib/webdesign-automatik.js @@ -0,0 +1,237 @@ +/* ===================================================================== + webdesign-automatik.js — was von selbst passieren soll + + Der Leitgedanke: Ich soll nichts von Hand nachtragen müssen, was die + Seite selbst wissen kann. Eine Zahlung geht ein — dann muss niemand + daran denken, die Uhr zu starten, den Status umzustellen und sich + eine Notiz zu machen. Das passiert, weil es passiert ist. + + ZWEI REGELN, DIE DAS ERTRÄGLICH MACHEN + + 1. AUTOMATIK NUR IN EINE RICHTUNG. + Sie setzt Dinge in Gang, nimmt aber nie eine Entscheidung zurück, + die ein Mensch getroffen hat. Wer den Status von Hand auf + "pausiert" stellt, will das so — dann darf eine eingehende Zahlung + ihn nicht kommentarlos wieder auf "Design" schieben. + + 2. JEDER SCHRITT HINTERLÄSST EINE SPUR. + Alles, was hier von selbst geschieht, steht danach im Verlauf UND + als Benachrichtigung. Eine Automatik, die stillschweigend arbeitet, + ist keine Hilfe, sondern ein Rätsel: Man sieht ein verändertes + Projekt und weiss nicht, wer es angefasst hat. + ===================================================================== */ +import { db } from "../db.js"; +import { jetzt, neueId, verlauf, centZuText } from "./webdesign-helfer.js"; +import { terminVorschlag, laufzeitFuer, hatLiefertermin } from "./webdesign-termine.js"; + +/* --------------------------------------------------------------------- + BENACHRICHTIGUNGEN + --------------------------------------------------------------------- */ +export function meldungAnlegen({ art, titel, text, ziel, zielId, dringend }) { + const id = neueId(); + db.prepare( + `INSERT INTO wd_meldungen (id, art, titel, text, ziel, ziel_id, dringend, gelesen, erstellt_am) + VALUES (?,?,?,?,?,?,?,0,?)` + ).run(id, art, titel, text || null, ziel || null, zielId || null, dringend ? 1 : 0, jetzt()); + return id; +} + +export function meldungenLesen(nurOffene = true, grenze = 20) { + return db.prepare( + `SELECT id, art, titel, text, ziel, ziel_id, dringend, gelesen, erstellt_am + FROM wd_meldungen ${nurOffene ? "WHERE gelesen = 0" : ""} + ORDER BY gelesen ASC, erstellt_am DESC LIMIT ?` + ).all(grenze); +} + +export function meldungenOffen() { + return db.prepare(`SELECT COUNT(*) AS n FROM wd_meldungen WHERE gelesen = 0`).get().n; +} + +/* --------------------------------------------------------------------- + DIE UHR STARTEN + + Wird aufgerufen, wenn eine Zahlung als bezahlt vermerkt wird — egal + ob PayPal das gemeldet hat oder ich es von Hand eingetragen habe. + Beide Wege müssen dasselbe auslösen, sonst hinge das Verhalten davon + ab, WIE das Geld ankam. + + Rückgabe: was tatsächlich geschehen ist, damit der Aufrufer es + weitermelden kann. Ein stilles `void` würde bedeuten, dass die + Oberfläche raten muss, ob etwas passiert ist. + --------------------------------------------------------------------- */ +export function zahlungVerbucht(zahlungId, wer) { + const z = db.prepare( + `SELECT id, nummer, art, projekt_id, kunde_id, betrag_cent, waehrung + FROM wd_zahlungen WHERE id = ?` + ).get(zahlungId); + if (!z) return { getan: [] }; + + const getan = []; + const kunde = z.kunde_id + ? db.prepare(`SELECT name FROM wd_kunden WHERE id = ?`).get(z.kunde_id) + : null; + const wieViel = centZuText(z.betrag_cent, z.waehrung); + + /* Ohne Projekt gibt es keine Uhr — etwa bei einer laufenden Betreuung. + Gemeldet wird trotzdem: Geld, das eingeht, will man sehen. */ + if (!z.projekt_id) { + meldungAnlegen({ + art: "zahlung_da", + titel: wieViel + " eingegangen", + text: (kunde ? kunde.name + " · " : "") + (z.nummer || ""), + ziel: "zahlungen", zielId: z.id, + }); + return { getan: ["gemeldet"] }; + } + + const p = db.prepare( + `SELECT id, nummer, titel, paket, status, uhr_start_am, termin_am, termin_geplant_am + FROM wd_projekte WHERE id = ?` + ).get(z.projekt_id); + if (!p) return { getan: [] }; + + /* ---- Ist das die Anzahlung, und steht die Uhr noch? ---- */ + const istAnzahlung = z.art === "anzahlung"; + const uhrStehtNoch = !p.uhr_start_am; + /* Ein abgebrochenes oder uebergebenes Projekt startet nicht mehr -- + eine verspaetet verbuchte Zahlung darf kein totes Projekt + wiederbeleben. */ + const laeuftNoch = p.status !== "abgebrochen" && p.status !== "uebergeben"; + + if (istAnzahlung && uhrStehtNoch && laeuftNoch) { + const start = jetzt(); + /* Der Termin wird ab HEUTE neu gerechnet, nicht ab der Annahme. + + Sonst verbraucht ein Kunde, der zehn Tage bis zur Zahlung + braucht, zehn Tage der zugesagten Zeit -- und ich stehe am Ende + als der da, der seinen Termin reisst. Der urspruenglich genannte + Termin bleibt als termin_geplant_am erhalten, damit man den + Unterschied sieht. */ + const neuerTermin = hatLiefertermin(p.paket) + ? terminVorschlag(p.paket, new Date()).termin + : null; + + db.prepare( + `UPDATE wd_projekte + SET uhr_start_am = ?, start_am = ?, + termin_geplant_am = COALESCE(termin_geplant_am, termin_am), + termin_am = COALESCE(?, termin_am), + anzahlung_bezahlt = 1, + status = CASE WHEN status = 'briefing' THEN 'design' ELSE status END, + wartet_auf = 'dogfather', + naechster_schritt = CASE + WHEN naechster_schritt IS NULL OR naechster_schritt = '' + OR naechster_schritt LIKE 'Unterlagen%' + THEN 'Es geht los — erste Entwürfe' + ELSE naechster_schritt END, + aktualisiert_am = ? + WHERE id = ?` + ).run(start, start, neuerTermin, jetzt(), p.id); + + getan.push("uhr_gestartet"); + verlauf("projekt", p.id, "uhr_gestartet", wer || "system", + `Anzahlung ${wieViel} verbucht` + (neuerTermin ? ` · Liefertermin ${neuerTermin}` : "")); + + meldungAnlegen({ + art: "anzahlung_da", + titel: "Anzahlung da — " + p.nummer + " läuft", + text: (kunde ? kunde.name + ": " : "") + wieViel + " eingegangen." + + (neuerTermin ? " Liefertermin " + neuerTermin + "." : "") + + (p.termin_am && neuerTermin && p.termin_am !== neuerTermin + ? " (bei der Annahme war " + p.termin_am + " vorgesehen)" : ""), + ziel: "projekte", zielId: p.id, + dringend: 1, + }); + return { getan, projektId: p.id, terminNeu: neuerTermin }; + } + + /* ---- Restzahlung oder eine weitere Zahlung ---- */ + if (z.art === "rest") { + db.prepare(`UPDATE wd_projekte SET rest_bezahlt = 1, aktualisiert_am = ? WHERE id = ?`) + .run(jetzt(), p.id); + getan.push("rest_vermerkt"); + } + meldungAnlegen({ + art: "zahlung_da", + titel: wieViel + " eingegangen — " + p.nummer, + text: (kunde ? kunde.name + " · " : "") + (z.nummer || ""), + ziel: "projekte", zielId: p.id, + }); + getan.push("gemeldet"); + return { getan, projektId: p.id }; +} + +/* --------------------------------------------------------------------- + ERSTATTUNGSVORSCHLAG BEIM ABBRUCH + + Rechnet vor, statt zu entscheiden. Der Vorschlag ergibt sich aus dem + Anteil erledigter Arbeitsschritte: Wer bei 40 % abbricht, hat 40 % + der Leistung bekommen. + + Bewusst ein VORSCHLAG und keine Regel. Ob im Einzelfall mehr oder + weniger angemessen ist, hängt an Dingen, die keine Tabelle kennt -- + wer abgebrochen hat, ob überhaupt mitgewirkt wurde, wie lange + gewartet wurde. Die Zahl nimmt einem das Rechnen ab, nicht das + Urteilen. + + Der Fortschritt zählt nur Aufgaben, die nicht "entfaellt" sind: + Gestrichene Punkte sind keine erbrachte Leistung, würden die Quote + aber schönen. + --------------------------------------------------------------------- */ +export function erstattungsVorschlag(projektId) { + const p = db.prepare( + `SELECT id, preis_cent, anzahlung_cent, waehrung FROM wd_projekte WHERE id = ?` + ).get(projektId); + if (!p) return null; + + const a = db.prepare( + `SELECT + COUNT(*) AS gesamt, + SUM(CASE WHEN status = 'erledigt' THEN 1 ELSE 0 END) AS fertig + FROM wd_aufgaben + WHERE projekt_id = ? AND status <> 'entfaellt' AND nicht_enthalten = 0` + ).get(projektId); + + const gesamt = a.gesamt || 0; + const fertig = a.fertig || 0; + const prozent = gesamt ? Math.round((fertig / gesamt) * 100) : 0; + + /* Bezahlt ist, was tatsächlich verbucht wurde -- nicht, was + vorgesehen war. Ein Projekt kann angenommen sein, ohne dass je + Geld floss. */ + const bezahlt = db.prepare( + `SELECT COALESCE(SUM(betrag_cent),0) AS n FROM wd_zahlungen + WHERE projekt_id = ? AND status = 'bezahlt'` + ).get(projektId).n; + + /* Wert der erbrachten Leistung, gedeckelt auf das, was gezahlt wurde: + Mehr als vorhanden kann man nicht behalten. */ + const verdient = Math.min(bezahlt, Math.round((p.preis_cent || 0) * prozent / 100)); + const erstattung = Math.max(0, bezahlt - verdient); + + return { + prozent, fertig, gesamt, + bezahltCent: bezahlt, bezahltText: centZuText(bezahlt, p.waehrung), + behaltenCent: verdient, behaltenText: centZuText(verdient, p.waehrung), + erstattungCent: erstattung, erstattungText: centZuText(erstattung, p.waehrung), + }; +} + +/* Wie viel Zeit ist seit der Annahme vergangen, ohne dass gezahlt wurde? + + Gebraucht für die Erinnerung: Ein Projekt, das seit zwei Wochen auf + eine Anzahlung wartet, ist meistens keins mehr -- man will das sehen, + bevor man es vergisst. */ +export function wartetAufAnzahlung(tageGrenze = 7) { + return db.prepare( + `SELECT p.id, p.nummer, p.titel, p.angenommen_am, k.name AS kunde_name + FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id + WHERE p.archiviert = 0 + AND p.uhr_start_am IS NULL + AND p.status NOT IN ('abgebrochen','uebergeben') + AND p.angenommen_am IS NOT NULL + AND julianday('now') - julianday(p.angenommen_am) >= ? + ORDER BY p.angenommen_am ASC` + ).all(tageGrenze); +} diff --git a/server-internal/routes/webdesign-annahme.js b/server-internal/routes/webdesign-annahme.js index 002b181b..f8797351 100644 --- a/server-internal/routes/webdesign-annahme.js +++ b/server-internal/routes/webdesign-annahme.js @@ -40,6 +40,7 @@ import { terminVorschlag, werktageBis, laufzeitFuer, hatLiefertermin } from "../lib/webdesign-termine.js"; import { vorlageAnwenden } from "./webdesign-aufgaben.js"; import { einladungAnlegen, einladungsLink } from "./webdesign-kunden.js"; +import { erstattungsVorschlag, meldungAnlegen } from "../lib/webdesign-automatik.js"; async function darf(req, recht) { return darfWebdesign(req, recht, async () => { @@ -484,3 +485,141 @@ export async function anfrageAblehnen(req, res) { : null, }); } + +/* ===================================================================== + AUFTRAG ABBRECHEN + + Ein angenommener Auftrag muss abbrechbar bleiben. Der Kunde zahlt + nicht, meldet sich nicht mehr, springt ab — ohne diesen Weg bliebe + das Projekt für immer offen in der Liste und verfälschte jede Zahl im + Cockpit. + + ZWEI SCHRITTE, BEWUSST GETRENNT + + `abbruchVorschau` rechnet vor, `projektAbbrechen` führt aus. Ein + Abbruch ist endgültig und berührt Geld — man soll SEHEN, wie viel + Arbeit erledigt ist und welcher Betrag sich daraus ergibt, bevor man + entscheidet. Ein Dialog, der nur "wirklich abbrechen?" fragt, liefert + für diese Entscheidung keine Grundlage. + + Der Betrag ist ein VORSCHLAG. Ob im Einzelfall mehr oder weniger + angemessen ist, hängt an Dingen, die keine Tabelle kennt: wer + abgebrochen hat, ob überhaupt mitgewirkt wurde, wie lange gewartet + wurde. Die Zahl nimmt das Rechnen ab, nicht das Urteilen. + ===================================================================== */ +export const ABBRUCH_GRUENDE = [ + "keine_zahlung", "kunde_abgesprungen", "keine_mitwirkung", + "einvernehmlich", "mein_ruecktritt", "sonstiges", +]; + +export async function abbruchVorschau(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const projekt = db.prepare( + `SELECT p.*, k.name AS kunde_name FROM wd_projekte p + JOIN wd_kunden k ON k.id = p.kunde_id WHERE p.id = ?` + ).get(String(req.params.id || "")); + if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404); + + return json(res, { + ok: true, + projekt: { + id: projekt.id, nummer: projekt.nummer, titel: projekt.titel, + kunde: projekt.kunde_name, status: projekt.status, + uhrLaeuft: !!projekt.uhr_start_am, + schonAbgebrochen: !!projekt.abbruch_am, + }, + rechnung: erstattungsVorschlag(projekt.id), + }); +} + +export async function projektAbbrechen(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const projekt = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`) + .get(String(req.params.id || "")); + if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404); + + /* Zweimal abbrechen geht nicht. Wie beim Annehmen kommt dieselbe + Antwort zurück statt eines Fehlers -- das Ergebnis ist ja dasselbe. */ + if (projekt.abbruch_am) { + return json(res, { ok: true, bereitsAbgebrochen: true, + hinweis: "Dieses Projekt war bereits abgebrochen." }); + } + if (projekt.status === "uebergeben") { + return json(res, { ok: false, + error: "Dieses Projekt ist bereits übergeben. Ein abgeschlossener Auftrag lässt sich nicht mehr abbrechen." }, 409); + } + + const grund = ABBRUCH_GRUENDE.includes(text(req.body?.grund, 40)) + ? text(req.body?.grund, 40) : "sonstiges"; + const wer = req.body?.wer === "kunde" ? "kunde" : "dogfather"; + const notiz = text(req.body?.notiz, 1000) || null; + + const rechnung = erstattungsVorschlag(projekt.id); + /* Der Betrag kommt aus dem Formular, NICHT aus der Rechnung. + + Die Rechnung ist ein Vorschlag; entschieden hat der Mensch. Würde + hier stillschweigend der berechnete Wert genommen, wäre die + Eingabemöglichkeit im Dialog eine Attrappe -- und der Betrag im + Nachhinein nicht der, den man gesehen und bestätigt hat. */ + const gewuenscht = Number(req.body?.erstattungEuro); + const erstattungCent = Number.isFinite(gewuenscht) && gewuenscht >= 0 + ? Math.round(gewuenscht * 100) + : (rechnung ? rechnung.erstattungCent : 0); + + db.transaction(() => { + db.prepare( + `UPDATE wd_projekte + SET status = 'abgebrochen', abbruch_am = ?, abbruch_grund = ?, abbruch_wer = ?, + abbruch_erstattung_cent = ?, abbruch_fortschritt = ?, + /* Die Uhr steht ab jetzt. uhr_start_am bleibt stehen, damit + man später sieht, wie lange gelaufen wurde. */ + wartet_auf = 'niemand', + naechster_schritt = ?, + aktualisiert_am = ? + WHERE id = ?` + ).run(jetzt(), grund, wer, erstattungCent, + rechnung ? rechnung.prozent : null, + "Abgebrochen" + (notiz ? " — " + notiz.slice(0, 200) : ""), + jetzt(), projekt.id); + + /* Offene Zahlungen zurückziehen. Eine Rechnung, die zu einem + abgebrochenen Projekt weiter im Portal steht, ist eine + Zahlungsaufforderung ohne Gegenleistung -- und der Kunde würde + zurecht fragen, wofür. */ + db.prepare( + `UPDATE wd_zahlungen SET status = 'storniert', aktualisiert_am = ? + WHERE projekt_id = ? AND status IN ('offen','freigegeben')` + ).run(jetzt(), projekt.id); + })(); + + verlauf("projekt", projekt.id, "abgebrochen", werIst(p), + `${grund} · durch ${wer}` + + (erstattungCent ? ` · Erstattung ${(erstattungCent / 100).toFixed(2)} €` : " · keine Erstattung")); + + meldungAnlegen({ + art: "abgebrochen", + titel: projekt.nummer + " abgebrochen", + text: `Grund: ${grund}` + + (erstattungCent ? ` · ${(erstattungCent / 100).toFixed(2)} € zu erstatten` : " · keine Erstattung"), + ziel: "projekte", zielId: projekt.id, + dringend: erstattungCent > 0 ? 1 : 0, + }); + + return json(res, { + ok: true, + grund, wer, + erstattungCent, + fortschritt: rechnung ? rechnung.prozent : null, + /* Die Erstattung wird NICHT automatisch ausgelöst. Geld + zurückzuschicken ist eine Handlung mit Folgen, und PayPal- + Rückzahlungen sind nicht umkehrbar. Hier steht, was zu tun ist -- + getan wird es von Hand. */ + hinweis: erstattungCent > 0 + ? `Zu erstatten: ${(erstattungCent / 100).toFixed(2)} €. Die Rückzahlung löst die Seite bewusst nicht selbst aus — überweise sie über PayPal und vermerke sie danach.` + : "Es ist nichts zu erstatten.", + }); +} diff --git a/server-internal/routes/webdesign-zahlungen.js b/server-internal/routes/webdesign-zahlungen.js index a10eb48a..9ed21e22 100644 --- a/server-internal/routes/webdesign-zahlungen.js +++ b/server-internal/routes/webdesign-zahlungen.js @@ -52,6 +52,8 @@ import { brauchtKunde } from "./webdesign-portal.js"; import { zustimmungSpeichern } from "./webdesign-widerruf.js"; import * as paypal from "../lib/webdesign-paypal.js"; import * as geheim from "../lib/webdesign-geheimnisse.js"; +import { zahlungVerbucht, meldungenLesen, meldungenOffen, erstattungsVorschlag, wartetAufAnzahlung } + from "../lib/webdesign-automatik.js"; import { jetzt, neueId, naechsteNummer, text, verlauf, centZuText, besucherIp, spracheOderDe, @@ -286,16 +288,32 @@ function zahlungAlsBezahltVermerken(zahlungId, einzug, wer) { nun, nun, zahlungId ); - /* Anzahlung/Rest am Projekt mitführen, damit die Verwaltung den Stand - sieht, ohne die Zahlungen einzeln durchzugehen. */ - const z = db.prepare(`SELECT art, projekt_id FROM wd_zahlungen WHERE id = ?`).get(zahlungId); - if (z?.projekt_id && (z.art === "anzahlung" || z.art === "rest")) { - const feld = z.art === "anzahlung" ? "anzahlung_bezahlt" : "rest_bezahlt"; - db.prepare(`UPDATE wd_projekte SET ${feld} = 1, aktualisiert_am = ? WHERE id = ?`) - .run(nun, z.projekt_id); - } - verlauf("zahlung", zahlungId, "bezahlt", wer, { captureId: einzug.captureId || null }); + + /* AB HIER UEBERNIMMT DIE AUTOMATIK. + + Vorher wurde hier nur ein Haekchen am Projekt gesetzt. Das reichte, + solange der Liefertermin mit der Annahme begann -- jetzt haengt an + einer eingehenden Anzahlung deutlich mehr: Die Uhr startet, der + Termin wird ab heute NEU gerechnet, der Status wechselt, und ich + bekomme eine Benachrichtigung. + + Bewusst an DIESER Stelle und nicht im Webhook: Hier laufen beide + Wege zusammen -- PayPals Meldung und das Vermerken von Hand. Haenge + man es nur an den Webhook, verhielte sich die Seite verschieden, je + nachdem WIE das Geld ankam, und eine von Hand verbuchte Zahlung + starte die Uhr nie. */ + try { + zahlungVerbucht(zahlungId, wer); + } catch (fehler) { + /* Die Zahlung selbst ist zu diesem Zeitpunkt schon sicher verbucht. + Scheitert die Automatik danach, darf das NICHT die Buchung + umwerfen -- lieber ein Projekt, dessen Uhr man von Hand startet, + als eine Zahlung, die verlorengeht. Der Fehler wird aber + festgehalten, damit er nicht unbemerkt bleibt. */ + verlauf("zahlung", zahlungId, "automatik_fehlgeschlagen", "system", + String(fehler && fehler.message || fehler).slice(0, 300)); + } } /* --------------------------------------------------------------------- @@ -722,5 +740,35 @@ export async function uebersicht(req, res) { eingerichtet: paypal.istEingerichtet("einmal"), live: paypal.istLive(), }, + /* Benachrichtigungen. Sie stehen ganz oben im Cockpit, weil sie das + Einzige sind, was seit dem letzten Hinsehen NEU ist -- alles + andere sind Bestaende, die man auch morgen noch sieht. */ + meldungen: meldungenLesen(true, 8), + meldungenOffen: meldungenOffen(), + /* Projekte, die seit einer Woche auf ihre Anzahlung warten. Ohne + diese Liste vergisst man sie: Sie stehen in keiner Zahl, weil + ihre Uhr nie zu laufen begann. */ + wartetAufAnzahlung: wartetAufAnzahlung(7), }); } + +/* Eine Benachrichtigung wegklicken. + + Nur als gelesen markieren, nicht löschen: Der Verlauf soll + nachvollziehbar bleiben, und ein versehentliches Wegklicken darf + nichts endgültig verschwinden lassen. */ +export async function meldungGelesen(req, res) { + const p = await darf(req, "WD_ANFRAGEN_VIEW"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const id = String(req.params.id || ""); + if (id === "alle") { + const n = db.prepare( + `UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE gelesen = 0` + ).run(jetzt()).changes; + return json(res, { ok: true, anzahl: n }); + } + db.prepare(`UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE id = ?`) + .run(jetzt(), id); + return json(res, { ok: true, anzahl: 1 }); +} diff --git a/server-internal/test-automatik.mjs b/server-internal/test-automatik.mjs new file mode 100644 index 00000000..9c2b3e4c --- /dev/null +++ b/server-internal/test-automatik.mjs @@ -0,0 +1,226 @@ +/* ===================================================================== + test-automatik.mjs — die Uhr, die Meldungen und der Abbruch + + Der Kern: Die zugesagte Zeit darf NICHT bei der Annahme zu laufen + beginnen, sondern erst, wenn die Anzahlung da ist. Sonst verbraucht + ein Kunde, der zehn Tage bis zur Zahlung braucht, zehn Tage der + zugesagten Zeit — und am Ende steht der Anbieter als der da, der + seinen Termin reisst. + + Das ist nicht mit den Augen prüfbar: Ein Termin, der zehn Tage zu + früh liegt, sieht aus wie ein richtiger. + ===================================================================== */ +import { mkdtempSync, rmSync, writeSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes, createHmac } from "node:crypto"; + +const L = (...a) => writeSync(1, a.join(" ") + "\n"); + +/* better-sqlite3 bricht beim Beenden hart ab (bekannter Fehler in seiner + Aufraeumphase) und verschluckt dabei jede Ausgabe -- auch eine + Fehlermeldung. Ein Absturz im Test saehe dann aus wie "der Test lief + gar nicht": Migrationen, dann Stille. Genau das ist hier passiert. + Deshalb werden Fehler ausdruecklich abgefangen und SOFORT geschrieben. */ +process.on("uncaughtException", (e) => { + writeSync(2, "\nABGEBROCHEN: " + ((e && e.stack) || e) + "\n"); + process.exit(1); +}); +process.on("unhandledRejection", (e) => { + writeSync(2, "\nABGELEHNT: " + ((e && e.stack) || e) + "\n"); + process.exit(1); +}); +const tmp = mkdtempSync(join(tmpdir(), "wd-auto-")); +process.env.DB_PATH = join(tmp, "t.db"); +process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64"); +process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex"); +process.env.WD_BASIS_URL = "https://dogfather-universe.com"; + +const { initDb, db } = await import("./db.js"); +initDb(); +const geheim = await import("./lib/webdesign-geheimnisse.js"); +await geheim.geheimnisseLaden(); +const { vorlagenSicherstellen } = await import("./lib/webdesign-vorlagen.js"); +vorlagenSicherstellen(); +const A = await import("./routes/webdesign-annahme.js"); +const Auto = await import("./lib/webdesign-automatik.js"); +const { werktageBis } = await import("./lib/webdesign-termine.js"); + +let ok = 0, fehler = 0; +const pruefe = (n, b, z) => b + ? (ok++, L(" ok " + n)) + : (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : ""))); + +function ausweis(g = 60000) { + const n = Buffer.from(JSON.stringify({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + g })) + .toString("base64url"); + return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url"); +} +async function ruf(fn, extra) { + let out = null; + await fn({ headers: { authorization: "Bearer " + ausweis() }, cookies: {}, + params: {}, body: {}, query: {}, ...extra }, + { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }); + return out; +} + +const nun = new Date().toISOString(); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) + VALUES ('a1','A-1','Anna Muster','anna@example.com','website','neu','de',?)`).run(nun); + +L("\n1. ANNEHMEN — die Uhr steht noch"); +const an = await ruf(A.anfrageAnnehmen, { params: { id: "a1" }, body: { preisEuro: 2000 } }); +pruefe("die Annahme gelingt", an && an.ok === true, JSON.stringify(an).slice(0, 120)); +let p = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("es gibt einen geplanten Liefertermin", !!p.termin_am, p.termin_am); +pruefe("aber die Uhr laeuft NOCH NICHT", p.uhr_start_am === null, String(p.uhr_start_am)); +pruefe("und der Status steht auf briefing", p.status === "briefing", p.status); +let terminBeiAnnahme = p.termin_am; // wird gleich auf den zurueckdatierten Wert gesetzt + +L("\n2. EINE OFFENE RECHNUNG AENDERT NICHTS"); +db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am) + VALUES ('z1','Z-1',?,?,'anzahlung','Anzahlung',60000,'offen',?)`).run(an.kundeId, an.projektId, nun); +p = db.prepare(`SELECT uhr_start_am FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("eine offene Rechnung startet die Uhr nicht", p.uhr_start_am === null); + +L("\n3. ZAHLUNGSEINGANG — jetzt laeuft sie, Termin ab HEUTE neu gerechnet"); +/* Die Annahme wird kuenstlich zehn Tage zurueckdatiert. Waere der Termin + an die Annahme gebunden, blieben jetzt zehn Tage weniger uebrig -- + genau der Fehler, um den es hier geht. */ +/* Beides zurueckdatieren: die Annahme UND den damals errechneten Termin. + + Die erste Fassung setzte nur angenommen_am zurueck. Der Termin war + aber schon "heute + 20 Werktage" -- und die Neuberechnung beim + Zahlungseingang kam auf denselben Tag heraus. Die Pruefung + "der Termin liegt jetzt spaeter" schlug fehl, obwohl der Code + richtig rechnete: Der Testaufbau bildete den Fall gar nicht ab, um + den es geht. */ +const vor10 = new Date(Date.now() - 10 * 864e5).toISOString(); +const { werktageAddieren } = await import("./lib/webdesign-termine.js"); +const terminVonDamals = werktageAddieren(vor10, 20); +db.prepare(`UPDATE wd_projekte SET angenommen_am = ?, termin_am = ? WHERE id = ?`) + .run(vor10, terminVonDamals, an.projektId); +terminBeiAnnahme = terminVonDamals; +db.prepare(`UPDATE wd_zahlungen SET status = 'bezahlt', bezahlt_am = ? WHERE id = 'z1'`).run(nun); + +const erg = Auto.zahlungVerbucht("z1", "dogfather"); +pruefe("die Automatik meldet den Uhrstart", + erg.getan.includes("uhr_gestartet"), JSON.stringify(erg)); + +p = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("die Uhr laeuft jetzt", !!p.uhr_start_am, String(p.uhr_start_am)); +pruefe("die Anzahlung ist am Projekt vermerkt", p.anzahlung_bezahlt === 1); +pruefe("der Status wechselte von briefing auf design", p.status === "design", p.status); +pruefe("jetzt bin ICH am Zug, nicht der Kunde", p.wartet_auf === "dogfather", p.wartet_auf); +pruefe("der urspruenglich geplante Termin bleibt erhalten", + p.termin_geplant_am === terminBeiAnnahme, p.termin_geplant_am + " vs. " + terminBeiAnnahme); +/* DER KERN dieser ganzen Aenderung. */ +pruefe("der Liefertermin ist ab HEUTE neu gerechnet: volle 20 Werktage", + werktageBis(p.termin_am, new Date()) === 20, + "noch " + werktageBis(p.termin_am, new Date()) + " Werktage (erwartet 20)"); +pruefe("er liegt damit spaeter als der bei der Annahme genannte", + p.termin_am > terminBeiAnnahme, p.termin_am + " > " + terminBeiAnnahme); + +L("\n4. BENACHRICHTIGUNG"); +const m = Auto.meldungenLesen(true, 10); +pruefe("es gibt eine ungelesene Meldung", m.length >= 1, m.length); +const anz = m.find((x) => x.art === "anzahlung_da"); +pruefe("sie meldet die Anzahlung", !!anz, JSON.stringify(m.map((x) => x.art))); +pruefe("sie nennt die Projektnummer", /P-/.test(anz.titel), anz.titel); +pruefe("sie fuehrt zum Projekt", anz.ziel === "projekte" && anz.ziel_id === an.projektId); +pruefe("sie ist als dringend gekennzeichnet", anz.dringend === 1); +/* Beide Termine im Text: Nur so sieht man, dass sich etwas verschoben + hat -- und muss nicht selbst nachrechnen, warum. */ +pruefe("sie nennt beide Termine, damit der Unterschied sichtbar ist", + anz.text.includes(p.termin_am) && anz.text.includes(terminBeiAnnahme), anz.text); + +L("\n5. EINE ZWEITE VERBUCHUNG STARTET NICHTS ERNEUT"); +const vorher = p.uhr_start_am; +Auto.zahlungVerbucht("z1", "dogfather"); +const p2 = db.prepare(`SELECT uhr_start_am, termin_am FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("die Uhr bleibt beim ersten Start", p2.uhr_start_am === vorher); +pruefe("und der Termin verschiebt sich nicht noch einmal", p2.termin_am === p.termin_am); + +L("\n6. ERSTATTUNGSVORSCHLAG AUS DEM FORTSCHRITT"); +const alle = db.prepare(`SELECT id FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge`).all(an.projektId); +const wieViele = Math.floor(alle.length / 4); +for (let i = 0; i < wieViele; i++) { + db.prepare(`UPDATE wd_aufgaben SET status = 'erledigt' WHERE id = ?`).run(alle[i].id); +} +const r = Auto.erstattungsVorschlag(an.projektId); +pruefe("der Fortschritt wird gezaehlt", r.fertig === wieViele, r.fertig + "/" + r.gesamt); +pruefe("bezahlt sind 600 EUR", r.bezahltCent === 60000, r.bezahltCent); +pruefe("behalten plus erstatten ergibt genau das Bezahlte", + r.behaltenCent + r.erstattungCent === r.bezahltCent, + r.behaltenCent + " + " + r.erstattungCent + " = " + r.bezahltCent); +pruefe("man kann nie mehr behalten als bezahlt wurde", r.behaltenCent <= r.bezahltCent); + +L("\n7. DIE ABBRUCH-VORSCHAU VERAENDERT NICHTS"); +const vs = await ruf(A.abbruchVorschau, { params: { id: an.projektId } }); +pruefe("die Vorschau antwortet", vs.ok === true); +pruefe("sie nennt denselben Fortschritt", vs.rechnung.prozent === r.prozent, vs.rechnung.prozent); +pruefe("das Projekt ist danach noch NICHT abgebrochen", + db.prepare(`SELECT status FROM wd_projekte WHERE id = ?`).get(an.projektId).status === "design"); + +L("\n8. ABBRECHEN"); +db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am) + VALUES ('z2','Z-2',?,?,'rest','Restzahlung',140000,'offen',?)`).run(an.kundeId, an.projektId, nun); +const ab = await ruf(A.projektAbbrechen, { + params: { id: an.projektId }, + body: { grund: "keine_zahlung", wer: "kunde", erstattungEuro: 100 }, +}); +pruefe("das Abbrechen gelingt", ab.ok === true, JSON.stringify(ab).slice(0, 140)); +const pa = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId); +pruefe("der Status steht auf abgebrochen", pa.status === "abgebrochen"); +pruefe("der Grund ist auswertbar hinterlegt", pa.abbruch_grund === "keine_zahlung"); +pruefe("wer abgebrochen hat, steht dabei", pa.abbruch_wer === "kunde"); +/* Der eingegebene Betrag muss gelten -- sonst waere das Eingabefeld im + Dialog eine Attrappe. */ +pruefe("der eingegebene Betrag gilt, nicht der vorgeschlagene", + pa.abbruch_erstattung_cent === 10000, pa.abbruch_erstattung_cent); +pruefe("der Fortschritt beim Abbruch ist festgehalten", + pa.abbruch_fortschritt === r.prozent, pa.abbruch_fortschritt); +pruefe("offene Rechnungen wurden storniert", + db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z2'`).get().status === "storniert", + db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z2'`).get().status); +pruefe("die bereits BEZAHLTE Zahlung bleibt unangetastet", + db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z1'`).get().status === "bezahlt"); +pruefe("der Hinweis sagt, dass die Rueckzahlung von Hand erfolgt", + /nicht selbst/.test(ab.hinweis || ""), ab.hinweis); + +L("\n9. NACH DEM ABBRUCH"); +const ab2 = await ruf(A.projektAbbrechen, { params: { id: an.projektId }, body: { grund: "sonstiges" } }); +pruefe("ein zweiter Abbruch meldet keinen Fehler", + ab2.ok === true && ab2.bereitsAbgebrochen === true, JSON.stringify(ab2).slice(0, 90)); +db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am) + VALUES ('z3','Z-3',?,?,'anzahlung','Nachzuegler',1000,'bezahlt',?)`).run(an.kundeId, an.projektId, nun); +Auto.zahlungVerbucht("z3", "system"); +pruefe("eine spaet verbuchte Zahlung belebt es NICHT wieder", + db.prepare(`SELECT status FROM wd_projekte WHERE id = ?`).get(an.projektId).status === "abgebrochen"); + +L("\n10. WER SEIT EINER WOCHE AUF SEINE ANZAHLUNG WARTET"); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) + VALUES ('a2','A-2','Bert Beispiel','bert@example.com','onepager','neu','de',?)`).run(nun); +const an2 = await ruf(A.anfrageAnnehmen, { params: { id: "a2" } }); +db.prepare(`UPDATE wd_projekte SET angenommen_am = ? WHERE id = ?`) + .run(new Date(Date.now() - 9 * 864e5).toISOString(), an2.projektId); +const wartet = Auto.wartetAufAnzahlung(7); +pruefe("das seit neun Tagen wartende Projekt erscheint", + wartet.some((x) => x.id === an2.projektId), JSON.stringify(wartet.map((x) => x.nummer))); +pruefe("das abgebrochene erscheint NICHT", + !wartet.some((x) => x.id === an.projektId)); + +L("\n11. MELDUNGEN WEGKLICKEN"); +const offenVorher = Auto.meldungenOffen(); +pruefe("es gibt ungelesene Meldungen", offenVorher > 0, offenVorher); +db.prepare(`UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE gelesen = 0`) + .run(new Date().toISOString()); +pruefe("danach sind es null", Auto.meldungenOffen() === 0); +/* Nur als gelesen markieren, nicht loeschen: Ein versehentliches + Wegklicken darf nichts endgueltig verschwinden lassen. */ +pruefe("aber geloescht wurde nichts", + db.prepare(`SELECT COUNT(*) AS n FROM wd_meldungen`).get().n >= offenVorher); + +L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`); +process.exitCode = fehler ? 1 : 0; +rmSync(tmp, { recursive: true, force: true }); diff --git a/webdesign/sw.js b/webdesign/sw.js index c22b85f4..80d9d208 100644 --- a/webdesign/sw.js +++ b/webdesign/sw.js @@ -34,7 +34,7 @@ Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten Stand fest. */ -const CACHE_NAME = "dogfather-webdesign-v26"; +const CACHE_NAME = "dogfather-webdesign-v27"; /* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE HTML-Datei in dieser Liste. */ diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html index 9258f928..a4109e8b 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -93,6 +93,36 @@ Sie waren doppelt so gross wie die Zahlen, um die es geht, und die Seite las sich wie ein Plakat statt wie ein Arbeitsplatz. Dieselbe Falle wie frueher bei `.wd a` und `.wd .wd-btn--haupt`. */ + /* ---------- Benachrichtigungen ---------- */ + .vw-meld { + display: flex; align-items: flex-start; gap: .7rem; + padding: .7rem .85rem; margin-bottom: .5rem; + border-radius: var(--wd-radius-m, 14px); + border: 1px solid rgb(var(--wd-blau-rgb) / .3); + background: linear-gradient(rgb(var(--wd-blau-rgb) / .07), rgb(var(--wd-blau-rgb) / .07)), + var(--wd-flaeche-2, #16191d); + } + /* Dringend heisst hier: Geld ist geflossen oder eine Frist laeuft. + Nur DIESE bekommen Farbe -- bekaeme jede Meldung eine, waere keine + mehr auffaellig. */ + .vw-meld--dringend { + border-color: rgb(var(--wd-gold-rgb) / .5); + background: linear-gradient(rgb(var(--wd-gold-rgb) / .08), rgb(var(--wd-gold-rgb) / .08)), + var(--wd-flaeche-2, #16191d); + } + .vw-meld-inhalt { flex: 1; min-width: 0; cursor: pointer; text-align: left; + background: none; border: 0; color: inherit; font: inherit; padding: 0; } + .wd .vw-meld-titel { font-weight: 700; font-size: .95rem; margin: 0; } + .wd .vw-meld-text { font-size: .84rem; color: var(--wd-text-soft); margin: .15rem 0 0; } + .vw-meld-weg { + flex-shrink: 0; min-width: 44px; min-height: 44px; + background: none; border: 0; color: var(--wd-text-soft); + cursor: pointer; font-size: 1.1rem; border-radius: 8px; + } + .vw-meld-weg:hover { background: rgb(255 255 255 / .07); color: var(--wd-text); } + .vw-meld-weg:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; } + .vw-meld-alle { margin: .1rem 0 1rem; } + .wd .vw-ub-h { font-size: 1.05rem; font-weight: 650; margin: 1.9rem 0 .75rem; @@ -1113,6 +1143,14 @@
Wird geladen…