Compare commits

..
50 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 fa1ce2c4cb Design: Serverpuls-Projekttabelle (Sortierkoepfe und Zeilen als Buttons, Deltas als Farbe)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:43:53 +00:00
ClaudeandClaude Sonnet 5.5 ab4a300e70 Design: Serverpuls-Kopf mit Text-Badge, Warnungen als Zeilen, Kacheln als Kennzahlen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:43:53 +00:00
ClaudeandClaude Sonnet 5.5 bca99d4790 Serverpuls: Richtung, Vorzeichen und Stufen-Ton als Format-Helfer mit Test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:43:53 +00:00
ClaudeandClaude Sonnet 5.5 6adeac1642 Design: Admin mit Tabelle, Mitarbeiter-Formular als Dialog, AdminPage.css neu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:42:34 +00:00
ClaudeandClaude Sonnet 5.5 0a881fce8f Design: Zugangsdaten-, sr-only- und spin-Klassen nach global.css
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:42:28 +00:00
ClaudeandClaude Sonnet 5.5 84186d1429 Design: Vertrags- und Versions-Formular als Dialoge, Fehler im Dialog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:41:40 +00:00
ClaudeandClaude Sonnet 5.5 a9fa9f58e6 Design: Anbieter-Vertraege als Tabelle, Dringlichkeit nur im Terminfeld
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:41:40 +00:00
ClaudeandClaude Sonnet 5.5 8f5013aafc Design: Rechtstexte als Abschnitte mit Haarlinien, Aufklappzeile als Button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:41:40 +00:00
ClaudeandClaude Sonnet 5.5 38beaead99 Admin: Pruefung des Mitarbeiter-Formulars als Lib mit Test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:41:26 +00:00
ClaudeandClaude Sonnet 5.5 313ead77f4 Rechtliches: Anzeige-Texte der Vertragsliste als Lib mit Test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:41:26 +00:00
ClaudeandClaude Sonnet 5.5 625efc7f18 Finanzen: Rechnung schreiben auf Dialog-Baustein (breit, Enter sendet nie, Esc schliesst Liste zuerst)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:38:00 +00:00
ClaudeandClaude Sonnet 5.5 3b26fcdf0b Finanzen: Buchung zuordnen auf Dialog-Baustein (Esc, Sperre, Vorschlaege als Zeilen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:38:00 +00:00
ClaudeandClaude Sonnet 5.5 d1a1046969 Finanzen: Einstellungen, Bankzugang, Fixkosten und Bankrueckkehr ohne Karten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:38:00 +00:00
ClaudeandClaude Sonnet 5.5 b5d27d5801 Finanzen: Rechnungen und Buchungen als Tabellen ohne Karten, Textaktionen, Blaetterleiste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:38:00 +00:00
ClaudeandClaude Sonnet 5.5 bd1eccb4e3 Finanzen: Vorschau, Steuer und Abos ohne Karten, Container-Query auf Wrapper
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:38:00 +00:00
ClaudeandClaude Sonnet 5.5 6d015df6fa Finanzen: Seitenkopf, Statuszeile, Kennzahlen und Aufgaben ohne Karten; Kpi entfernt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:38:00 +00:00
ClaudeandClaude Sonnet 5.5 e1932a2a48 Finanzen: Helfer fuer Vorzeichen-Rot und Meldungsklassen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:38:00 +00:00
ClaudeandClaude Sonnet 5.5 a2a6d83b15 Design P6: Aktionszelle der Gutschriften-Liste mit nowrap, Handy bricht Prüfen/Ansehen nicht mehr in Buchstaben
Die letzte Zelle (Prüfen/Ansehen) wurde bei Handybreite auf etwa 19 px gequetscht, weil
.table.table-wrap td overflow-wrap:anywhere hat. Wie im E-Mail-Speicher bekommt die
Aktionszelle nowrap (Aktion 58 px, Kunde 139/124/120 px bei 390/375/360 px, kein Überlauf).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 3e5839bee6 Design P6: Angebotsspalte mit Mindestbreite (col-offer), Statusspalte am Handy schmaler, Kundenspalte 7,5rem
Die Angebotstabelle brach bei 390 px gewoehnliche Woerter mitten im Wort
("Ansprechpartne-r", "Geschaeftsfuehru-ng"): die Spalte "Angebot" hatte keine
Mindestbreite und bekam nur etwa 103 px. Jetzt hat <th>Angebot</th> die Klasse
col-offer (min-width 8rem), col-status faellt bis 600 px auf 6rem, col-name
steigt auf 7,5rem ("Anlagentechnik" brach sonst in der Kundenspalte).
Bis 600 px summieren sich die Mindestbreiten auf 344 px in 358 px.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 5e5093b7f2 Design P6: Kundenspalte in Angebote und Gutschriften mit Mindestbreite (col-name)
Die Kopfzelle "Kunde" bekommt in OfferList und CreditList die Klasse col-name
(min-width 7rem, die Regel gab es schon fuer den E-Mail-Speicher). Ohne sie
gab die Tabelle der Kundenspalte am Handy fast nichts ab (td bricht ueberall),
und Namen brachen mitten im Wort ("Fahrschul/e Roth", 79 px bei 390 px).
Kommentar der Regel nennt jetzt alle drei Tabellen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 a505d38c96 Design P6: admin-areas.css geloescht, KOMPAT-Reste entfernt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 6eae3c4aae Design P6: E-Mail-Speicher als Tabelle, Erledigt als Textlink mit data-storage-done
Kundenspalte mit Mindestbreite (col-name) und Erledigt/Bestellt ohne Umbruch, damit am Handy und bei 1440 px keine Woerter mitten im Wort brechen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 568911967d Design P6: Angebots-Formular als Dialog (Esc, Sperre beim Speichern, Vorschlagsliste geprueft)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 2f6437ee42 Design P6: Angebote-Liste als Tabelle mit Reitern, Aktionen als Textlinks
Reiter statt Filterknoepfe, Statusspalte mit Badge, Stripe-Links, Mail-Zeile und Aktionen (E-Mail senden als Textlink, Zurueckziehen als danger), Spalten Angelegt und Gueltig bis nach Breite ausgeblendet. Mindestbreite der Statusspalte, damit Woerter nicht mitten im Wort brechen. Neues Angebot ohne Icon in der Werkzeugleiste, Hinweis als table-foot.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 b369a0edf9 Design P6: Gutschrift-Detail zweispaltig mit Abschnitten und kv-Listen, Verhalten unveraendert
Neuer Zustand busyAction (Speichert … / Sendet …) neben busy; Formularbloecke der Berechnung stehen in .form-stack (16 px) statt auf dem Altabstand .page .field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 c96aa870a9 Design P6: Gutschriften-Liste als Tabelle mit Reitern und Spaltenprioritaet
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 cb7ac6edf6 Design P6: Chat-Liste als Haarlinien-Zeilen, Zaehler und Zurueck-Pfeil als Text, side-count entfaellt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 ea528486aa Design P6: chat.css neu (Praefix .woms-chat, Umschaltung bei 860 px), Fehlerzeile ohne Icon
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 308bf545da Design P6: kleine Tabellen-, Dialog- und Autocomplete-Ergaenzungen
Block 'Kunden-Unterseiten (P6)' direkt vor KOMPAT: table-wrap bricht lange Woerter, aria-current-Zeile auf --bg-subtle, .cell-actions fuer Zeilenaktionen, Dialogfuss mit flex-wrap und umbrechenden Knoepfen, .autocomplete .input mit voller Breite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00
ClaudeandClaude Sonnet 5.5 b81e53eef5 Design P6: reine Helfer fuer Gutschrift-, Angebots- und Speicherzeilen mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:10 +00:00
ClaudeandClaude Sonnet 5.5 910fa0ff30 Projekte: Zugangsdaten-Block "Login wurde angelegt" mit Abstand zwischen den Textlinks
`.admin-credential-result span { display: block }` (0,1,1) schlug `.credential` (display: flex,
gap 8 px): "Anzeigen" und "Kopieren" standen ohne Abstand nebeneinander (gemessen 0 px bei 1440
und 390 px). Die Regel gilt jetzt nur noch fuer den Hinweistext im ersten Feld
(`.admin-credential-result > div > span`); die Textlinks stehen 8 px auseinander, der Wert
hat wieder die Textfarbe.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:43 +00:00
ClaudeandClaude Sonnet 5.5 f703c88c90 Projekte: Textlinks der Zugangswerte stehen in der Admin-Tabelle unter dem Wert
Die Tabelle "Mitarbeiterkonten" war bei 1440 px rund 80 px breiter als die Karte, der
Loeschen-Knopf lag hinter dem inneren Rollbalken. Kopieren/Anzeigen stehen jetzt unter
dem Wert (Spalte statt Zeile), nur in .admin-table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:43 +00:00
ClaudeandClaude Sonnet 5.5 816d4552fa Projekte: Services als untereinander stehende Abschnitte mit Zeilen, Preview-Zustand als Badge
Das Kachelraster entfaellt, jede Art ist ein Abschnitt mit Beschreibung, Funktions-Badges und Zeilen. Der Preview-Punkt wird zum Badge "Preview: <Zustand>", Ticket und Zuordnen sind Textlinks. Zwei CSS-Zeilen (.services-stack) im Block Projekte (P5).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:43 +00:00
ClaudeandClaude Sonnet 5.5 8b688f9df9 Projekte: Zuordnen-Assistent mit Abschnitten, Brotkrumen und im Text markierter Auswahl
Der Assistent bekommt Brotkrume und Untertitel im Seitenkopf, je Schritt einen Abschnitt, Auswahlzeilen als Knoepfe mit aria-pressed und dem Badge Ausgewaehlt, Felder mit Label-Verknuepfung und keine Icons oder Inline-Stile mehr. Logik, Zustaende und Texte bleiben unveraendert. Vier CSS-Zeilen im Block Projekte (P5) fuer Abstand der Schrittspalte und Kontrast der Kundennummer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:43 +00:00
ClaudeandClaude Sonnet 5.5 860079f51d Projekte: Liste als Tabelle mit Reitern, Neu- und Bearbeiten-Dialog, Laden nur beim ersten Mal
- Seite mit PageHeader, Reitern (Zaehler aus projectTabs), Filterzeile und Tabelle (Rollen table/row/cell/columnheader, Zeilenklick ueber isRowActivation)
- Neues Projekt und Projekt bearbeiten als Dialog mit Field/htmlFor; Speichern im Fuss ueber das Attribut form
- Ladetext "Wird geladen …" nur beim ersten Laden (loadedOnce), danach bleibt die Tabelle beim Nachladen stehen
- global.css: Ticket-Zelle bricht lange IDs um, .faint in archivierten Zeilen ohne Kontrastverlust (Block Projekte (P5))

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00
ClaudeandClaude Sonnet 5.5 f1aac2ada9 Projekte: Projektabschnitt am Ticket als Abschnitt, Art-Frage als Dialog
Der Abschnitt nutzt Card, Button und Dialog statt Altklassen und Icons. TicketProjects laedt nur noch bei einer anderen Ticket-ID neu (load haengt an ticket.$id, der neueste Ticketstand kommt ueber ein ref), damit "Suche neue Repos ..." bei Status-, Zustaendig- oder Dringend-Aenderung nicht erneut blinkt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00
ClaudeandClaude Sonnet 5.5 47cf82eb91 Projekte: Projektzeile am Ticket und beim Kunden ohne Kasten, Aktionen als Textlinks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00
ClaudeandClaude Sonnet 5.5 8807af3f42 Projekte: Website-Analyse-Zeile ohne Farbliterale, Zustimmung als Dialog (Wortlaut unveraendert)
Zeile mit Statusbadge und Textlinks, Zustimmungsablauf im Dialog mit Fuss (Abbrechen, Einschalten), Ausschalten-Rueckfrage bleibt inline. Drei Zeilen CSS fuer die Textvorlage (details) im Dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00
ClaudeandClaude Sonnet 5.5 154aea44a8 Projekte: Preview-Block ohne Kleinknoepfe, Statuspunkt als Badge, Log auf hellem Grund
Markup auf Bausteine (Button, Badge) und die Klassen pv-*, input, select, msg, link umgestellt; keine Inline-Stile, kein btn-sm, kein Farbliteral. Fehlertexte des Runners behalten ihren Zeilenumbruch ueber .pv-block .msg.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00
ClaudeandClaude Sonnet 5.5 92fef9d4a5 Projekte: Vorschau-, Gitea- und Zugangsknoepfe als Textlinks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00
ClaudeandClaude Sonnet 5.5 fa2a66a58f Projekte: CSS-Block fuer Tabelle, Dialog-Inhalte, Preview-Block und Zuordnen
Neuer Block Projekte (P5) direkt vor KOMPAT: Projekttabelle (unter 860 px zweispaltiges Raster), Aktionszeile, Dialog-Formular, Preview-Block, Projektzeile am Ticket, Zuordnen-Assistent. Die alten Regeln .ticket-projects und .ticket-projects-assign .form-control entfallen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00
ClaudeandClaude Sonnet 5.5 9da42d52c3 Projekte: Tonabbildung fuer Preview-, Analyse- und Projektstatus
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:20 +00:00
ClaudeandClaude Sonnet 5.5 5647412e34 Projekte: Reiter-Liste der sechs Ansichten als getestete Funktion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:20 +00:00
ClaudeandClaude Sonnet 5.5 d2d4ad9cfb Projekte: Typ-, Hosting- und Aktionslogik als getestete Lib-Funktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:20 +00:00
ClaudeandClaude Sonnet 5.5 bea91d9beb Reiterleiste scrollt den aktiven Reiter in den sichtbaren Bereich (Deep-Links am Handy)
Tabs fuehrt die Leiste nach dem Laden und bei jedem Reiterwechsel waagerecht nach, wenn der aktive Reiter nicht ganz sichtbar ist (zentriert, nur die
Leiste scrollt, nicht die Seite). Betrifft alle Seiten mit Tabs; bei 390 px sah man auf der Kundenseite mit ?tab=protokoll, abo oder gutschriften
keinen aktiven Reiter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:33:02 +00:00
ClaudeandClaude Sonnet 5.5 b24121e34f Abo-Status und Protokoll ohne Karten und Icons, neuer Grundstil fuer admin-areas.css
Protokoll als Tabelle (Zeit, Mitarbeiter, Aktion) statt Liste mit festen Mindestbreiten, damit bei 390 px nichts mitten im Wort bricht. Abo-Status als dl.kv, Fehler als .msg.err. admin-areas.css: Haarlinien statt Kaesten, Kopfzeilen ohne Grossbuchstaben, Links ohne eigene Regel, Responsive-Bloecke am Dateiende.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:33:02 +00:00
ClaudeandClaude Sonnet 5.5 8c34e932c1 Kunden-Reiter Funktionen als Tabelle mit Textaktionen, Bestaetigung als eigene Zeile
Alle Funktionen als eine Tabelle: Projektzeile, Funktionszeilen mit Status, Ticket (ticketPath) und Textaktionen, Bestaetigung, Einbauanleitung und Zustimmung in Textform als Zeilen darunter, Webshop-Anfrage als Block unter der Tabelle. Alle .funktionen-*-Regeln aus admin-areas.css in den Block Kunden (P4), Handy mit Zweispalten-Zeilen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:33:02 +00:00
ClaudeandClaude Sonnet 5.5 8ef47713b5 Kundenseite: Brotkrumen, Eyebrow, Uebersicht in zwei Spalten, Tickets-Reiter mit Links
Kopf mit Brotkrume, Eyebrow (Nummer, Stufe) und Abzeichen unter dem Namen, kein schwarzer Knopf. Uebersicht mit .kv-Zeilen in zwei Spalten, Rueckmeldungen als .msg, Tickets-Reiter als Tabelle mit echten Links (ticketPath) und Zeilenklick ueber isRowActivation. KundenansichtButton ohne Icon, Standard ist jetzt default. Block Kunden (P4) in global.css.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:31:42 +00:00
ClaudeandClaude Sonnet 5.5 7d4676121e Kundenliste als Tabelle mit Reitern und Zaehlern, Neuer Kunde und Neuer Lead als Dialoge
Zeilenklick nur ueber isRowActivation, Link.row-link in der ersten Zelle. CreateCustomerForm bleibt exportiert (Zuordnen-Assistent), dort entfaellt der Umschlag um den Dialog. KOMPAT-Regel .side-logo gestrichen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:31:42 +00:00
ClaudeandClaude Sonnet 5.5 4b6ab7505a Kunden: Filter, Zaehler und Abzeichen als reine Funktionen (lib/customerList.js, Tests)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:31:42 +00:00
90 changed files with 4418 additions and 3920 deletions

No files matched your search

+1 -2
View File
@@ -7,8 +7,7 @@ export default function AdminRoute({ children }) {
if (loading) {
return (
<div className="text-center p-4" style={{ marginTop: 80 }}>
<div className="spinner"></div>
<div className="loading">
<p>Wird geladen …</p>
</div>
)
+76 -69
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
import { analyseStatus, textformLabel, canSubmitTextform, channelLabel, legalLinks, chatTemplate, privacyHint } from '../lib/analyseView'
const COLORS = { green: 'var(--green-fg)', red: 'var(--red-fg)', grey: 'var(--text-2)' }
import { Badge, Button, Dialog, Field } from './ui'
import { analyseColorTone } from './projectTones'
// Kompakte Zeile "Website-Analyse" je Projekt (lädt beim Einbinden; mit key=Projekt-ID verwenden)
export default function AnalyseRow({ projectId }) {
@@ -54,80 +54,87 @@ export default function AnalyseRow({ projectId }) {
} catch { /* Kopieren nicht möglich: Text bleibt markierbar */ }
}
const status = analyseStatus(data)
const label = data ? textformLabel(data.avvVersion) : null
const links = data ? legalLinks(data.avvVersion) : null
const dateId = `analyse-datum-${projectId}`
const errorLine = error ? <p className="msg err" role="alert">{error}</p> : null
return (
<div style={{ marginTop: 10, fontSize: 13 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="text-grey">Website-Analyse:</span>
{loadError ? (
<span style={{ color: COLORS.red }}>{loadError}</span>
) : data ? (
<span style={{ color: COLORS[status.color] }}>{status.text}</span>
) : (
<span className="text-grey">Lädt …</span>
)}
{status.hint && <span className="text-grey">({status.hint})</span>}
{status.canEnable && !mode && (
<button type="button" className="btn btn-sm" onClick={() => setMode('on')}>{status.enableLabel || 'Einschalten'}</button>
)}
{status.canDisable && !mode && (
<button type="button" className="btn btn-sm" onClick={() => setMode('off')}>Ausschalten</button>
<>
<div className="pv-block">
<div className="pv-row">
<span className="muted">Website-Analyse:</span>
{loadError ? (
<span className="msg err" role="alert">{loadError}</span>
) : data ? (
<Badge tone={analyseColorTone(status.color)} className="wrap">{status.text}</Badge>
) : (
<span className="muted">Lädt …</span>
)}
{status.hint && <span className="muted">({status.hint})</span>}
{status.canEnable && mode !== 'off' && (
<button type="button" className="link" onClick={() => setMode('on')}>{status.enableLabel || 'Einschalten'}</button>
)}
{status.canDisable && !mode && (
<button type="button" className="link" onClick={() => setMode('off')}>Ausschalten</button>
)}
</div>
{mode === 'off' && (
<div className="pv-row">
<span>Website-Analyse wirklich ausschalten?</span>
<button type="button" className="link-danger" disabled={busy} onClick={() => apply(false)}>Ja, ausschalten</button>
<button type="button" className="link" disabled={busy} onClick={cancel}>Abbrechen</button>
</div>
)}
{mode !== 'on' && errorLine}
</div>
{mode === 'on' && data && (() => {
const label = textformLabel(data.avvVersion)
const links = legalLinks(data.avvVersion)
return (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'flex-start' }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} style={{ marginTop: 3 }} />
<span>
{label.before}
<a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
{label.between}
<a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
{label.after}
</span>
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<label>
Datum der Zustimmung:{' '}
<input type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
{mode === 'on' && data && (
<Dialog
title="Website-Analyse"
onClose={cancel}
busy={busy}
footer={
<>
<span className="grow" />
<Button type="button" disabled={busy} onClick={cancel}>Abbrechen</Button>
<Button type="button" variant="primary" disabled={!canSubmit || busy} onClick={() => apply(true)}>Einschalten</Button>
</>
}
>
{errorLine}
<label className="check top">
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span>
{label.before}
<a className="link" href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a>
{label.between}
<a className="link" href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a>
{label.after}
</span>
</label>
<Field label="Datum der Zustimmung:" htmlFor={dateId}>
<input id={dateId} className="input" type="date" required value={date} min={minDate} max={today} onChange={(e) => setDate(e.target.value)} />
</Field>
<div className="field" role="radiogroup" aria-label="Weg der Zustimmung">
<span className="strong">Weg:</span>
{['chat', 'email'].map((c) => (
<label key={c} className="check">
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
{channelLabel(c)}
</label>
<span role="radiogroup" aria-label="Weg der Zustimmung" style={{ display: 'inline-flex', gap: 10, alignItems: 'center' }}>
<span>Weg:</span>
{['chat', 'email'].map((c) => (
<label key={c} style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
<input type="radio" name={`analyse-weg-${projectId}`} value={c} checked={channel === c} onChange={() => setChannel(c)} />
{channelLabel(c)}
</label>
))}
</span>
</div>
{privacyBlocked && <div style={{ color: COLORS.red }}>{privacyHint(data.privacy)}</div>}
<details>
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{chatTemplate(data.avvVersion)}</p>
<button type="button" className="btn btn-sm" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</button>
</details>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-sm" disabled={!canSubmit || busy} onClick={() => apply(true)}>Einschalten</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
</div>
))}
</div>
)
})()}
{mode === 'off' && (
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span>Website-Analyse wirklich ausschalten?</span>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => apply(false)}>Ja, ausschalten</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
</div>
{privacyBlocked && <p className="msg err">{privacyHint(data.privacy)}</p>}
<details>
<summary>Textvorlage für den Chat</summary>
<p className="prose">{chatTemplate(data.avvVersion)}</p>
<Button type="button" onClick={copyTemplate}>{copied ? 'Kopiert' : 'Kopieren'}</Button>
</details>
</Dialog>
)}
{error && <div style={{ marginTop: 6, color: COLORS.red }}>{error}</div>}
</div>
</>
)
}
+35 -81
View File
@@ -1,37 +1,18 @@
import { useState } from 'react'
import { FaCodeBranch } from 'react-icons/fa'
import { FaLinkSlash } from 'react-icons/fa6'
import { Badge, Button } from './ui'
import PreviewBlock from './PreviewBlock'
import AnalyseRow from './AnalyseRow'
import GiteaLinkButton from './GiteaLinkButton'
import PreviewLinkButton from './PreviewLinkButton'
import { statusValueTone } from './projectTones'
import { suggestSubdomain } from '../lib/subdomain'
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
const STATUS_COLORS = {
ready: 'var(--green-fg)',
live: 'var(--green-fg)',
online: 'var(--green-fg)',
provisioning: 'var(--orange-fg)',
pending: 'var(--orange-fg)',
building: 'var(--blue-fg)',
error: 'var(--red-fg)',
failed: 'var(--red-fg)',
}
function StatusBadge({ label, value }) {
// Status und Provisioning: Wert als Badge, ohne Wert nichts
function StatusValue({ label, value }) {
if (!value) return null
const color = STATUS_COLORS[String(value).toLowerCase()] || 'var(--text-2)'
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', marginRight: 12 }}>
{label}:
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
{value}
</span>
</span>
)
return <span>{label}: <Badge tone={statusValueTone(value)}>{value}</Badge></span>
}
export default function AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} }) {
@@ -79,65 +60,47 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
}
return (
<div style={{ background: 'var(--bg-subtle)', border: '1px solid var(--line)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div style={{ minWidth: 0 }}>
<div className="proj-item">
<div className="proj-head">
<div className="proj-main">
<strong>{project.projectName}</strong>
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
{project.repoFullName && (
<div className="text-grey" style={{ fontSize: 12, marginTop: 4 }}>
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
</div>
)}
{project.subdomain && <>{' '}<span className="muted small">({project.subdomain})</span></>}
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<div className="row-actions">
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
<GiteaLinkButton href={project.giteaRepoUrl} />
{showActions && onUnassign && (
<button
type="button"
title="Vom Ticket lösen"
aria-label="Vom Ticket lösen"
onClick={() => onUnassign(project.$id)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 6, background: 'var(--red-bg)', border: '1px solid var(--line)', color: 'var(--red-fg)', cursor: 'pointer' }}
>
<FaLinkSlash size={16} />
</button>
<Button variant="danger" type="button" title="Vom Ticket lösen" aria-label="Vom Ticket lösen" onClick={() => onUnassign(project.$id)}>
Lösen
</Button>
)}
</div>
</div>
{/* Status- und Provisioning-Zeile */}
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
<StatusBadge label="Status" value={project.status} />
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
</div>
{(project.status || project.provisioningStatus) && (
<div className="proj-meta">
<StatusValue label="Status" value={project.status} />
<StatusValue label="Provisioning" value={project.provisioningStatus} />
</div>
)}
{project.subdomain && <AnalyseRow key={project.$id} projectId={project.$id} />}
{showActions && project.subdomain && (
<div style={{ marginTop: 10 }}>
<button
type="button"
className="btn btn-sm"
aria-expanded={showPreviewBlock}
onClick={() => setShowPreviewBlock((v) => !v)}
>
<>
<button type="button" className="link" aria-expanded={showPreviewBlock} onClick={() => setShowPreviewBlock((v) => !v)}>
Preview {showPreviewBlock ? '▴' : '▾'}
</button>
{showPreviewBlock && (
<div style={{ marginTop: 8 }}>
<PreviewBlock key={project.$id} projectId={project.$id} />
</div>
)}
</div>
{showPreviewBlock && <PreviewBlock key={project.$id} projectId={project.$id} />}
</>
)}
{showActions && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<>
<div className="row-actions">
{needsPreviewButton(project) && !showPreviewInput && (
<button type="button" className="btn btn-sm" onClick={openPreviewInput}>
<button type="button" className="link" onClick={openPreviewInput}>
Preview einschalten
</button>
)}
@@ -145,32 +108,23 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
<>
<input
type="text"
className="form-control"
style={{ maxWidth: 200 }}
className="input"
aria-label="Subdomain für die Preview"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
/>
<button
type="button"
className="btn btn-sm"
disabled={!subdomain || previewBusy}
onClick={handleEnablePreview}
>
<Button type="button" disabled={!subdomain || previewBusy} onClick={handleEnablePreview}>
Einschalten
</button>
</Button>
</>
)}
<button type="button" className="btn btn-sm" disabled={pushBusy} onClick={handleBackfill}>
<button type="button" className="link" disabled={pushBusy} onClick={handleBackfill}>
Pushes nachtragen
</button>
</div>
{(previewMessage || pushMessage) && (
<div className="text-grey" style={{ marginTop: 6, fontSize: 12 }}>
{previewMessage && <div>{previewMessage}</div>}
{pushMessage && <div>{pushMessage}</div>}
</div>
)}
</div>
{previewMessage && <p className="msg muted">{previewMessage}</p>}
{pushMessage && <p className="msg muted">{pushMessage}</p>}
</>
)}
</div>
)
+7 -8
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { FaCheck, FaCopy, FaEye, FaEyeSlash } from 'react-icons/fa6'
export async function copyText(text) {
try {
@@ -53,26 +52,26 @@ export default function CopyableCredential({ value, secret = false, label = 'Wer
{secret && (
<button
type="button"
className="credential-action"
className="link"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setVisible((current) => !current)
}}
title={visible ? `${label} ausblenden` : `${label} anzeigen`}
aria-label={visible ? `${label} ausblenden` : `${label} anzeigen`}
title={visible ? `${label} verbergen` : `${label} anzeigen`}
aria-label={visible ? `${label} verbergen` : `${label} anzeigen`}
>
{visible ? <FaEyeSlash /> : <FaEye />}
{visible ? 'Verbergen' : 'Anzeigen'}
</button>
)}
<button
type="button"
className="credential-action"
className="link"
onClick={handleCopy}
title={copied ? 'Kopiert' : `${label} kopieren`}
title={copied ? `${label} kopiert` : `${label} kopieren`}
aria-label={copied ? `${label} kopiert` : `${label} kopieren`}
>
{copied ? <FaCheck className="credential-check" /> : <FaCopy />}
{copied ? 'Kopiert' : 'Kopieren'}
</button>
</span>
)
+3 -24
View File
@@ -1,30 +1,9 @@
import { SiGitea } from 'react-icons/si'
export default function GiteaLinkButton({ href, title = 'In Gitea oeffnen', size = 16 }) {
export default function GiteaLinkButton({ href, label = 'In Gitea öffnen' }) {
if (!href) return null
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title={title}
aria-label={title}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 6,
background: 'var(--violet-bg)',
border: '1px solid var(--line)',
color: 'var(--violet-fg)',
textDecoration: 'none',
flexShrink: 0,
}}
>
<SiGitea size={size} />
<a className="link" href={href} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
+54 -66
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { modeOptionsFor, previewStatus, modeHint, isDevUnavailableError, NO_DEV_HINT } from '../lib/previewView'
import { isValidEnvName, buildEnvPayload, isDevPreview, dotColor, envValueTooLong, MAX_ENV_VARS } from '../lib/previewEnv'
import { isValidEnvName, buildEnvPayload, isDevPreview, envValueTooLong, MAX_ENV_VARS } from '../lib/previewEnv'
import {
fetchPreview,
setPreviewMode,
@@ -10,8 +10,9 @@ import {
stopPreviewDev,
fetchPreviewLog,
} from '../lib/previewApi'
import { Button, Badge } from './ui'
import { previewColorTone } from './projectTones'
const STATE_OF_COLOR = { green: 'online', yellow: 'building', red: 'failed', grey: 'off' }
const NOT_ACTIVE = 'Preview-Runner ist nicht aktiv'
const logDate = (at) => {
@@ -171,7 +172,7 @@ export default function PreviewBlock({ projectId }) {
}
}
if (loading) return <div className="text-grey" style={{ fontSize: 12 }}>Lädt …</div>
if (loading) return <p className="muted xs">Lädt …</p>
const view = data ? { ...data } : { state: 'off' }
const status = previewStatus(view)
@@ -182,18 +183,15 @@ export default function PreviewBlock({ projectId }) {
const dev = isDevPreview(data)
const logs = [...(data?.logs || [])].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 5)
const small = { fontSize: 12 }
return (
<div style={{ fontSize: 13 }}>
<div className="pv-block">
{data && (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<label className="text-grey" htmlFor={`pv-mode-${projectId}`} style={small}>Modus</label>
<div className="pv-row">
<label className="muted xs" htmlFor={`pv-mode-${projectId}`}>Modus</label>
<select
id={`pv-mode-${projectId}`}
className="form-control"
style={{ maxWidth: 200 }}
className="select pv-mode"
value={mode}
disabled={busy || inactive}
onChange={(e) => run(() => setPreviewMode(projectId, e.target.value))}
@@ -201,47 +199,45 @@ export default function PreviewBlock({ projectId }) {
{modeOptionsFor(data.devAvailable).map((o) => <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)}
</select>
</div>
{data.devAvailable === false && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{NO_DEV_HINT}</div>}
{hint && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{hint}</div>}
<div style={{ marginTop: 8, display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dotColor(STATE_OF_COLOR[status.color]), display: 'inline-block', flexShrink: 0, marginTop: 4 }} />
<span style={{ overflowWrap: 'anywhere' }}>{status.text}</span>
{data.devAvailable === false && <p className="muted xs">{NO_DEV_HINT}</p>}
{hint && <p className="muted xs">{hint}</p>}
<div className="pv-row">
<Badge tone={previewColorTone(status.color)} className="wrap">{status.text}</Badge>
</div>
{data.state === 'failed' && data.error && !isDevUnavailableError(data.error) && (
<div style={{ color: 'var(--red-fg)', ...small, marginTop: 4, overflowWrap: 'anywhere' }}>{data.error}</div>
<p className="msg err">{data.error}</p>
)}
</>
)}
{inactive && <div className="text-grey" style={{ ...small, marginTop: 8 }}>{NOT_ACTIVE}.</div>}
{error && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
{inactive && <p className="muted xs">{NOT_ACTIVE}.</p>}
{error && <p className="msg err" role="alert">{error}</p>}
{data && !inactive && (
<>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button type="button" className="btn btn-sm" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
<div className="pv-row">
<Button type="button" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
Neu bauen
</button>
<button type="button" className="btn btn-sm" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
</Button>
<Button type="button" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
Protokoll ansehen
</button>
</Button>
{dev && (
<>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
<Button type="button" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
Jetzt starten
</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
</Button>
<Button type="button" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
Stoppen
</button>
</Button>
</>
)}
</div>
{showLogs && (
<div style={{ marginTop: 8 }}>
<>
<select
className="form-control"
style={{ maxWidth: '100%' }}
className="select"
aria-label="Lauf wählen"
value={logFile}
disabled={busy}
@@ -250,47 +246,39 @@ export default function PreviewBlock({ projectId }) {
<option value="">Lauf wählen …</option>
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
</select>
{logFile && (
<pre style={{ marginTop: 6, maxHeight: 260, overflow: 'auto', background: 'var(--bg-subtle)', padding: 8, borderRadius: 6, fontFamily: 'monospace', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{logText || '…'}
</pre>
)}
</div>
{logFile && <pre className="pv-log">{logText || '…'}</pre>}
</>
)}
<div style={{ marginTop: 14 }}>
<strong style={small}>Variablen</strong>
<div style={{ marginTop: 6 }}>
{names.length === 0 && <div className="text-grey" style={small}>Keine Variablen.</div>}
{names.map((n) => (
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid var(--line)' }}>
<code style={{ flex: '1 1 140px', overflowWrap: 'anywhere' }}>{n}</code>
<span className="text-grey">•••</span>
{editName === n ? (
<>
<input className="form-control" style={{ flex: '1 1 120px', minWidth: 0 }} type="password" autoComplete="off" placeholder="Neuer Wert" value={editValue} onKeyDown={enter(handleChange)} onChange={(e) => setEditValue(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={handleChange}>Übernehmen</button>
</>
) : (
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
)}
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
</div>
))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginTop: 8 }}>
<input type="text" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="NAME" onKeyDown={enter(handleAdd)} value={newName} onChange={(e) => setNewName(e.target.value)} />
<input type="password" autoComplete="off" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleAdd}>Hinzufügen</button>
</div>
{envError && <div style={{ color: 'var(--red-fg)', ...small, marginTop: 4 }}>{envError}</div>}
{dirty && (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</button>
</div>
<h4 className="pv-title">Variablen</h4>
{names.length === 0 && <p className="muted xs">Keine Variablen.</p>}
{names.map((n) => (
<div key={n} className="pv-var">
<code>{n}</code>
<span className="muted">•••</span>
{editName === n ? (
<>
<input className="input" type="password" autoComplete="off" placeholder="Neuer Wert" value={editValue} onKeyDown={enter(handleChange)} onChange={(e) => setEditValue(e.target.value)} />
<button type="button" className="link" onClick={handleChange}>Übernehmen</button>
</>
) : (
<button type="button" className="link" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
)}
<div className="text-grey" style={{ ...small, marginTop: 6 }}>Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</div>
<button type="button" className="link" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
</div>
))}
<div className="pv-add">
<input type="text" className="input" placeholder="NAME" onKeyDown={enter(handleAdd)} value={newName} onChange={(e) => setNewName(e.target.value)} />
<input type="password" autoComplete="off" className="input" placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
<Button type="button" disabled={busy} onClick={handleAdd}>Hinzufügen</Button>
</div>
{envError && <p className="msg err">{envError}</p>}
{dirty && (
<div className="pv-row">
<Button type="button" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</Button>
</div>
)}
<p className="muted xs">Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</p>
</>
)}
</div>
+4 -25
View File
@@ -1,10 +1,9 @@
import { FaEye } from 'react-icons/fa6'
import { createPreviewLink } from '../lib/teamApi'
import { openInNewTab } from '../lib/openInNewTab'
// Mit subdomain holt der Knopf einen Einmal-Link vom Portal (Mitarbeiter sind dort nicht mehr angemeldet);
// Mit subdomain holt der Link einen Einmal-Link vom Portal (Mitarbeiter sind dort nicht mehr angemeldet);
// ohne subdomain bleibt er ein einfacher Link.
export default function PreviewLinkButton({ href, subdomain, title = 'Preview oeffnen', size = 16 }) {
export default function PreviewLinkButton({ href, subdomain, label = 'Vorschau öffnen' }) {
if (!href) return null
const open = (event) => {
@@ -16,28 +15,8 @@ export default function PreviewLinkButton({ href, subdomain, title = 'Preview oe
}
return (
<a
href={href}
onClick={open}
target="_blank"
rel="noopener noreferrer"
title={title}
aria-label={title}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 6,
background: 'var(--green-bg)',
border: '1px solid var(--line)',
color: 'var(--green-fg)',
textDecoration: 'none',
flexShrink: 0,
}}
>
<FaEye size={size} />
<a className="link" href={href} onClick={open} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
+43 -39
View File
@@ -1,5 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaLink } from 'react-icons/fa6'
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
@@ -7,6 +6,7 @@ import { slugify } from '../lib/subdomain'
import AssignedProjectCard from './AssignedProjectCard'
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
import { kindLabelFor } from '../lib/assignFlow'
import { Card, Button, Dialog } from './ui'
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
@@ -29,12 +29,18 @@ export default function TicketProjects({ ticket }) {
fetchServices().then((s) => setKinds(s.kinds || [])).catch(() => {})
}, [])
// Neu laden (samt "Suche neue Repos ...") nur bei einem anderen Ticket, nicht bei Status, Zustaendig oder Dringend:
// load haengt an der Ticket-ID, den jeweils neuesten Ticketstand (Kunde) liest es ueber ticketRef
const ticketRef = useRef(ticket)
ticketRef.current = ticket
const ticketId = ticket?.$id
const load = useCallback(async () => {
if (!ticket?.$id) return
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
if (!ticketId) return
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticketId)])
setAssigned(mine)
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
}, [ticket, fetchAllProjects, fetchByTicketId])
setAvailable(assignableProjects(all, ticketRef.current, new Set(mine.map((p) => p.$id))))
}, [ticketId, fetchAllProjects, fetchByTicketId])
useEffect(() => {
let cancelled = false
@@ -156,16 +162,15 @@ export default function TicketProjects({ ticket }) {
}
return (
<div className="ui-card pad ticket-projects">
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
{importing && <p className="muted" style={{ marginTop: 0 }}>Suche neue Repos …</p>}
{importHint && <div className="text-red" style={{ marginBottom: 8 }}>{importHint}</div>}
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<Card title={`Projekte (${assigned.length})`}>
{importing && <p className="muted">Suche neue Repos …</p>}
{importHint && <p className="msg err" role="alert">{importHint}</p>}
{error && <p className="msg err" role="alert">{error}</p>}
{assigned.length === 0
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
? <p className="muted">Noch kein Projekt an diesem Ticket.</p>
: assigned.map((p) => (
<div key={p.$id}>
<span className="muted" style={{ fontSize: 12 }} data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
<span className="muted xs" data-testid="project-kind">{kindLabelFor(kinds, p.kind)}</span>
<AssignedProjectCard
project={p}
showActions
@@ -177,7 +182,7 @@ export default function TicketProjects({ ticket }) {
))}
<div className="ticket-projects-assign">
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
<select className="select" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="">Projekt wählen …</option>
{available.map((p) => (
<option key={p.$id} value={p.$id}>
@@ -186,44 +191,43 @@ export default function TicketProjects({ ticket }) {
</option>
))}
</select>
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
<Button type="button" disabled={!selected || busy} onClick={assign}>Zuweisen</Button>
<Button variant="quiet" type="button" onClick={() => setShowCreate((s) => !s)}>Neues Projekt</Button>
</div>
{assignResult && (
<div style={{ marginTop: 8, fontSize: 13 }}>
<div>{assignResult.text}</div>
<p className="msg">
{assignResult.text}
{assignResult.warnings.map((w, i) => (
<div key={i} className="muted" style={{ marginLeft: 12 }}>{w}</div>
<Fragment key={i}>
<br />
<span className="muted">{w}</span>
</Fragment>
))}
</div>
</p>
)}
{showCreate && (
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value }))} required />
<button type="submit" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button>
<form className="ticket-projects-assign" onSubmit={create}>
<input className="input" aria-label="Anzeigename" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
<input className="input" aria-label="Subdomain" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value }))} required />
<Button type="submit" variant="primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</Button>
</form>
)}
{kindDialog && (
<div className="overlay">
<span className="overlay-close" onClick={() => setKindDialog(false)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Was ist das?</h2>
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
{kinds.length === 0 && (
<div className="text-red" style={{ marginBottom: 8 }}>Arten konnten nicht geladen werden – bitte neu laden.</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{kinds.map((k) => (
<button key={k.key} type="button" className="ui-btn" onClick={() => assignWithKind(k.key)}>{k.label}</button>
))}
</div>
<Dialog title="Was ist das?" onClose={() => setKindDialog(false)}>
<p className="muted">Bitte wähle die Art des Projekts, bevor es zugewiesen wird.</p>
{kinds.length === 0 && (
<p className="msg err">Arten konnten nicht geladen werden – bitte neu laden.</p>
)}
<div className="row-buttons">
{kinds.map((k) => (
<Button key={k.key} type="button" onClick={() => assignWithKind(k.key)}>{k.label}</Button>
))}
</div>
</div>
</Dialog>
)}
</div>
</Card>
)
}
+2 -3
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { FaRotateRight } from 'react-icons/fa6'
import { Button } from '../ui'
import { PORTAL_URL, chatUploadUrl, getChatMessages, markChatRead, sendChatMessage, teamAuthHeaders } from '../../lib/teamApi'
import { loadChatComposer } from '../../lib/loadChatComposer'
@@ -119,8 +118,8 @@ export default function ChatThread({ customerId, customerName }) {
<div className="woms-chat" ref={zoneRef}>
{error && (
<div className="flex items-center gap-2 wrap">
<span className="muted">{error}</span>
<Button variant="ghost" onClick={() => setReloadKey((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
<span className="msg err" role="alert">{error}</span>
<Button onClick={() => setReloadKey((k) => k + 1)}>Erneut versuchen</Button>
</div>
)}
<div className="chat-messages" ref={boxRef} aria-live="polite" />
+157 -139
View File
@@ -1,14 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { FaSpinner, FaArrowUpRightFromSquare } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import { Card, Badge, Button, Field } from '../ui'
import KundenansichtButton from '../customer/KundenansichtButton'
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
import { applyMessage, BILLING_MONTHS, calcLine, creditStatus, decisionConfirm, decisionError, decisionMessage, fmtBytes, historyLabel } from '../../lib/creditFormat'
import { applyMessage, BILLING_MONTHS, calcLine, checkGlyph, checkTone, creditStatus, decisionConfirm, decisionError, decisionMessage, fmtBytes, historyLabel } from '../../lib/creditFormat'
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
import { storageLabel } from '../../lib/storageFormat'
import '../../styles/admin-areas.css'
const CHECK_TONE = { ok: 'ok', warn: 'warn', info: 'info' }
const staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
const toCents = (value) => Math.round(Number(value) * 100)
@@ -24,6 +21,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
const [calcText, setCalcText] = useState('')
const [actionError, setActionError] = useState('')
const [busy, setBusy] = useState(false)
const [busyAction, setBusyAction] = useState('')
const busyRef = useRef(false)
const amountTouched = useRef(false)
const calcTimer = useRef(null)
@@ -85,6 +83,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
if (busyRef.current) return
busyRef.current = true
setBusy(true)
setBusyAction(action)
try {
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
onChanged?.()
@@ -94,6 +93,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
} finally {
busyRef.current = false
setBusy(false)
setBusyAction('')
}
}
@@ -101,6 +101,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
if (busyRef.current) return
busyRef.current = true
setBusy(true)
setBusyAction('apply')
try {
const result = await applyCredit(id)
onChanged?.()
@@ -110,11 +111,12 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
} finally {
busyRef.current = false
setBusy(false)
setBusyAction('')
}
}
if (error) return <Card><div className="area-error">{error}</div><Button variant="ghost" onClick={onClose}>Schließen</Button></Card>
if (!d || !form) return <Card><FaSpinner className="spinner" /></Card>
if (error) return <Card><p className="msg err mb-2" role="alert">{error}</p><Button onClick={onClose}>Schließen</Button></Card>
if (!d || !form) return <div className="loading">Wird geladen …</div>
const r = d.request
const c = d.customer
@@ -124,160 +126,176 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
const allChecked = Object.values(form.checks).every(Boolean)
const canApprove = allChecked && Number(form.amount) > 0 && !busy
const errorLine = actionError ? <p className="msg err" role="alert">{actionError}</p> : null
return (
<Card>
<div className="area-inline" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<Card title={`Antrag ${r.ref} · ${c.name || c.companyName}`} actions={<Button onClick={onClose}>Schließen</Button>}>
<p className="muted">eingegangen {fmtDateTime(r.createdAt)} <Badge tone={st.tone}>{st.label}</Badge></p>
{message && <p className="msg ok" role="status">{message}</p>}
<div className="two-col">
<div>
<h3 style={{ margin: 0 }}>Antrag {r.ref} · {c.name || c.companyName}</h3>
<span className="area-muted">eingegangen {fmtDateTime(r.createdAt)}</span> <Badge tone={st.tone}>{st.label}</Badge>
</div>
<Button variant="ghost" onClick={onClose}>Schließen</Button>
</div>
{message && <div className="area-message">{message}</div>}
<div className="credit-cols">
<div>
<h4>Antrag</h4>
<dl className="area-kv">
<dt>Leistung</dt><dd>{r.services}</dd>
<dt>Bisher bei</dt><dd>{r.provider}</dd>
<dt>Domain</dt><dd>{r.domain ? <a href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
<dt>Antwort an</dt><dd>{r.email}</dd>
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
</dl>
<h4>Grund / Nachricht</h4>
<p style={{ whiteSpace: 'pre-wrap' }}>{r.message}</p>
<h4>Nachweise</h4>
{r.files.length ? (
<ul className="credit-files">
{r.files.map((f) => (
<li key={f.id}>
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="area-muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
</li>
))}
</ul>
) : <p className="area-muted">Keine Nachweise (mehr) vorhanden.</p>}
<h4>Verlauf</h4>
{r.history.map((h, i) => (
<div key={i} className="credit-history">
<strong>{historyLabel(h.type)}</strong> <span className="area-muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
{h.text && <div style={{ whiteSpace: 'pre-wrap' }}>{h.text}</div>}
{(h.files || []).length > 0 && <div className="area-muted">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
</div>
))}
</div>
<div>
<h4>Kunde &amp; Vertrag</h4>
<dl className="area-kv">
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
<dt>Abo</dt>
<dd>
{s ? <>{productLabelOf(s.productKey)} <Badge tone={s.active ? 'ok' : 'danger'}>{s.status}</Badge><br /><span className="area-muted">Zusatzspeicher: {storageLabel(s.storageUnits)}</span></> : <Badge tone="danger">kein Abo</Badge>}
</dd>
{s && (
<>
<dt>Leistungsbeginn</dt><dd>{fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>{s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
{s.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(s.cancelAt)}</dd></>}
<dt>Stripe-Guthaben</dt><dd>{s.creditBalanceCents == null ? '–' : formatCents(s.creditBalanceCents)}</dd>
</>
)}
</dl>
<div className="area-inline" style={{ margin: '12px 0' }}>
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} variant="ghost" />
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer" variant="ghost"><FaArrowUpRightFromSquare /> In Stripe öffnen</Button>}
</div>
{d.otherRequests.length > 0 && (
<>
<h4>Weitere Anträge des Kunden</h4>
<ul className="credit-files">
{d.otherRequests.map((o) => {
const ost = creditStatus(o.status)
return (
<li key={o.id}>
<a href="#" onClick={(e) => { e.preventDefault(); onOpen?.(o.id) }}>{o.ref}</a> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
</li>
)
})}
</ul>
</>
)}
<h4>Automatische Prüfung</h4>
<ul className="credit-checks">
{d.autoChecks.map((k) => (
<li key={k.key}><Badge tone={CHECK_TONE[k.status] || 'muted'}>{k.status === 'ok' ? '✓' : k.status === 'warn' ? '!' : 'i'}</Badge> {k.label}{k.detail && <><br /><small className="area-muted">{k.detail}</small></>}</li>
<Card title="Antrag">
<dl className="kv">
<dt>Leistung</dt><dd>{r.services}</dd>
<dt>Bisher bei</dt><dd>{r.provider}</dd>
<dt>Domain</dt><dd>{r.domain ? <a className="link" href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
<dt>Antwort an</dt><dd>{r.email}</dd>
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
</dl>
</Card>
<Card title="Grund / Nachricht">
<p className="prose">{r.message}</p>
</Card>
<Card title="Nachweise">
{r.files.length ? (
r.files.map((f) => (
<div key={f.id} className="list-row">
<div className="list-row-text">
<a className="link" href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
</div>
</div>
))
) : <p className="muted">Keine Nachweise (mehr) vorhanden.</p>}
</Card>
<Card title="Verlauf">
{r.history.map((h, i) => (
<div key={i} className="list-row">
<div className="list-row-text">
<strong>{historyLabel(h.type)}</strong> <span className="muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
{h.text && <p className="prose">{h.text}</p>}
{(h.files || []).length > 0 && <div className="cell-sub">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
</div>
</div>
))}
</ul>
</Card>
</div>
<div>
<Card title="Kunde & Vertrag">
<dl className="kv">
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
<dt>Abo</dt>
<dd>
{s ? <>{productLabelOf(s.productKey)} <Badge tone={s.active ? 'ok' : 'danger'}>{s.status}</Badge><div className="cell-sub">Zusatzspeicher: {storageLabel(s.storageUnits)}</div></> : <Badge tone="danger">kein Abo</Badge>}
</dd>
{s && (
<>
<dt>Leistungsbeginn</dt><dd>{fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>{s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
{s.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(s.cancelAt)}</dd></>}
<dt>Stripe-Guthaben</dt><dd>{s.creditBalanceCents == null ? '–' : formatCents(s.creditBalanceCents)}</dd>
</>
)}
</dl>
<div className="toolbar">
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} />
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer">In Stripe öffnen</Button>}
</div>
</Card>
{d.otherRequests.length > 0 && (
<Card title="Weitere Anträge des Kunden">
{d.otherRequests.map((o) => {
const ost = creditStatus(o.status)
return (
<div key={o.id} className="list-row">
<div className="list-row-text">
<button type="button" className="link" onClick={() => onOpen?.(o.id)}>{o.ref}</button> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
</div>
</div>
)
})}
</Card>
)}
<Card title="Automatische Prüfung">
{d.autoChecks.map((k) => (
<div key={k.key} className="list-row">
<div className="list-row-text">
<Badge tone={checkTone(k.status)}>{checkGlyph(k.status)}</Badge> {k.label}{k.detail && <div className="cell-sub">{k.detail}</div>}
</div>
</div>
))}
</Card>
{pending ? (
<>
<h4>Prüfung durch dich</h4>
<div className="credit-manual">
{d.requiredChecks.map((k) => (
<label key={k.key}>
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
</label>
))}
</div>
<h4>Berechnung</h4>
<div className="credit-calc">
<label>Rechnungsbetrag (€)<input id="calc-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} /></label>
<label>Rechnung gilt für
<select id="calc-months" className="form-control" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
</select>
</label>
<label>Bezahlt bis<input id="calc-paid-until" className="form-control" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} /></label>
<label>Übernahme ab<input id="calc-from" className="form-control" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} /></label>
</div>
<div className="area-summary">{calcText}</div>
<p className="area-muted">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
<label className="credit-field">Gutschrift (€)
<input id="credit-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.amount} style={{ maxWidth: 180 }}
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
</label>
<label className="credit-field">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
<textarea id="credit-note" className="form-control" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</label>
<div className="area-inline">
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>Genehmigen &amp; gutschreiben</Button>
<Button variant="ghost" onClick={() => decide('question')} disabled={busy}>Rückfrage senden</Button>
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>Ablehnen</Button>
{busy && <FaSpinner className="spinner" />}
</div>
<p className="area-muted">
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
</p>
<Card title="Prüfung durch dich">
<div className="field">
{d.requiredChecks.map((k) => (
<label key={k.key} className="check">
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
</label>
))}
</div>
</Card>
<Card title="Berechnung">
<div className="form-stack">
<div className="field-row">
<Field label="Rechnungsbetrag (€)" htmlFor="calc-amount">
<input id="calc-amount" className="input" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} />
</Field>
<Field label="Rechnung gilt für" htmlFor="calc-months">
<select id="calc-months" className="select" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
</select>
</Field>
</div>
<div className="field-row">
<Field label="Bezahlt bis" htmlFor="calc-paid-until">
<input id="calc-paid-until" className="input" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} />
</Field>
<Field label="Übernahme ab" htmlFor="calc-from">
<input id="calc-from" className="input" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} />
</Field>
</div>
<div>
<p className="strong">{calcText}</p>
<p className="muted small">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
</div>
<Field label="Gutschrift (€)" htmlFor="credit-amount">
<input id="credit-amount" className="input" type="number" step="0.01" min="0.01" value={form.amount}
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
</Field>
<Field label="Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)" htmlFor="credit-note">
<textarea id="credit-note" className="textarea" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</Field>
<div className="flex items-center gap-3 wrap">
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>{busyAction === 'approve' ? 'Speichert …' : 'Genehmigen & gutschreiben'}</Button>
<Button onClick={() => decide('question')} disabled={busy}>{busyAction === 'question' ? 'Sendet …' : 'Rückfrage senden'}</Button>
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>{busyAction === 'reject' ? 'Speichert …' : 'Ablehnen'}</Button>
</div>
<p className="muted small">
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
</p>
{errorLine}
</div>
</Card>
</>
) : (
<>
<h4>Entscheidung</h4>
<Card title="Entscheidung">
{r.status === 'genehmigt' ? (
<>
<dl className="area-kv">
<dl className="kv">
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
<dt>Berechnung</dt><dd>{r.calculation}</dd>
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</dd>
{r.decisionNote && <><dt>Nachricht</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd></>}
{r.decisionNote && <><dt>Nachricht</dt><dd className="prose">{r.decisionNote}</dd></>}
<dt>Verrechnung</dt>
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="area-muted">({r.stripeBalanceTransactionId})</span></> : <Badge tone="warn">noch nicht in Stripe gebucht</Badge>}</dd>
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="muted">({r.stripeBalanceTransactionId})</span></> : <Badge tone="warn">noch nicht in Stripe gebucht</Badge>}</dd>
</dl>
{!r.appliedAt && <div className="area-inline" style={{ marginTop: 12 }}><Button variant="primary" onClick={apply} disabled={busy}>Jetzt in Stripe gutschreiben</Button></div>}
{!r.appliedAt && <div className="toolbar"><Button variant="primary" onClick={apply} disabled={busy}>{busyAction === 'apply' ? 'Speichert …' : 'Jetzt in Stripe gutschreiben'}</Button></div>}
</>
) : (
<dl className="area-kv">
<dl className="kv">
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd>
<dt>Begründung</dt><dd className="prose">{r.decisionNote}</dd>
</dl>
)}
</>
{errorLine}
</Card>
)}
{actionError && <div className="area-error">{actionError}</div>}
</div>
</div>
</Card>
+42 -33
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import { Badge, EmptyState, Tabs } from '../ui'
import { listCredits } from '../../lib/teamApi'
import { creditStatus } from '../../lib/creditFormat'
import { creditActionLabel, creditStatus, creditStatusNote } from '../../lib/creditFormat'
import { fmtDate, fmtDateTime, formatCents } from '../../lib/format'
import '../../styles/admin-areas.css'
// Liste der Uebernahmegutschriften (Filter "Offen"/"Alle"), wie im frueheren Admin-Panel; optional nur ein Kunde
export default function CreditList({ customerId, selectedId, onSelect, reloadKey }) {
@@ -23,41 +21,52 @@ export default function CreditList({ customerId, selectedId, onSelect, reloadKey
return (
<div>
<div className="area-filter seg">
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen</button>
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
</div>
<Card pad={false}>
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
{!rows && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
{rows && (
<div className="area-rows">
<div className="area-row credit-row head"><span>Eingang</span><span>Kunde</span><span>Leistung / bisher bei</span><span>Rechnung</span><span>Bezahlt bis</span><span>Status</span><span /></div>
{rows.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'}</span></div>}
<Tabs tabs={[{ id: 'open', label: 'Offen' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
{error && <div className="msg err" role="alert">{error}</div>}
{!rows && !error && <div className="loading">Wird geladen …</div>}
{rows && rows.length === 0 && (
<EmptyState title={filter === 'open' ? 'Keine offenen Anträge.' : 'Noch keine Anträge.'} />
)}
{rows && rows.length > 0 && (
<table className="table table-wrap">
<thead>
<tr>
<th className="hide-sm">Eingang</th>
<th className="col-name">Kunde</th>
<th className="hide-md">Leistung / bisher bei</th>
<th className="hide-sm right num">Rechnung</th>
<th className="hide-md">Bezahlt bis</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const st = creditStatus(r.status)
const note = creditStatusNote(r)
return (
<div key={r.id} className={`area-row credit-row ${r.id === selectedId ? 'active' : ''}`}>
<span>{fmtDateTime(r.createdAt)}<br /><span className="area-muted">{r.ref}</span></span>
<span><strong>{r.customerName}</strong>{r.customerCode && <><br /><span className="area-muted">Kd.-Nr. {r.customerCode}</span></>}</span>
<span>{r.services}<br /><span className="area-muted">{r.provider}{r.domain ? ` · ${r.domain}` : ''}</span></span>
<span>{formatCents(r.invoiceAmountCents)}<br /><span className="area-muted">für {r.billingMonths} Mon.</span></span>
<span>{fmtDate(r.paidUntil)}</span>
<span>
<tr key={r.id} aria-current={r.id === selectedId ? 'true' : undefined}>
<td className="hide-sm">{fmtDateTime(r.createdAt)}<div className="cell-sub">{r.ref}</div></td>
<td>
<div className="cell-title">{r.customerName}</div>
{r.customerCode && <div className="cell-sub">Kd.-Nr. {r.customerCode}</div>}
</td>
<td className="hide-md">{r.services}<div className="cell-sub">{r.provider}{r.domain ? ` · ${r.domain}` : ''}</div></td>
<td className="hide-sm right num">{formatCents(r.invoiceAmountCents)}<div className="cell-sub">für {r.billingMonths} Mon.</div></td>
<td className="hide-md">{fmtDate(r.paidUntil)}</td>
<td>
<Badge tone={st.tone}>{st.label}</Badge>
{r.status === 'genehmigt' && <><br /><span className="area-muted">{formatCents(r.amountCents)}{r.appliedAt ? '' : ' · nicht gebucht'}</span></>}
</span>
<span>
<Button size="sm" variant="primary" onClick={() => onSelect(r.id)}>
{['offen', 'rueckfrage'].includes(r.status) ? 'Prüfen' : 'Ansehen'}
</Button>
</span>
</div>
{note && <div className="cell-sub">{note}</div>}
</td>
<td className="nowrap">
<button type="button" className="link" onClick={() => onSelect(r.id)}>{creditActionLabel(r.status)}</button>
</td>
</tr>
)
})}
</div>
)}
</Card>
</tbody>
</table>
)}
</div>
)
}
+4 -4
View File
@@ -23,11 +23,11 @@ export default function CreditsView({ customerId }) {
return (
<div className="credits-view">
{selected && (
<div style={{ marginBottom: 16 }}>
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
</div>
<CreditDetail key={selected} id={selected} onClose={() => select('')} onOpen={select} onChanged={() => setReloadKey((k) => k + 1)} />
)}
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
<div className={selected ? 'mt-3' : undefined}>
<CreditList customerId={customerId} selectedId={selected} onSelect={select} reloadKey={reloadKey} />
</div>
</div>
)
}
+5 -7
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge } from '../ui'
import { getCustomerAboStatus } from '../../lib/teamApi'
import { fmtDate, productLabelOf } from '../../lib/format'
import { storageLabel } from '../../lib/storageFormat'
import '../../styles/admin-areas.css'
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
export default function AboStatusCard({ customerId }) {
@@ -23,13 +21,13 @@ export default function AboStatusCard({ customerId }) {
return (
<Card title="Abo">
{error && <div className="area-error">{error}</div>}
{!abo && !error && <FaSpinner className="spinner" />}
{abo && !abo.status && <span className="area-muted">kein Abo</span>}
{error && <div className="msg err" role="alert">{error}</div>}
{!abo && !error && <div className="loading">Wird geladen …</div>}
{abo && !abo.status && <p className="muted">kein Abo</p>}
{abo && abo.status && (
<dl className="area-kv">
<dl className="kv">
<dt>Abo</dt>
<dd className="area-inline">
<dd className="flex items-center gap-2 wrap">
{productLabelOf(abo.productKey)}
<Badge tone={abo.active ? 'ok' : 'danger'}>{abo.active ? 'aktiv' : abo.status}</Badge>
</dd>
+159 -135
View File
@@ -1,6 +1,5 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
@@ -11,9 +10,9 @@ import {
} from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd, fmtDateTime } from '../../lib/format'
import { ticketPath } from '../../lib/ticketRoutes'
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
import ZeitenPanel from './ZeitenPanel'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand, Terminplaner und Webshop je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
@@ -30,13 +29,13 @@ function SiteKey({ value }) {
timer.current = setTimeout(() => setCopied(false), 1800)
}
return (
<span className="funktionen-sitekey">
<span className="area-muted">Site-Key</span>
<div className="funktionen-sitekey">
<span className="muted">Site-Key</span>
<code>{value}</code>
<button type="button" className="funktionen-sitekey-copy" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
{copied ? <FaCheck className="funktionen-sitekey-ok" /> : <FaCopy />}
<button type="button" className="link" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
{copied ? 'Kopiert' : 'Kopieren'}
</button>
</span>
</div>
)
}
@@ -52,8 +51,8 @@ function KopierKnopf({ text, label }) {
timer.current = setTimeout(() => setCopied(false), 1800)
}
return (
<Button size="sm" variant="ghost" onClick={copy} title={label}>
{copied ? <><FaCheck className="funktionen-sitekey-ok" /> Kopiert</> : <><FaCopy /> {label}</>}
<Button type="button" onClick={copy}>
{copied ? 'Kopiert' : label}
</Button>
)
}
@@ -78,31 +77,31 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
const hindernis = hindernisText(feature.hindernis)
if (fehler || !antwort) {
return (
<div className="area-row funktionen-panel">
{fehler ? <div className="area-error">{fehler}</div> : <FaSpinner className="spinner" />}
{hindernis && <div className="area-summary warn">{hindernis}</div>}
{fehler && <div><Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)}><FaRotateRight /> Erneut laden</Button></div>}
<div className="funktionen-panel">
{fehler ? <div className="msg err" role="alert">{fehler}</div> : <div className="muted">Wird geladen …</div>}
{hindernis && <div className="msg warn">{hindernis}</div>}
{fehler && <div><Button type="button" onClick={() => setRetry((n) => n + 1)}>Erneut laden</Button></div>}
</div>
)
}
const modell = panelModell(antwort, feature)
return (
<div className="area-row funktionen-panel">
<div className="funktionen-panel">
<div className="funktionen-panel-head">
<strong>Einbauanleitung</strong>
{modell.kopf && <span className="area-muted">{modell.kopf}</span>}
{modell.ticketPfad && <Link to={modell.ticketPfad}>Zum Ticket</Link>}
<Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)} title="Anleitung neu laden" aria-label="Anleitung neu laden"><FaRotateRight /></Button>
{modell.kopf && <span className="muted small">{modell.kopf}</span>}
{antwort.ticketWoid && <Link className="link" to={ticketPath(antwort.ticketWoid)}>Zum Ticket</Link>}
<button type="button" className="link" onClick={() => setRetry((n) => n + 1)}>Anleitung neu laden</button>
</div>
{modell.warnungen.length > 0 && (
<ul className="area-error funktionen-panel-warnungen">
<ul className="msg err funktionen-panel-warnungen">
{modell.warnungen.map((text, i) => <li key={i}>{text}</li>)}
</ul>
)}
{modell.hinweis && <div className="area-summary warn">{modell.hinweis}</div>}
{modell.hinweis && <div className="msg warn">{modell.hinweis}</div>}
{modell.kopien.length > 0 && (
<div className="area-inline">
<div className="flex items-center gap-2 wrap">
{modell.kopien.map((k) => <KopierKnopf key={k.id} text={k.text} label={k.label} />)}
</div>
)}
@@ -122,26 +121,26 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
const ansicht = textformAnsicht(feld, funktion)
const { label, links, vorlage } = ansicht
return (
<div className="area-row funktionen-panel">
<label className="area-inline" style={{ alignItems: 'flex-start', flexWrap: 'nowrap' }}>
<input type="checkbox" style={{ marginTop: 3 }} checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
<div className="funktionen-panel">
<label className="funktionen-zustimmung">
<input type="checkbox" checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
<span>
{label.before}
{links.lb ? <a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a> : label.lb}
{links.lb ? <a className="link" href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a> : label.lb}
{label.between}
{links.avv ? <a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a> : label.avv}
{links.avv ? <a className="link" href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a> : label.avv}
{label.after}
</span>
</label>
<div className="area-inline">
<label>
Datum der Zustimmung:{' '}
<input type="date" required className="form-control" value={value.date} min={ansicht.version} max={today} disabled={disabled} onChange={(e) => onChange({ ...value, date: e.target.value })} />
<div className="flex items-center gap-2 wrap">
<label className="flex items-center gap-2 wrap">
Datum der Zustimmung:
<input type="date" required className="input" value={value.date} min={ansicht.version} max={today} disabled={disabled} onChange={(e) => onChange({ ...value, date: e.target.value })} />
</label>
<span role="radiogroup" aria-label="Weg der Zustimmung" className="area-inline">
<span role="radiogroup" aria-label="Weg der Zustimmung" className="flex items-center gap-2 wrap">
<span>Weg:</span>
{CHANNELS.map((c) => (
<label key={c} className="area-inline">
<label key={c} className="check">
<input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
{channelLabel(c)}
</label>
@@ -149,13 +148,13 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
</span>
</div>
{vorlage ? (
<details>
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{vorlage}</p>
<details className="funktionen-vorlage">
<summary>Textvorlage für den Chat</summary>
<p>{vorlage}</p>
<KopierKnopf text={vorlage} label="Vorlage kopieren" />
</details>
) : (
<div className="area-summary warn">Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.</div>
<div className="msg warn">Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.</div>
)}
</div>
)
@@ -190,7 +189,7 @@ function ShopPanel({ customerId, shop, onDecided }) {
}
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
return (
<div className="area-row funktionen-panel">
<div className="funktionen-shop">
<div className="funktionen-panel-head">
<strong>Webshop: {shop.projectName}</strong>
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
@@ -198,34 +197,34 @@ function ShopPanel({ customerId, shop, onDecided }) {
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
</div>
{a && (
<dl className="area-kv">
<dl className="kv">
{anfrageZeilen(a.anfrage).map((z) => (
<Fragment key={z.label}><dt>{z.label}</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{z.text}</dd></Fragment>
<Fragment key={z.label}><dt>{z.label}</dt><dd>{z.text}</dd></Fragment>
))}
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>}
{a.begruendung && <><dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{a.begruendung}</dd></>}
{a.begruendung && <><dt>Begründung</dt><dd>{a.begruendung}</dd></>}
</dl>
)}
<div className="area-muted">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
<div className="muted small">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
{a?.status === 'offen' && (
frage ? (
<div className="area-inline">
<div className="flex items-center gap-2 wrap">
<span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span>
{frage === 'ablehnen' && (
<textarea className="form-control" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} style={{ width: '100%' }} />
<textarea className="textarea funktionen-begruendung" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" aria-label="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} />
)}
<Button size="sm" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
</div>
) : (
<div className="area-inline">
<Button size="sm" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
<Button size="sm" variant="ghost" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
<div className="flex items-center gap-2 wrap">
<Button type="button" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
<Button type="button" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
</div>
)
)}
{status && <div className={status.ok ? 'area-message' : 'area-error'}>{status.text}</div>}
{status && <div className={`msg ${status.ok ? 'ok' : 'err'}`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
</div>
)
}
@@ -296,97 +295,122 @@ export default function CustomerFunktionen({ customerId }) {
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
if (error) return <Card><div className="area-error">{error}</div></Card>
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card>
if (error) return <div className="msg err" role="alert">{error}</div>
if (!projects) return <div className="loading">Wird geladen …</div>
if (!projects.length) return <EmptyState title="Keine Projekte für diesen Kunden." />
const shopEintraege = shop.filter(shopHatEintrag)
return (
<Card title="Funktionen" pad={false}>
<div className="area-rows">
<div className="area-row funktionen-row head"><span>Projekt</span><span>Funktion</span><span>Status</span><span>Ticket</span><span>Aktionen</span></div>
{projects.flatMap((p) => FUNKTIONEN.map((f, index) => {
const feature = funktionOf(p, f.key)
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
return (
<Fragment key={`${p.projectId}:${f.key}`}>
<div className="area-row funktionen-row">
{index === 0 ? (
<span>
<strong>{p.projectName}</strong>
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
</span>
) : (
<span className="funktionen-leer" />
)}
<span>{f.label}</span>
<span>
<Badge tone={tone}>{label}</Badge>
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>}
{zeitenText && <><br /><span className={feature.hindernis ? 'area-warn' : 'area-muted'}>{zeitenText}</span></>}
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>}
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>}
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
</span>
<span>
{feature.ticketMissing
? <Badge tone="danger">Ticket fehlt</Badge>
: feature.ticketWoid
? <Link to={`/tickets?woid=${encodeURIComponent(feature.ticketWoid)}`}>#{feature.ticketWoid}</Link>
: '–'}
</span>
<span className="area-inline">
{isConfirming ? (
<Fragment>
<span>{f.bestaetigen[confirm.action]}</span>
<input className="form-control" placeholder="Notiz (optional)" maxLength={120} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: 220 }} />
<Button size="sm" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</Fragment>
) : (
<Fragment>
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]}
</Button>
))}
{hatAnleitung && (
<Button size="sm" variant="ghost" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
Anleitung
</Button>
<Card title="Funktionen">
<table className="table funktionen-table">
<thead>
<tr><th>Funktion</th><th>Status</th><th>Ticket</th><th>Aktionen</th></tr>
</thead>
{projects.map((p) => (
<tbody key={p.projectId}>
<tr className="funktionen-group">
<th colSpan={4} scope="rowgroup">
{p.projectName}
{p.liveUrl && <>{' '}<a className="link" href={p.liveUrl} target="_blank" rel="noreferrer">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
</th>
</tr>
{FUNKTIONEN.map((f) => {
const feature = funktionOf(p, f.key)
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
const siteKey = siteKeyOf(feature, f.key)
const hindernis = feature.state === 'setup' ? hindernisText(feature.hindernis) : ''
const abgeschaltet = abgeschaltetText(feature)
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
// Zusatzzeilen direkt unter der Funktionszeile in dieser Reihenfolge: Bestaetigung, Zustimmung in Textform, Einbauanleitung.
// Alle Zeilen bis auf die letzte verlieren die Haarlinie, damit sie als eine Gruppe lesbar sind (.funktionen-offen).
const letzte = [isConfirming, braucheTextform, anleitungOffenHier, hatZeitenPanel].lastIndexOf(true)
const offen = (nr) => nr < letzte
return (
<Fragment key={f.key}>
<tr className={`funktionen-zeile${offen(-1) ? ' funktionen-offen' : ''}`}>
<td>{f.label}</td>
<td>
<Badge tone={tone}>{label}</Badge>
{hindernis && <div className="cell-sub msg warn">{hindernis}</div>}
{zeitenText && <div className={feature.hindernis ? 'cell-sub msg warn' : 'cell-sub'}>{zeitenText}</div>}
{abgeschaltet && <div className="cell-sub">{abgeschaltet}</div>}
{feature.adminNote && <div className="cell-sub" title={feature.adminNote}>{short(feature.adminNote)}</div>}
{siteKey && <SiteKey value={siteKey} />}
</td>
<td className={feature.ticketMissing || feature.ticketWoid ? undefined : 'funktionen-ohne-ticket'}>
{feature.ticketMissing
? <Badge tone="danger">Ticket fehlt</Badge>
: feature.ticketWoid
? <Link className="link" to={ticketPath(feature.ticketWoid)}>#{feature.ticketWoid}</Link>
: <span className="faint">–</span>}
</td>
<td>
{!isConfirming && (
<div className="funktionen-actions">
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
<button key={action} type="button" className="link" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]}
</button>
))}
{hatAnleitung && (
<button type="button" className="link" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
Anleitung
</button>
)}
</div>
)}
</Fragment>
</td>
</tr>
{isConfirming && (
<tr className={`funktionen-confirm${offen(0) ? ' funktionen-offen' : ''}`}>
<td colSpan={4}>
<div className="flex items-center gap-2 wrap">
<span>{f.bestaetigen[confirm.action]}</span>
<input className="input funktionen-note" placeholder="Notiz (optional)" maxLength={120} aria-label="Notiz (optional)" value={note} onChange={(e) => setNote(e.target.value)} />
<Button type="button" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</div>
</td>
</tr>
)}
</span>
</div>
{braucheTextform && (
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
)}
{hatZeitenPanel && (
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
)}
{anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
)}
</Fragment>
)
}))}
</div>
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
{shopFehler && <div className="area-error" style={{ margin: 12 }}>{shopFehler}</div>}
{shop.filter(shopHatEintrag).length > 0 && (
<div className="area-rows">
{shop.filter(shopHatEintrag).map((s) => (
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
))}
</div>
)}
{braucheTextform && (
<tr className={`funktionen-extra${offen(1) ? ' funktionen-offen' : ''}`}>
<td colSpan={4}>
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
</td>
</tr>
)}
{anleitungOffenHier && (
<tr className={`funktionen-extra${offen(2) ? ' funktionen-offen' : ''}`}>
<td colSpan={4}>
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
</td>
</tr>
)}
{hatZeitenPanel && (
<tr className="funktionen-extra">
<td colSpan={4}>
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
))}
</table>
{status && <div className={`msg ${status.ok ? 'ok' : 'err'} funktionen-msg`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
{shopFehler && <div className="msg err funktionen-msg" role="alert">{shopFehler}</div>}
{shopEintraege.map((s) => (
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
))}
</Card>
)
}
+25 -21
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
import { Card, Button, EmptyState } from '../ui'
import { Button, EmptyState } from '../ui'
import { getCustomerViewLog } from '../../lib/teamApi'
import { formatViewLogTime, viewLogLabel } from '../../lib/viewLog'
@@ -24,33 +23,38 @@ export default function CustomerViewLog({ customerId }) {
if (error) {
return (
<Card>
<div className="flex items-center gap-2 wrap">
<span className="muted">{error}</span>
<Button variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
</div>
</Card>
<div className="flex items-center gap-2 wrap">
<div className="msg err" role="alert">{error}</div>
<Button onClick={load}>Erneut versuchen</Button>
</div>
)
}
if (!entries) return <Card><FaSpinner className="spinner" /></Card>
if (!entries) return <div className="loading">Wird geladen …</div>
if (entries.length === 0) {
return <Card><EmptyState title="Noch keine Einträge" hint="Hier steht, wer die Kundenansicht geöffnet oder darin etwas geändert hat." /></Card>
return <EmptyState title="Noch keine Einträge" hint="Hier steht, wer die Kundenansicht geöffnet oder darin etwas geändert hat." />
}
return (
<Card pad={false}>
<div className="list">
<table className="table">
<thead>
<tr>
<th>Zeit</th>
<th>Mitarbeiter</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<div key={e.id} className="list-row">
<span className="mono muted list-row-text" style={{ minWidth: 130 }}>{formatViewLogTime(e.at)}</span>
<span className="list-row-text" style={{ minWidth: 120, fontWeight: 600 }}>{e.staffName || '–'}</span>
<div className="grow list-row-text">
<tr key={e.id}>
<td className="nowrap num">{formatViewLogTime(e.at)}</td>
<td>{e.staffName || '–'}</td>
<td>
<div>{viewLogLabel(e.action)}</div>
{e.detail && <div className="muted">{e.detail}</div>}
</div>
</div>
{e.detail && <div className="cell-sub">{e.detail}</div>}
</td>
</tr>
))}
</div>
</Card>
</tbody>
</table>
)
}
@@ -1,5 +1,4 @@
import { useState } from 'react'
import { FaArrowUpRightFromSquare, FaSpinner } from 'react-icons/fa6'
import { Button } from '../ui'
import { createKundenansichtLink } from '../../lib/teamApi'
import { openInNewTab } from '../../lib/openInNewTab'
@@ -8,7 +7,7 @@ import { openInNewTab } from '../../lib/openInNewTab'
* Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut.
* checkAccess=false: Kunden-Dokument liegt nicht vor (z. B. Gutschrift-Detail), den Zugang prüft dann der Server (409).
*/
export default function KundenansichtButton({ customer, variant = 'primary', checkAccess = true }) {
export default function KundenansichtButton({ customer, variant = 'default', checkAccess = true }) {
const [busy, setBusy] = useState(false)
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
@@ -30,7 +29,7 @@ export default function KundenansichtButton({ customer, variant = 'primary', che
disabled={!hasAccess || busy}
title={hasAccess ? 'Portal so öffnen, wie der Kunde es sieht' : 'Kein Portal-Zugang'}
>
{busy ? <FaSpinner className="spinner" /> : <FaArrowUpRightFromSquare />} Kundenansicht öffnen
{busy ? 'Wird geladen …' : 'Kundenansicht öffnen'}
</Button>
)
}
+22 -23
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { FaCopy, FaSpinner } from 'react-icons/fa6'
import { Button } from '../ui'
import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi'
import {
@@ -9,8 +8,8 @@ import {
// Panel "Öffnungszeiten live" unter der Zeile im Reiter "Funktionen": Team-Einstellungen der Website-Leisten (nur das Team ändert sie)
// und "Ersatz-HTML kopieren" je Plan. Entscheidungen, Texte und Prüfungen stehen in zeitenView (rein, getestet); hier wird nur verdrahtet.
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (area-row funktionen-panel) liegt als eigene Zeile außerhalb
// des Fünf-Spalten-Rasters, wie die Einbauanleitung des Terminplaners.
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (funktionen-panel) steht in einer eigenen Tabellenzeile
// unter der Funktionszeile, wie die Einbauanleitung des Terminplaners; weißes Design: keine Icons, Bausteine aus ../ui.
export default function ZeitenPanel({ customerId, projectId, feature, onSaved, onReload }) {
const start = teamFormular(feature)
const [form, setForm] = useState(start)
@@ -62,17 +61,17 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
const knoepfe = ersatzKnoepfe(feature)
return (
<div className="area-row funktionen-panel funktionen-panel-zeiten">
<div className="funktionen-panel funktionen-panel-zeiten">
<div className="funktionen-panel-head">
<strong>Öffnungszeiten live</strong>
<span className="area-muted">Einstellungen des Teams (der Kunde sieht und ändert sie nicht)</span>
<span className="muted small">Einstellungen des Teams (der Kunde sieht und ändert sie nicht)</span>
</div>
<form onSubmit={speichern} className="funktionen-panel-form">
<label className="funktionen-panel-feld">
<span>Header-Selektor</span>
<input
className="form-control"
className="input"
value={form.navSelektor}
onChange={(e) => aendern({ navSelektor: e.target.value })}
disabled={busy}
@@ -82,51 +81,51 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
aria-invalid={selektorFehler ? 'true' : 'false'}
/>
</label>
<div className="area-muted">
<div className="muted small">
Nur nötig, wenn die Hinweis-Leiste den festen Header der Website überlappt: CSS-Selektor des Headers.
</div>
{selektorFehler && <div className="area-error" role="alert">{selektorFehler}</div>}
<div className="area-inline">
<label className="area-inline">
{selektorFehler && <div className="msg err" role="alert">{selektorFehler}</div>}
<div className="flex items-center gap-2 wrap">
<label className="check">
<input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} />
Kontakt-Leiste aus
</label>
<label className="area-inline">
<label className="check">
<input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} />
Hinweis-Leiste aus
</label>
</div>
<div className="area-inline">
<Button type="submit" size="sm" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
{busy ? <FaSpinner className="spinner" /> : 'Speichern'}
<div className="flex items-center gap-2 wrap">
<Button type="submit" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
{busy ? 'Speichert …' : 'Speichern'}
</Button>
{meldung && <span role="status" className={meldung.ok ? 'area-message funktionen-panel-meldung' : 'area-error funktionen-panel-meldung'}>{meldung.text}</span>}
{meldung && <span role="status" className={meldung.ok ? 'msg ok' : 'msg err'}>{meldung.text}</span>}
{meldung?.neuLaden && (
<Button type="button" size="sm" variant="ghost" onClick={() => { setMeldung(null); onReload() }}>Neu laden</Button>
<Button type="button" onClick={() => { setMeldung(null); onReload() }}>Neu laden</Button>
)}
</div>
</form>
<div className="funktionen-panel-head">
<strong>Ersatz-HTML</strong>
<span className="area-muted">Wochentabelle des Plans für den Platzhalter der Website (Einbau)</span>
<span className="muted small">Wochentabelle des Plans für den Platzhalter der Website (Einbau)</span>
</div>
{knoepfe.length === 0 ? (
<div className="area-muted">{KEINE_ZEITEN_TEXT}</div>
<div className="muted small">{KEINE_ZEITEN_TEXT}</div>
) : (
<div className="area-inline">
<div className="flex items-center gap-2 wrap">
{knoepfe.map((k) => (
<Button key={k.kurz} type="button" size="sm" variant="ghost" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
{ersatz.busy && ersatz.kurz === k.kurz ? <FaSpinner className="spinner" /> : <FaCopy />} {k.label}
<Button key={k.kurz} type="button" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
{ersatz.busy && ersatz.kurz === k.kurz ? 'Lädt …' : k.label}
</Button>
))}
</div>
)}
{ersatz.meldung && <div role="status" className={ersatz.meldung.ok ? 'area-message' : 'area-error'}>{ersatz.meldung.text}</div>}
{ersatz.meldung && <div role="status" className={ersatz.meldung.ok ? 'msg ok' : 'msg err'}>{ersatz.meldung.text}</div>}
{ersatz.html && (
<textarea
readOnly
className="form-control funktionen-panel-html"
className="textarea funktionen-panel-html"
rows={8}
value={ersatz.html}
aria-label="Ersatz-HTML zum Kopieren"
+34 -39
View File
@@ -1,6 +1,6 @@
import { FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Card, EmptyState } from '../ui'
import { Badge, Card, EmptyState, Stat } from '../ui'
import { aboView } from '../../lib/financeView'
import { isNegText } from '../../lib/financeUi'
/**
* Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen
@@ -12,50 +12,45 @@ export default function AboCard({ abos }) {
if (v.error) {
return (
<Card title="Abos" className="fin-abo-card">
<p className="fin-hint fin-hint-warn" role="alert">
<FaTriangleExclamation aria-hidden="true" /> {v.error}
</p>
<Card title="Abos">
<div className="fin-body">
<p className="msg warn" role="alert">
{v.error}
</p>
</div>
</Card>
)
}
return (
<Card title="Abos" className="fin-abo-card" bodyStyle={{ padding: 0 }}>
<div className="fin-stats">
<span className="fin-amount fin-stat-value">{v.monthlyText}</span>
<span className="fin-stat-label">Abo-Einnahmen pro Monat</span>
<span className="fin-amount fin-stat-value">{v.payingText}</span>
<span className="fin-stat-label">zahlende Kunden</span>
{v.atRiskText && (
<>
<span className="fin-amount fin-stat-value fin-flag">{v.atRiskText}</span>
<span className="fin-stat-label fin-flag">
<FaTriangleExclamation aria-hidden="true" /> gefährdet
</span>
</>
<Card title="Abos">
<div className="fin-body">
<div className="stats fin-abo-stats">
<Stat label="Abo-Einnahmen pro Monat" value={v.monthlyText} tone={isNegText(v.monthlyText) ? 'neg' : ''} />
<Stat label="zahlende Kunden" value={v.payingText} />
{v.atRiskText && <Stat label="gefährdet" value={v.atRiskText} tone="neg" />}
</div>
{v.rows.length === 0 ? (
<EmptyState title="Noch keine Abos." />
) : (
<ul className="fin-abos">
{v.rows.map((row) => (
<li key={row.key} className="fin-abo">
<div className="fin-abo-main">
<span className="fin-abo-customer">{row.customer}</span>
<span className="fin-abo-product muted"> · {row.product}</span>
</div>
<div className={`fin-amount fin-abo-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}>{row.amountText}</div>
<div className="fin-abo-meta">
<Badge tone={row.tone}>{row.statusLabel}</Badge>
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
</div>
</li>
))}
</ul>
)}
</div>
{v.rows.length === 0 ? (
<EmptyState title="Noch keine Abos." />
) : (
<ul className="fin-abos">
{v.rows.map((row) => (
<li key={row.key} className="fin-abo">
<div className="fin-abo-main">
<span className="fin-abo-customer">{row.customer}</span>
<span className="fin-abo-product muted"> · {row.product}</span>
</div>
<div className="fin-amount fin-abo-amount">{row.amountText}</div>
<div className="fin-abo-meta">
<Badge tone={row.tone}>{row.statusLabel}</Badge>
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
</div>
</li>
))}
</ul>
)}
</Card>
)
}
+5 -4
View File
@@ -5,19 +5,20 @@ import { ADDRESS_INCOMPLETE_TEXT, addressComplete } from '../../lib/financeView'
* Hinweis „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit Weg zur
* Übersicht des Kunden (dort stehen die Stammdaten). Rendert nichts, wenn die Anschrift vollständig ist. Mit
* `onShowOverview` (Reiter am Kunden) wechselt ein Knopf zum Reiter Übersicht, sonst führt ein Link zur Kundenseite.
* `variant`: 'dialog' (im Rechnungsdialog) oder 'page' (über der Rechnungsliste des Kunden).
* `variant`: 'dialog' (im Rechnungsdialog) oder 'page' (über der Rechnungsliste des Kunden). Der Knopf trägt immer
* type="button", denn die Variante 'dialog' steht in einem Formular.
*/
export default function AddressWarning({ customer, onShowOverview, variant = 'page' }) {
if (!customer || addressComplete(customer)) return null
return (
<div className={`fin-address-warn fin-address-warn-${variant}`} role="alert">
<div className={`msg warn fin-address-warn fin-address-warn-${variant}`} role="alert">
<span>{ADDRESS_INCOMPLETE_TEXT}</span>
{onShowOverview ? (
<button type="button" className="fin-link" onClick={onShowOverview}>
<button type="button" className="link" onClick={onShowOverview}>
Zur Übersicht
</button>
) : customer.$id ? (
<Link className="fin-link" to={`/customers/${customer.$id}`}>
<Link className="link" to={`/customers/${customer.$id}`}>
Zur Übersicht
</Link>
) : null}
+146 -156
View File
@@ -1,7 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaXmark } from 'react-icons/fa6'
import { Badge, Button } from '../ui'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { Badge, Button, Dialog, Field } from '../ui'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
import {
@@ -20,7 +18,8 @@ const NOTE_MAX = 500
* bezahlt markiert und das lässt sich nicht rückgängig machen, deshalb zweistufig: erst „Zuordnen“, dann der
* Bestätigungstext mit „Ja, als bezahlt markieren“; vorher geht keine Anfrage raus. Fehler des Servers (409 Betrag
* weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung)
* lässt sich mit „Zuordnung lösen“ aufheben.
* lässt sich mit „Zuordnung lösen“ aufheben. × und Esc kommen vom Baustein Dialog und wirken nicht, solange eine
* Anfrage läuft; lockRef sperrt zusätzlich den Augenblick, bevor der Zustand „busy“ gezeichnet ist.
*
* invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand; invoicesLoaded =
* frisch und ohne Fehler geladen, dann sind Vorschläge zu nicht mehr offenen Rechnungen gesperrt).
@@ -39,14 +38,12 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
const lockRef = useRef(false)
const aliveRef = useRef(true)
const dirtyRef = useRef(false)
const dialogRef = useRef(null)
const confirmRef = useRef(null)
const errorRef = useRef(null)
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
const unassigned = !tx.category
const onOverlayKeyDown = useDialogFocus(dialogRef)
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
useScrollIntoView(errorRef, error)
@@ -82,15 +79,6 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
onClose({ refresh: dirtyRef.current })
}
useEffect(() => {
const onKey = (e) => {
// Escape schließt zuerst nur die offene Auswahl (z. B. ein Aufklappmenü): dann nicht den Dialog
if (e.key === 'Escape' && !e.defaultPrevented) close()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
})
function choose(key) {
setSelected(key)
setConfirming(false)
@@ -154,7 +142,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : ''
const radio = (o) => (
<label key={o.key} className={`fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
<label key={o.key} className={`check fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
<input
type="radio"
name="fin-assign-target"
@@ -166,156 +154,158 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi
<span className="fin-choice-text">
<span className="fin-choice-label">{o.label}</span>
{(o.confidence || o.detail) && (
<span className="fin-choice-detail muted">
<span className="fin-choice-detail">
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
{o.confidence && o.detail ? ' · ' : ''}
{o.detail}
</span>
)}
{o.disabled && o.disabledReason && <span className="fin-choice-detail fin-hint-warn">{o.disabledReason}</span>}
{o.disabled && o.disabledReason && <span className="fin-choice-detail warn">{o.disabledReason}</span>}
</span>
</label>
)
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={close} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog fin-assign"
role="dialog"
aria-modal="true"
aria-labelledby="fin-assign-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-assign-title" className="mb-2">Buchung zuordnen</h2>
<dl className="fin-tx-facts">
<div><dt>Datum</dt><dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd></div>
<div><dt>Gegenpartei</dt><dd>{row?.counterparty}</dd></div>
{row?.remittance && <div><dt>Verwendungszweck</dt><dd className="fin-wrap">{row.remittance}</dd></div>}
<div><dt>Betrag</dt><dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd></div>
</dl>
{error && (
<div className="fin-dialog-error" role="alert" ref={errorRef}>
{error}
</div>
)}
{model.assigned ? (
<>
<p className="fin-assigned">
Zugeordnet: <strong>{model.currentText}</strong>
</p>
{!model.canUnassign && (
<p className="fin-dialog-sub">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
)}
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Schließen</Button>
{model.canUnassign && (
<Button type="button" onClick={doUnassign} disabled={disabled}>
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
</Button>
)}
</div>
</>
) : (
<>
{model.hint && <p className="fin-dialog-sub" role="note">{model.hint}</p>}
{showInvoiceState && invoicesLoading && <p className="fin-dialog-sub" role="status">Offene Rechnungen werden geladen …</p>}
{showInvoiceState && invoicesError && (
<p className="fin-dialog-warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
)}
{outgoing && fixed.loading && <p className="fin-dialog-sub" role="status">Fixkosten werden geladen …</p>}
{outgoing && fixed.error && <p className="fin-dialog-warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
{model.suggestions.length > 0 && (
<fieldset className="fin-choices">
<legend className="form-label">Vorschläge</legend>
{model.suggestions.map(radio)}
</fieldset>
)}
{model.invoices.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-invoice">Andere offene Rechnung</label>
<select
id="fin-assign-invoice"
className="form-control"
value={invoiceSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Rechnung wählen …</option>
{model.invoices.map((o) => (
<option key={o.key} value={o.key} disabled={o.disabled}>
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
</option>
))}
</select>
</div>
)}
{model.fixedCosts.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-fixed">Fixkosten</label>
<select
id="fin-assign-fixed"
className="form-control"
value={fixedSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Fixkosten wählen …</option>
{model.fixedCosts.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</div>
)}
<fieldset className="fin-choices">
<legend className="form-label">Kategorie</legend>
{model.categories.map(radio)}
</fieldset>
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-note">Notiz (optional)</label>
<textarea
id="fin-assign-note"
className="form-control"
rows={2}
maxLength={NOTE_MAX}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
{confirming && option ? (
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
</Button>
</div>
</div>
) : (
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
</Button>
</div>
)}
</>
let footer
if (model.assigned) {
footer = (
<div className="fin-foot">
<Button type="button" onClick={close} disabled={disabled}>Schließen</Button>
{model.canUnassign && (
<Button type="button" onClick={doUnassign} disabled={disabled}>
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
</Button>
)}
</div>
</div>
)
} else if (confirming && option) {
footer = (
<div className="fin-foot">
<Button type="button" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
</Button>
</div>
)
} else {
footer = (
<div className="fin-foot">
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
</Button>
</div>
)
}
return (
<Dialog title="Buchung zuordnen" onClose={close} busy={disabled} footer={footer}>
<dl className="kv">
<dt>Datum</dt>
<dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd>
<dt>Gegenpartei</dt>
<dd>{row?.counterparty}</dd>
{row?.remittance && (
<>
<dt>Verwendungszweck</dt>
<dd>{row.remittance}</dd>
</>
)}
<dt>Betrag</dt>
<dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd>
</dl>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
{model.assigned ? (
<>
<p className="fin-assigned">
Zugeordnet: <strong>{model.currentText}</strong>
</p>
{!model.canUnassign && (
<p className="msg muted">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
)}
</>
) : (
<>
{model.hint && <p className="msg muted" role="note">{model.hint}</p>}
{showInvoiceState && invoicesLoading && <p className="msg muted" role="status">Offene Rechnungen werden geladen …</p>}
{showInvoiceState && invoicesError && (
<p className="msg warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
)}
{outgoing && fixed.loading && <p className="msg muted" role="status">Fixkosten werden geladen …</p>}
{outgoing && fixed.error && <p className="msg warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
{model.suggestions.length > 0 && (
<fieldset className="fin-choices">
<legend>Vorschläge</legend>
{model.suggestions.map(radio)}
</fieldset>
)}
{model.invoices.length > 0 && (
<Field label="Andere offene Rechnung" htmlFor="fin-assign-invoice">
<select
id="fin-assign-invoice"
className="select"
value={invoiceSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Rechnung wählen …</option>
{model.invoices.map((o) => (
<option key={o.key} value={o.key} disabled={o.disabled}>
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
</option>
))}
</select>
</Field>
)}
{model.fixedCosts.length > 0 && (
<Field label="Fixkosten" htmlFor="fin-assign-fixed">
<select
id="fin-assign-fixed"
className="select"
value={fixedSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Fixkosten wählen …</option>
{model.fixedCosts.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</Field>
)}
<fieldset className="fin-choices">
<legend>Kategorie</legend>
{model.categories.map(radio)}
</fieldset>
<Field label="Notiz (optional)" htmlFor="fin-assign-note">
<textarea
id="fin-assign-note"
className="textarea"
rows={2}
maxLength={NOTE_MAX}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</Field>
{confirming && option && (
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
</div>
)}
</>
)}
</Dialog>
)
}
+16 -27
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { FaRotate, FaSpinner } from 'react-icons/fa6'
import { Fragment, useEffect, useRef, useState } from 'react'
import { Badge, Button, Card } from '../ui'
import { useFinanceResource } from '../../hooks/useFinanceResource'
import { noticeProps } from '../../lib/financeUi'
import { getBank, refreshBank, startBankAuth } from '../../lib/financeApi'
import { bankCardModel, refreshResultMessage } from '../../lib/financeSettings'
import { safeUrl } from '../../lib/financeView'
@@ -67,14 +67,11 @@ export default function BankAccessCard() {
return (
<Card title="Bankzugang">
{res.loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Bankzugang wird geladen …</p>
</div>
<div className="loading" role="status">Bankzugang wird geladen …</div>
) : (
<div className="fin-load-error" style={{ padding: 0 }}>
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
<div className="fin-load-error">
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
<Button onClick={res.reload}>Erneut versuchen</Button>
</div>
)}
</Card>
@@ -85,36 +82,28 @@ export default function BankAccessCard() {
return (
<Card title="Bankzugang" actions={<Badge tone={model.tone}>{model.statusLabel}</Badge>}>
{res.error && (
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">
Der Stand konnte nicht neu geladen werden: {res.error}
</p>
<p {...noticeProps('warn', 'fin-notice')}>Der Stand konnte nicht neu geladen werden: {res.error}</p>
)}
{model.rows.length > 0 && (
<dl className="fin-bank-facts">
<dl className="kv fin-kv">
{model.rows.map((row) => (
<div key={row.key}>
<Fragment key={row.key}>
<dt>{row.label}</dt>
<dd>{row.text}</dd>
</div>
<dd className="num">{row.text}</dd>
</Fragment>
))}
</dl>
)}
{model.error && (
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">Fehler: {model.error}</p>
)}
{model.hint && <p className="fin-hint fin-form-notice">{model.hint}</p>}
{message && (
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
{model.error && <p {...noticeProps('warn', 'fin-notice')}>Fehler: {model.error}</p>}
{model.hint && <p {...noticeProps('info', 'fin-notice')}>{model.hint}</p>}
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
<div className="fin-form-actions">
{model.canRefresh && (
<Button variant="primary" onClick={refresh} disabled={busy !== null}>
<FaRotate className={busy === 'refresh' ? 'fin-spin' : undefined} aria-hidden="true" /> {busy === 'refresh' ? 'Rufe ab …' : 'Jetzt abrufen'}
{busy === 'refresh' ? 'Rufe ab …' : 'Jetzt abrufen'}
</Button>
)}
<Button variant={model.canRefresh ? 'ghost' : 'primary'} onClick={connect} disabled={busy !== null}>
<Button variant={model.canRefresh ? 'default' : 'primary'} onClick={connect} disabled={busy !== null}>
{busy === 'connect' ? 'Leite weiter …' : model.connectLabel}
</Button>
</div>
+6 -8
View File
@@ -1,4 +1,4 @@
import { Button, Card, EmptyState } from '../ui'
import { Button, EmptyState } from '../ui'
import AddressWarning from './AddressWarning'
import InvoiceTable from './InvoiceTable'
import { customerInvoicesGate } from '../../lib/financeView'
@@ -13,13 +13,11 @@ export default function CustomerInvoices({ customer, onShowOverview }) {
const gate = customerInvoicesGate(customer)
if (!gate.ok) {
return (
<Card>
<EmptyState
title={gate.title}
hint={gate.hint}
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
/>
</Card>
<EmptyState
title={gate.title}
hint={gate.hint}
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
/>
)
}
return (
+25 -30
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaSpinner } from 'react-icons/fa6'
import { Badge, Button, Card, EmptyState } from '../ui'
import FormField from './FormField'
import { useFinanceResource } from '../../hooks/useFinanceResource'
import { noticeProps } from '../../lib/financeUi'
import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi'
import {
FIXED_COST_DELETE_CONFIRM,
@@ -146,45 +146,44 @@ export default function FixedCostsCard() {
let body
if (!res.data) {
body = res.loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Fixkosten werden geladen …</p>
</div>
<div className="loading" role="status">Fixkosten werden geladen …</div>
) : (
<div className="fin-load-error" style={{ padding: 0 }}>
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
<div className="fin-load-error">
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
<Button onClick={res.reload}>Erneut versuchen</Button>
</div>
)
} else {
body = (
<>
{res.error && (
<p className="fin-hint fin-hint-warn" role="alert">
<p {...noticeProps('warn', 'fin-notice')}>
Die Liste konnte nicht neu geladen werden: {res.error}{' '}
<Button size="sm" variant="ghost" onClick={res.reload} disabled={res.loading}>Erneut versuchen</Button>
<button type="button" className="link" onClick={res.reload} disabled={res.loading}>Erneut versuchen</button>
</p>
)}
{editing && (
<form className="fin-fixed-form" onSubmit={submit} noValidate aria-labelledby="fin-fix-form-title">
<h4 id="fin-fix-form-title" className="fin-fixed-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h4>
<h3 id="fin-fix-form-title" className="fin-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h3>
<fieldset className="fin-fieldset" disabled={busy === 'save'}>
<div className="fin-form-grid">
<div className="field-row">
<FormField id={fieldId('name')} label="Name" error={errors.name}>
<input className="form-control" autoComplete="off" value={form.name} onChange={change('name')} placeholder="z. B. Hetzner" />
<input className="input" autoComplete="off" value={form.name} onChange={change('name')} placeholder="z. B. Hetzner" />
</FormField>
<FormField id={fieldId('amount')} label="Betrag (€)" error={errors.amount}>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
</FormField>
</div>
<div className="field-row">
<FormField id={fieldId('interval')} label="Rhythmus" error={errors.interval}>
<select className="form-control" value={form.interval} onChange={change('interval')}>
<select className="select" value={form.interval} onChange={change('interval')}>
{INTERVAL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
<FormField id={fieldId('nextDate')} label="Nächster Termin" error={errors.nextDate}>
<input className="form-control" type="date" value={form.nextDate} onChange={change('nextDate')} />
<input className="input" type="date" value={form.nextDate} onChange={change('nextDate')} />
</FormField>
</div>
<FormField
@@ -193,22 +192,18 @@ export default function FixedCostsCard() {
hint={`Teil des Namens der Gegenpartei in der Bankbuchung, z. B. hetzner (höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen). Damit schlägt die Seite diese Abbuchung nicht noch einmal als neue Fixkosten vor.`}
error={errors.counterpartyMatch}
>
<input className="form-control" autoComplete="off" value={form.counterpartyMatch} onChange={change('counterpartyMatch')} />
<input className="input" autoComplete="off" value={form.counterpartyMatch} onChange={change('counterpartyMatch')} />
</FormField>
</fieldset>
<div className="fin-form-actions">
<Button type="submit" variant="primary" disabled={busy !== null}>
{busy === 'save' ? 'Speichere …' : 'Speichern'}
</Button>
<Button type="button" variant="ghost" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
<Button type="button" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
</div>
</form>
)}
{message && (
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
{rows.length === 0 ? (
<EmptyState title="Noch keine Fixkosten." hint="Lege regelmäßige Ausgaben an, damit Vorschau und „Verfügbar“ sie berücksichtigen." />
) : (
@@ -222,13 +217,13 @@ export default function FixedCostsCard() {
<div className="fin-amount fin-fixed-amount">{row.amountText}</div>
<div className="fin-fixed-meta muted">{row.metaText}</div>
<div className="fin-actions fin-fixed-actions">
<Button size="sm" variant="ghost" onClick={() => openEdit(row)} disabled={busy !== null} aria-label={`Bearbeiten: ${row.name}`}>
<button type="button" className="link" onClick={() => openEdit(row)} disabled={busy !== null} aria-label={`Bearbeiten: ${row.name}`}>
Bearbeiten
</Button>
<Button size="sm" variant="ghost" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
</button>
<button type="button" className="link" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
{busy === `${row.id}:toggle` ? 'Speichere …' : row.toggleLabel}
</Button>
<Button size="sm" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
</button>
<Button type="button" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
{busy === `${row.id}:delete` ? 'Lösche …' : 'Löschen'}
</Button>
</div>
@@ -245,8 +240,8 @@ export default function FixedCostsCard() {
title="Fixkosten"
actions={
res.data && (
<Button size="sm" variant="primary" onClick={openNew} disabled={busy !== null || editing?.id === null}>
<FaPlus aria-hidden="true" /> Neue Position
<Button onClick={openNew} disabled={busy !== null || editing?.id === null}>
Neue Position
</Button>
)
}
+69 -62
View File
@@ -1,12 +1,12 @@
import { FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Card, EmptyState } from '../ui'
import { forecastNotes, forecastRows } from '../../lib/financeView'
import { isNegText } from '../../lib/financeUi'
/**
* Block 3: „Vorschau 30 Tage“. Je Posten Datum, Bezeichnung, „sicher“/„erwartet“ (als Text), Betrag mit Vorzeichen
* und der laufende Saldo beider Linien ab dem Kontostand. Ohne Kontostand entfallen die Saldo-Spalten (mit Hinweis).
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Karte bis 560 px)
* wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen.
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Wrapper .fin-body bis
* 560 px) wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen.
*/
export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) {
const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
@@ -15,70 +15,77 @@ export default function ForecastTable({ vorschau, kontostandCents, stripeDown =
const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
return (
<Card title="Vorschau 30 Tage" className="fin-forecast-card" bodyStyle={{ padding: 0 }}>
{rows.length === 0 ? (
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
) : (
<div className="fin-table-wrap">
<table className={`ui-table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Posten</th>
<th role="columnheader" scope="col">Sicherheit</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
{hasBalance && (
<>
<th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
<th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
</>
)}
</tr>
</thead>
<tbody role="rowgroup">
{rows.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-forecast-date">{row.dateText}</td>
<td role="cell" className="fin-forecast-label">{row.label}</td>
<td role="cell" className="fin-forecast-kind">
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
</td>
<td role="cell" className="fin-amount fin-forecast-amount">{row.amountText}</td>
<Card title="Vorschau 30 Tage">
<div className="fin-body">
{rows.length === 0 ? (
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
) : (
<div className="fin-table-wrap">
<table className={`table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Posten</th>
<th role="columnheader" scope="col">Sicherheit</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
{hasBalance && (
<>
<td
role="cell"
data-label="Saldo sicher"
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoSicherText}</span>
</td>
<td
role="cell"
data-label="Saldo erwartet"
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoErwartetText}</span>
</td>
<th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
<th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{(notes.length > 0 || assumption) && (
<div className="fin-forecast-foot">
{notes.map((note) => (
<p key={note.key} className="fin-hint fin-hint-warn" role="status">
<FaTriangleExclamation aria-hidden="true" /> {note.text}
</p>
))}
{assumption && <p className="fin-hint">{assumption}</p>}
</div>
)}
</thead>
<tbody role="rowgroup">
{rows.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-forecast-date">{row.dateText}</td>
<td role="cell" className="fin-forecast-label">{row.label}</td>
<td role="cell" className="fin-forecast-kind">
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
</td>
<td
role="cell"
className={`fin-amount fin-forecast-amount${isNegText(row.amountText) ? ' fin-neg' : ''}`}
>
{row.amountText}
</td>
{hasBalance && (
<>
<td
role="cell"
data-label="Saldo sicher"
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoSicherText}</span>
</td>
<td
role="cell"
data-label="Saldo erwartet"
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoErwartetText}</span>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{(notes.length > 0 || assumption) && (
<div className="fin-forecast-foot">
{notes.map((note) => (
<p key={note.key} className="msg warn" role="status">
{note.text}
</p>
))}
{assumption && <p className="msg muted">{assumption}</p>}
</div>
)}
</div>
</Card>
)
}
+5 -4
View File
@@ -1,8 +1,9 @@
import { cloneElement } from 'react'
/**
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. `children` ist genau ein Eingabeelement; es bekommt
* `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler). Die Fehlermeldung wird vorgelesen (role="alert").
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung (Klassen des Bausteins `Field`: `.field`, `.hint`, `.msg.err`).
* `children` ist genau ein Eingabeelement; es bekommt `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler).
* Den roten Ring am Feld liefert allein `aria-invalid`. Die Fehlermeldung wird vorgelesen (role="alert").
*/
export default function FormField({ id, label, hint, error, children }) {
const hintId = hint ? `${id}-hint` : undefined
@@ -13,12 +14,12 @@ export default function FormField({ id, label, hint, error, children }) {
<label htmlFor={id}>{label}</label>
{cloneElement(children, { id, 'aria-invalid': error ? 'true' : undefined, 'aria-describedby': describedBy })}
{hint && (
<div id={hintId} className="faint fin-form-hint">
<div id={hintId} className="hint">
{hint}
</div>
)}
{error && (
<div id={errorId} className="fin-field-error" role="alert">
<div id={errorId} className="msg err" role="alert">
{error}
</div>
)}
+183 -209
View File
@@ -1,9 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
import { AutocompleteInput, Button } from '../ui'
import { AutocompleteInput, Button, Dialog, Field } from '../ui'
import AddressWarning from './AddressWarning'
import { useCustomers } from '../../hooks/useCustomers'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { createInvoice } from '../../lib/financeApi'
import { canInvoice, euro } from '../../lib/financeFormat'
@@ -65,10 +63,8 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
const [busy, setBusy] = useState(null) // 'draft' | 'send'
const lockRef = useRef(false)
const aliveRef = useRef(true)
const dialogRef = useRef(null)
const errorRef = useRef(null)
const onOverlayKeyDown = useDialogFocus(dialogRef)
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
useScrollIntoView(errorRef, error)
@@ -79,14 +75,12 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
}
}, [])
useEffect(() => {
const onKey = (e) => {
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
// × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt).
// Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab.
const close = () => {
if (lockRef.current) return
onClose()
}
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
const resolved = fixedCustomer
@@ -144,203 +138,183 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview
const disabled = busy !== null
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="fin-invoice-title"
tabIndex={-1}
ref={dialogRef}
const footer = (
<div className="fin-foot">
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button
type="button"
variant="primary"
onClick={() => submit(true)}
disabled={disabled || !gate.sendAllowed}
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
>
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
{error && (
<div className="fin-dialog-error" role="alert" ref={errorRef}>
{error}
</div>
)}
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
<form onSubmit={(e) => e.preventDefault()}>
<div className="form-group">
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
{fixedCustomer ? (
<p className="fin-fixed-customer">
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
</p>
) : source?.loading ? (
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
) : source?.error ? (
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
) : (
<AutocompleteInput
id="fin-invoice-customer"
value={customerText}
onChange={(v) => {
setCustomerText(v)
if (error) setError('')
}}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
{!customerBlocked.ok && (
<p className="fin-dialog-warn" role="alert">
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!fixedCustomer && resolved.state === 'unknown' && (
<p className="fin-dialog-sub">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
)}
{gate.warn && (
<AddressWarning
customer={customer}
variant="dialog"
onShowOverview={
onShowOverview
? () => {
if (lockRef.current) return
onClose()
onShowOverview()
}
: undefined
}
/>
)}
</div>
<fieldset className="fin-items">
<legend className="form-label">Positionen</legend>
{items.map((item, index) => {
const line = lineTotalCents(item)
const n = index + 1
return (
<div key={item.uid} className="fin-item">
<div className="fin-item-field fin-item-desc">
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
<input
id={`fin-item-desc-${item.uid}`}
className="form-control"
maxLength={INVOICE_LIMITS.maxDescription}
value={item.description}
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
aria-label={`Position ${n}: Beschreibung`}
placeholder="z. B. Webdesign Startseite"
/>
</div>
<div className="fin-item-field fin-item-qty">
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
<input
id={`fin-item-qty-${item.uid}`}
className="form-control fin-amount"
inputMode="numeric"
value={item.quantity}
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
aria-label={`Position ${n}: Menge`}
/>
</div>
<div className="fin-item-field fin-item-price">
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
<input
id={`fin-item-price-${item.uid}`}
className="form-control fin-amount"
inputMode="decimal"
value={item.price}
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
aria-label={`Position ${n}: Einzelpreis in Euro`}
placeholder="0,00"
/>
</div>
<div className="fin-item-field fin-item-sum">
<span className="fin-item-label">Summe</span>
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
</div>
<div className="fin-item-field fin-item-remove">
<button
type="button"
className="fin-icon-btn"
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
disabled={items.length <= 1}
aria-label={`Position ${n} entfernen`}
title="Position entfernen"
>
<FaTrashCan aria-hidden="true" />
</button>
</div>
</div>
)
})}
<div className="fin-items-foot">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
disabled={items.length >= INVOICE_LIMITS.maxItems}
>
<FaPlus aria-hidden="true" /> Position
</Button>
<div className="fin-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount fin-total-value">{euro(total)}</span>
</div>
</div>
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
</fieldset>
<div className="fin-dialog-row">
<div className="form-group fin-days">
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
<input
id="fin-invoice-days"
className="form-control fin-amount"
inputMode="numeric"
value={days}
onChange={(e) => {
setDays(e.target.value)
if (error) setError('')
}}
/>
</div>
<div className="form-group fin-memo">
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
<textarea
id="fin-invoice-memo"
className="form-control"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</div>
</div>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={onClose} disabled={disabled}>Abbrechen</Button>
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button
type="button"
variant="primary"
onClick={() => submit(true)}
disabled={disabled || !gate.sendAllowed}
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
</form>
</div>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
)
// Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
return (
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={disabled} onSubmit={(e) => e.preventDefault()} footer={footer}>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
<Field label="Kunde" htmlFor="fin-invoice-customer">
{fixedCustomer ? (
<p className="fin-fixed-customer">
<strong className="strong">{customerName(fixedCustomer) || 'Kunde'}</strong>
{fixedCustomer.email ? <span className="msg muted"> · {fixedCustomer.email}</span> : null}
</p>
) : source?.loading ? (
<p className="msg muted" role="status">Kunden werden geladen …</p>
) : source?.error ? (
<p className="msg warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
) : (
<AutocompleteInput
id="fin-invoice-customer"
value={customerText}
onChange={(v) => {
setCustomerText(v)
if (error) setError('')
}}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
{!customerBlocked.ok && (
<p className="msg warn" role="alert">
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!fixedCustomer && resolved.state === 'unknown' && (
<p className="msg muted">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
<p className="msg muted">Die Rechnung geht an {customer.email}.</p>
)}
{gate.warn && (
<AddressWarning
customer={customer}
variant="dialog"
onShowOverview={
onShowOverview
? () => {
if (lockRef.current) return
onClose()
onShowOverview()
}
: undefined
}
/>
)}
</Field>
<fieldset className="fin-items">
<legend>Positionen</legend>
{items.map((item, index) => {
const line = lineTotalCents(item)
const n = index + 1
return (
<div key={item.uid} className="fin-item">
<div className="fin-item-field fin-item-desc">
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
<input
id={`fin-item-desc-${item.uid}`}
className="input"
maxLength={INVOICE_LIMITS.maxDescription}
value={item.description}
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
aria-label={`Position ${n}: Beschreibung`}
placeholder="z. B. Webdesign Startseite"
/>
</div>
<div className="fin-item-field fin-item-qty">
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
<input
id={`fin-item-qty-${item.uid}`}
className="input fin-amount"
inputMode="numeric"
value={item.quantity}
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
aria-label={`Position ${n}: Menge`}
/>
</div>
<div className="fin-item-field fin-item-price">
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
<input
id={`fin-item-price-${item.uid}`}
className="input fin-amount"
inputMode="decimal"
value={item.price}
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
aria-label={`Position ${n}: Einzelpreis in Euro`}
placeholder="0,00"
/>
</div>
<div className="fin-item-field fin-item-sum">
<span className="fin-item-label">Summe</span>
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
</div>
<div className="fin-item-field fin-item-remove">
<button
type="button"
className="link"
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
disabled={items.length <= 1}
aria-label={`Position ${n} entfernen`}
>
Entfernen
</button>
</div>
</div>
)
})}
<div className="fin-items-foot">
<Button
type="button"
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
disabled={items.length >= INVOICE_LIMITS.maxItems}
>
+ Position
</Button>
<div className="fin-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount">{euro(total)}</span>
</div>
</div>
<p className="msg muted">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
</fieldset>
<div className="fin-dialog-row">
<Field label="Zahlungsziel in Tagen" htmlFor="fin-invoice-days">
<input
id="fin-invoice-days"
className="input fin-amount"
inputMode="numeric"
value={days}
onChange={(e) => {
setDays(e.target.value)
if (error) setError('')
}}
/>
</Field>
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="fin-invoice-memo">
<textarea
id="fin-invoice-memo"
className="textarea"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</Field>
</div>
</Dialog>
)
}
+132 -122
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaSpinner } from 'react-icons/fa6'
import { Badge, Button, Card, EmptyState } from '../ui'
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
import InvoiceCreateModal from './InvoiceCreateModal'
import Pager from './Pager'
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
import { pageOf } from '../../lib/financeFormat'
import { noticeProps } from '../../lib/financeUi'
import {
ADDRESS_INCOMPLETE_TEXT,
DEFAULT_INVOICE_FILTER,
@@ -33,8 +33,8 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del
* eine Anfrage läuft, sind alle Aktionen gesperrt. „Rechnung schreiben“ öffnet den Dialog.
*
* customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede
* Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Wrapper .fin-body bis 860 px)
* wird jede Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
* fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
*
@@ -147,137 +147,147 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
changed()
}
return (
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
<Card
title="Rechnungen"
className="fin-invoices-card"
bodyStyle={{ padding: 0 }}
actions={
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
<FaPlus aria-hidden="true" /> Rechnung schreiben
<Button variant="primary" onClick={() => setCreateOpen(true)}>
Rechnung schreiben
</Button>
}
>
{ages.length > 0 && (
<ul className="fin-ages" aria-label="Offene Rechnungen nach Alter">
{ages.map((cell) => (
<li key={cell.key} className="fin-age">
<span className="fin-age-label">{cell.label}</span>
<span className="fin-age-count">{cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}</span>
<span className="fin-amount fin-age-sum">{cell.sumText}</span>
</li>
))}
</ul>
)}
<div className="fin-body">
{ages.length > 0 && (
<ul className="stats fin-ages" aria-label="Offene Rechnungen nach Alter">
{ages.map((cell) => (
<li key={cell.key}>
<Stat label={cell.label} value={cell.sumText} sub={`${cell.count} ${cell.count === 1 ? 'Rechnung' : 'Rechnungen'}`} />
</li>
))}
</ul>
)}
<div className="fin-toolbar">
<div className="seg" role="group" aria-label="Rechnungen filtern">
{INVOICE_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={status === f.key ? 'active' : ''}
aria-pressed={status === f.key}
onClick={() => pickFilter(f.key)}
>
{f.label}
</button>
))}
<div className="toolbar">
<div className="seg" role="group" aria-label="Rechnungen filtern">
{INVOICE_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={status === f.key ? 'on' : ''}
aria-pressed={status === f.key}
onClick={() => pickFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
</div>
{message && (
<p className={`fin-hint fin-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{message.text}</p>}
{loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Rechnungen werden geladen …</p>
</div>
) : loadError ? (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{loadError}</p>
<Button size="sm" onClick={reload}>Erneut versuchen</Button>
</div>
) : rows.length === 0 ? (
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
) : (
<div className="fin-table-wrap">
<table className="ui-table fin-invoices" role="table">
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Nr.</th>
<th role="columnheader" scope="col">Kunde</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Fällig</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-inv-number">
{row.numberText}
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
</td>
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
<td role="cell" className="fin-inv-status">
<div className="fin-inv-statusbox">
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
</div>
</td>
<td role="cell" className="fin-inv-actions">
<div className="fin-actions">
{row.actions.map((action) =>
action.url ? (
<Button
key={action.key}
as="a"
size="sm"
variant="ghost"
href={action.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
>
{action.label}
</Button>
) : (
<Button
key={action.key}
size="sm"
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
onClick={() => run(row, action)}
disabled={busy !== null || (action.key === 'send' && sendBlocked(byId[row.id]))}
title={action.key === 'send' && sendBlocked(byId[row.id]) ? ADDRESS_INCOMPLETE_TEXT : undefined}
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
>
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
</Button>
)
)}
</div>
</td>
{loading ? (
<div className="loading" role="status">Rechnungen werden geladen …</div>
) : loadError ? (
<div className="fin-load-error">
<p className="msg warn" role="alert">{loadError}</p>
<Button onClick={reload}>Erneut versuchen</Button>
</div>
) : rows.length === 0 ? (
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
) : (
<div className="fin-table-wrap">
<table className="table fin-invoices" role="table">
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Nr.</th>
<th role="columnheader" scope="col">Kunde</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Fällig</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Aktionen</th>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-inv-number">
{row.numberText}
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
</td>
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
<td role="cell" className="fin-inv-status">
<div className="fin-inv-statusbox">
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
</div>
</td>
<td role="cell" className="fin-inv-actions">
<div className="fin-actions">
{row.actions.map((action) => {
const label = `${action.label}: ${row.numberText}, ${row.customerText}`
if (action.url) {
return (
<a
key={action.key}
className="link"
href={action.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
>
{action.label}
</a>
)
}
const running = busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label
if (action.key === 'delete' || action.key === 'void') {
return (
<Button
key={action.key}
type="button"
variant="danger"
onClick={() => run(row, action)}
disabled={busy !== null}
aria-label={label}
>
{running}
</Button>
)
}
const gated = action.key === 'send' && sendBlocked(byId[row.id])
return (
<button
key={action.key}
type="button"
className="link"
onClick={() => run(row, action)}
disabled={busy !== null || gated}
title={gated ? ADDRESS_INCOMPLETE_TEXT : undefined}
aria-label={label}
>
{running}
</button>
)
})}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
</div>
</Card>
<InvoiceCreateModal
+13 -13
View File
@@ -1,8 +1,8 @@
import { useId, useState } from 'react'
import { FaChevronDown, FaChevronUp } from 'react-icons/fa6'
import { Kpi } from '../ui'
import { Stat } from '../ui'
import { euroWhole } from '../../lib/financeFormat'
import { breakdownRows, lowPointLines } from '../../lib/financeView'
import { isNegText } from '../../lib/financeUi'
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
@@ -15,20 +15,21 @@ export default function LiquidityHead({ kopf }) {
const hasBalance = isNum(k.kontostandCents)
const shortfall = isNum(k.verfuegbarCents) && k.verfuegbarCents < 0
const lows = lowPointLines(k)
const anyLowNegative = lows.some((l) => l && l.negative)
const balanceText = euroWhole(k.kontostandCents)
return (
<div className="kpi-grid fin-head">
<Kpi
<div className="stats fin-head">
<Stat
label="Kontostand"
value={euroWhole(k.kontostandCents)}
value={balanceText}
tone={isNegText(balanceText) ? 'neg' : ''}
sub={hasBalance ? 'gebucht' : 'Noch kein Kontostand abgerufen'}
/>
<Kpi
<Stat
label="Verfügbar nach Rücklagen"
value={euroWhole(k.verfuegbarCents)}
accent={shortfall ? 'var(--danger)' : 'var(--accent)'}
tone={shortfall ? 'neg' : ''}
sub={
<>
{shortfall && <div className="fin-flag">Rücklagen nicht gedeckt</div>}
@@ -36,12 +37,12 @@ export default function LiquidityHead({ kopf }) {
<>
<button
type="button"
className="fin-link"
className="link"
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((v) => !v)}
>
Aufschlüsselung {open ? <FaChevronUp aria-hidden="true" /> : <FaChevronDown aria-hidden="true" />}
Aufschlüsselung <span aria-hidden="true">{open ? '▴' : '▾'}</span>
</button>
{open && (
<ul id={listId} className="fin-breakdown">
@@ -60,9 +61,8 @@ export default function LiquidityHead({ kopf }) {
}
/>
<Kpi
<Stat
label="Tiefster Stand in 30 Tagen"
accent={anyLowNegative ? 'var(--danger)' : 'var(--info)'}
value={
lows.some(Boolean) ? (
<div className="fin-lows">
@@ -71,7 +71,7 @@ export default function LiquidityHead({ kopf }) {
low && (
<div key={low.key} className="fin-low">
<span className="fin-low-label">{low.label}:</span>
<span className="fin-amount">{low.amountText}</span>
<span className={`fin-amount${low.negative ? ' fin-neg' : ''}`}>{low.amountText}</span>
<span className="fin-low-when">
am {low.dateText}
{low.negative && <span className="fin-flag"> · im Minus</span>}
+3 -3
View File
@@ -7,15 +7,15 @@ import { Button } from '../ui'
export default function Pager({ page, pages, total, noun = 'Einträge', onPage }) {
if (pages <= 1) return null
return (
<nav className="fin-pager" aria-label={`Seiten der ${noun}`}>
<Button size="sm" variant="ghost" onClick={() => onPage(page - 1)} disabled={page <= 1}>
<nav className="table-foot fin-pager" aria-label={`Seiten der ${noun}`}>
<Button type="button" variant="quiet" onClick={() => onPage(page - 1)} disabled={page <= 1}>
Zurück
</Button>
<span className="fin-pager-info" aria-live="polite">
Seite {page} von {pages}
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
</span>
<Button size="sm" variant="ghost" onClick={() => onPage(page + 1)} disabled={page >= pages}>
<Button type="button" variant="quiet" onClick={() => onPage(page + 1)} disabled={page >= pages}>
Weiter
</Button>
</nav>
+15 -20
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { FaFloppyDisk, FaSpinner } from 'react-icons/fa6'
import { Button, Card } from '../ui'
import FormField from './FormField'
import { useFinanceResource } from '../../hooks/useFinanceResource'
import { noticeProps } from '../../lib/financeUi'
import { getSettings, patchSettings } from '../../lib/financeApi'
import {
BANK_DETAILS_EXAMPLE,
@@ -29,14 +29,11 @@ export default function SettingsCard() {
return (
<Card title="Einstellungen">
{res.loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Einstellungen werden geladen …</p>
</div>
<div className="loading" role="status">Einstellungen werden geladen …</div>
) : (
<div className="fin-load-error" style={{ padding: 0 }}>
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
<div className="fin-load-error">
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
<Button onClick={res.reload}>Erneut versuchen</Button>
</div>
)}
</Card>
@@ -103,14 +100,14 @@ function SettingsForm({ initial }) {
<form onSubmit={submit} noValidate>
{/* Während des Speicherns gesperrt, damit die Antwort keine neuen Eingaben überschreibt */}
<fieldset className="fin-fieldset" disabled={busy}>
<div className="fin-form-grid">
<div className="field-row">
<FormField
id={fieldId('minBuffer')}
label="Mindestpuffer (€)"
hint="Dieser Betrag bleibt bei „Verfügbar“ immer auf dem Konto. 0 ist möglich."
error={errors.minBuffer}
>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.minBuffer} onChange={change('minBuffer')} placeholder="z. B. 500,00" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.minBuffer} onChange={change('minBuffer')} placeholder="z. B. 500,00" />
</FormField>
<FormField
id={fieldId('taxReservePercent')}
@@ -118,15 +115,17 @@ function SettingsForm({ initial }) {
hint={`Anteil des Gewinns, der als Rücklage gilt (0 bis ${SETTINGS_LIMITS.maxPercent}).`}
error={errors.taxReservePercent}
>
<input className="form-control fin-input-amount" inputMode="numeric" autoComplete="off" value={form.taxReservePercent} onChange={change('taxReservePercent')} placeholder="30" />
<input className="input fin-input-amount" inputMode="numeric" autoComplete="off" value={form.taxReservePercent} onChange={change('taxReservePercent')} placeholder="30" />
</FormField>
</div>
<div className="field-row">
<FormField
id={fieldId('previousYearRevenue')}
label="Vorjahresumsatz (€)"
hint="Für die Kleinunternehmer-Grenze. Leer lassen, wenn unbekannt."
error={errors.previousYearRevenue}
>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.previousYearRevenue} onChange={change('previousYearRevenue')} placeholder="leer = unbekannt" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.previousYearRevenue} onChange={change('previousYearRevenue')} placeholder="leer = unbekannt" />
</FormField>
<FormField
id={fieldId('estPrepayment')}
@@ -134,7 +133,7 @@ function SettingsForm({ initial }) {
hint="Betrag je Termin (10.03., 10.06., 10.09., 10.12.). 0 = keine Vorauszahlung."
error={errors.estPrepayment}
>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.estPrepayment} onChange={change('estPrepayment')} placeholder="0,00" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.estPrepayment} onChange={change('estPrepayment')} placeholder="0,00" />
</FormField>
</div>
@@ -144,21 +143,17 @@ function SettingsForm({ initial }) {
hint={`Steht im Fuß jeder Rechnung, z. B. „${BANK_DETAILS_EXAMPLE}“.`}
error={errors.bankDetailsText}
>
<textarea className="form-control" rows={3} value={form.bankDetailsText} onChange={change('bankDetailsText')} />
<textarea className="textarea" rows={3} value={form.bankDetailsText} onChange={change('bankDetailsText')} />
</FormField>
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
</div>
</fieldset>
{notice && (
<p className={`fin-hint fin-form-notice${notice.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={notice.tone === 'warn' ? 'alert' : 'status'}>
{notice.text}
</p>
)}
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}
<div className="fin-form-actions">
<Button type="submit" variant="primary" disabled={busy}>
{busy ? <FaSpinner className="spinner" aria-hidden="true" /> : <FaFloppyDisk aria-hidden="true" />} {busy ? 'Speichere …' : 'Speichern'}
{busy ? 'Speichere …' : 'Speichern'}
</Button>
</div>
</form>
+3 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { FaRotate, FaTriangleExclamation } from 'react-icons/fa6'
import { Button } from '../ui'
import { statusItems } from '../../lib/financeView'
import { noticeProps } from '../../lib/financeUi'
/**
* Block 0: Stand von Bank und Stripe in einer Zeile und der Knopf „Aktualisieren“.
@@ -37,21 +37,15 @@ export default function StatusLine({ stand, onRefresh, refreshing = false }) {
<div className="fin-status-items">
{items.map((item) => (
<span key={item.key} className={`fin-status-item${item.ok ? '' : ' fin-status-warn'}`}>
{!item.ok && <FaTriangleExclamation aria-hidden="true" />}
{item.text}
</span>
))}
</div>
<Button size="sm" variant="ghost" onClick={handleRefresh} disabled={refreshing}>
<FaRotate className={refreshing ? 'fin-spin' : undefined} aria-hidden="true" />{' '}
<Button onClick={handleRefresh} disabled={refreshing}>
{refreshing ? 'Aktualisiere …' : 'Aktualisieren'}
</Button>
</div>
{hint && (
<p className={`fin-hint${hint.limited ? '' : ' fin-hint-warn'}`} role={hint.limited ? 'status' : 'alert'}>
{hint.text}
</p>
)}
{hint && <p {...noticeProps(hint.limited ? 'info' : 'warn', 'fin-notice')}>{hint.text}</p>}
</div>
)
}
+39 -32
View File
@@ -1,5 +1,6 @@
import { Card } from '../ui'
import { taxView } from '../../lib/financeView'
import { isNegText } from '../../lib/financeUi'
/**
* Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen,
@@ -10,43 +11,49 @@ import { taxView } from '../../lib/financeView'
export default function TaxCard({ steuer }) {
const v = taxView(steuer)
return (
<Card title="Steuer" className="fin-tax-card">
<div className="fin-facts">
<div className="fin-fact">
<div className="fin-fact-row">
<span className="fin-fact-label">Steuer-Rücklage</span>
<span className="fin-amount fin-fact-big">{v.reserve.amountText}</span>
</div>
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
</div>
<div className="fin-fact">
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
<div className="fin-fact-sub">{v.small.limitText}</div>
<div className="fin-fact-sub">{v.small.hardText}</div>
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
</div>
{v.gewerbeText && (
<div className="fin-fact">
<div className="fin-fact-label">Gewerbesteuer</div>
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
</div>
)}
{v.prepayment && (
<Card title="Steuer">
<div className="fin-body">
<div className="fin-facts">
<div className="fin-fact">
<div className="fin-fact-row">
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
<span className="fin-amount fin-fact-strong">{v.prepayment.amountText}</span>
<span className="fin-fact-label">Steuer-Rücklage</span>
<span className={`fin-amount fin-fact-big${isNegText(v.reserve.amountText) ? ' fin-neg' : ''}`}>
{v.reserve.amountText}
</span>
</div>
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
</div>
)}
<p className="fin-hint fin-tax-note">{v.noteText}</p>
<div className="fin-fact">
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
<div className="fin-fact-sub">{v.small.limitText}</div>
<div className="fin-fact-sub">{v.small.hardText}</div>
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
</div>
{v.gewerbeText && (
<div className="fin-fact">
<div className="fin-fact-label">Gewerbesteuer</div>
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
</div>
)}
{v.prepayment && (
<div className="fin-fact">
<div className="fin-fact-row">
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
<span className={`fin-amount fin-fact-strong${isNegText(v.prepayment.amountText) ? ' fin-neg' : ''}`}>
{v.prepayment.amountText}
</span>
</div>
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
</div>
)}
<p className="msg muted fin-tax-note">{v.noteText}</p>
</div>
</div>
</Card>
)
+10 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Badge, Button, Card, EmptyState } from '../ui'
import { Badge, Card, EmptyState } from '../ui'
import { TODO_LIMIT, todoButtonLabel, todoKey, urgencyOf, visibleTodos } from '../../lib/financeView'
/**
@@ -35,12 +35,7 @@ export default function TodoList({ aufgaben, onAction }) {
}
return (
<Card
title="Zu erledigen"
actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}
className="fin-todo-card"
bodyStyle={{ padding: 0 }}
>
<Card title="Zu erledigen" actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}>
{total === 0 ? (
<EmptyState title="Nichts offen" />
) : (
@@ -51,31 +46,31 @@ export default function TodoList({ aufgaben, onAction }) {
const urgency = urgencyOf(todo)
const label = todoButtonLabel(todo)
return (
<li key={key} className={`fin-todo fin-todo-${todo.dringlichkeit || 'info'}`}>
<li key={key} className="fin-todo">
<Badge tone={urgency.tone}>{urgency.label}</Badge>
<div className="fin-todo-main">
<div>
<div className="fin-todo-title">{todo.titel}</div>
{todo.text && <div className="fin-todo-text muted">{todo.text}</div>}
{failure?.key === key && (
<div className="fin-hint fin-hint-warn" role="alert">
<div className="msg warn" role="alert">
{failure.message}
</div>
)}
</div>
{label && (
<Button size="sm" onClick={() => run(todo, key)} disabled={running !== null}>
<button type="button" className="link" onClick={() => run(todo, key)} disabled={running !== null}>
{running === key ? 'Moment …' : label}
</Button>
</button>
)}
</li>
)
})}
</ul>
{total > TODO_LIMIT && (
<div className="fin-todos-more">
<Button size="sm" variant="ghost" onClick={() => setExpanded((v) => !v)}>
<div className="table-foot">
<button type="button" className="link" onClick={() => setExpanded((v) => !v)}>
{expanded ? 'Weniger anzeigen' : `Alle ${total} anzeigen`}
</Button>
</button>
</div>
)}
</>
+33 -29
View File
@@ -1,8 +1,8 @@
import { useMemo, useState } from 'react'
import { FaSpinner, FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Button, Card, EmptyState } from '../ui'
import Pager from './Pager'
import { pageOf } from '../../lib/financeFormat'
import { isNegText, noticeProps } from '../../lib/financeUi'
import { transactionRows } from '../../lib/financeView'
const PAGE_SIZE = 50
@@ -13,8 +13,8 @@ export const TRANSACTIONS_ANCHOR = 'finanzen-buchungen'
* Verwendungszweck (zweizeilig), Betrag mit Vorzeichen, Zuordnung (bei offenen Buchungen der stärkste Vorschlag als
* Text) und der Knopf „Zuordnen“ bzw. „Ändern“. Vorgemerkte Buchungen (PDNG) tragen „vorgemerkt“. Die Daten kommen
* vom Aufrufer (useFinanceTransactions), damit Seite und Zuordnen-Dialog dieselbe Liste benutzen.
* onAssign(buchung) öffnet den Dialog. Schmal (Karte bis 640 px) wird jede Zeile ein Raster: Datum und Betrag /
* Gegenpartei und Verwendungszweck / Zuordnung und Knopf.
* onAssign(buchung) öffnet den Dialog. Schmal (Wrapper .fin-body bis 640 px) wird jede Zeile ein Raster: Datum und
* Betrag / Gegenpartei und Verwendungszweck / Zuordnung und Knopf. Beträge mit „−“ stehen in Rot (fin-neg).
*/
export default function TransactionTable({ transactions, stripeFehler = '', loaded = false, loading = false, error = '', onReload, onAssign }) {
const [page, setPage] = useState(1)
@@ -24,16 +24,13 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
let body
if (!loaded && loading) {
body = (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Buchungen werden geladen …</p>
</div>
<div className="loading" role="status">Buchungen werden geladen …</div>
)
} else if (!loaded) {
body = (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
<Button size="sm" onClick={onReload}>Erneut versuchen</Button>
<p className="msg warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
<Button onClick={onReload}>Erneut versuchen</Button>
</div>
)
} else if (rows.length === 0) {
@@ -41,7 +38,7 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
} else {
body = (
<div className="fin-table-wrap">
<table className="ui-table fin-tx" role="table">
<table className="table fin-tx" role="table">
<caption className="fin-sr">Buchungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
@@ -62,7 +59,12 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
<div className="fin-tx-remittance muted" title={row.remittance}>{row.remittance}</div>
)}
</td>
<td role="cell" className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}`}>{row.amountText}</td>
<td
role="cell"
className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}${isNegText(row.amountText) ? ' fin-neg' : ''}`}
>
{row.amountText}
</td>
<td role="cell" className="fin-tx-assign">
<div className="fin-tx-badges">
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
@@ -73,14 +75,14 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
</td>
<td role="cell" className="fin-tx-action">
{row.action && (
<Button
size="sm"
variant={row.action === 'assign' ? 'default' : 'ghost'}
<button
type="button"
className="link"
onClick={() => onAssign(row.tx)}
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
>
{row.actionLabel}
</Button>
</button>
)}
</td>
</tr>
@@ -93,20 +95,22 @@ export default function TransactionTable({ transactions, stripeFehler = '', load
return (
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
<Card title="Buchungen" className="fin-tx-card" bodyStyle={{ padding: 0 }}>
{loaded && error && (
<p className="fin-hint fin-hint-warn fin-notice" role="alert">
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
<Button size="sm" variant="ghost" onClick={onReload} disabled={loading}>Erneut versuchen</Button>
</p>
)}
{stripeFehler && (
<p className="fin-hint fin-hint-warn fin-notice" role="status">
<FaTriangleExclamation aria-hidden="true" /> {stripeFehler}
</p>
)}
{body}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
<Card title="Buchungen">
<div className="fin-body">
{loaded && error && (
<p {...noticeProps('warn', 'fin-notice')}>
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
<button type="button" className="link" onClick={onReload} disabled={loading}>Erneut versuchen</button>
</p>
)}
{stripeFehler && (
<p className="msg warn fin-notice" role="status">
{stripeFehler}
</p>
)}
{body}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
</div>
</Card>
</section>
)
+55 -54
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { Button } from '../ui'
import { useRef, useState } from 'react'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { Button, Dialog, Field } from '../ui'
const FIELDS = ['provider', 'purpose', 'cost', 'costInterval', 'startDate', 'termMonths', 'renewalMonths', 'noticeValue', 'noticeUnit', 'note']
const NUMBERS = ['termMonths', 'renewalMonths', 'noticeValue']
@@ -31,6 +31,8 @@ export default function ContractForm({ contract, onSave, onClose }) {
const [files, setFiles] = useState([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
const submit = async (e) => {
@@ -47,60 +49,59 @@ export default function ContractForm({ contract, onSave, onClose }) {
}
const input = (key, label, props = {}) => (
<div className="form-group">
<label className="form-label">{label}</label>
<input className="form-control" name={key} value={form[key] || ''} onChange={set(key)} {...props} />
</div>
<Field label={label} htmlFor={`contract-${key}`}>
<input className="input" id={`contract-${key}`} name={key} value={form[key] || ''} onChange={set(key)} {...props} />
</Field>
)
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
<div className="overlay-content">
<h2 className="mb-2">{contract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}</h2>
{error && <div className="legal-error">{error}</div>}
<form onSubmit={submit}>
{input('provider', 'Anbieter', { required: true })}
{input('purpose', 'Wofür')}
<div className="legal-form-row">
{input('cost', 'Kosten (€)', { inputMode: 'decimal' })}
<div className="form-group">
<label className="form-label">pro</label>
<select className="form-control" name="costInterval" value={form.costInterval} onChange={set('costInterval')}>
<option value="month">Monat</option>
<option value="year">Jahr</option>
</select>
</div>
</div>
{input('startDate', 'Vertragsbeginn', { type: 'date' })}
<div className="legal-form-row">
{input('termMonths', 'Laufzeit (Monate, 0 = jederzeit kündbar)', { type: 'number', min: 0 })}
{input('renewalMonths', 'Verlängert sich um (Monate, 0 = endet)', { type: 'number', min: 0 })}
</div>
<div className="legal-form-row">
{input('noticeValue', 'Kündigungsfrist', { type: 'number', min: 0 })}
<div className="form-group">
<label className="form-label">Einheit</label>
<select className="form-control" name="noticeUnit" value={form.noticeUnit} onChange={set('noticeUnit')}>
<option value="months">Monate</option>
<option value="days">Tage</option>
</select>
</div>
</div>
<div className="form-group">
<label className="form-label">Notiz</label>
<textarea className="form-control" name="note" rows={3} value={form.note || ''} onChange={set('note')} />
</div>
<div className="form-group">
<label className="form-label">Dateien hinzufügen</label>
<input type="file" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
</div>
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
<Button type="button" onClick={onClose}>Abbrechen</Button>
</div>
</form>
<Dialog
as="form"
wide
title={contract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
busy={saving}
onClose={onClose}
onSubmit={submit}
footer={
<>
<span className="grow" />
<Button type="button" onClick={onClose} disabled={saving}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
</>
}
>
{error && <div className="msg err" role="alert" ref={errorRef}>{error}</div>}
{input('provider', 'Anbieter', { required: true })}
{input('purpose', 'Wofür')}
<div className="field-row">
{input('cost', 'Kosten (€)', { inputMode: 'decimal' })}
<Field label="pro" htmlFor="contract-costInterval">
<select className="select" id="contract-costInterval" name="costInterval" value={form.costInterval} onChange={set('costInterval')}>
<option value="month">Monat</option>
<option value="year">Jahr</option>
</select>
</Field>
</div>
</div>
{input('startDate', 'Vertragsbeginn', { type: 'date' })}
<div className="field-row">
{input('termMonths', 'Laufzeit (Monate, 0 = jederzeit kündbar)', { type: 'number', min: 0 })}
{input('renewalMonths', 'Verlängert sich um (Monate, 0 = endet)', { type: 'number', min: 0 })}
</div>
<div className="field-row">
{input('noticeValue', 'Kündigungsfrist', { type: 'number', min: 0 })}
<Field label="Einheit" htmlFor="contract-noticeUnit">
<select className="select" id="contract-noticeUnit" name="noticeUnit" value={form.noticeUnit} onChange={set('noticeUnit')}>
<option value="months">Monate</option>
<option value="days">Tage</option>
</select>
</Field>
</div>
<Field label="Notiz" htmlFor="contract-note">
<textarea className="textarea" id="contract-note" name="note" rows={3} value={form.note || ''} onChange={set('note')} />
</Field>
<Field label="Dateien hinzufügen" htmlFor="contract-files">
<input type="file" className="legal-file" id="contract-files" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
</Field>
</Dialog>
)
}
+16 -11
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Badge, Button, Card } from '../ui'
import { Badge, Card } from '../ui'
import { currentVersion, formatDate } from '../../lib/legal'
import VersionForm from './VersionForm'
import { FileLinks } from './legalUi'
@@ -21,8 +21,8 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
}
return (
<div className="legal-grid">
{actionError && <div className="legal-error">{actionError}</div>}
<div className="legal-docs">
{actionError && <div className="msg err legal-msg" role="alert">{actionError}</div>}
{documents.map((doc) => {
const own = versions.filter((v) => v.documentKey === doc.key).sort(byNewest)
const current = currentVersion(own)
@@ -33,9 +33,14 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
key={doc.$id}
className="legal-doc"
title={doc.title}
actions={<Button size="sm" onClick={() => setFormFor(doc)}>Neue Version</Button>}
actions={<button type="button" className="link" onClick={() => setFormFor(doc)}>Neue Version</button>}
>
<div className="legal-doc-summary" onClick={() => setOpen(expanded ? null : doc.key)} role="button">
<button
type="button"
className="legal-doc-summary"
aria-expanded={expanded}
onClick={() => setOpen(expanded ? null : doc.key)}
>
{current ? (
<Badge tone="ok">{current.version} · gilt seit {formatDate(current.validFrom)}</Badge>
) : (
@@ -44,23 +49,23 @@ export default function LegalTextsTab({ documents, versions, fileNames, addVersi
{drafts.map((d) => (
<Badge key={d.$id} tone="muted">Entwurf {d.version}</Badge>
))}
<span className="faint legal-toggle">{expanded ? 'Verlauf ausblenden' : `Verlauf (${own.length})`}</span>
</div>
<span className="legal-toggle">{expanded ? 'Verlauf ausblenden' : `Verlauf (${own.length})`}</span>
</button>
{doc.liveUrl && (
<a href={doc.liveUrl} target="_blank" rel="noreferrer" className="legal-live">Live-Seite öffnen</a>
<a href={doc.liveUrl} target="_blank" rel="noreferrer" className="link legal-live">Live-Seite öffnen</a>
)}
{expanded && (
<ol className="legal-history">
{own.length === 0 && <li className="faint">Noch keine Versionen eingetragen.</li>}
{own.map((v) => (
<li key={v.$id} className={`legal-version legal-version-${v.status}`}>
<li key={v.$id} className={v.status === 'superseded' ? 'legal-version is-superseded' : 'legal-version'}>
<div className="legal-version-head">
<strong>{v.version}</strong>
<span>{formatDate(v.validFrom)} – {v.status === 'current' ? 'heute' : formatDate(v.validTo)}</span>
<span className="muted">{formatDate(v.validFrom)} – {v.status === 'current' ? 'heute' : formatDate(v.validTo)}</span>
{v.status === 'current' && <Badge tone="ok">gültig</Badge>}
{v.status === 'draft' && <Badge tone="muted">Entwurf</Badge>}
{v.status === 'draft' && (
<Button size="sm" onClick={() => promote(v)}>Als gültig markieren</Button>
<button type="button" className="link" onClick={() => promote(v)}>Als gültig markieren</button>
)}
</div>
{v.changeNote && <div className="legal-note">{v.changeNote}</div>}
+69 -68
View File
@@ -1,39 +1,12 @@
import { Fragment, useState } from 'react'
import { Button, EmptyState } from '../ui'
import { Badge, Button, EmptyState } from '../ui'
import { deadlineTone, formatDate, nextCancellationDate } from '../../lib/legal'
import { costLabel, termLabel, noticeLabel, deadlineView } from '../../lib/legalView'
import { isRowActivation } from '../../lib/rowClick'
import ContractForm from './ContractForm'
import { FileLinks, todayIso } from './legalUi'
const TONE_ORDER = { red: 0, orange: 1, normal: 2, missing: 3, grey: 4 }
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
function costLabel(c) {
if (c.cost === null || c.cost === undefined) return '–'
return `${euro.format(c.cost)}/${c.costInterval === 'year' ? 'Jahr' : 'Monat'}`
}
function termLabel(c) {
if (c.termMonths === null || c.termMonths === undefined) return '–'
if (c.termMonths === 0) return 'jederzeit kündbar'
const renew = c.renewalMonths ? `, dann je ${c.renewalMonths} Monate` : ', endet danach'
return `${c.termMonths} Monate${renew}`
}
function noticeLabel(c) {
if (c.noticeValue === null || c.noticeValue === undefined) return '–'
return `${c.noticeValue} ${c.noticeUnit === 'days' ? 'Tage' : 'Monate'}`
}
function deadlineLabel(r) {
switch (r.state) {
case 'active': return `kündbar bis ${formatDate(r.noticeBy)} (Periode endet ${formatDate(r.periodEnd)})`
case 'anytime': return 'jederzeit kündbar'
case 'ends': return `endet am ${formatDate(r.periodEnd)}`
case 'ended': return `beendet seit ${formatDate(r.periodEnd)}`
case 'cancelled': return r.periodEnd ? `gekündigt zum ${formatDate(r.periodEnd)}` : 'gekündigt'
default: return 'Fristen fehlen'
}
}
export default function ProviderContractsTab({ contracts, fileNames, saveContract, deleteContract }) {
const [open, setOpen] = useState(null)
@@ -77,55 +50,83 @@ export default function ProviderContractsTab({ contracts, fileNames, saveContrac
<div className="toolbar">
<Button variant="primary" onClick={() => setEditing('new')}>Neuer Vertrag</Button>
</div>
{actionError && <div className="legal-error">{actionError}</div>}
{actionError && <div className="msg err legal-msg" role="alert">{actionError}</div>}
{rows.length === 0 ? (
<EmptyState title="Noch keine Verträge eingetragen" />
) : (
<table className="ui-table legal-contracts">
<table className="table legal-contracts">
<thead>
<tr>
<th>Anbieter</th><th>Wofür</th><th>Kosten</th><th>Laufzeit</th><th>Kündigungsfrist</th><th>Nächster Termin</th>
<th>Anbieter</th>
<th className="hide-sm">Wofür</th>
<th className="hide-sm">Kosten</th>
<th className="hide-sm">Laufzeit</th>
<th className="hide-sm">Kündigungsfrist</th>
<th>Nächster Termin</th>
</tr>
</thead>
<tbody>
{rows.map(({ c, result, tone }) => (
<Fragment key={c.$id}>
<tr className={`legal-tone-${tone}`} onClick={() => setOpen(open === c.$id ? null : c.$id)}>
<td><strong>{c.provider}</strong></td>
<td>{c.purpose || '–'}</td>
<td>{costLabel(c)}</td>
<td>{termLabel(c)}</td>
<td>{noticeLabel(c)}</td>
<td className="legal-deadline">{deadlineLabel(result)}</td>
</tr>
{open === c.$id && (
<tr className="legal-detail">
<td colSpan={6}>
{c.startDate && <div>Beginn: {formatDate(c.startDate)}</div>}
{c.note && <div className="legal-note">{c.note}</div>}
<div><FileLinks fileIds={c.fileIds} fileNames={fileNames} /></div>
<div className="flex gap-2 legal-actions">
<Button size="sm" onClick={() => setEditing(c)}>Bearbeiten</Button>
{!c.cancelledAt && cancelling !== c.$id && (
<Button size="sm" onClick={() => { setCancelling(c.$id); setEndsAt(result.periodEnd || '') }}>
Als gekündigt markieren
</Button>
)}
{cancelling === c.$id && (
<span className="flex gap-2 items-center">
<label>Endet am</label>
<input type="date" className="form-control" value={endsAt} onChange={(e) => setEndsAt(e.target.value)} />
<Button size="sm" variant="primary" onClick={() => confirmCancel(c)}>Speichern</Button>
<Button size="sm" onClick={() => setCancelling(null)}>Abbrechen</Button>
</span>
)}
<Button size="sm" variant="danger" onClick={() => remove(c)}>Löschen</Button>
{rows.map(({ c, result }) => {
const view = deadlineView(result, today)
const toggle = () => setOpen(open === c.$id ? null : c.$id)
return (
<Fragment key={c.$id}>
<tr className="row" onClick={(e) => { if (isRowActivation(e)) toggle() }}>
<td>
<div className="cell-title">
<button type="button" className="link" aria-expanded={open === c.$id} onClick={toggle}>{c.provider}</button>
</div>
</td>
<td className="hide-sm">{c.purpose || '–'}</td>
<td className="hide-sm">{costLabel(c)}</td>
<td className="hide-sm">{termLabel(c)}</td>
<td className="hide-sm">{noticeLabel(c)}</td>
<td>
{view.badge ? (
<>
<Badge tone={view.badge}>{view.lead}</Badge>
<span className="muted"> {view.rest}</span>
</>
) : (
<span className={view.muted ? 'muted' : undefined}>{view.text}</span>
)}
</td>
</tr>
)}
</Fragment>
))}
{open === c.$id && (
<tr className="legal-detail">
<td colSpan={6}>
<dl className="kv legal-facts">
<dt>Wofür</dt><dd>{c.purpose || '–'}</dd>
<dt>Kosten</dt><dd>{costLabel(c)}</dd>
<dt>Laufzeit</dt><dd>{termLabel(c)}</dd>
<dt>Kündigungsfrist</dt><dd>{noticeLabel(c)}</dd>
</dl>
{c.startDate && <div>Beginn: {formatDate(c.startDate)}</div>}
{c.note && <div className="legal-note">{c.note}</div>}
<div><FileLinks fileIds={c.fileIds} fileNames={fileNames} /></div>
<div className="legal-actions">
<button type="button" className="link" onClick={() => setEditing(c)}>Bearbeiten</button>
{!c.cancelledAt && cancelling !== c.$id && (
<button type="button" className="link" onClick={() => { setCancelling(c.$id); setEndsAt(result.periodEnd || '') }}>
Als gekündigt markieren
</button>
)}
{cancelling === c.$id && (
<span className="legal-cancel">
<label htmlFor={`cancel-${c.$id}`}>Endet am</label>
<input id={`cancel-${c.$id}`} type="date" className="input" value={endsAt} onChange={(e) => setEndsAt(e.target.value)} />
<Button variant="primary" onClick={() => confirmCancel(c)}>Speichern</Button>
<Button onClick={() => setCancelling(null)}>Abbrechen</Button>
</span>
)}
<Button variant="danger" onClick={() => remove(c)}>Löschen</Button>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
)}
+39 -39
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { Button } from '../ui'
import { useRef, useState } from 'react'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { Button, Dialog, Field } from '../ui'
import { todayIso } from './legalUi'
export default function VersionForm({ title, onSave, onClose }) {
@@ -8,6 +8,8 @@ export default function VersionForm({ title, onSave, onClose }) {
const [files, setFiles] = useState([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
const submit = async (e) => {
@@ -24,41 +26,39 @@ export default function VersionForm({ title, onSave, onClose }) {
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}><FaTimes /></span>
<div className="overlay-content">
<h2 className="mb-2">Neue Version: {title}</h2>
{error && <div className="legal-error">{error}</div>}
<form onSubmit={submit}>
<div className="form-group">
<label className="form-label">Version</label>
<input className="form-control" name="version" value={form.version} onChange={set('version')} placeholder="z. B. v6" required />
</div>
<div className="form-group">
<label className="form-label">Status</label>
<select className="form-control" name="status" value={form.status} onChange={set('status')}>
<option value="current">Gültig</option>
<option value="draft">Entwurf (noch nicht live)</option>
</select>
</div>
<div className="form-group">
<label className="form-label">Gültig ab</label>
<input className="form-control" type="date" name="validFrom" value={form.validFrom} onChange={set('validFrom')} required />
</div>
<div className="form-group">
<label className="form-label">Was hat sich geändert?</label>
<textarea className="form-control" name="changeNote" rows={4} value={form.changeNote} onChange={set('changeNote')} />
</div>
<div className="form-group">
<label className="form-label">Dateien (PDF, HTML, Bild, Word)</label>
<input type="file" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
</div>
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
<Button type="button" onClick={onClose}>Abbrechen</Button>
</div>
</form>
</div>
</div>
<Dialog
as="form"
title={`Neue Version: ${title}`}
busy={saving}
onClose={onClose}
onSubmit={submit}
footer={
<>
<span className="grow" />
<Button type="button" onClick={onClose} disabled={saving}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={saving}>{saving ? 'Speichert …' : 'Speichern'}</Button>
</>
}
>
{error && <div className="msg err" role="alert" ref={errorRef}>{error}</div>}
<Field label="Version" htmlFor="version-version">
<input className="input" id="version-version" name="version" value={form.version} onChange={set('version')} placeholder="z. B. v6" required />
</Field>
<Field label="Status" htmlFor="version-status">
<select className="select" id="version-status" name="status" value={form.status} onChange={set('status')}>
<option value="current">Gültig</option>
<option value="draft">Entwurf (noch nicht live)</option>
</select>
</Field>
<Field label="Gültig ab" htmlFor="version-validFrom">
<input className="input" id="version-validFrom" type="date" name="validFrom" value={form.validFrom} onChange={set('validFrom')} required />
</Field>
<Field label="Was hat sich geändert?" htmlFor="version-changeNote">
<textarea className="textarea" id="version-changeNote" name="changeNote" rows={4} value={form.changeNote} onChange={set('changeNote')} />
</Field>
<Field label="Dateien (PDF, HTML, Bild, Word)" htmlFor="version-files">
<input type="file" className="legal-file" id="version-files" multiple accept=".pdf,.html,.htm,.png,.jpg,.jpeg,.webp,.doc,.docx" onChange={(e) => setFiles([...e.target.files])} />
</Field>
</Dialog>
)
}
+2 -2
View File
@@ -11,9 +11,9 @@ export function FileLinks({ fileIds = [], fileNames = {} }) {
<span className="legal-files">
{fileIds.map((id) => (
<span key={id}>
<a href={fileUrl(id)} target="_blank" rel="noreferrer">{fileNames[id] || 'Datei'}</a>
<a href={fileUrl(id)} target="_blank" rel="noreferrer" className="link">{fileNames[id] || 'Datei'}</a>
{' '}
<a href={fileDownloadUrl(id)} className="faint">(herunterladen)</a>
<a href={fileDownloadUrl(id)} className="link legal-dl">(herunterladen)</a>
</span>
))}
</span>
+54 -45
View File
@@ -1,11 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Button, AutocompleteInput } from '../ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Dialog, Field, Button, AutocompleteInput } from '../ui'
import { useCustomers } from '../../hooks/useCustomers'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { createOffer, getOfferContext } from '../../lib/teamApi'
import { customerPickLabel, findCustomerByLabel } from '../../lib/chatFormat'
import { createdMessage, evaluateOfferForm, offerConfirm } from '../../lib/offerFormat'
import '../../styles/admin-areas.css'
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
@@ -29,6 +28,8 @@ export default function OfferForm({ fixedCustomerId, fixedCustomerName, onCreate
const [ctx, setCtx] = useState(null) // { customerId, data } | { customerId, error }
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const errorRef = useRef(null)
useScrollIntoView(errorRef, error)
useEffect(() => {
setCtx(null)
@@ -83,62 +84,70 @@ export default function OfferForm({ fixedCustomerId, fixedCustomerName, onCreate
}
let previewBox
if (!customerId) previewBox = <div>Bitte einen Kunden aus der Liste wählen.</div>
else if (ctx?.error && ctx.customerId === customerId) previewBox = <div className="area-danger">{ctx.error}</div>
else if (!ready) previewBox = <div>Lade Kunde…</div>
if (!customerId) previewBox = <p>Bitte einen Kunden aus der Liste wählen.</p>
else if (ctx?.error && ctx.customerId === customerId) previewBox = <p className="msg err">{ctx.error}</p>
else if (!ready) previewBox = <p>Lade Kunde…</p>
else {
previewBox = (
<>
{evaluation.errors.map((l) => <div key={l} className="area-danger">{l}</div>)}
{evaluation.previewLines.map((l) => <div key={l}>{l}</div>)}
{evaluation.errors.map((l) => <p key={l} className="msg err">{l}</p>)}
{evaluation.previewLines.map((l) => <p key={l}>{l}</p>)}
</>
)
}
return (
<Card className="offer-form">
<h3 style={{ marginTop: 0 }}>Neues Angebot</h3>
<div className="credit-calc">
<label style={{ gridColumn: '1 / -1' }}>Kunde
{fixedCustomerId
? <strong style={{ color: 'var(--text)' }}>{fixedCustomerName || 'Kunde'}</strong>
: <AutocompleteInput value={label} onChange={onPick} getSuggestions={suggestions} placeholder="Name, E-Mail oder Kundennummer tippen …" />}
</label>
<label>Produkt
<select className="form-control" value={productKey} onChange={(e) => setProductKey(e.target.value)}>
<Dialog
title="Neues Angebot"
onClose={onCancel}
busy={busy}
footer={
<>
<span className="grow" />
<Button type="button" onClick={onCancel} disabled={busy}>Abbrechen</Button>
<Button variant="primary" type="button" onClick={save} disabled={!evaluation || evaluation.errors.length > 0 || busy}>
{busy ? 'Speichert …' : evaluation?.saveLabel || 'Angebot anlegen'}
</Button>
</>
}
>
<Field label="Kunde" htmlFor={fixedCustomerId ? undefined : 'offer-customer'}>
{fixedCustomerId
? <p className="strong">{fixedCustomerName || 'Kunde'}</p>
: <AutocompleteInput id="offer-customer" value={label} onChange={onPick} getSuggestions={suggestions} placeholder="Name, E-Mail oder Kundennummer tippen …" />}
</Field>
<div className="field-row">
<Field label="Produkt" htmlFor="offer-product">
<select id="offer-product" className="select" value={productKey} onChange={(e) => setProductKey(e.target.value)}>
<option value="wartung">Wartungs-Abo</option>
<option value="hosting">Hosting-Abo</option>
</select>
</label>
<label>Preis pro Monat im Angebot (€)
<input className="form-control" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
</label>
<label>für
<select className="form-control" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
</Field>
<Field label="Preis pro Monat im Angebot (€)" htmlFor="offer-price">
<input id="offer-price" className="input" type="number" min="0" step="0.01" value={price} onChange={(e) => setPrice(e.target.value)} />
</Field>
</div>
<div className="field-row">
<Field label="für" htmlFor="offer-months">
<select id="offer-months" className="select" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
{MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
</select>
</label>
<label>Angebot gültig bis
<input className="form-control" type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
</label>
</Field>
<Field label="Angebot gültig bis" htmlFor="offer-valid-until">
<input id="offer-valid-until" className="input" type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
</Field>
</div>
<label className="credit-field">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)
<input className="form-control" maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</label>
<label className="credit-field">Interne Notiz (optional, sieht nur das Team)
<input className="form-control" maxLength={2000} value={internalNote} onChange={(e) => setInternalNote(e.target.value)} />
</label>
<label style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
<Field label="Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)" htmlFor="offer-note">
<input id="offer-note" className="input" maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
<Field label="Interne Notiz (optional, sieht nur das Team)" htmlFor="offer-internal-note">
<input id="offer-internal-note" className="input" maxLength={2000} value={internalNote} onChange={(e) => setInternalNote(e.target.value)} />
</Field>
<label className="check">
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} /> Kunden per E-Mail über das Angebot informieren
</label>
<div className="area-summary">{previewBox}</div>
<div className="area-inline">
<Button variant="primary" onClick={save} disabled={!evaluation || evaluation.errors.length > 0 || busy}>
{busy ? <FaSpinner className="spinner" /> : evaluation?.saveLabel || 'Angebot anlegen'}
</Button>
<Button variant="ghost" onClick={onCancel}>Abbrechen</Button>
</div>
{error && <div className="area-error">{error}</div>}
</Card>
<div>{previewBox}</div>
{error && <p className="msg err" role="alert" ref={errorRef}>{error}</p>}
</Dialog>
)
}
+60 -56
View File
@@ -1,12 +1,10 @@
import { useEffect, useState } from 'react'
import { FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button } from '../ui'
import { Badge, Button, EmptyState, Tabs } from '../ui'
import { listOffers, notifyOffer, withdrawOffer } from '../../lib/teamApi'
import { discountLine, offerStatus } from '../../lib/offerFormat'
import { discountLine, filterOffers, offerMailLine, offerRowActions, offerStatus } from '../../lib/offerFormat'
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
import '../../styles/admin-areas.css'
// Liste der Abo-Angebote (Filter "Offen & aktiv"/"Alle") wie im frueheren Admin-Panel; optional nur ein Kunde
// Liste der Abo-Angebote (Reiter "Offen & aktiv"/"Alle") als Tabelle; optional nur ein Kunde
export default function OfferList({ customerId, reloadKey, onMessage, onChanged }) {
const [filter, setFilter] = useState('open')
const [offers, setOffers] = useState(null)
@@ -51,69 +49,75 @@ export default function OfferList({ customerId, reloadKey, onMessage, onChanged
}
}
const shown = (offers || []).filter((o) => filter === 'all' || ['offen', 'aktiv'].includes(o.status))
const shown = filterOffers(offers, filter)
return (
<div>
<div className="area-filter seg">
<button type="button" className={filter === 'open' ? 'active' : ''} onClick={() => setFilter('open')}>Offen &amp; aktiv</button>
<button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>Alle</button>
</div>
<Card pad={false}>
{error && <div className="area-error" style={{ margin: 12 }}>{error}</div>}
{!offers && !error && <div style={{ padding: 16 }}><FaSpinner className="spinner" /></div>}
{offers && (
<div className="area-rows">
<div className="area-row offer-row head"><span>Angelegt</span><span>Kunde</span><span>Angebot</span><span>Gültig bis</span><span>Status</span></div>
{shown.length === 0 && <div className="area-row"><span className="area-muted">{filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</span></div>}
<Tabs tabs={[{ id: 'open', label: 'Offen & aktiv' }, { id: 'all', label: 'Alle' }]} active={filter} onChange={setFilter} />
{error && <div className="msg err" role="alert">{error}</div>}
{!offers && !error && <div className="loading">Wird geladen …</div>}
{offers && shown.length === 0 && (
<EmptyState title={filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'} />
)}
{offers && shown.length > 0 && (
<table className="table table-wrap">
<thead>
<tr>
<th className="hide-md">Angelegt</th>
<th className="col-name">Kunde</th>
<th className="col-offer">Angebot</th>
<th className="hide-sm">Gültig bis</th>
<th className="col-status">Status</th>
</tr>
</thead>
<tbody>
{shown.map((o) => {
const st = offerStatus(o.status)
const discount = discountLine(o)
const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : ''
const mail = offerMailLine(o)
const { sendLabel, canWithdraw } = offerRowActions(o)
return (
<div key={o.id} className="area-row offer-row">
<span>{fmtDateTime(o.createdAt)}<br /><span className="area-muted">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</span></span>
<span>
<strong>{o.customerName}</strong>
{o.customerEmail && <><br /><span className="area-muted">{o.customerEmail}</span></>}
{o.customerCode && <><br /><span className="area-muted">Kd.-Nr. {o.customerCode}</span></>}
</span>
<span>
<span className="area-muted">{productLabelOf(o.productKey)}</span><br />{o.text}
<br /><span className="area-muted">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</span>
{discount && <><br /><span className="area-muted">{discount}</span></>}
{o.internalNote && <><br /><span className="area-muted"><em>intern: {o.internalNote}</em></span></>}
</span>
<span>{o.scope === 'laufend' ? <span className="area-muted">laufendes Abo</span> : fmtDate(o.validUntil)}</span>
<span>
<tr key={o.id}>
<td className="hide-md">
{fmtDateTime(o.createdAt)}
<div className="cell-sub">{o.ref}{o.createdBy ? ` · ${o.createdBy.replace(/\s*<.*>$/, '')}` : ''}</div>
</td>
<td>
<div className="cell-title">{o.customerName}</div>
{o.customerEmail && <div className="cell-sub">{o.customerEmail}</div>}
{o.customerCode && <div className="cell-sub">Kd.-Nr. {o.customerCode}</div>}
</td>
<td>
<div className="cell-sub">{productLabelOf(o.productKey)}</div>
{o.text}
<div className="cell-sub">Ersparnis {formatCents(o.savingsCents)}{o.note ? ` · „${o.note}“` : ''}</div>
{discount && <div className="cell-sub">{discount}</div>}
{o.internalNote && <div className="cell-sub"><em>intern: {o.internalNote}</em></div>}
</td>
<td className="hide-sm nowrap">{o.scope === 'laufend' ? <span className="muted">laufendes Abo</span> : fmtDate(o.validUntil)}</td>
<td>
<Badge tone={st.tone}>{st.label}</Badge>
{sub && <><br /><span className="area-muted">{sub}</span></>}
<br /><span className="area-muted">Stripe:</span>{' '}
{o.stripeCouponUrl ? <a href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
{o.stripeSubscriptionUrl && <> · <a href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
{o.notifiedAt
? <><br /><span className="area-muted">E-Mail gesendet {fmtDateTime(o.notifiedAt)}</span></>
: o.mailSendable
? <><br /><span className="area-warn">E-Mail nicht verschickt</span></>
: o.status === 'offen' && !o.customerEmail ? <><br /><span className="area-warn">Keine E-Mail-Adresse hinterlegt</span></> : null}
{(['offen', 'abgelaufen'].includes(o.status) || o.mailSendable) && (
<span className="area-inline" style={{ marginTop: 6 }}>
{o.mailSendable && (
<Button size="sm" variant="ghost" title={`Angebots-E-Mail an ${o.customerEmail} senden`} onClick={() => sendMail(o)} disabled={busyId === o.id}>
{o.notifiedAt ? 'Erneut senden' : 'E-Mail senden'}
</Button>
{sub && <div className="cell-sub">{sub}</div>}
<div className="cell-sub">
Stripe: {o.stripeCouponUrl ? <a className="link" href={o.stripeCouponUrl} target="_blank" rel="noreferrer">Coupon</a> : '–'}
{o.stripeSubscriptionUrl && <> · <a className="link" href={o.stripeSubscriptionUrl} target="_blank" rel="noreferrer">Abo</a></>}
</div>
{mail && <div className="cell-sub">{mail.tone === 'warn' ? <span className="msg warn">{mail.text}</span> : mail.text}</div>}
{(sendLabel || canWithdraw) && (
<div className="cell-actions">
{sendLabel && (
<button type="button" className="link" title={`Angebots-E-Mail an ${o.customerEmail} senden`} onClick={() => sendMail(o)} disabled={busyId === o.id}>{sendLabel}</button>
)}
{['offen', 'abgelaufen'].includes(o.status) && (
<Button size="sm" variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>
)}
</span>
{canWithdraw && <Button variant="danger" onClick={() => withdraw(o)} disabled={busyId === o.id}>Zurückziehen</Button>}
</div>
)}
</span>
</div>
</td>
</tr>
)
})}
</div>
)}
</Card>
</tbody>
</table>
)}
</div>
)
}
+12 -17
View File
@@ -1,5 +1,4 @@
import { useState } from 'react'
import { FaPlus } from 'react-icons/fa6'
import { Button } from '../ui'
import OfferList from './OfferList'
import OfferForm from './OfferForm'
@@ -13,23 +12,19 @@ export default function OffersView({ customerId, customerName }) {
return (
<div>
{message && <div className="area-message">{message}</div>}
{!showForm && (
<div style={{ marginBottom: 12 }}>
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}><FaPlus /> Neues Angebot</Button>
</div>
)}
{showForm && (
<div style={{ marginBottom: 16 }}>
<OfferForm
fixedCustomerId={customerId}
fixedCustomerName={customerName}
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
onCancel={() => setShowForm(false)}
/>
</div>
)}
{message && <p className="msg ok mb-2" role="status">{message}</p>}
<div className="toolbar">
<Button variant="primary" onClick={() => { setMessage(''); setShowForm(true) }}>Neues Angebot</Button>
</div>
<OfferList customerId={customerId} reloadKey={reloadKey} onMessage={setMessage} onChanged={reload} />
{showForm && (
<OfferForm
fixedCustomerId={customerId}
fixedCustomerName={customerName}
onCreated={(text) => { setMessage(text); setShowForm(false); reload() }}
onCancel={() => setShowForm(false)}
/>
)}
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
/* Tonabbildung der Projekt-Bausteine: reine Zuordnung, ohne JSX (Test: tests/unit/projectTones.test.mjs).
Die Töne sind die Namen der Logik aus Badge.jsx: ok, warn, info, danger, muted, plain.
Jede Zuordnung ist eine Map, kein Objekt: sonst liefert ein Schlüssel wie 'constructor' eine Funktion. */
// previewStatus().color (lib/previewView.js)
const PREVIEW_COLOR_TONES = new Map([
['green', 'ok'],
['yellow', 'warn'],
['red', 'danger'],
['grey', 'plain'],
])
// previewState der Services-Antwort (online, building, starting, sleeping, failed, off)
const PREVIEW_STATE_TONES = new Map([
['online', 'ok'],
['building', 'warn'],
['starting', 'warn'],
['sleeping', 'warn'],
['failed', 'danger'],
])
// analyseStatus().color (lib/analyseView.js)
const ANALYSE_COLOR_TONES = new Map([
['green', 'ok'],
['red', 'danger'],
])
// status und provisioningStatus eines Projekts, wie die Farbtabelle STATUS_COLORS in AssignedProjectCard heute.
// 'deployed' ist absichtlich nicht dabei: es blieb dort in der Standardfarbe (grau).
const STATUS_VALUE_TONES = new Map([
['ready', 'ok'],
['live', 'ok'],
['online', 'ok'],
['provisioning', 'warn'],
['pending', 'warn'],
['building', 'info'],
['error', 'danger'],
['failed', 'danger'],
])
export function previewColorTone(color) {
return PREVIEW_COLOR_TONES.get(color) ?? 'plain'
}
export function previewStateTone(state) {
return PREVIEW_STATE_TONES.get(state) ?? 'plain'
}
export function analyseColorTone(color) {
return ANALYSE_COLOR_TONES.get(color) ?? 'muted'
}
export function statusValueTone(value) {
return STATUS_VALUE_TONES.get(String(value ?? '').toLowerCase()) ?? 'muted'
}
+73 -50
View File
@@ -1,22 +1,26 @@
import { useMemo, useState } from 'react'
import { formatBytes, formatDelta, formatPct } from '../../lib/serverpulsFormat'
import { isRowActivation } from '../../lib/rowClick'
import Sparkline from './Sparkline'
const COLS = [
{ key: 'name', label: 'Projekt', get: (r) => r.name.toLowerCase() },
{ key: 'sizeBytes', label: 'Größe', get: (r) => r.sizeBytes },
{ key: 'delta7d', label: 'Δ 7 T', get: (r) => r.delta7d },
{ key: 'delta30d', label: 'Δ 30 T', get: (r) => r.delta30d },
{ key: 'cpuPct', label: 'CPU', get: (r) => r.cpuPct },
{ key: 'memBytes', label: 'RAM', get: (r) => r.memBytes },
]
// Das Vorzeichen steckt in d.text, die Richtung färbt den Text (rot = gestiegen, grün = gesunken)
function Delta({ pct }) {
const d = formatDelta(pct)
const arrow = d.dir === 'up' ? '▲ ' : d.dir === 'down' ? '▼ ' : ''
return <span className={`sp-delta sp-delta-${d.dir}`}>{arrow}{d.text}</span>
return <span className={`sp-delta sp-delta-${d.dir}`}>{d.text}</span>
}
// num: Zahlenspalte (rechts, tabellarische Ziffern), hide: unter 860 px ausgeblendet (Kernspalten bleiben)
const COLS = [
{ key: 'name', label: 'Projekt', get: (r) => r.name.toLowerCase() },
{ key: 'sizeBytes', label: 'Größe', num: true, get: (r) => r.sizeBytes, cell: (r) => formatBytes(r.sizeBytes) },
{ key: 'delta7d', label: 'Δ 7 T', num: true, get: (r) => r.delta7d, cell: (r) => <Delta pct={r.delta7d} /> },
{ key: 'delta30d', label: 'Δ 30 T', num: true, hide: true, get: (r) => r.delta30d, cell: (r) => <Delta pct={r.delta30d} /> },
{ key: 'cpuPct', label: 'CPU', num: true, hide: true, get: (r) => r.cpuPct, cell: (r) => (r.cpuPct === null ? '–' : formatPct(r.cpuPct)) },
{ key: 'memBytes', label: 'RAM', num: true, get: (r) => r.memBytes, cell: (r) => formatBytes(r.memBytes) },
]
const cellClass = (c) => [c.num && 'right num', c.hide && 'hide-sm'].filter(Boolean).join(' ') || undefined
export default function AppsTable({ rows, selectedId, onSelect }) {
const [q, setQ] = useState('')
const [sort, setSort] = useState({ key: 'sizeBytes', dir: 'desc' })
@@ -39,52 +43,71 @@ export default function AppsTable({ rows, selectedId, onSelect }) {
const toggle = (key) =>
setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' ? 'asc' : 'desc' }))
// Der Knopf in der ersten Zelle trägt die Tastatur, der Zeilenklick die Maus: isRowActivation filtert Klicks auf Knöpfe aus,
// so löst jede Aktivierung onSelect genau einmal aus
const pick = (id) => onSelect(selectedId === id ? null : id)
return (
<div className="sp-apps" data-testid="sp-apps">
<input
className="sp-search"
data-testid="sp-apps-search"
type="search"
placeholder="Projekt suchen …"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="sp-table-wrap">
<table className="sp-table">
<thead>
<tr>
{COLS.map((c) => (
<th key={c.key} onClick={() => toggle(c.key)} className="sp-sortable" data-sort={c.key}>
{c.label}{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}
</th>
))}
<th>30 Tage</th>
</tr>
</thead>
<tbody>
{list.map((r) => (
<div data-testid="sp-apps">
<div className="toolbar">
<input
className="input search"
data-testid="sp-apps-search"
type="search"
placeholder="Projekt suchen …"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<table className="table">
<thead>
<tr>
{COLS.map((c) => (
<th
key={c.key}
className={cellClass(c)}
data-sort={c.key}
aria-sort={sort.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
>
<button type="button" className="sp-sort" onClick={() => toggle(c.key)}>
{c.label}
<span aria-hidden="true">{sort.key === c.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''}</span>
</button>
</th>
))}
<th className="hide-sm">30 Tage</th>
</tr>
</thead>
<tbody>
{list.map((r) => {
const selected = selectedId === r.id
return (
<tr
key={r.id}
className={`row${selected ? ' sp-row-active' : ''}`}
data-testid="sp-app-row"
data-app-id={r.id}
className={`sp-row ${selectedId === r.id ? 'sp-row-active' : ''}`}
onClick={() => onSelect(selectedId === r.id ? null : r.id)}
onClick={(e) => {
if (isRowActivation(e)) pick(r.id)
}}
>
<td className="sp-name">{r.name}</td>
<td>{formatBytes(r.sizeBytes)}</td>
<td><Delta pct={r.delta7d} /></td>
<td><Delta pct={r.delta30d} /></td>
<td>{r.cpuPct === null ? '–' : formatPct(r.cpuPct)}</td>
<td>{formatBytes(r.memBytes)}</td>
<td><Sparkline points={r.spark30d} width={90} height={24} /></td>
<td className="sp-name">
<button type="button" className="link" aria-pressed={selected} onClick={() => pick(r.id)}>
{r.name}
</button>
</td>
{COLS.slice(1).map((c) => (
<td key={c.key} className={cellClass(c)}>{c.cell(r)}</td>
))}
<td className="hide-sm"><Sparkline points={r.spark30d} width={90} height={24} /></td>
</tr>
))}
{list.length === 0 && (
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
)}
</tbody>
</table>
</div>
)
})}
{list.length === 0 && (
<tr><td colSpan={7} className="sp-empty">Keine Projekte gefunden.</td></tr>
)}
</tbody>
</table>
</div>
)
}
-6
View File
@@ -1,6 +0,0 @@
import Stat from './Stat'
// Altname fuer Stat (wird in P7 geloescht); "icon" wird ignoriert, die Warnfarbe wird zu 'neg'
export default function Kpi({ label, value, sub, icon, accent }) {
return <Stat label={label} value={value} sub={sub} tone={accent === 'var(--danger)' ? 'neg' : ''} />
}
+18 -1
View File
@@ -1,6 +1,23 @@
import { useEffect, useRef } from 'react'
export default function Tabs({ tabs, active, onChange }) {
const listRef = useRef(null)
// Der aktive Reiter gehoert in den sichtbaren Teil der Leiste (Deep-Links ?tab=..., am Handy sind es mehr Reiter als Platz).
// Nur die Leiste scrollt, waagerecht und nur wenn noetig; die Seite selbst bewegt sich nicht (anders als bei scrollIntoView).
useEffect(() => {
const list = listRef.current
const tab = list?.querySelector('[role="tab"][aria-selected="true"]')
if (!list || !tab) return
const l = list.getBoundingClientRect()
const t = tab.getBoundingClientRect()
if (t.left < l.left || t.right > l.right) {
list.scrollLeft += (t.left + t.right) / 2 - (l.left + l.right) / 2
}
}, [active, tabs.length])
return (
<div className="tabs" role="tablist">
<div className="tabs" role="tablist" ref={listRef}>
{tabs.map((t) => (
<button
key={t.id}
-1
View File
@@ -7,7 +7,6 @@ export { default as Tabs } from './Tabs'
export { default as EmptyState } from './EmptyState'
export { default as Field } from './Field'
export { default as Stat } from './Stat'
export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput'
export { default as DateTimeInput } from './DateTimeInput'
export { default as DurationSelect } from './DurationSelect'
+11
View File
@@ -10,6 +10,17 @@ export const CREDIT_STATUS = {
}
export const creditStatus = (status) => CREDIT_STATUS[status] || { tone: 'muted', label: status }
/** Knopf der Listenzeile: offene und rueckgefragte Antraege werden geprueft, alle anderen nur angesehen */
export const creditActionLabel = (status) => (['offen', 'rueckfrage'].includes(status) ? 'Prüfen' : 'Ansehen')
/** Zweite Zeile unter dem Status: bei genehmigten Antraegen Betrag und Buchungsstand, sonst leer */
export const creditStatusNote = (r) =>
(r.status === 'genehmigt' ? `${formatCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}` : '')
/** Zeichen und Ton einer Zeile der automatischen Pruefung (unbekannt: "i" in gedaempftem Ton) */
export const checkGlyph = (status) => (status === 'ok' ? '✓' : status === 'warn' ? '!' : 'i')
export const checkTone = (status) => (status === 'ok' || status === 'warn' || status === 'info' ? status : 'muted')
const HISTORY_LABELS = {
eingereicht: 'Antrag eingereicht',
rueckfrage: 'Rückfrage an den Kunden',
+82
View File
@@ -0,0 +1,82 @@
// Reine Funktionen der Kundenliste und der Kundenseite: Stufe, Filter, Zähler und Abzeichen.
// Ohne Importe, damit Liste, Zähler und Tests dieselbe Logik benutzen.
export function customerStage(c) {
const s = (c?.customerStatus || '').toLowerCase()
if (s === 'lead') return 'lead'
if (s === 'lost') return 'lost'
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
}
/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */
export function isArchived(c) {
return Boolean(c?.archived)
}
/** Reiter der Kundenliste in Anzeigereihenfolge. */
export const CUSTOMER_TABS = [
{ id: 'customer', label: 'Feste Kunden' },
{ id: 'lead', label: 'Leads' },
{ id: 'archived', label: 'Archiviert' },
{ id: 'all', label: 'Alle' },
]
/**
* Filtert die Kundenliste nach Reiter und Suchtext. Die Reihenfolge der Eingabe bleibt.
* Archivierte erscheinen nur im Reiter `archived`. Die Stufe `lost` erscheint nur im Reiter `all`.
*/
export function filterCustomers(customers, { stage = 'customer', search = '' } = {}) {
const s = String(search || '').toLowerCase()
const matchesStage = (c) => {
const archived = isArchived(c)
if (stage === 'archived') return archived
if (archived) return false
if (stage === 'all') return true
if (stage === 'customer' || stage === 'lead') return customerStage(c) === stage
return false
}
return (customers || []).filter((c) => {
if (!matchesStage(c)) return false
if (!s) return true
return `${c.name || ''} ${c.code || ''} ${c.location || ''} ${c.email || ''} ${c.contactName || ''}`.toLowerCase().includes(s)
})
}
/** Zähler je Reiter, berechnet mit derselben Filterfunktion wie die Liste (beachtet den Suchtext). */
export function stageCounts(customers, search = '') {
const counts = {}
for (const tab of CUSTOMER_TABS) counts[tab.id] = filterCustomers(customers, { stage: tab.id, search }).length
return counts
}
const STAGE_LABELS = { customer: 'Fester Kunde', lead: 'Lead', lost: 'Abgesagt' }
export function stageLabel(stage) {
return STAGE_LABELS[stage] || ''
}
/**
* Abzeichen eines Kunden in fester Reihenfolge: Wichtig, Stufe, Archiviert.
* Die Stufe „Fester Kunde“ ist kein Abzeichen. Mit `stage: false` entfällt die Stufe (Kundenseite, dort steht sie in der Eyebrow).
*/
export function customerBadges(c, { stage = true } = {}) {
const badges = []
if (c?.importantCustomer) badges.push({ key: 'important', label: 'Wichtig', tone: 'warn' })
if (stage) {
const st = customerStage(c)
if (st === 'lead') badges.push({ key: 'lead', label: stageLabel('lead'), tone: 'muted' })
else if (st === 'lost') badges.push({ key: 'lost', label: stageLabel('lost'), tone: 'plain' })
}
if (isArchived(c)) badges.push({ key: 'archived', label: 'Archiviert', tone: 'plain' })
return badges
}
/** Kleine Zeile über dem Titel der Kundenseite: „Kundennummer · Stufe“. */
export function customerEyebrow(c) {
return [String(c?.code || '').trim(), stageLabel(customerStage(c))].filter(Boolean).join(' · ')
}
/** Titel der Kundenseite: Name, sonst E-Mail, sonst Gedankenstrich. */
export function customerTitle(c) {
return c?.name || c?.email || '–'
}
+20
View File
@@ -0,0 +1,20 @@
// Prüfung des Mitarbeiter-Formulars (Admin). Rein, unter Node testbar.
const NEED = 'Name und E-Mail sind erforderlich.'
const MIN_PASSWORD = 8
/**
* Prüft das Formular zum Anlegen (isEdit false) oder Bearbeiten (isEdit true).
* Gibt den deutschen Fehlertext zurück, null heißt gültig. Beim Bearbeiten
* heißt ein leeres Passwort „unverändert“.
*/
export function validateEmployeeForm(form, isEdit) {
if (!form.displayName.trim() || !form.email.trim()) return NEED
if (!isEdit && form.password.length < MIN_PASSWORD) {
return 'Das Passwort muss mindestens 8 Zeichen haben.'
}
if (isEdit && form.password && form.password.length < MIN_PASSWORD) {
return 'Ein neues Passwort muss mindestens 8 Zeichen haben.'
}
return null
}
+17
View File
@@ -0,0 +1,17 @@
// Kleine Darstellungs-Helfer fuer die Finanzen-Seiten (kein React, keine Abhaengigkeit, unter Node testbar).
const MINUS = '−' // U+2212, das Minus aus financeFormat.js
/** Wahr, wenn der angezeigte Text mit dem Minuszeichen (U+2212) beginnt. Nur dann wird ein Betrag rot (`fin-neg`). */
export function isNegText(text) {
return typeof text === 'string' && text.startsWith(MINUS)
}
/** Ton ('ok' | 'warn' | sonst) -> Klasse und Rolle einer Meldung; `extra` wird mit Leerzeichen an die Klasse gehaengt. */
export function noticeProps(tone, extra = '') {
const base = tone === 'ok' ? 'msg ok' : tone === 'warn' ? 'msg warn' : 'msg muted'
return {
className: extra ? `${base} ${extra}` : base,
role: tone === 'warn' ? 'alert' : 'status',
}
}
+1 -11
View File
@@ -5,17 +5,7 @@ export function isInvoiceReady(c) {
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
}
export function customerStage(c) {
const s = (c?.customerStatus || '').toLowerCase()
if (s === 'lead') return 'lead'
if (s === 'lost') return 'lost'
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
}
/** Archiviert = aus der Standardansicht ausgeblendet (orthogonal zur CRM-Stufe). */
export function isArchived(c) {
return Boolean(c?.archived)
}
export { customerStage, isArchived, filterCustomers, stageCounts } from './customerList'
/** Kunde archivieren / wiederherstellen. */
export async function setCustomerArchived(customer, archived) {
+53
View File
@@ -0,0 +1,53 @@
// Anzeige-Texte der Vertragsliste im Bereich Rechtliches (reine Funktionen, ohne React).
// Die Rechenlogik der Fristen steht in legal.js; hier nur Beschriftung, Badge und Daempfung.
import { deadlineTone, formatDate } from './legal'
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
export function costLabel(c) {
if (c.cost === null || c.cost === undefined) return '–'
return `${euro.format(c.cost)}/${c.costInterval === 'year' ? 'Jahr' : 'Monat'}`
}
export function termLabel(c) {
if (c.termMonths === null || c.termMonths === undefined) return '–'
if (c.termMonths === 0) return 'jederzeit kündbar'
const renew = c.renewalMonths ? `, dann je ${c.renewalMonths} Monate` : ', endet danach'
return `${c.termMonths} Monate${renew}`
}
export function noticeLabel(c) {
if (c.noticeValue === null || c.noticeValue === undefined) return '–'
return `${c.noticeValue} ${c.noticeUnit === 'days' ? 'Tage' : 'Monate'}`
}
export function deadlineLabel(r) {
switch (r.state) {
case 'active': return `kündbar bis ${formatDate(r.noticeBy)} (Periode endet ${formatDate(r.periodEnd)})`
case 'anytime': return 'jederzeit kündbar'
case 'ends': return `endet am ${formatDate(r.periodEnd)}`
case 'ended': return `beendet seit ${formatDate(r.periodEnd)}`
case 'cancelled': return r.periodEnd ? `gekündigt zum ${formatDate(r.periodEnd)}` : 'gekündigt'
default: return 'Fristen fehlen'
}
}
/**
* Darstellung der Frist-Spalte: text (voller Satz), lead und rest (Aufteilung, wenn ein
* Badge gesetzt ist, sonst lead = text und rest leer), badge ('danger' bei rot, 'warn' bei
* orange, sonst null) und muted (Vertrag beendet, gekuendigt oder unvollstaendig).
*/
export function deadlineView(result, today) {
const tone = deadlineTone(result, today)
const text = deadlineLabel(result)
const badge = tone === 'red' ? 'danger' : tone === 'orange' ? 'warn' : null
const muted = tone === 'grey' || tone === 'missing'
if (!badge) return { text, lead: text, rest: '', badge, muted }
return {
text,
lead: `kündbar bis ${formatDate(result.noticeBy)}`,
rest: `(Periode endet ${formatDate(result.periodEnd)})`,
badge,
muted,
}
}
+19 -1
View File
@@ -1,4 +1,4 @@
import { berlinYmd, fmtBerlin, fmtDate, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
import { berlinYmd, fmtBerlin, fmtDate, fmtDateTime, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
// Abo-Angebote: Texte, Rabattende und Formularpruefung wortgleich aus dem frueheren Admin-Panel des Portals.
// "today" ist der Kalendertag in Deutschland (YYYY-MM-DD) und fuer Tests injizierbar.
@@ -11,6 +11,24 @@ export const OFFER_STATUS = {
}
export const offerStatus = (status) => OFFER_STATUS[status] || { tone: 'muted', label: status }
/** Listenfilter: "all" zeigt alles, sonst nur offene und aktive Angebote; ohne Liste leer, die Eingabe bleibt unveraendert */
export const filterOffers = (offers, filter) =>
(offers || []).filter((o) => filter === 'all' || ['offen', 'aktiv'].includes(o.status))
/** Aktionen der Zeile: Beschriftung des E-Mail-Knopfs ('' = kein Knopf) und ob Zurueckziehen moeglich ist */
export const offerRowActions = (o) => ({
sendLabel: o.mailSendable ? (o.notifiedAt ? 'Erneut senden' : 'E-Mail senden') : '',
canWithdraw: ['offen', 'abgelaufen'].includes(o.status),
})
/** Mail-Zeile unter dem Status (Reihenfolge: gesendet, nicht verschickt, ohne Adresse), sonst null */
export function offerMailLine(o) {
if (o.notifiedAt) return { tone: 'muted', text: `E-Mail gesendet ${fmtDateTime(o.notifiedAt)}` }
if (o.mailSendable) return { tone: 'warn', text: 'E-Mail nicht verschickt' }
if (o.status === 'offen' && !o.customerEmail) return { tone: 'warn', text: 'Keine E-Mail-Adresse hinterlegt' }
return null
}
const todayBerlin = () => berlinYmd(new Date())
export function offerPreviewText(price, months, regular, running) {
+16
View File
@@ -29,6 +29,22 @@ export function projectsInView(projects, filter, { isAdmin = false } = {}) {
export const VIEW_LABELS = { all: 'Aktive Projekte', templates: 'Vorlagen', archived: 'Archiviert', everything: 'Alle inkl. Archiv' }
/**
* Reiter der Projektliste (Reihenfolge wie die bisherige Filter-Auswahl), je mit Zaehler aus
* `projectCounts`. Die IDs sind die Filterwerte der Seite; die Vorlagen-Ansicht gibt es nur fuer Admins.
*/
export function projectTabs(counts, { isAdmin = false } = {}) {
const tab = (id, label) => ({ id, label, count: counts?.[id] ?? 0 })
return [
tab('all', VIEW_LABELS.all),
tab('unassigned', 'Unzugeordnet'),
tab('assigned', 'Zugeordnet'),
...(isAdmin ? [tab('templates', VIEW_LABELS.templates)] : []),
tab('archived', VIEW_LABELS.archived),
tab('everything', VIEW_LABELS.everything),
]
}
// Ansichten, die ein Projekt zeigen koennen - die Vorlagen-Ansicht gibt es nur fuer Admins
const viewsFor = (isAdmin) => ['all', ...(isAdmin ? ['templates'] : []), 'archived', 'everything']
+90
View File
@@ -0,0 +1,90 @@
// Logik der Projektliste (Typ, Hosting-Zustand, Aktionen je Zeile) ohne Markup.
// Aus ProjectsPage herausgezogen; die Töne (info/ok/warn/muted) lösen die Badge-Komponenten auf.
import { formatDistance } from 'date-fns'
import { de } from 'date-fns/locale'
import { fmtDateTime } from './format'
// Projekt-Typen (= "Template"/Hosting-Modus). Die Reihenfolge der Schlüssel bestimmt die Optionen im Dialog.
export const PROJECT_TYPES = {
preview: {
label: 'Preview (mit Login)',
short: 'Preview',
tone: 'info',
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
},
website: {
label: 'Oeffentliche Website',
short: 'Website',
tone: 'ok',
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
},
template: {
label: 'Vorlage',
short: 'Vorlage',
tone: 'warn',
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
adminOnly: true,
},
project: {
label: 'Internes Projekt (kein Hosting)',
short: 'Projekt',
tone: 'muted',
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
},
}
// Typen mit Hosting (Subdomain + Deploy)
export const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
export function typeOf(project) {
if (project?.isTemplate) return 'template'
if (project?.isPublic) return 'website'
if (project?.subdomain) return 'preview'
return 'project'
}
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
export function isProjectReady(project) {
const st = String(project?.provisioningStatus || project?.status || '').toLowerCase()
return ['ready', 'deployed'].includes(st)
}
/**
* Welche Aktionen und welches Hosting-Kennzeichen eine Zeile zeigt.
* host = "Hosten" (noch nicht deployt), preview = Vorschau-Link, refresh = "Aktualisieren" (erneut deployen).
*/
export function projectActions(project) {
const type = typeOf(project)
const hosted = HOSTED_TYPES.has(type)
const ready = isProjectReady(project)
const archived = Boolean(project?.archived)
const host = hosted && !ready && !archived
const preview = !host && Boolean(project?.previewUrl)
const refresh = hosted && ready && !archived
const gitea = Boolean(project?.giteaRepoUrl)
const hostedBadge = !hosted || archived
? null
: ready
? { text: 'gehostet', tone: 'ok' }
: { text: 'nicht deployt', tone: 'warn' }
return { type, hosted, ready, host, preview, refresh, gitea, hostedBadge }
}
/** Ticketnummer (woid) der Zeile: aus der Ticketliste, sonst die $id, ohne Ticket leer. */
export function projectTicketRef(project, ticketMap) {
const id = project?.ticketId
if (!id) return ''
return String(ticketMap?.[id] ?? id)
}
/** "vor etwa 3 Stunden" mit Titel in Berliner Zeit; fehlend oder unlesbar -> "-". */
export function updatedLabel(iso, now = new Date()) {
if (!iso) return { text: '-', title: '' }
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return { text: '-', title: '' }
return {
text: formatDistance(date, now, { addSuffix: true, locale: de }),
title: fmtDateTime(iso),
}
}
+17
View File
@@ -57,6 +57,23 @@ export function formatNumber(v, digits = 2) {
return nf(digits).format(v)
}
// Richtung eines Kachel-Deltas: unter 1e-9 gilt als unverändert (flat).
export function deltaDir(d) {
return Math.abs(d) < 1e-9 ? 'flat' : d > 0 ? 'up' : 'down'
}
// Vorzeichen zur Richtung: '+', Minuszeichen U+2212, bei flat nichts.
export function deltaSign(dir) {
return dir === 'up' ? '+' : dir === 'down' ? '−' : ''
}
// Badge-Ton je Warnstufe; liest nur das erste Argument (.map(levelTone) reicht Index und Array mit).
const LEVEL_TONES = { ok: 'ok', medium: 'warn', critical: 'danger' }
export function levelTone(level) {
return Object.prototype.hasOwnProperty.call(LEVEL_TONES, level) ? LEVEL_TONES[level] : 'muted'
}
// --- Zeitachsen (Europe/Berlin, de-DE) ---
const TZ = 'Europe/Berlin'
const dtf = (opts) => new Intl.DateTimeFormat('de-DE', { timeZone: TZ, ...opts })
+7 -1
View File
@@ -1,4 +1,4 @@
import { fmtDate } from './format'
import { fmtDate, fmtDateTime } from './format'
// E-Mail-Speicher (Zusatzspeicher-Aufgaben in konsoleH): Texte wortgleich aus dem frueheren Admin-Panel des Portals.
@@ -35,5 +35,11 @@ export const orderedKind = (t) => (t.toUnits > t.fromUnits ? 'Bestellung (Plus)'
export const isOverdue = (t, now = Date.now()) => Boolean(t.dueAt && new Date(t.dueAt).getTime() <= now)
/** Faelligkeitsspalte: Einrichten sofort, Verringern zum Periodenende (ueberfaellig = warn); sub in gedaempft, wenn subMuted */
export function storageDue(t, now = Date.now()) {
if (t.kind === 'einrichten') return { tone: 'warn', label: 'jetzt einrichten', sub: `bestellt ${fmtDateTime(t.createdAt)}`, subMuted: true }
return { tone: isOverdue(t, now) ? 'warn' : 'muted', label: 'zum Periodenende', sub: fmtDate(t.dueAt), subMuted: false }
}
export const failedText = (f) =>
`${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}`
+18 -416
View File
@@ -1,418 +1,20 @@
/* .page (global.css, wird danach geladen) gewinnt bei gleicher Spezifitaet: darum .page.admin-page */
.page.admin-page {
max-width: 1480px;
}
.admin-page {
padding-bottom: 48px;
}
/* Admin: Mitarbeiter-Tabelle, Zugangsdaten, Dialog. Karten, Rahmen und Schatten gibt es nicht mehr; Abstände zwischen den */
/* Abschnitten liefert .page section + section (global.css). AdminPage.css lädt vor global.css, darum haben Regeln, die eine */
/* Global-Klasse überstimmen, die Spezifität 0,2,0. Zugangsdaten (.credential*) und .sr-only stehen in global.css. */
.page.admin-page { max-width: 1480px; }
.admin-notice { margin-bottom: 16px; }
.admin-lead { margin: 0 0 8px; }
.admin-shortcode { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; letter-spacing: .04em; }
.admin-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 16px; }
.credential-list { display: flex; flex-wrap: wrap; gap: 8px 32px; margin-top: 8px; }
.admin-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
/* Admin-Haken im Dialog: Titel und Erklärung untereinander, der Haken oben links */
.check.admin-check { align-items: flex-start; }
.admin-check small { display: block; margin-top: 2px; font-size: 13px; line-height: 18px; color: var(--text-2); }
.admin-check.is-disabled { cursor: not-allowed; opacity: .55; }
.admin-header h1 {
margin: 4px 0 6px;
font-size: clamp(28px, 3vw, 38px);
line-height: 1.1;
letter-spacing: -0.03em;
}
.admin-header p,
.admin-current-user span,
.admin-section-head span {
color: var(--text-muted);
font-size: 13px;
}
.admin-eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.admin-current-user {
min-width: 220px;
padding-left: 16px;
border-left: 2px solid var(--accent);
}
.admin-current-user span,
.admin-current-user strong {
display: block;
}
.admin-current-user strong {
margin-top: 3px;
font-size: 14px;
}
.admin-tabs {
display: flex;
gap: 0;
margin-bottom: 22px;
border-bottom: 1px solid var(--admin-line);
}
.admin-tabs button {
position: relative;
padding: 12px 18px;
border: 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 14px;
font-weight: 650;
cursor: pointer;
}
.admin-tabs button::after {
content: '';
position: absolute;
right: 18px;
bottom: -1px;
left: 18px;
height: 2px;
background: transparent;
}
.admin-tabs button:hover,
.admin-tabs button.active {
color: var(--text);
}
.admin-tabs button.active::after {
background: var(--accent);
}
.admin-notice {
margin-bottom: 16px;
padding: 11px 14px;
border: 1px solid var(--admin-line);
border-left-width: 3px;
background: var(--surface-1);
font-size: 14px;
}
.admin-notice.success { border-left-color: var(--ok); }
.admin-notice.error { border-left-color: var(--danger); }
.admin-form-card,
.admin-list-card {
border: 1px solid var(--admin-line);
background: var(--surface-1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.admin-section-head {
display: flex;
min-height: 62px;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid var(--admin-line);
}
.admin-section-head h2,
.admin-section-head h3 {
margin: 0 0 3px;
font-size: 15px;
line-height: 1.3;
}
.admin-icon-button {
display: inline-flex;
min-width: 34px;
min-height: 34px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 7px 9px;
border: 1px solid var(--admin-line-strong);
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.admin-icon-button:hover {
border-color: var(--accent-ring);
color: var(--text);
background: var(--accent-soft);
}
.admin-icon-button.danger:hover {
border-color: var(--danger);
color: var(--red-fg);
background: var(--red-bg);
}
.admin-primary-button {
display: inline-flex;
min-height: 40px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 15px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #fff;
font: inherit;
font-size: 13px;
font-weight: 750;
cursor: pointer;
}
.admin-primary-button:hover { background: var(--accent-hover); }
.admin-primary-button:disabled { opacity: 0.55; cursor: wait; }
.admin-split-layout {
display: grid;
grid-template-columns: minmax(290px, 360px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.admin-form-card {
position: sticky;
top: 20px;
padding-bottom: 16px;
}
.admin-form-card > .admin-field,
.admin-form-card > .admin-checkbox,
.admin-form-card > .admin-primary-button {
margin-right: 16px;
margin-left: 16px;
}
.admin-field {
display: block;
margin-top: 13px;
}
.admin-field > span {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 6px;
color: var(--text-muted);
font-size: 12px;
font-weight: 650;
}
.admin-field em {
color: var(--text-faint);
font-style: normal;
font-weight: 500;
}
.admin-form-card > .admin-primary-button {
width: calc(100% - 32px);
margin-top: 16px;
}
.admin-checkbox {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 16px;
padding: 11px;
border: 1px solid var(--admin-line);
cursor: pointer;
}
.admin-checkbox input { margin-top: 2px; accent-color: var(--accent); }
.admin-checkbox span,
.admin-checkbox strong,
.admin-checkbox small { display: block; }
.admin-checkbox strong { font-size: 13px; }
.admin-checkbox small { margin-top: 3px; color: var(--text-muted); line-height: 1.35; }
.admin-checkbox.disabled { cursor: not-allowed; opacity: 0.65; }
.admin-list-card { min-width: 0; }
.admin-table-wrap { width: 100%; overflow-x: auto; }
.admin-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
.admin-table th {
padding: 10px 13px;
border-bottom: 1px solid var(--admin-line);
color: var(--text-faint);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
}
.admin-table td {
padding: 12px 13px;
border-bottom: 1px solid var(--line);
color: var(--text);
font-size: 13px;
vertical-align: middle;
}
.admin-table tbody tr:last-child td { border-bottom: 0; }
.admin-table tbody tr:hover td { background: var(--bg-subtle); }
.admin-table td > strong,
.admin-table td > span { display: block; }
.admin-table td > span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
.admin-row-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
/* Tabellenzellen: .admin-table td > span (0,1,2) setzt display/Farbe/Schrift; die Selektoren hier sind spezifischer (0,2,1) */
.admin-table td > .admin-status,
.admin-table td > .admin-role {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 3px 7px;
border: 1px solid var(--admin-line-strong);
border-radius: 4px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
}
.admin-table td > .admin-status.active,
.admin-table td > .admin-role.admin {
border-color: var(--green-fg);
color: var(--green-fg);
}
.admin-table td > .admin-shortcode {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
letter-spacing: 0.04em;
}
.credential {
display: flex;
max-width: 300px;
align-items: center;
gap: 5px;
margin: 2px 0;
}
.credential-value {
min-width: 0;
overflow: hidden;
color: var(--text);
font-size: 12px;
text-overflow: ellipsis;
}
.credential-value.is-masked {
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.08em;
}
.credential-action {
display: inline-flex;
width: 25px;
height: 25px;
flex: 0 0 25px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.credential-action:hover { background: var(--surface-2); color: var(--text); }
.credential-check { color: var(--ok); }
.credential-empty { color: var(--text-faint); }
.admin-credential-result {
display: grid;
grid-template-columns: minmax(190px, 1fr) minmax(180px, auto) minmax(180px, auto) auto;
gap: 16px;
align-items: center;
margin-bottom: 16px;
padding: 13px 15px;
border: 1px solid var(--line-strong);
background: var(--bg-subtle);
}
.admin-credential-result strong,
.admin-credential-result span { display: block; }
.admin-credential-result span { margin-top: 3px; color: var(--text-muted); font-size: 12px; }
.admin-loading,
.admin-empty-state,
.admin-empty-line {
padding: 28px 18px;
color: var(--text-muted);
text-align: center;
}
.admin-loading .spinner { width: 22px; height: 22px; margin-bottom: 9px; }
.admin-empty-state h3 { margin-bottom: 5px; color: var(--text); font-size: 15px; }
.admin-empty-state p { font-size: 13px; }
.admin-empty-line { padding: 16px 8px; font-size: 13px; }
.admin-access-denied {
max-width: 540px;
margin: 12vh auto 0;
padding: 32px;
border-left: 3px solid var(--danger);
background: var(--surface-1);
}
.admin-access-denied > span { color: var(--danger); font-size: 12px; font-weight: 800; letter-spacing: 0.1em; }
.admin-access-denied h1 { margin: 8px 0; }
.admin-access-denied p { color: var(--text-muted); }
.spin { animation: admin-spin 0.8s linear infinite; }
@keyframes admin-spin { to { transform: rotate(360deg); } }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1060px) {
.admin-split-layout { grid-template-columns: 1fr; }
.admin-form-card { position: static; }
.admin-credential-result { grid-template-columns: 1fr auto; }
}
@media (max-width: 760px) {
.admin-page { padding: 16px; }
.admin-header { align-items: flex-start; flex-direction: column; }
.admin-current-user { width: 100%; }
.admin-tabs { overflow-x: auto; }
.admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
.admin-credential-result { grid-template-columns: 1fr; }
}
/* 403 */
.admin-access-denied { max-width: 540px; margin: 12vh auto 0; }
.admin-access-denied > span { font-size: 12px; font-weight: 500; color: var(--red-fg); }
.admin-access-denied h1 { margin: 4px 0 8px; font-size: 28px; line-height: 34px; font-weight: 700; letter-spacing: -.02em; }
.admin-access-denied p { color: var(--text-2); }
+192 -134
View File
@@ -1,15 +1,10 @@
import { useState } from 'react'
import {
FaFloppyDisk,
FaPlus,
FaSpinner,
FaTrash,
FaXmark,
} from 'react-icons/fa6'
import { FaEdit } from 'react-icons/fa'
import { useRef, useState } from 'react'
import CopyableCredential from '../components/CopyableCredential'
import { Badge, Button, Card, Dialog, EmptyState, Field, PageHeader, Tabs } from '../components/ui'
import { useAuth } from '../context/AuthContext'
import { useEmployees } from '../hooks/useEmployees'
import { useScrollIntoView } from '../hooks/useScrollIntoView'
import { validateEmployeeForm } from '../lib/employeeForm'
import './AdminPage.css'
const EMPTY_EMPLOYEE = {
@@ -22,7 +17,11 @@ const EMPTY_EMPLOYEE = {
function Notice({ notice }) {
if (!notice?.text) return null
return <div className={`admin-notice ${notice.type}`}>{notice.text}</div>
return (
<div className={`msg ${notice.type === 'error' ? 'err' : 'ok'} admin-notice`} role={notice.type === 'error' ? 'alert' : 'status'}>
{notice.text}
</div>
)
}
export default function AdminPage() {
@@ -40,9 +39,14 @@ export default function AdminPage() {
const [activeSection, setActiveSection] = useState('employees')
const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE)
const [editingEmployee, setEditingEmployee] = useState(null)
const [formOpen, setFormOpen] = useState(false)
const [formError, setFormError] = useState(null)
const [employeeBusy, setEmployeeBusy] = useState(false)
const [createdCredential, setCreatedCredential] = useState(null)
const [notice, setNotice] = useState(null)
const errorRef = useRef(null)
// Hooks stehen vor dem frühen return bei !isAdmin
useScrollIntoView(errorRef, formError)
const showNotice = (type, text) => {
setNotice({ type, text })
@@ -61,43 +65,21 @@ export default function AdminPage() {
)
}
const resetEmployeeForm = () => {
// Der Admin-Haken des eigenen Kontos ist gesperrt: man nimmt sich nicht selbst die Rechte
const editingSelf = Boolean(editingEmployee) && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id
const closeForm = () => {
setFormOpen(false)
setFormError(null)
setEmployeeForm(EMPTY_EMPLOYEE)
setEditingEmployee(null)
}
const submitEmployee = async (event) => {
event.preventDefault()
if (!employeeForm.displayName.trim() || !employeeForm.email.trim()) {
showNotice('error', 'Name und E-Mail sind erforderlich.')
return
}
if (!editingEmployee && employeeForm.password.length < 8) {
showNotice('error', 'Das Passwort muss mindestens 8 Zeichen haben.')
return
}
if (editingEmployee && employeeForm.password && employeeForm.password.length < 8) {
showNotice('error', 'Ein neues Passwort muss mindestens 8 Zeichen haben.')
return
}
setEmployeeBusy(true)
const result = editingEmployee
? await updateEmployee(editingEmployee, employeeForm)
: await createEmployee(employeeForm)
setEmployeeBusy(false)
if (!result.success) {
showNotice('error', result.error || 'Mitarbeiter konnte nicht gespeichert werden.')
return
}
if (!editingEmployee) {
setCreatedCredential({
email: employeeForm.email.trim(),
password: employeeForm.password,
})
}
showNotice('success', editingEmployee ? 'Mitarbeiter aktualisiert.' : 'Mitarbeiter angelegt. Der Login ist sofort aktiv.')
resetEmployeeForm()
const createNew = () => {
setEmployeeForm(EMPTY_EMPLOYEE)
setEditingEmployee(null)
setFormError(null)
setFormOpen(true)
}
const editEmployee = (employee) => {
@@ -109,6 +91,36 @@ export default function AdminPage() {
shortcode: employee.shortcode || '',
isAdmin: Boolean(employee.isAdmin),
})
setFormError(null)
setFormOpen(true)
}
const submitEmployee = async (event) => {
event.preventDefault()
const message = validateEmployeeForm(employeeForm, Boolean(editingEmployee))
if (message) {
setFormError(message)
return
}
setFormError(null)
setEmployeeBusy(true)
const result = editingEmployee
? await updateEmployee(editingEmployee, employeeForm)
: await createEmployee(employeeForm)
setEmployeeBusy(false)
if (!result.success) {
setFormError(result.error || 'Mitarbeiter konnte nicht gespeichert werden.')
return
}
if (!editingEmployee) {
setCreatedCredential({
email: employeeForm.email.trim(),
password: employeeForm.password,
})
}
showNotice('success', editingEmployee ? 'Mitarbeiter aktualisiert.' : 'Mitarbeiter angelegt. Der Login ist sofort aktiv.')
closeForm()
}
const removeEmployee = async (employee) => {
@@ -120,107 +132,153 @@ export default function AdminPage() {
)
}
const setField = (key) => (event) => setEmployeeForm({ ...employeeForm, [key]: event.target.value })
return (
<div className="page admin-page">
<header className="admin-header">
<div>
<span className="admin-eyebrow">Systemverwaltung</span>
<h1>Admin</h1>
<p>Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“.</p>
</div>
<div className="admin-current-user">
<span>Angemeldet als</span>
<strong>{user?.name || user?.email}</strong>
</div>
</header>
<PageHeader
eyebrow="Systemverwaltung"
title="Admin"
subtitle="Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“."
actions={
<>
<span className="muted small">Angemeldet als <strong>{user?.name || user?.email}</strong></span>
<Button variant="primary" type="button" onClick={createNew}>Mitarbeiter anlegen</Button>
</>
}
/>
<Notice notice={notice} />
<nav className="admin-tabs" aria-label="Admin-Bereiche">
{[
['employees', `Mitarbeiter (${employees.length})`],
].map(([id, label]) => (
<button
type="button"
key={id}
className={activeSection === id ? 'active' : ''}
onClick={() => setActiveSection(id)}
>
{label}
</button>
))}
</nav>
<Tabs
tabs={[{ id: 'employees', label: `Mitarbeiter (${employees.length})` }]}
active={activeSection}
onChange={setActiveSection}
/>
{activeSection === 'employees' && (
<section>
<>
{createdCredential && (
<div className="admin-credential-result">
<div><strong>Login wurde angelegt</strong><span>Diese Zugangsdaten jetzt sicher weitergeben.</span></div>
<CopyableCredential value={createdCredential.email} label="E-Mail" />
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
<button type="button" className="admin-icon-button" onClick={() => setCreatedCredential(null)} aria-label="Hinweis schließen"><FaXmark /></button>
</div>
)}
<div className="admin-split-layout">
<form className="admin-form-card" onSubmit={submitEmployee}>
<div className="admin-section-head">
<div>
<h2>{editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}</h2>
<span>Die User-ID wird automatisch vergeben.</span>
</div>
{editingEmployee && <button type="button" className="admin-icon-button" onClick={resetEmployeeForm} aria-label="Bearbeitung schließen"><FaXmark /></button>}
<Card
title="Login wurde angelegt"
actions={
<button type="button" className="btn btn-quiet" onClick={() => setCreatedCredential(null)} aria-label="Hinweis schließen">×</button>
}
>
<p className="muted small">Diese Zugangsdaten jetzt sicher weitergeben.</p>
<div className="credential-list">
<CopyableCredential value={createdCredential.email} label="E-Mail" />
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
</div>
<label className="admin-field"><span>Name</span><input className="form-control" required value={employeeForm.displayName} onChange={(event) => setEmployeeForm({ ...employeeForm, displayName: event.target.value })} /></label>
<label className="admin-field"><span>E-Mail</span><input type="email" className="form-control" required value={employeeForm.email} onChange={(event) => setEmployeeForm({ ...employeeForm, email: event.target.value })} /></label>
<label className="admin-field"><span>{editingEmployee ? 'Neues Passwort (optional)' : 'Passwort'}</span><input type="password" className="form-control" autoComplete="new-password" required={!editingEmployee} value={employeeForm.password} onChange={(event) => setEmployeeForm({ ...employeeForm, password: event.target.value })} placeholder="Mindestens 8 Zeichen" /></label>
<label className="admin-field"><span>Kürzel</span><input className="form-control" maxLength={10} value={employeeForm.shortcode} onChange={(event) => setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })} placeholder="z. B. KNSO" /></label>
<label className={`admin-checkbox ${editingEmployee && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id ? 'disabled' : ''}`}>
<input
type="checkbox"
checked={employeeForm.isAdmin}
disabled={editingEmployee && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id}
onChange={(event) => setEmployeeForm({ ...employeeForm, isAdmin: event.target.checked })}
/>
<span><strong>Admin-Account</strong><small>Darf Systemkonfiguration und Benutzer verwalten.</small></span>
</label>
<button className="admin-primary-button" disabled={employeeBusy}>
{employeeBusy ? <FaSpinner className="spin" /> : editingEmployee ? <FaFloppyDisk /> : <FaPlus />}
{editingEmployee ? 'Änderungen speichern' : 'Mitarbeiter anlegen'}
</button>
</form>
</Card>
)}
<section className="admin-list-card">
<div className="admin-section-head"><div><h2>Mitarbeiterkonten</h2><span>Aktive Logins für das Ticketsystem</span></div></div>
{employeesError && <Notice notice={{ type: 'error', text: employeesError }} />}
{employeesLoading ? (
<div className="admin-loading"><FaSpinner className="spinner" /> Mitarbeiter werden geladen …</div>
) : employees.length === 0 ? (
<div className="admin-empty-state"><h3>Noch keine Mitarbeiter</h3><p>Lege links den ersten Login an.</p></div>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead><tr><th>Mitarbeiter</th><th>E-Mail</th><th>Kürzel</th><th>Rolle</th><th>User-ID</th><th><span className="sr-only">Aktionen</span></th></tr></thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.$id}>
<td><strong>{employee.displayName || '–'}</strong></td>
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
<td><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
<td><span className={`admin-role ${employee.isAdmin ? 'admin' : ''}`}>{employee.isAdmin ? 'Admin' : 'Mitarbeiter'}</span></td>
<td><CopyableCredential value={employee.userId} label="User-ID" /></td>
<td className="admin-row-actions">
<button type="button" className="admin-icon-button" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}><FaEdit /></button>
{employee.userId !== user?.$id && <button type="button" className="admin-icon-button danger" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}><FaTrash /></button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</section>
<Card title="Mitarbeiterkonten">
<p className="muted small admin-lead">Aktive Logins für das Ticketsystem</p>
{employeesError && <div className="msg err" role="alert">{employeesError}</div>}
{employeesLoading ? (
<div className="loading">Mitarbeiter werden geladen …</div>
) : employees.length === 0 ? (
<EmptyState title="Noch keine Mitarbeiter" hint="Lege mit „Mitarbeiter anlegen“ den ersten Login an." />
) : (
<table className="table">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>E-Mail</th>
<th className="hide-sm">Kürzel</th>
<th className="hide-sm">Rolle</th>
<th className="hide-sm">User-ID</th>
<th className="right"><span className="sr-only">Aktionen</span></th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.$id}>
<td><strong>{employee.displayName || '–'}</strong></td>
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
<td className="hide-sm"><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
<td className="hide-sm">
{employee.isAdmin ? <Badge tone="ok">Admin</Badge> : <span className="muted">Mitarbeiter</span>}
</td>
<td className="hide-sm"><CopyableCredential value={employee.userId} label="User-ID" /></td>
<td className="right">
<div className="admin-actions">
<button type="button" className="link" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}>Bearbeiten</button>
{employee.userId !== user?.$id && (
<Button variant="danger" type="button" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}>Löschen</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</>
)}
{formOpen && (
<Dialog
as="form"
title={editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}
busy={employeeBusy}
onClose={closeForm}
onSubmit={submitEmployee}
footer={
<>
<span className="grow" />
<Button type="button" disabled={employeeBusy} onClick={closeForm}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={employeeBusy}>
{employeeBusy ? 'Speichert …' : editingEmployee ? 'Änderungen speichern' : 'Mitarbeiter anlegen'}
</Button>
</>
}
>
{formError && <div className="msg err" role="alert" ref={errorRef}>{formError}</div>}
<p className="muted small">Die User-ID wird automatisch vergeben.</p>
<Field label="Name" htmlFor="employee-displayName">
<input className="input" id="employee-displayName" required value={employeeForm.displayName} onChange={setField('displayName')} />
</Field>
<Field label="E-Mail" htmlFor="employee-email">
<input className="input" id="employee-email" type="email" required value={employeeForm.email} onChange={setField('email')} />
</Field>
<Field label={editingEmployee ? 'Neues Passwort (optional)' : 'Passwort'} htmlFor="employee-password">
<input
className="input"
id="employee-password"
type="password"
autoComplete="new-password"
required={!editingEmployee}
value={employeeForm.password}
onChange={setField('password')}
placeholder="Mindestens 8 Zeichen"
/>
</Field>
<Field label="Kürzel" htmlFor="employee-shortcode">
<input
className="input"
id="employee-shortcode"
maxLength={10}
value={employeeForm.shortcode}
onChange={(event) => setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })}
placeholder="z. B. KNSO"
/>
</Field>
<label className={`check admin-check${editingSelf ? ' is-disabled' : ''}`}>
<input
type="checkbox"
checked={employeeForm.isAdmin}
disabled={editingSelf}
onChange={(event) => setEmployeeForm({ ...employeeForm, isAdmin: event.target.checked })}
/>
<span>
<strong>Admin-Account</strong>
<small>Darf Systemkonfiguration und Benutzer verwalten.</small>
</span>
</label>
</Dialog>
)}
</div>
)
+181 -163
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FaSpinner, FaPlus, FaArrowLeft, FaArrowRight, FaCheck } from 'react-icons/fa6'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
@@ -9,7 +8,7 @@ import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from
import { suggestSubdomain, slugify } from '../lib/subdomain'
import { isArchived } from '../lib/leads'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { PageHeader, Card, Button, Field } from '../components/ui'
import { PageHeader, Card, Button, Field, Badge } from '../components/ui'
import { CreateCustomerForm } from './CustomersPage'
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
@@ -176,36 +175,45 @@ export default function AssignProjectPage() {
}
}
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
if (loadError) {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" />
<Card><div className="text-red">{loadError}</div></Card>
<PageHeader crumb={[{ label: 'Projekte', to: '/projects' }]} title="Projekt zuordnen" />
<p className="msg err" role="alert">{loadError}</p>
</div>
)
}
const createdLine = project.$createdAt && (
<> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>
)
const header = (
<Card>
<div style={{ fontWeight: 700, fontSize: 18 }}>{repoName || project.projectName}</div>
<div className="muted" style={{ marginTop: 4 }}>
{repoUrl && <a href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
{project.$createdAt && <> · angelegt am {fmtDate(project.$createdAt)}{project.createdByGitea ? ` von ${project.createdByGitea}` : ''}</>}
</div>
</Card>
<PageHeader
crumb={[{ label: 'Projekte', to: '/projects' }, { label: repoName || project.projectName }]}
title="Projekt zuordnen"
subtitle={(repoUrl || createdLine) ? (
<>
{repoUrl && <a className="link" href={repoUrl} target="_blank" rel="noreferrer">Repository in Gitea öffnen</a>}
{createdLine}
</>
) : undefined}
/>
)
if (done) {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" />
{header}
<Card title="Zugeordnet, mit Hinweisen">
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
<Button variant="primary" onClick={() => navigate(done.path)}>Weiter <FaArrowRight /></Button>
<div className="assign-step">
<ul className="summary">
{done.warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
<div className="row-buttons">
<Button type="button" variant="primary" onClick={() => navigate(done.path)}>Weiter</Button>
</div>
</div>
</Card>
</div>
)
@@ -215,16 +223,15 @@ export default function AssignProjectPage() {
if (project.kind) {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" />
{header}
<Card>
{notice && <div className="text-red" style={{ marginBottom: 8 }}>{notice}</div>}
<p style={{ marginTop: 0 }}>
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
</p>
{project.ticketId && (
<p style={{ marginBottom: 0 }}><TicketLink ticketId={project.ticketId} /></p>
)}
<div className="assign-step">
{notice && <p className="msg err" role="alert">{notice}</p>}
<p>
Bereits erledigt von <strong>{project.kindSetBy || 'unbekannt'}</strong> am {fmtDate(project.kindSetAt)}.
</p>
{project.ticketId && <p><TicketLink ticketId={project.ticketId} /></p>}
</div>
</Card>
</div>
)
@@ -236,45 +243,44 @@ export default function AssignProjectPage() {
return (
<div className="page">
<PageHeader title="Projekt zuordnen" subtitle={STEP_TITLES[state.step]} />
{header}
<Card title={STEP_TITLES[state.step]}>
{state.step === 'kind' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10 }}>
{kinds.map((k) => (
<Button
key={k.key}
variant={state.kind === k.key ? 'primary' : 'default'}
style={{ padding: '18px 12px', fontSize: 16, fontWeight: 700 }}
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
>
{k.label}
</Button>
))}
</div>
)}
{state.step === 'customer?' && (
<div className="flex gap-2 wrap">
<Button variant="primary" style={{ padding: '14px 28px' }}
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
<Button style={{ padding: '14px 28px' }}
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
</div>
)}
{state.step === 'customer' && (
<>
<div className="flex gap-2 wrap" style={{ marginBottom: 10 }}>
<input className="form-control search-input" placeholder="Kunde suchen …" value={search}
onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
{isAdmin && (
<Button onClick={() => setShowNewCustomer((s) => !s)}><FaPlus /> Neuer Kunde</Button>
)}
<div className="assign-step">
{state.step === 'kind' && (
<div className="seg wrap" role="group" aria-label={STEP_TITLES.kind}>
{kinds.map((k) => (
<button
key={k.key}
type="button"
aria-pressed={state.kind === k.key}
onClick={() => patch({ kind: k.key, newTicket: null, ticketId: '' })}
>
{k.label}
</button>
))}
</div>
{showNewCustomer && isAdmin && (
<div style={{ marginBottom: 12 }}>
)}
{state.step === 'customer?' && (
<div className="row-buttons">
<Button type="button" variant="primary"
onClick={() => { patch({ forCustomer: true }); go('customer') }}>Ja, für einen Kunden</Button>
<Button type="button"
onClick={() => { patch({ forCustomer: false, customerId: '', ticketId: '', newTicket: null }); go('summary') }}>Nein, internes Projekt</Button>
</div>
)}
{state.step === 'customer' && (
<>
<div className="row-buttons">
<input className="input search" aria-label="Kunde suchen" placeholder="Kunde suchen …" value={search}
onChange={(e) => setSearch(e.target.value)} />
{isAdmin && (
<Button type="button" onClick={() => setShowNewCustomer((s) => !s)}>Neuer Kunde</Button>
)}
</div>
{showNewCustomer && isAdmin && (
<CreateCustomerForm
onCancel={() => setShowNewCustomer(false)}
onCreate={async (data) => {
@@ -286,111 +292,123 @@ export default function AssignProjectPage() {
return r
}}
/>
</div>
)}
<div className="list" style={{ maxHeight: 360, overflowY: 'auto' }}>
{filteredCustomers.slice(0, 100).map((c) => (
<div key={c.$id} className="list-row" style={{ cursor: 'pointer', background: state.customerId === c.$id ? 'var(--surface-2)' : undefined }}
onClick={() => patch({ customerId: c.$id, ticketId: '', newTicket: null })}>
<div className="grow">
<div style={{ fontWeight: 700 }}>{c.name}{c.code && <span className="faint" style={{ fontWeight: 400 }}> · {c.code}</span>}</div>
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
</div>
{state.customerId === c.$id && <FaCheck />}
</div>
))}
{filteredCustomers.length === 0 && <div className="muted" style={{ padding: 12 }}>Kein Kunde gefunden.</div>}
</div>
</>
)}
{state.step === 'ticket' && (
<>
{ticketsLoading ? <FaSpinner className="spinner" /> : ticketsError ? (
<div style={{ marginBottom: 12 }}>
<div className="text-red" style={{ marginBottom: 8 }}>Die Tickets des Kunden konnten nicht geladen werden.</div>
<Button onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
</div>
) : (
<div className="list" style={{ marginBottom: 12 }}>
{openTickets.map((t) => (
<div key={t.$id} className="list-row" style={{ cursor: 'pointer', background: state.ticketId === t.$id ? 'var(--surface-2)' : undefined }}
onClick={() => patch({ ticketId: t.$id, newTicket: null })}>
<div className="grow">
<div style={{ fontWeight: 700 }}>#{t.woid} {t.topic}</div>
<div className="muted">{[t.type, t.status].filter(Boolean).join(' · ')}</div>
</div>
{state.ticketId === t.$id && <FaCheck />}
</div>
))}
{openTickets.length === 0 && <div className="muted" style={{ padding: 12 }}>Dieser Kunde hat keine offenen Tickets.</div>}
</div>
)}
{!ticketsError && !ticketsLoading && (
<Button variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}><FaPlus /> Neues Ticket anlegen</Button>
)}
{state.newTicket && (
<div style={{ marginTop: 12 }}>
<div className="flex gap-3 wrap">
<Field label="Thema"><input className="form-control" value={state.newTicket.topic} onChange={setNT('topic')} /></Field>
<Field label="Angefragt von"><input className="form-control" value={state.newTicket.requestedBy} onChange={setNT('requestedBy')} /></Field>
<Field label="Start"><input className="form-control" type="date" value={state.newTicket.startDate} onChange={setNT('startDate')} /></Field>
</div>
<Field label="Details"><textarea className="form-control" rows={3} value={state.newTicket.details} onChange={setNT('details')} /></Field>
<div className="muted" style={{ fontSize: 13 }}>Ticket-Art: {kindObj?.ticketType === 'Migration' ? 'Migration' : 'Webpage'} (ergibt sich aus der Art)</div>
</div>
)}
</>
)}
{state.step === 'summary' && (
<>
<ul style={{ marginTop: 0, paddingLeft: 18 }}>
<li>Art: <strong>{kindObj?.label}</strong></li>
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
{state.forCustomer && (
<li>
{state.ticketId
? <>Ticket: <strong>{openTickets.find((t) => t.$id === state.ticketId)?.topic || 'vorhandenes Ticket'}</strong></>
: <>Neues Ticket: <strong>{state.newTicket?.topic}</strong></>}
</li>
)}
</ul>
{isWebsiteWithCustomer && hasPreviewAlready && (
<p className="muted" style={{ marginTop: 0 }}>
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
</p>
)}
{isWebsiteWithCustomer && !hasPreviewAlready && (
<div style={{ marginBottom: 12 }}>
<label className="flex gap-2" style={{ alignItems: 'center' }}>
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
Preview einschalten
</label>
{previewOn && (
<Field label="Subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
<input className="form-control" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
</Field>
)}
<div className="pick-list">
{filteredCustomers.slice(0, 100).map((c) => {
const picked = state.customerId === c.$id
return (
<button key={c.$id} type="button" className="list-row" aria-pressed={picked}
onClick={() => patch({ customerId: c.$id, ticketId: '', newTicket: null })}>
<span className="list-row-text">
<span className="cell-title">{c.name}{c.code && <span className="faint"> · {c.code}</span>}</span>
<span className="cell-sub">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</span>
</span>
{picked && <Badge>Ausgewählt</Badge>}
</button>
)
})}
{filteredCustomers.length === 0 && <p className="muted pick-empty">Kein Kunde gefunden.</p>}
</div>
</>
)}
{state.step === 'ticket' && (
<>
{ticketsLoading ? <p className="muted">Wird geladen …</p> : ticketsError ? (
<>
<p className="msg err" role="alert">Die Tickets des Kunden konnten nicht geladen werden.</p>
<div className="row-buttons">
<Button type="button" onClick={() => setTicketsReload((n) => n + 1)}>Erneut versuchen</Button>
</div>
</>
) : (
<div className="pick-list">
{openTickets.map((t) => {
const picked = state.ticketId === t.$id
return (
<button key={t.$id} type="button" className="list-row" aria-pressed={picked}
onClick={() => patch({ ticketId: t.$id, newTicket: null })}>
<span className="list-row-text">
<span className="cell-title">#{t.woid} {t.topic}</span>
<span className="cell-sub">{[t.type, t.status].filter(Boolean).join(' · ')}</span>
</span>
{picked && <Badge>Ausgewählt</Badge>}
</button>
)
})}
{openTickets.length === 0 && <p className="muted pick-empty">Dieser Kunde hat keine offenen Tickets.</p>}
</div>
)}
{!ticketsError && !ticketsLoading && (
<div className="row-buttons">
<Button type="button" variant={state.newTicket ? 'primary' : 'default'} onClick={startNewTicket}>Neues Ticket anlegen</Button>
</div>
)}
{state.newTicket && (
<>
<div className="field-row-3">
<Field label="Thema" htmlFor="assign-topic"><input id="assign-topic" className="input" value={state.newTicket.topic} onChange={setNT('topic')} /></Field>
<Field label="Angefragt von" htmlFor="assign-requested-by"><input id="assign-requested-by" className="input" value={state.newTicket.requestedBy} onChange={setNT('requestedBy')} /></Field>
<Field label="Start" htmlFor="assign-start"><input id="assign-start" className="input" type="date" value={state.newTicket.startDate} onChange={setNT('startDate')} /></Field>
</div>
<Field label="Details" htmlFor="assign-details"><textarea id="assign-details" className="textarea" rows={3} value={state.newTicket.details} onChange={setNT('details')} /></Field>
<p className="muted small">Ticket-Art: {kindObj?.ticketType === 'Migration' ? 'Migration' : 'Webpage'} (ergibt sich aus der Art)</p>
</>
)}
</>
)}
{state.step === 'summary' && (
<>
<ul className="summary">
<li>Art: <strong>{kindObj?.label}</strong></li>
<li>{state.forCustomer ? <>Kunde: <strong>{customer?.name}</strong></> : 'Internes Projekt (ohne Kunde)'}</li>
{state.forCustomer && (
<li>
{state.ticketId
? <>Ticket: <strong>{openTickets.find((t) => t.$id === state.ticketId)?.topic || 'vorhandenes Ticket'}</strong></>
: <>Neues Ticket: <strong>{state.newTicket?.topic}</strong></>}
</li>
)}
</ul>
{isWebsiteWithCustomer && hasPreviewAlready && (
<p className="muted">
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
</p>
)}
{isWebsiteWithCustomer && !hasPreviewAlready && (
<>
<label className="check">
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
Preview einschalten
</label>
{previewOn && (
<Field label="Subdomain" htmlFor="assign-subdomain" hint={`Ergebnis: https://${slugify(subdomain) || '<subdomain>'}.project.webklar.com`}>
<input id="assign-subdomain" className="input" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} />
</Field>
)}
</>
)}
<div className="row-buttons">
<Button type="button" variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
{busy ? 'Speichert …' : 'Abschließen'}
</Button>
</div>
</>
)}
{error && <p className="msg err" role="alert">{error}</p>}
<div className="row-buttons">
{state.step !== 'kind' && (
<Button type="button" variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}>Zurück</Button>
)}
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
</Button>
</>
)}
{error && <div className="text-red" style={{ marginTop: 10 }}>{error}</div>}
<div className="flex gap-2" style={{ marginTop: 16 }}>
{state.step !== 'kind' && (
<Button variant="ghost" disabled={busy} onClick={() => go(prevStep(state))}><FaArrowLeft /> Zurück</Button>
)}
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
<Button variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter <FaArrowRight /></Button>
)}
{(state.step === 'kind' || state.step === 'customer' || state.step === 'ticket') && (
<Button type="button" variant="primary" disabled={!stepReady()} onClick={() => go(nextStep(state))}>Weiter</Button>
)}
</div>
</div>
</Card>
</div>
@@ -403,5 +421,5 @@ function TicketLink({ ticketId }) {
databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticketId)
.then((t) => setWoid(t.woid)).catch(() => setWoid(null))
}, [ticketId])
return woid ? <Link to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
return woid ? <Link className="link" to={ticketPath(woid)}>Zum Ticket #{woid}</Link> : null
}
+6 -9
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { PageHeader, Card, Button } from '../components/ui'
import { PageHeader, Button } from '../components/ui'
import { completeBankAuth } from '../lib/financeApi'
import { dateDe } from '../lib/financeFormat'
import { parseCallback, successResult, failureResult, isResult } from '../lib/bankCallback'
@@ -38,28 +38,25 @@ export default function BankCallbackPage() {
if (result) {
body = (
<>
<p role={result.ok ? 'status' : 'alert'} style={{ fontWeight: 600 }}>
<p className={`msg ${result.ok ? 'ok' : 'err'} fin-result`} role={result.ok ? 'status' : 'alert'}>
{result.ok
? `Bankzugang erneuert, gültig bis ${dateDe(result.validUntil)}`
: result.message}
</p>
<div style={{ marginTop: 16 }}>
<div className="fin-form-actions">
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
</div>
</>
)
} else if (params) {
body = (
<div className="text-center" role="status">
<div className="spinner" />
<p className="muted">Bankzugang wird eingerichtet …</p>
</div>
<div className="loading" role="status">Bankzugang wird eingerichtet …</div>
)
} else {
body = (
<>
<p className="muted">Zu dieser Seite liegt keine Rückmeldung der Bank vor.</p>
<div style={{ marginTop: 16 }}>
<div className="fin-form-actions">
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
</div>
</>
@@ -69,7 +66,7 @@ export default function BankCallbackPage() {
return (
<div className="page">
<PageHeader title="Bankzugang" />
<Card>{body}</Card>
{body}
</div>
)
}
+1 -1
View File
@@ -7,7 +7,7 @@ export default function CreditsPage() {
<div className="page">
<PageHeader title="Gutschriften" subtitle="Übernahmegutschriften: bereits beim alten Anbieter bezahlte Leistungen verrechnen" />
<CreditsView />
<p className="area-muted" style={{ marginTop: 12 }}>
<p className="table-foot">
Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.
</p>
</div>
+30 -34
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { FaArrowLeft, FaRotateRight } from 'react-icons/fa6'
import { PageHeader, Card, Button, EmptyState, AutocompleteInput } from '../components/ui'
import ChatThread from '../components/chat/ChatThread'
import { useCustomers } from '../hooks/useCustomers'
@@ -64,43 +63,40 @@ export default function CustomerChatPage() {
<div className={`chat-page ${kunde ? 'has-thread' : ''}`}>
<div className="chat-side">
<AutocompleteInput value={query} onChange={onPick} getSuggestions={suggestions} placeholder="Neue Nachricht an Kunde…" />
<Card pad={false}>
{error && (
<div className="flex items-center gap-2 wrap" style={{ padding: 12 }}>
<span className="muted">{error}</span>
<Button variant="ghost" onClick={() => setReload((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
</div>
)}
{!error && chats && sorted.length === 0 && <EmptyState title="Noch keine Unterhaltungen" />}
<div className="chat-list">
{sorted.map((c) => (
<button
key={c.customerId}
type="button"
className={`chat-list-item ${c.customerId === kunde ? 'active' : ''}`}
onClick={() => open(c.customerId)}
>
<span className="chat-list-top">
<strong>{c.customerName}</strong>
{c.unreadFromCustomer > 0 && <span className="side-count">{c.unreadFromCustomer}</span>}
</span>
<span className="chat-list-last">{lastLine(c)}</span>
<span className="chat-list-time">{formatViewLogTime(c.lastAt)}</span>
</button>
))}
{error && (
<div className="flex items-center gap-2 wrap">
<span className="msg err" role="alert">{error}</span>
<Button onClick={() => setReload((k) => k + 1)}>Erneut versuchen</Button>
</div>
</Card>
)}
{!error && chats && sorted.length === 0 && <EmptyState title="Noch keine Unterhaltungen" />}
<div className="chat-list">
{sorted.map((c) => (
<button
key={c.customerId}
type="button"
className="chat-list-item"
aria-current={c.customerId === kunde ? 'true' : undefined}
onClick={() => open(c.customerId)}
>
<span className="chat-list-top">
<strong>{c.customerName}</strong>
<span className="chat-list-time">{formatViewLogTime(c.lastAt)}</span>
</span>
<span className="chat-list-last">{lastLine(c)}</span>
{c.unreadFromCustomer > 0 && <span className="chat-unread">{c.unreadFromCustomer}</span>}
</button>
))}
</div>
</div>
<div className="chat-main">
{kunde ? (
<Card title={
<span className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="chat-back" onClick={() => setParams({})}><FaArrowLeft /> Alle Unterhaltungen</Button>
{nameOf(kunde)}
</span>
}>
<ChatThread key={kunde} customerId={kunde} customerName={nameOf(kunde)} />
</Card>
<>
<Button className="chat-back" onClick={() => setParams({})}>← Alle Unterhaltungen</Button>
<Card title={nameOf(kunde)}>
<ChatThread key={kunde} customerId={kunde} customerName={nameOf(kunde)} />
</Card>
</>
) : (
<Card><EmptyState title="Unterhaltung links auswählen" hint="Oder oben eine neue Nachricht an einen Kunden beginnen." /></Card>
)}
+163 -122
View File
@@ -1,8 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaBoxArchive, FaBoxOpen, FaTrash } from 'react-icons/fa6'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
import CustomerInvoices from '../components/finance/CustomerInvoices'
import AssignedProjectCard from '../components/AssignedProjectCard'
@@ -11,7 +10,11 @@ import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '
import { useAuth } from '../context/AuthContext'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { ticketNumber } from '../lib/ticketView'
import { isRowActivation } from '../lib/rowClick'
import CustomerViewLog from '../components/customer/CustomerViewLog'
import ChatThread from '../components/chat/ChatThread'
import AboStatusCard from '../components/customer/AboStatusCard'
@@ -24,6 +27,12 @@ import { formatLastLogin } from '../lib/lastLogin'
const PORTAL_URL = 'https://project.webklar.com'
// Rueckmeldung neben einem Knopf: err und warn mit role="alert", ok ohne
function Msg({ msg }) {
if (!msg) return null
return <span className={`msg ${msg.tone}`} role={msg.tone === 'ok' ? undefined : 'alert'}>{msg.text}</span>
}
export default function CustomerDetailPage() {
const { id } = useParams()
const [customer, setCustomer] = useState(null)
@@ -53,13 +62,14 @@ export default function CustomerDetailPage() {
useEffect(() => { load() }, [load])
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurück</Button>} /></div>
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers">Zurück</Button>} /></div>
const stage = customerStage(customer)
// „Rechnungen“ nur für Admins (die Finanzen-API gibt sonst 403); ein nicht erlaubter Reiter fällt auf die Übersicht zurück
const activeTab = resolveCustomerTab(tab, isAdmin)
const archived = isArchived(customer)
const title = customerTitle(customer)
const toggleArchived = async () => {
await setCustomerArchived(customer, !archived)
@@ -77,28 +87,30 @@ export default function CustomerDetailPage() {
}
}
// Unter dem Namen: Abzeichen (Wichtig, Archiviert; die Stufe steht in der Eyebrow), dann Standort · E-Mail · Telefon
const badges = customerBadges(customer, { stage: false })
const contact = [customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')
const subtitle = badges.length > 0 || contact ? (
<span className="kunde-sub">
{badges.map((b) => (
<Badge key={b.key} tone={b.tone}>{b.label}</Badge>
))}
{contact}
</span>
) : null
return (
<div className="page">
<PageHeader
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
{customer.name}
{stage === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
{stage === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
{stage === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
{archived && <Badge tone="muted"><FaBoxArchive /> Archiviert</Badge>}
</span>
}
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')}
crumb={[{ label: 'Kunden', to: '/customers' }, { label: title }]}
eyebrow={customerEyebrow(customer)}
title={title}
subtitle={subtitle}
actions={
<>
{isAdmin && <Button variant="danger" onClick={removeCustomer}>Kunde löschen</Button>}
<Button onClick={toggleArchived}>{archived ? 'Wiederherstellen' : 'Archivieren'}</Button>
<KundenansichtButton customer={customer} />
<Button variant="ghost" onClick={toggleArchived}>
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
</Button>
{isAdmin && <Button variant="danger" onClick={removeCustomer}><FaTrash /> Kunde löschen</Button>}
<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>
</>
}
/>
@@ -113,7 +125,7 @@ export default function CustomerDetailPage() {
{activeTab === 'abo' && (
<>
<AboStatusCard customerId={customer.$id} />
<div style={{ marginTop: 16 }}><OffersView customerId={customer.$id} customerName={customer.name} /></div>
<div className="kunde-abo-angebote"><OffersView customerId={customer.$id} customerName={customer.name} /></div>
</>
)}
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
@@ -141,7 +153,7 @@ function OverviewTab({ customer, stage, onSaved }) {
importantCustomer: !!customer.importantCustomer,
})
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
const ready = isInvoiceReady({ ...customer, ...form })
@@ -149,64 +161,76 @@ function OverviewTab({ customer, stage, onSaved }) {
setBusy(true); setMsg(null)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
setMsg('Gespeichert')
setMsg({ tone: 'ok', text: 'Gespeichert' })
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
} finally { setBusy(false) }
}
const upgrade = async () => {
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
setBusy(true); setMsg(null)
try {
await upgradeToCustomer(customer, form)
setMsg('Zum festen Kunden gemacht.')
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
} finally { setBusy(false) }
}
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
<Card title="Stammdaten">
<div className="flex gap-3 wrap">
<Field label="Unternehmen / Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></Field>
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
</div>
<Field label="Standort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
<Field label="Ansprechpartner"><input className="form-control" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></Field>
<Field label="Notizen"><textarea className="form-control" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></Field>
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
Wichtiger Kunde
</label>
<div className="flex gap-2 items-center wrap">
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}><FaArrowUp /> Zum festen Kunden machen</Button>}
{msg && <span className="muted">{msg}</span>}
</div>
</Card>
<div className="two-col">
<div>
<Card title="Stammdaten">
<dl className="kv kunde-kv">
<dt><label htmlFor="kunde-name">Unternehmen / Name</label></dt>
<dd><input id="kunde-name" className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></dd>
<dt><label htmlFor="kunde-code">Code</label></dt>
<dd><input id="kunde-code" className="input" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></dd>
<dt><label htmlFor="kunde-email">E-Mail</label></dt>
<dd><input id="kunde-email" className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></dd>
<dt><label htmlFor="kunde-phone">Telefon</label></dt>
<dd><input id="kunde-phone" className="input" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></dd>
<dt><label htmlFor="kunde-location">Standort</label></dt>
<dd><input id="kunde-location" className="input" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></dd>
<dt><label htmlFor="kunde-contact">Ansprechpartner</label></dt>
<dd><input id="kunde-contact" className="input" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></dd>
<dt className="kunde-kv-oben"><label htmlFor="kunde-notes">Notizen</label></dt>
<dd><textarea id="kunde-notes" className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></dd>
</dl>
<label className="check">
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
Wichtiger Kunde
</label>
<div className="toolbar">
<Button variant="primary" onClick={save} disabled={busy}>{busy ? 'Speichert …' : 'Speichern'}</Button>
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}>Zum festen Kunden machen</Button>}
<Msg msg={msg} />
</div>
</Card>
<Card title="Rechnungsdaten">
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Adresse unvollständig</div>}
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
<div className="flex gap-3 wrap">
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
<Field label="Stadt"><input className="form-control" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
</div>
</Card>
<Card title="Rechnungsdaten">
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
<dl className="kv kunde-kv">
<dt><label htmlFor="kunde-street">Strasse + Nr.</label></dt>
<dd><input id="kunde-street" className="input" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></dd>
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
<dd><input id="kunde-postal" className="input" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></dd>
<dt><label htmlFor="kunde-city">Stadt</label></dt>
<dd><input id="kunde-city" className="input" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></dd>
<dt><label htmlFor="kunde-country">Land</label></dt>
<dd><input id="kunde-country" className="input" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></dd>
<dt><label htmlFor="kunde-vat">USt-IdNr.</label></dt>
<dd><input id="kunde-vat" className="input" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></dd>
</dl>
</Card>
</div>
<PortalAccessCard customer={customer} onSaved={onSaved} />
<div>
<PortalAccessCard customer={customer} onSaved={onSaved} />
</div>
</div>
)
}
@@ -214,7 +238,7 @@ function OverviewTab({ customer, stage, onSaved }) {
function PortalAccessCard({ customer, onSaved }) {
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
const currentPassword = getCustomerPortalPassword(customer)
@@ -230,21 +254,21 @@ function PortalAccessCard({ customer, onSaved }) {
const savePassword = async () => {
if (!customer.email) {
setMsg('Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.')
setMsg({ tone: 'warn', text: 'Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.' })
return
}
if (!password || password.length < 8) {
setMsg('Das Passwort muss mindestens 8 Zeichen haben.')
setMsg({ tone: 'warn', text: 'Das Passwort muss mindestens 8 Zeichen haben.' })
return
}
setBusy(true); setMsg(null)
try {
await updateCustomerWithPortalAccess(customer.$id, { password })
setPassword('')
setMsg(hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.')
setMsg({ tone: 'ok', text: hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.' })
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
} finally {
setBusy(false)
}
@@ -252,44 +276,41 @@ function PortalAccessCard({ customer, onSaved }) {
return (
<Card title="Portal-Zugang">
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
<span className="muted">Kundenportal</span>
{hasAccess
? <Badge tone="ok" dot>freigeschaltet</Badge>
: <Badge tone="muted">kein Zugang</Badge>}
</div>
<dl className="kv kunde-kv">
<dt>Kundenportal</dt>
<dd>
{hasAccess
? <Badge tone="ok">freigeschaltet</Badge>
: <Badge tone="muted">kein Zugang</Badge>}
</dd>
<dt>Letzter Login</dt>
<dd>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</dd>
<dt>Login (E-Mail)</dt>
<dd><CopyableCredential value={customer.email} label="Login" /></dd>
<dt>Passwort</dt>
<dd><CopyableCredential value={currentPassword} secret label="Passwort" /></dd>
<dt><label htmlFor="kunde-neues-passwort">{hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}</label></dt>
<dd>
<input
id="kunde-neues-passwort"
className="input"
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="min. 8 Zeichen"
autoComplete="off"
/>
</dd>
</dl>
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
<span className="muted">Letzter Login</span>
<span>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</span>
</div>
<Field label="Login (E-Mail)">
<CopyableCredential value={customer.email} label="Login" />
</Field>
<Field label="Passwort">
<CopyableCredential value={currentPassword} secret label="Passwort" />
</Field>
<Field label={hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}>
<input
className="form-control"
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="min. 8 Zeichen"
autoComplete="off"
/>
</Field>
<div className="flex gap-2 items-center wrap">
<Button variant="primary" onClick={savePassword} disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
<div className="toolbar">
<Button onClick={savePassword} disabled={busy}>
{busy ? 'Speichert …' : hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}
</Button>
<KundenansichtButton customer={customer} variant="ghost" />
{msg && <span className="muted">{msg}</span>}
<KundenansichtButton customer={customer} />
<Msg msg={msg} />
</div>
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
<p className="muted small">
Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
und sieht dort seine Projekte/Previews.
</p>
@@ -298,6 +319,7 @@ function PortalAccessCard({ customer, onSaved }) {
}
function CustomerTickets({ customerId }) {
const navigate = useNavigate()
const [tickets, setTickets] = useState([])
const [loading, setLoading] = useState(true)
@@ -321,25 +343,44 @@ function CustomerTickets({ customerId }) {
return () => { active = false }
}, [customerId])
if (loading) return <Card><FaSpinner className="spinner" /></Card>
if (tickets.length === 0) return <Card><EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." /></Card>
if (loading) return <div className="loading">Wird geladen …</div>
if (tickets.length === 0) return <EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." />
return (
<Card pad={false}>
<div className="list">
{tickets.map((t) => (
<div key={t.$id} className="list-row">
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
<div className="grow">
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
<div className="muted">{t.type}</div>
</div>
<UrgentPill priority={t.priority} />
<StatusPill status={t.status} />
</div>
))}
</div>
</Card>
<table className="table">
<thead>
<tr>
<th>Ticket</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{tickets.map((t) => {
const path = ticketPath(ticketRef(t))
return (
<tr
key={t.$id}
className="row"
onClick={(e) => {
if (isRowActivation(e)) navigate(path)
}}
>
<td>
<div className="cell-title">
<Link className="row-link" to={path}>
<span className="id">{ticketNumber(t)}</span>
<span>{t.topic || t.title || '-'}</span>
</Link>
<UrgentPill priority={t.priority} />
</div>
{t.type && <div className="cell-sub">{t.type}</div>}
</td>
<td><StatusPill status={t.status} /></td>
</tr>
)
})}
</tbody>
</table>
)
}
@@ -359,7 +400,7 @@ function CustomerProjects({ customerId }) {
return () => { active = false }
}, [customerId, fetchAllProjects])
if (loading) return <Card><FaSpinner className="spinner" /></Card>
if (loading) return <div className="loading">Wird geladen …</div>
if (projects.length === 0) return <Card><EmptyState title="Keine Projekte" hint="Diesem Kunden sind keine Projekte/Previews zugeordnet." /></Card>
return (
+162 -129
View File
@@ -1,37 +1,74 @@
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaPlus, FaStar } from 'react-icons/fa6'
import { Link, useNavigate } from 'react-router-dom'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { createLead, customerStage, isArchived } from '../lib/leads'
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
import { createLead } from '../lib/leads'
import { CUSTOMER_TABS, customerBadges, customerTitle, filterCustomers, stageCounts } from '../lib/customerList'
import { isRowActivation } from '../lib/rowClick'
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
export default function CustomersPage() {
const { customers, loading, refresh, createCustomer } = useCustomers()
const { user, isAdmin } = useAuth()
const navigate = useNavigate()
const [search, setSearch] = useState('')
// 105 von 110 Eintraegen sind Leads der Lead-Automatik, deshalb startet die Liste bei den festen Kunden
const [stage, setStage] = useState('customer') // customer | lead | all | archived
const [stage, setStage] = useState('customer') // customer | lead | archived | all
const [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
const filtered = useMemo(() => {
return (customers || [])
.filter((c) => {
const archived = isArchived(c)
// Archivierte nur im Archiviert-Tab, sonst nie
if (stage === 'archived') return archived
if (archived) return false
const st = customerStage(c)
if (st === 'lost') return stage === 'all'
if (stage === 'all') return true
return st === stage
})
.filter((c) => {
if (!search) return true
const s = search.toLowerCase()
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
})
}, [customers, search, stage])
const filtered = useMemo(() => filterCustomers(customers, { stage, search }), [customers, stage, search])
// Zähler mit derselben Funktion wie die Liste (beachten den Suchtext); bis zum Laden ohne Zahl
const counts = useMemo(() => stageCounts(customers, search), [customers, search])
const tabs = CUSTOMER_TABS.map((tab) => ({ id: tab.id, label: tab.label, count: loading ? undefined : counts[tab.id] }))
let content
if (loading) {
content = <div className="loading">Wird geladen …</div>
} else if (filtered.length === 0) {
content = <EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
} else {
content = (
<table className="table">
<thead>
<tr>
<th>Unternehmen</th>
<th className="hide-sm num">Kundennummer</th>
<th>Standort</th>
<th className="hide-md muted">E-Mail</th>
<th className="hide-md muted nowrap num">Telefon</th>
</tr>
</thead>
<tbody>
{filtered.map((c) => {
const path = `/customers/${c.$id}`
return (
<tr
key={c.$id}
className="row"
onClick={(e) => {
if (isRowActivation(e)) navigate(path)
}}
>
<td>
<div className="cell-title">
<Link className="row-link" to={path}>{customerTitle(c)}</Link>
{customerBadges(c).map((b) => (
<Badge key={b.key} tone={b.tone}>{b.label}</Badge>
))}
</div>
{c.contactName && <div className="cell-sub">{c.contactName}</div>}
</td>
<td className="hide-sm num">{c.code || <span className="faint">–</span>}</td>
<td>{c.location || '–'}</td>
<td className="hide-md muted">{c.email || '–'}</td>
<td className="hide-md muted nowrap num">{c.phone || '–'}</td>
</tr>
)
})}
</tbody>
</table>
)
}
return (
<div className="page">
@@ -41,89 +78,54 @@ export default function CustomersPage() {
actions={
<>
{isAdmin && (
<Button onClick={() => setShowCreate((s) => (s === 'customer' ? null : 'customer'))}><FaPlus /> Neuer Kunde</Button>
<Button variant="primary" type="button" onClick={() => setShowCreate('customer')}>Neuer Kunde</Button>
)}
<Button variant="primary" onClick={() => setShowCreate((s) => (s === 'lead' ? null : 'lead'))}><FaPlus /> Neuer Lead</Button>
<Button type="button" onClick={() => setShowCreate('lead')}>Neuer Lead</Button>
</>
}
/>
<Tabs tabs={tabs} active={stage} onChange={setStage} />
<div className="toolbar">
<input
className="input search"
type="search"
placeholder="Kunde suchen …"
aria-label="Kunde suchen"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{content}
{showCreate === 'lead' && (
<div style={{ marginBottom: 16 }}>
<CreateLeadForm
onCancel={() => setShowCreate(null)}
onDone={async (data) => {
const r = await createLead(data, user)
setShowCreate(null)
refresh()
return r
}}
/>
</div>
<CreateLeadForm
onCancel={() => setShowCreate(null)}
onDone={async (data) => {
const r = await createLead(data, user)
setShowCreate(null)
refresh()
return r
}}
/>
)}
{showCreate === 'customer' && isAdmin && (
<div style={{ marginBottom: 16 }}>
<CreateCustomerForm
onCancel={() => setShowCreate(null)}
onCreate={async (data) => {
const r = await createCustomer(data)
if (r.success) {
setShowCreate(null)
setStage('customer')
refresh()
}
return r
}}
/>
</div>
<CreateCustomerForm
onCancel={() => setShowCreate(null)}
onCreate={async (data) => {
const r = await createCustomer(data)
if (r.success) {
setShowCreate(null)
setStage('customer')
refresh()
}
return r
}}
/>
)}
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<div className="seg">
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
<button className={stage === 'archived' ? 'active' : ''} onClick={() => setStage('archived')}>Archiviert</button>
</div>
<input className="form-control search-input" placeholder="Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
) : (
<div className="list">
{filtered.map((c) => {
const st = customerStage(c)
return (
<Link key={c.$id} to={`/customers/${c.$id}`} className="list-row">
<div className="side-logo" style={{ width: 34, height: 34, fontSize: 13 }}>
{(c.name || '?').charAt(0).toUpperCase()}
</div>
<div className="grow">
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
{c.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} title="Wichtiger Kunde" />}
{c.name}
{c.code && <span className="faint" style={{ fontWeight: 400 }}>· {c.code}</span>}
</div>
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
</div>
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
{isArchived(c) && <Badge tone="muted">Archiviert</Badge>}
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
</div>
</Link>
)
})}
</div>
)}
</div>
</Card>
</div>
)
}
@@ -147,22 +149,33 @@ function CreateLeadForm({ onCancel, onDone }) {
}
return (
<Card title="Neuer Lead (potenzieller Kunde)">
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
<Dialog
as="form"
title="Neuer Lead (potenzieller Kunde)"
busy={busy}
onClose={onCancel}
onSubmit={submit}
footer={
<>
<span className="grow" />
<Button type="button" disabled={busy} onClick={onCancel}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={busy}>{busy ? 'Speichert …' : 'Lead anlegen + Akquise-Ticket'}</Button>
</>
}
>
{error && <div className="msg err" role="alert">{error}</div>}
<p className="muted small">
Fuer einen Lead reichen Unternehmen und E-Mail. Es wird automatisch ein Akquise-Ticket erstellt.
</p>
<form onSubmit={submit}>
<div className="flex gap-3 wrap">
<Field label="Unternehmen"><input className="form-control" value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
<Field label="E-Mail"><input className="form-control" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Lead anlegen + Akquise-Ticket'}</Button>
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
</div>
</form>
</Card>
<div className="field-row">
<Field label="Unternehmen" htmlFor="lead-company">
<input id="lead-company" className="input" value={company} onChange={(e) => setCompany(e.target.value)} required />
</Field>
<Field label="E-Mail" htmlFor="lead-email">
<input id="lead-email" className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
</div>
</Dialog>
)
}
@@ -185,24 +198,44 @@ export function CreateCustomerForm({ onCancel, onCreate }) {
}
return (
<Card title="Neuer Kunde mit Portal-Zugang">
<form onSubmit={submit}>
<div className="flex gap-3 wrap">
<Field label="Unternehmen / Name"><input className="form-control" name="name" value={form.name} onChange={set('name')} required /></Field>
<Field label="E-Mail (Portal-Login)"><input className="form-control" type="email" name="email" value={form.email} onChange={set('email')} required /></Field>
<Field label="Kundennummer"><input className="form-control" name="code" value={form.code} onChange={set('code')} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="Telefon"><input className="form-control" name="phone" value={form.phone} onChange={set('phone')} /></Field>
<Field label="Standort"><input className="form-control" name="location" value={form.location} onChange={set('location')} /></Field>
<Field label="Portal-Passwort (mind. 8 Zeichen)"><input className="form-control" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required /></Field>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Kunde anlegen'}</Button>
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
</div>
</form>
</Card>
<Dialog
as="form"
wide
title="Neuer Kunde mit Portal-Zugang"
busy={busy}
onClose={onCancel}
onSubmit={submit}
footer={
<>
<span className="grow" />
<Button type="button" disabled={busy} onClick={onCancel}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={busy}>{busy ? 'Speichert …' : 'Kunde anlegen'}</Button>
</>
}
>
{error && <div className="msg err" role="alert">{error}</div>}
<div className="field-row-3">
<Field label="Unternehmen / Name" htmlFor="customer-name">
<input id="customer-name" className="input" name="name" value={form.name} onChange={set('name')} required />
</Field>
<Field label="E-Mail (Portal-Login)" htmlFor="customer-email">
<input id="customer-email" className="input" type="email" name="email" value={form.email} onChange={set('email')} required />
</Field>
<Field label="Kundennummer" htmlFor="customer-code">
<input id="customer-code" className="input" name="code" value={form.code} onChange={set('code')} />
</Field>
</div>
<div className="field-row-3">
<Field label="Telefon" htmlFor="customer-phone">
<input id="customer-phone" className="input" name="phone" value={form.phone} onChange={set('phone')} />
</Field>
<Field label="Standort" htmlFor="customer-location">
<input id="customer-location" className="input" name="location" value={form.location} onChange={set('location')} />
</Field>
<Field label="Portal-Passwort (mind. 8 Zeichen)" htmlFor="customer-password">
<input id="customer-password" className="input" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required />
</Field>
</div>
</Dialog>
)
}
+16 -17
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FaGear, FaSpinner } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
import { PageHeader, Button } from '../components/ui'
import StatusLine from '../components/finance/StatusLine'
import LiquidityHead from '../components/finance/LiquidityHead'
import TodoList from '../components/finance/TodoList'
@@ -16,6 +15,7 @@ import { useFinanceTransactions } from '../hooks/useFinanceTransactions'
import { useOpenInvoices } from '../hooks/useOpenInvoices'
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
import { planTodoAction, safeUrl } from '../lib/financeView'
import { noticeProps } from '../lib/financeUi'
export default function FinancePage() {
const navigate = useNavigate()
@@ -139,29 +139,30 @@ export default function FinancePage() {
let body
if (!data && loading) {
body = (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Finanzen werden geladen …</p>
<div className="loading" role="status">
Finanzen werden geladen …
</div>
)
} else if (!data) {
body = (
<Card>
<p role="alert" style={{ fontWeight: 600, marginTop: 0 }}>{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
<div className="fin-load-error">
<p className="msg err" role="alert">{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
<Button variant="primary" onClick={reload}>Erneut versuchen</Button>
</Card>
</div>
)
} else {
body = (
<div className="fin-stack">
{error && (
<p className="fin-hint fin-hint-warn" role="alert">
<p {...noticeProps('warn')}>
Die Übersicht konnte nicht neu geladen werden: {error}{' '}
<Button size="sm" variant="ghost" onClick={reload} disabled={loading}>Erneut versuchen</Button>
<button type="button" className="link" onClick={reload} disabled={loading}>Erneut versuchen</button>
</p>
)}
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
<LiquidityHead kopf={data.kopf} />
<div className="fin-top">
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
<LiquidityHead kopf={data.kopf} />
</div>
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
<div className="fin-cols">
<ForecastTable
@@ -195,15 +196,13 @@ export default function FinancePage() {
title="Finanzen"
subtitle="Kontostand, Aufgaben und Zahlungen im Blick"
actions={
<Button as={Link} variant="ghost" to="/finance/einstellungen">
<FaGear aria-hidden="true" /> Einstellungen
</Button>
<Button as={Link} to="/finance/einstellungen">Einstellungen</Button>
}
/>
{assignNotice && (
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
<p {...noticeProps('warn', 'fin-page-notice')}>
{assignNotice}{' '}
<Button size="sm" variant="ghost" onClick={() => setAssignNotice('')}>Schließen</Button>
<button type="button" className="link" onClick={() => setAssignNotice('')}>Schließen</button>
</p>
)}
{body}
+3 -4
View File
@@ -1,5 +1,4 @@
import { Link } from 'react-router-dom'
import { FaArrowLeft } from 'react-icons/fa6'
import { PageHeader, Button } from '../components/ui'
import SettingsCard from '../components/finance/SettingsCard'
import BankAccessCard from '../components/finance/BankAccessCard'
@@ -7,7 +6,7 @@ import FixedCostsCard from '../components/finance/FixedCostsCard'
/**
* Finanzen / Einstellungen (/finance/einstellungen, nur Admins): Rücklagen und Bankverbindungstext, Bankzugang
* (Status, Abruf, neu verbinden) und die Fixkostenliste. Jede Karte lädt und speichert für sich.
* (Status, Abruf, neu verbinden) und die Fixkostenliste. Jeder Abschnitt lädt und speichert für sich.
*/
export default function FinanceSettingsPage() {
return (
@@ -16,8 +15,8 @@ export default function FinanceSettingsPage() {
title="Finanzen: Einstellungen"
subtitle="Rücklagen, Bankzugang und Fixkosten"
actions={
<Button as={Link} variant="ghost" to="/finance">
<FaArrowLeft aria-hidden="true" /> Zurück zu den Finanzen
<Button as={Link} to="/finance">
← Zurück zu den Finanzen
</Button>
}
/>
+2 -2
View File
@@ -16,7 +16,7 @@ export default function LegalPage() {
const active = tab === 'texte' ? texts : contracts
let body
if (active.loading) body = <div className="faint">Lädt …</div>
if (active.loading) body = <div className="loading">Lädt …</div>
else if (active.missingSetup) {
body = (
<EmptyState
@@ -31,7 +31,7 @@ export default function LegalPage() {
<div className="page">
<PageHeader title="Rechtliches" subtitle="Rechtstexte mit Versionsgeschichte und Verträge mit Anbietern" />
<Tabs tabs={TABS} active={tab} onChange={setTab} />
{active.error && <div className="legal-error">{active.error}</div>}
{active.error && <div className="msg err" role="alert">{active.error}</div>}
{body}
</div>
)
+1 -1
View File
@@ -7,7 +7,7 @@ export default function OffersPage() {
<div className="page">
<PageHeader title="Abo-Angebote" subtitle="Individuelle Angebote für das Wartungs- oder Hosting-Abo" />
<OffersView />
<p className="area-muted" style={{ marginTop: 12 }}>Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
<p className="table-foot">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
</div>
)
}
+332 -469
View File
@@ -1,18 +1,9 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import PreviewBlock from '../components/PreviewBlock'
import {
FaFolder,
FaPlus,
FaArrowsRotate,
FaPen,
FaBoxArchive,
FaBoxOpen,
FaGlobe,
FaLock,
FaCircleInfo,
FaClone,
} from 'react-icons/fa6'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { useCustomers } from '../hooks/useCustomers'
import { useWorkorders } from '../hooks/useWorkorders'
@@ -27,70 +18,22 @@ import {
importNewReposThrottled,
} from '../lib/projectAdminApi'
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
import { projectCounts, projectsInView, projectTabs, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
import { PROJECT_TYPES, typeOf, projectActions, projectTicketRef, updatedLabel } from '../lib/projectRow'
import { ticketPath } from '../lib/ticketRoutes'
import { isRowActivation } from '../lib/rowClick'
import { slugify } from '../lib/subdomain'
import PreviewLinkButton from '../components/PreviewLinkButton'
import GiteaLinkButton from '../components/GiteaLinkButton'
const WORKORDER_FILTERS = { limit: 500 }
// Projekt-Typen (= "Template"/Hosting-Modus)
const TYPES = {
preview: {
label: 'Preview (mit Login)',
short: 'Preview',
badge: 'badge-info',
icon: <FaLock />,
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
},
website: {
label: 'Oeffentliche Website',
short: 'Website',
badge: 'badge-ok',
icon: <FaGlobe />,
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
},
template: {
label: 'Vorlage',
short: 'Vorlage',
badge: 'badge-warn',
icon: <FaClone />,
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
adminOnly: true,
},
project: {
label: 'Internes Projekt (kein Hosting)',
short: 'Projekt',
badge: 'badge-muted',
icon: <FaFolder />,
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
},
}
function typeOf(project) {
if (project.isTemplate) return 'template'
if (project.isPublic) return 'website'
if (project.subdomain) return 'preview'
return 'project'
}
// Typen mit Hosting (Subdomain + Deploy)
const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
function isProjectReady(project) {
const st = String(project.provisioningStatus || project.status || '').toLowerCase()
return ['ready', 'deployed'].includes(st)
}
export default function ProjectsPage() {
const navigate = useNavigate()
const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
const { projects, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
const { customers } = useCustomers()
const { workorders } = useWorkorders(WORKORDER_FILTERS)
const { employees } = useEmployees()
const { isAdmin } = useAuth()
const [loadedOnce, setLoadedOnce] = useState(false)
const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('')
const [customerFilter, setCustomerFilter] = useState('')
@@ -121,21 +64,13 @@ export default function ProjectsPage() {
// sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
useEffect(() => {
let cancelled = false
fetchAllProjects()
fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) })
importNewReposThrottled()
.then((r) => { if (r && !cancelled) fetchAllProjects() })
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
return () => { cancelled = true }
}, [fetchAllProjects])
const customerMap = useMemo(() => {
const map = {}
for (const c of customers) {
map[c.$id] = c.name || c.code || c.$id
}
return map
}, [customers])
const ticketMap = useMemo(() => {
const map = {}
for (const wo of workorders) {
@@ -355,199 +290,57 @@ export default function ProjectsPage() {
}
}
const FILTER_OPTIONS = [
{ value: 'all', label: `Aktive Projekte (${counts.all})` },
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
// Vorlagen-Ansicht nur fuer Admins
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
{ value: 'archived', label: `Archiviert (${counts.archived})` },
{ value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` },
]
// Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins
const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
return (
<div className="page">
<header className="text-center mb-2">
<h2>Website-Projekte</h2>
</header>
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="search"
className="form-control"
placeholder="Nach Titel suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
/>
<select
className="form-control"
style={{ width: 'auto', minWidth: '170px' }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
{FILTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={customerFilter}
onChange={(e) => setCustomerFilter(e.target.value)}
title="Nach Kunde filtern"
>
<option value="">Alle Kunden</option>
{sortedCustomers.map((c) => (
<option key={c.$id} value={c.$id}>
{c.name}{c.code ? ` (${c.code})` : ''}
</option>
))}
</select>
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={employeeFilter}
onChange={(e) => setEmployeeFilter(e.target.value)}
title="Nach Mitarbeiter filtern"
>
<option value="">Alle Mitarbeiter</option>
{sortedEmployees.map((emp) => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName || emp.userId}
</option>
))}
</select>
<button
type="button"
className="btn btn-teal"
style={{ padding: '8px 12px' }}
onClick={() => setShowCreateModal(true)}
title="Neues Projekt"
aria-label="Neues Projekt"
>
<FaPlus />
</button>
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={handleSyncRepos}
disabled={syncLoading}
title="Gitea-Repos synchronisieren"
aria-label="Gitea-Repos synchronisieren"
>
<FaArrowsRotate style={syncLoading ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
</div>
{notice && (
<div className="text-center mb-2">
<span className="text-grey">{notice}</span>
</div>
)}
{hiddenHits.length > 0 && (
<div className="text-center mb-2" data-hidden-hits>
<span className="text-grey">Weitere Treffer: </span>
{hiddenHits.map((h, i) => (
<span key={h.view}>
{i > 0 && <span className="text-grey"> · </span>}
<button
type="button"
style={{ padding: 0, border: 'none', background: 'none', color: 'var(--accent)', textDecoration: 'underline', cursor: 'pointer', font: 'inherit' }}
onClick={() => setFilter(h.view)}
>
{h.count} unter „{VIEW_LABELS[h.view]}“
</button>
</span>
))}
</div>
)}
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
{loading ? (
<p className="text-center text-grey">Projekte werden geladen...</p>
) : filteredProjects.length === 0 ? (
<div className="text-center p-4">
<FaFolder size={64} className="text-grey" />
<p className="text-grey mt-2">
{filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'}
</p>
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))',
gap: '16px',
alignItems: 'start',
}}
>
let content
if (!loadedOnce) {
// Nur beim ersten Laden: spaeter bleibt die Tabelle beim Nachladen stehen
content = <div className="loading">Wird geladen …</div>
} else if (filteredProjects.length === 0) {
content = <EmptyState title={filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'} />
} else {
content = (
<table className="table projects-table" role="table">
<thead role="rowgroup">
<tr role="row">
<th role="columnheader">Projekt</th>
<th role="columnheader">Kunde</th>
<th role="columnheader" className="hide-md">Art</th>
<th role="columnheader" className="hide-md">Adresse</th>
<th role="columnheader" className="hide-sm num">Ticket</th>
<th role="columnheader">Status</th>
<th role="columnheader" className="hide-md right">Zuletzt geändert</th>
<th role="columnheader" className="right">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{filteredProjects.map((project) => {
const type = typeOf(project)
const t = TYPES[type]
const hosted = HOSTED_TYPES.has(type)
const ready = isProjectReady(project)
const needsHosting = hosted && !ready && !project.archived
const actions = projectActions(project)
const archived = Boolean(project.archived)
const type = PROJECT_TYPES[actions.type]
const name = project.projectName || project.giteaRepoName || '(ohne Name)'
const ticketRef = projectTicketRef(project, ticketMap)
const updated = updatedLabel(project.$updatedAt)
return (
<div
<tr
key={project.$id}
className="card"
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px', opacity: project.archived ? 0.72 : 1 }}
role="row"
className={`row${archived ? ' is-archived' : ''}`}
onClick={(e) => { if (isRowActivation(e)) openEdit(project) }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: '15px', wordBreak: 'break-word' }}>
{project.projectName || project.giteaRepoName || '(ohne Name)'}
</div>
{project.repoFullName && (
<div className="text-grey" style={{ fontSize: '12px', marginTop: '2px' }}>
{project.repoFullName}
</div>
)}
<td role="cell">
<div className="cell-title">
<button type="button" className="link" onClick={() => openEdit(project)}>{name}</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end', flexShrink: 0 }}>
<span className={`badge ${t.badge}`} style={{ whiteSpace: 'nowrap' }}>
{t.icon} {t.short}
</span>
{hosted && !project.archived && (
<span className={`badge ${ready ? 'badge-ok' : 'badge-warn'}`} style={{ whiteSpace: 'nowrap' }}>
{ready ? 'gehostet' : 'nicht deployt'}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px' }}>
<Row label="Subdomain">
{project.subdomain
? <code>{project.subdomain}</code>
: <span className="text-grey">ohne Website</span>}
</Row>
<Row label="Ticket">
{project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : <span className="text-grey">-</span>}
</Row>
<Row label="Status">
<span className="text-grey">{project.archived ? 'archiviert' : (project.status || '-')}</span>
</Row>
</div>
<div>
<label className="form-label" style={{ fontSize: '12px' }}>Kunde</label>
{project.repoFullName && <div className="cell-sub">{project.repoFullName}</div>}
</td>
<td role="cell" data-label="Kunde">
<select
className="form-control"
style={{ fontSize: '13px' }}
className="select"
aria-label={`Kunde von ${name}`}
value={project.customerId || ''}
disabled={assignBusyId === project.$id || project.archived}
disabled={assignBusyId === project.$id || archived}
onChange={(e) => handleAssignCustomer(project, e.target.value)}
>
<option value="">Kein Kunde</option>
@@ -557,234 +350,304 @@ export default function ProjectsPage() {
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: 'auto' }}>
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={() => openEdit(project)}
title="Bearbeiten"
aria-label="Bearbeiten"
>
<FaPen />
</button>
{needsHosting ? (
<button
type="button"
className="btn btn-teal"
style={{ padding: '8px 12px' }}
onClick={() => handleHostNow(project)}
disabled={hostBusyId === project.$id}
title="Projekt jetzt deployen/hosten"
aria-label="Projekt jetzt deployen/hosten"
>
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
) : (
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
)}
{hosted && ready && !project.archived && (
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={() => handleRefresh(project)}
disabled={refreshBusyId === project.$id}
title="Aktualisieren – Website neu deployen"
aria-label="Aktualisieren – Website neu deployen"
>
<FaArrowsRotate
style={refreshBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined}
/>
</button>
)}
<GiteaLinkButton href={project.giteaRepoUrl} />
</div>
</div>
</td>
<td role="cell" className="hide-md">
<Badge tone={type.tone}>{type.short}</Badge>
</td>
<td role="cell" className="hide-md cell-address">
{project.subdomain
? <code title={project.subdomain}>{project.subdomain}</code>
: <span className="muted">ohne Website</span>}
</td>
<td role="cell" className="hide-sm num">
{ticketRef
? <Link className="link" to={ticketPath(ticketRef)}>{ticketRef}</Link>
: <span className="faint">-</span>}
</td>
<td role="cell" data-label="Status">
{actions.hostedBadge && <Badge tone={actions.hostedBadge.tone}>{actions.hostedBadge.text}</Badge>}
<div className="cell-sub">{archived ? 'archiviert' : (project.status || '-')}</div>
</td>
<td role="cell" className="hide-md right cell-updated" title={updated.title || undefined}>
{updated.title ? updated.text : <span className="faint">{updated.text}</span>}
</td>
<td role="cell" className="right cell-actions">
<div className="row-actions">
{actions.host && (
<button
type="button"
className="link"
title="Projekt jetzt deployen/hosten"
onClick={() => handleHostNow(project)}
disabled={hostBusyId === project.$id}
>
{hostBusyId === project.$id ? 'Wird geladen …' : 'Hosten'}
</button>
)}
{actions.preview && <PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />}
{actions.refresh && (
<button
type="button"
className="link"
title="Aktualisieren – Website neu deployen"
onClick={() => handleRefresh(project)}
disabled={refreshBusyId === project.$id}
>
{refreshBusyId === project.$id ? 'Wird geladen …' : 'Aktualisieren'}
</button>
)}
{actions.gitea && <GiteaLinkButton href={project.giteaRepoUrl} />}
</div>
</td>
</tr>
)
})}
</div>
</tbody>
</table>
)
}
return (
<div className="page">
<PageHeader
title="Website-Projekte"
actions={
<>
<Button type="button" onClick={handleSyncRepos} disabled={syncLoading}>
{syncLoading ? 'Wird geladen …' : 'Gitea-Repos synchronisieren'}
</Button>
<Button variant="primary" type="button" onClick={() => setShowCreateModal(true)}>Neues Projekt</Button>
</>
}
/>
<Tabs tabs={projectTabs(counts, { isAdmin })} active={filter} onChange={setFilter} />
<div className="toolbar">
<input
type="search"
className="input search"
aria-label="Nach Titel suchen"
placeholder="Nach Titel suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
className="select select-quiet"
value={customerFilter}
onChange={(e) => setCustomerFilter(e.target.value)}
title="Nach Kunde filtern"
aria-label="Nach Kunde filtern"
>
<option value="">Alle Kunden</option>
{sortedCustomers.map((c) => (
<option key={c.$id} value={c.$id}>
{c.name}{c.code ? ` (${c.code})` : ''}
</option>
))}
</select>
<select
className="select select-quiet"
value={employeeFilter}
onChange={(e) => setEmployeeFilter(e.target.value)}
title="Nach Mitarbeiter filtern"
aria-label="Nach Mitarbeiter filtern"
>
<option value="">Alle Mitarbeiter</option>
{sortedEmployees.map((emp) => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName || emp.userId}
</option>
))}
</select>
</div>
{notice && <p className="msg muted" role="status">{notice}</p>}
{hiddenHits.length > 0 && (
<p className="msg muted" data-hidden-hits>
Weitere Treffer:{' '}
{hiddenHits.map((h, i) => (
<span key={h.view}>
{i > 0 && ' · '}
<button type="button" className="link" onClick={() => setFilter(h.view)}>
{h.count} unter „{VIEW_LABELS[h.view]}“
</button>
</span>
))}
</p>
)}
{error && <p className="msg err" role="alert">{error}</p>}
{content}
{/* --- Neues Projekt --- */}
{showCreateModal && (
<div className="overlay">
<span className="overlay-close" onClick={() => setShowCreateModal(false)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Neues Website-Projekt</h2>
{createError && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{createError}
</div>
)}
<form onSubmit={handleCreate}>
<div className="form-group">
<label className="form-label">Anzeigename</label>
<input
type="text"
className="form-control"
value={createForm.displayName}
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
required
/>
</div>
<div className="form-group">
<label className="form-label">Subdomain (optional)</label>
<input
type="text"
className="form-control"
value={createForm.subdomain}
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="leer lassen = Projekt ohne Website"
/>
<small className="text-grey">
Mit Subdomain wird die Website automatisch unter https://&lt;subdomain&gt;.project.webklar.com deployt.
Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt.
</small>
</div>
<div className="form-group">
<label className="form-label">Repo-Name (optional)</label>
<input
type="text"
className="form-control"
value={createForm.repoName}
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
/>
</div>
<div className="form-group">
<label className="form-label">Kunde (optional)</label>
<select
className="form-control"
value={createForm.customerId}
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</div>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={createLoading}>
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
</button>
</div>
</form>
</div>
</div>
<Dialog
as="form"
onSubmit={handleCreate}
title="Neues Website-Projekt"
busy={createLoading}
onClose={() => setShowCreateModal(false)}
footer={
<>
<span className="grow" />
<Button type="submit" variant="primary" disabled={createLoading}>
{createLoading ? 'Wird angelegt...' : 'Projekt anlegen'}
</Button>
</>
}
>
{createError && <p className="msg err" role="alert">{createError}</p>}
<Field label="Anzeigename" htmlFor="projekt-neu-name">
<input
id="projekt-neu-name"
type="text"
className="input"
value={createForm.displayName}
onChange={(e) => setCreateForm((p) => ({ ...p, displayName: e.target.value }))}
required
/>
</Field>
<Field
label="Subdomain (optional)"
htmlFor="projekt-neu-subdomain"
hint="Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt. Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt."
>
<input
id="projekt-neu-subdomain"
type="text"
className="input"
value={createForm.subdomain}
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="leer lassen = Projekt ohne Website"
/>
</Field>
<Field label="Repo-Name (optional)" htmlFor="projekt-neu-repo">
<input
id="projekt-neu-repo"
type="text"
className="input"
value={createForm.repoName}
onChange={(e) => setCreateForm((p) => ({ ...p, repoName: e.target.value }))}
placeholder="leer = aus Anzeigename/Subdomain abgeleitet"
/>
</Field>
<Field label="Kunde (optional)" htmlFor="projekt-neu-kunde">
<select
id="projekt-neu-kunde"
className="select"
value={createForm.customerId}
onChange={(e) => setCreateForm((p) => ({ ...p, customerId: e.target.value }))}
>
<option value="">Kein Kunde</option>
{customers.map((c) => (
<option key={c.$id} value={c.$id}>
({c.code || ''}) {c.name}
</option>
))}
</select>
</Field>
</Dialog>
)}
{/* --- Projekt bearbeiten --- */}
{editProject && (
<div className="overlay">
<span className="overlay-close" onClick={() => setEditProject(null)}>×</span>
<div className="overlay-content">
<h2 className="mb-2">Projekt bearbeiten</h2>
<p className="text-grey mb-2" style={{ fontSize: '13px' }}>
{editProject.repoFullName}
</p>
{editError && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{editError}
</div>
)}
<form onSubmit={handleEditSave}>
<div className="form-group">
<label className="form-label">Name</label>
<input
type="text"
className="form-control"
value={editForm.projectName}
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
required
/>
</div>
<div className="form-group">
<label className="form-label">Typ / Template</label>
<select
className="form-control"
value={editForm.projectType}
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
>
{typeOptions.map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
<small className="text-grey">
<FaCircleInfo /> {TYPES[editForm.projectType].hint}
</small>
</div>
{editForm.projectType !== 'project' && (
<div className="form-group">
<label className="form-label">Subdomain</label>
<input
type="text"
className="form-control"
value={editForm.subdomain}
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="z.B. musterfirma"
/>
<small className="text-grey">
Ergebnis: https://{slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com
{' - '}Aenderung wird neu deployt (alte Subdomain wird abgebaut).
</small>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginTop: '16px', flexWrap: 'wrap' }}>
<Dialog
wide
title="Projekt bearbeiten"
busy={editLoading || archiveBusy}
onClose={() => setEditProject(null)}
footer={
<>
{editProject.archived ? (
<button
type="button"
className={editProject.archived ? 'btn' : 'link-danger'}
className="link"
onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
>
{editProject.archived
? <><FaBoxOpen /> Reaktivieren</>
: <><FaBoxArchive /> Repository archivieren</>}
Reaktivieren
</button>
<button
type="submit"
className="btn btn-dark"
disabled={editLoading || archiveBusy || editProject.archived}
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
) : (
<Button
variant="danger"
type="button"
onClick={() => handleArchiveToggle(editProject)}
disabled={archiveBusy || editLoading}
>
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
</button>
</div>
{editProject.archived && (
<small className="text-grey" style={{ display: 'block', marginTop: 8 }}>
Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.
</small>
Repository archivieren
</Button>
)}
</form>
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
<div className="form-group" style={{ marginTop: 16 }}>
<label className="form-label">Preview</label>
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
</div>
<span className="grow" />
{/* Im Fuss ausserhalb des <form>, verbunden ueber das Attribut form: Enter im Namensfeld speichert, im PreviewBlock nie */}
<Button
type="submit"
form="projekt-bearbeiten"
variant="primary"
disabled={editLoading || archiveBusy || editProject.archived}
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
>
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
</Button>
</>
}
>
{editProject.repoFullName && <p className="muted small">{editProject.repoFullName}</p>}
{editError && <p className="msg err" role="alert">{editError}</p>}
<form id="projekt-bearbeiten" className="dialog-form" onSubmit={handleEditSave}>
<Field label="Name" htmlFor="projekt-bearbeiten-name">
<input
id="projekt-bearbeiten-name"
type="text"
className="input"
value={editForm.projectName}
onChange={(e) => setEditForm((p) => ({ ...p, projectName: e.target.value }))}
required
/>
</Field>
<Field label="Typ / Template" htmlFor="projekt-bearbeiten-typ" hint={PROJECT_TYPES[editForm.projectType].hint}>
<select
id="projekt-bearbeiten-typ"
className="select"
value={editForm.projectType}
onChange={(e) => setEditForm((p) => ({ ...p, projectType: e.target.value }))}
>
{typeOptions.map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
</Field>
{editForm.projectType !== 'project' && (
<Field
label="Subdomain"
htmlFor="projekt-bearbeiten-subdomain"
hint={`Ergebnis: https://${slugify(editForm.subdomain) || '<subdomain>'}.project.webklar.com - Aenderung wird neu deployt (alte Subdomain wird abgebaut).`}
>
<input
id="projekt-bearbeiten-subdomain"
type="text"
className="input"
value={editForm.subdomain}
onChange={(e) => setEditForm((p) => ({ ...p, subdomain: e.target.value }))}
placeholder="z.B. musterfirma"
/>
</Field>
)}
</div>
</div>
</form>
{editProject.archived && <p className="muted small">Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.</p>}
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
<section>
<h4 className="pv-title">Preview</h4>
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
</section>
)}
</Dialog>
)}
</div>
)
}
function Row({ label, children }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
<span className="text-grey">{label}</span>
<span style={{ textAlign: 'right', wordBreak: 'break-word' }}>{children}</span>
</div>
)
}
+62 -55
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useMemo, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { PageHeader } from '../components/ui'
import { PageHeader, Badge, Stat } from '../components/ui'
import { badgeToneClass } from '../components/ui/tones'
import { serverpulsApi } from '../lib/serverpulsApi'
import {
ageLabel, formatBytes, formatNumber, formatPct, formatSince, usedPct,
ageLabel, deltaDir, deltaSign, formatBytes, formatNumber, formatPct, formatSince, levelTone, usedPct,
} from '../lib/serverpulsFormat'
import Chart from '../components/serverpuls/Chart'
import { CHART } from '../components/serverpuls/chartColors'
@@ -81,20 +82,17 @@ function Tile({ id, label, model }) {
let deltaEl = null
if (model && model.delta !== null && model.delta !== undefined) {
const d = model.delta
const dir = Math.abs(d) < 1e-9 ? 'flat' : d > 0 ? 'up' : 'down'
const sign = dir === 'up' ? '▲ +' : dir === 'down' ? '▼ −' : ''
const dir = deltaDir(d)
deltaEl = (
<div className={`sp-tile-delta sp-delta-${dir}`}>
{sign}{model.fmtDelta(d)} ggü. gestern
{deltaSign(dir)}{model.fmtDelta(d)} ggü. gestern
</div>
)
}
return (
<div className="sp-tile" data-testid={`sp-tile-${id}`}>
<div className="sp-tile-label">{label}</div>
<div className="sp-tile-value">{model ? model.main : '–'}</div>
<div className="sp-tile-sub">{model?.sub || ' '}</div>
<Sparkline points={model?.spark || []} width={200} height={36} />
<Stat label={label} value={model ? model.main : '–'} sub={model?.sub || '\u00a0'} />
<Sparkline points={model?.spark || []} width={120} height={32} />
{deltaEl}
</div>
)
@@ -102,40 +100,44 @@ function Tile({ id, label, model }) {
function Pills({ items, value, onChange, testid }) {
return (
<div className="sp-pills" data-testid={testid}>
{items.map((it) => (
<button
key={it.id}
type="button"
className={`sp-pill ${value === it.id ? 'active' : ''}`}
onClick={() => onChange(it.id)}
>
{it.label}
</button>
))}
<div className="seg" data-testid={testid}>
{items.map((it) => {
const active = value === it.id
return (
<button
key={it.id}
type="button"
className={active ? 'on' : undefined}
aria-pressed={active}
onClick={() => onChange(it.id)}
>
{it.label}
</button>
)
})}
</div>
)
}
function Cleanup({ data }) {
if (!data || !data.day) return <div className="sp-muted">Noch keine Messung.</div>
if (!data || !data.day) return <div className="muted">Noch keine Messung.</div>
const stopped = Array.isArray(data.stopped) ? data.stopped : []
return (
<div className="sp-cleanup-body">
<div className="sp-cleanup-grid">
<div><span className="sp-muted">Ungenutzte Images</span><strong>{formatBytes(data.imagesBytes)}</strong></div>
<div><span className="sp-muted">Build-Cache</span><strong>{formatBytes(data.buildCacheBytes)}</strong></div>
<div><span className="sp-muted">Gestoppte Container</span><strong>{formatBytes(data.stoppedBytes)}</strong></div>
<>
<div className="stats sp-cleanup-stats">
<Stat label="Ungenutzte Images" value={formatBytes(data.imagesBytes)} />
<Stat label="Build-Cache" value={formatBytes(data.buildCacheBytes)} />
<Stat label="Gestoppte Container" value={formatBytes(data.stoppedBytes)} />
</div>
{stopped.length > 0 && (
<ul className="sp-cleanup-list">
{stopped.map((s) => (
<li key={s.name}><span>{s.name}</span><span>{formatBytes(s.bytes)}</span></li>
<li key={s.name} className="list-row"><span>{s.name}</span><span className="num">{formatBytes(s.bytes)}</span></li>
))}
</ul>
)}
<div className="sp-muted sp-small">Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.</div>
</div>
<p className="muted small">Stand {data.day} · nur Anzeige, hier wird nichts gelöscht.</p>
</>
)
}
@@ -245,7 +247,6 @@ export default function ServerpulsPage() {
const level = overview?.level || 'ok'
const alerts = overview?.alerts || []
const age = ageLabel(overview?.lastSample ?? null, now)
const dot = level === 'critical' ? '🔴' : level === 'medium' ? '🟠' : '🟢'
const headline = level === 'ok'
? 'Alles ok'
: level === 'critical'
@@ -256,14 +257,20 @@ export default function ServerpulsPage() {
<div className="page sp-page" data-testid="sp-page">
<PageHeader title="Serverpuls" subtitle="Zustand und Verlauf des Servers – nur Anzeige" />
{error && <div className="sp-error" data-testid="sp-error" role="alert">{error}</div>}
{error && <div className="msg err" data-testid="sp-error" role="alert">{error}</div>}
<section className={`sp-head sp-level-${level}`} data-testid="sp-head">
<section data-testid="sp-head">
<div className="sp-head-top">
<span className="sp-ampel" data-testid="sp-ampel" data-level={level}>{dot}</span>
<span
className={overview ? badgeToneClass(levelTone(level)) : 'badge plain'}
data-testid="sp-ampel"
data-level={level}
>
{overview ? (LEVEL_TEXT[level] || level) : '–'}
</span>
<strong className="sp-headline">{overview ? headline : (error ? 'Keine Daten' : 'Lädt …')}</strong>
{overview && (
<span className={`sp-age ${age.stale ? 'sp-age-stale' : ''}`} data-testid="sp-age" data-stale={age.stale ? '1' : '0'}>
<span className={age.stale ? 'sp-age sp-age-stale' : 'sp-age'} data-testid="sp-age" data-stale={age.stale ? '1' : '0'}>
Letzte Messung {age.text}{age.stale ? ' – Daten veraltet' : ''}
</span>
)}
@@ -271,46 +278,46 @@ export default function ServerpulsPage() {
{alerts.length > 0 && (
<ul className="sp-alerts" data-testid="sp-alerts">
{alerts.map((a) => (
<li key={a.id} className={`sp-alert sp-alert-${a.level}`} data-testid="sp-alert">
<span className="sp-alert-level">{LEVEL_LABEL[a.level] || a.level}</span>
<span className="sp-alert-title">{a.title}</span>
<span className="sp-alert-value">{a.value}</span>
<span className="sp-muted sp-small">seit {formatSince(a.since)}</span>
<li key={a.id} className="list-row" data-testid="sp-alert">
<div className="sp-alert-main">
<Badge tone={levelTone(a.level)}>{LEVEL_LABEL[a.level] || a.level}</Badge>
<span className="sp-alert-title">{a.title}</span>
<span className="sp-alert-value">{a.value}</span>
</div>
<span className="muted small nowrap">seit {formatSince(a.since)}</span>
</li>
))}
</ul>
)}
</section>
<section className="sp-tiles" data-testid="sp-tiles">
<section className="stats sp-tiles" data-testid="sp-tiles">
{METRICS.map((x) => (
<Tile key={x.id} id={x.id} label={x.label} model={tileModel(x.id, overview?.host?.[x.id])} />
))}
</section>
<section className="sp-panel" data-testid="sp-chart-panel">
<div className="sp-panel-head">
<h3>Verlauf</h3>
<div className="sp-controls">
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
</div>
<h2 className="section"><span>Verlauf</span></h2>
<div className="sp-controls">
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
</div>
{chartData && chartData.data[0].length > 0
? <Chart data={chartData.data} series={chartData.series} yFmt={m.fmt} />
: <div className="sp-muted sp-nodata">{chart ? 'Für diesen Zeitraum liegen keine Messwerte vor.' : 'Lädt …'}</div>}
: <div className="muted sp-nodata">{chart ? 'Für diesen Zeitraum liegen keine Messwerte vor.' : 'Lädt …'}</div>}
</section>
<section className="sp-panel" data-testid="sp-projects">
<div className="sp-panel-head">
<h3>Projekte</h3>
{appsData?.sizesDay && <span className="sp-muted sp-small">Größen von {appsData.sizesDay}</span>}
</div>
<h2 className="section">
<span>Projekte</span>
{appsData?.sizesDay && <span className="muted small">Größen von {appsData.sizesDay}</span>}
</h2>
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={selectApp} />
{selected && (
<div className="sp-detail" data-testid="sp-detail">
<div className="sp-panel-head">
<h3>{detail?.name || selected}</h3>
<h3 className="sp-detail-title">{detail?.name || selected}</h3>
<div className="sp-controls">
<Pills items={RANGES} value={detailRange} onChange={changeDetailRange} testid="sp-detail-range" />
</div>
{detailData ? (
@@ -319,13 +326,13 @@ export default function ServerpulsPage() {
<div><div className="sp-chart-title">RAM</div><Chart data={detailData.mem.data} series={detailData.mem.series} yFmt={formatBytes} height={200} /></div>
<div><div className="sp-chart-title">CPU</div><Chart data={detailData.cpu.data} series={detailData.cpu.series} yFmt={(v) => formatPct(v)} height={200} /></div>
</div>
) : <div className="sp-muted sp-nodata">Lädt …</div>}
) : <div className="muted sp-nodata">Lädt …</div>}
</div>
)}
</section>
<section className="sp-panel" data-testid="sp-cleanup">
<div className="sp-panel-head"><h3>Aufräumbar</h3></div>
<h2 className="section"><span>Aufräumbar</span></h2>
<Cleanup data={cleanup} />
</section>
</div>
+41 -43
View File
@@ -1,14 +1,12 @@
import { useState, useEffect, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FaSpinner } from 'react-icons/fa6'
import { dotColor } from '../lib/previewEnv'
import { Link } from 'react-router-dom'
import { fetchServices } from '../lib/projectKindsApi'
import { servicesCards } from '../lib/servicesView'
import { ticketPath } from '../lib/ticketRoutes'
import { previewStateTone } from '../components/projectTones'
import { PageHeader, Card, Button, Badge } from '../components/ui'
export default function ServicesPage() {
const navigate = useNavigate()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -28,59 +26,59 @@ export default function ServicesPage() {
const cards = useMemo(() => servicesCards(data), [data])
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
return (
<div className="page">
<PageHeader title="Services" subtitle="Unsere Projekte nach Art" />
{error && (
<Card>
<div className="text-red" style={{ marginBottom: 8 }}>{error}</div>
<Button onClick={load}>Erneut versuchen</Button>
</Card>
<div className="services-stack">
<p className="msg err" role="alert">{error}</p>
<div className="row-buttons">
<Button type="button" onClick={load}>Erneut versuchen</Button>
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 12 }}>
{cards.map((c) => (
<Card key={c.key} title={`${c.label} (${c.count})`}>
{c.description && <div className="muted" style={{ marginBottom: 8 }}>{c.description}</div>}
{cards.map((c) => (
<Card key={c.key} title={`${c.label} (${c.count})`}>
<div className="services-stack">
{c.description && <p className="muted">{c.description}</p>}
{c.features.length > 0 && (
<div className="flex gap-2 wrap" style={{ marginBottom: 8 }}>
<div className="pv-row">
{c.features.map((f) => (
<Badge key={f.label}>{f.label}{f.statusLabel ? ` · ${f.statusLabel}` : ''}</Badge>
))}
</div>
)}
<div className="list">
{c.projects.map((p) => (
<div key={p.id} className="list-row" style={{ flexWrap: 'wrap' }}>
<div className="grow" style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
<div style={{ fontWeight: 700 }}>
{p.previewState && (
<span
title={`Preview: ${p.previewState}`}
style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: dotColor(p.previewState), marginRight: 6 }}
>
<span style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{`Preview: ${p.previewState}`}</span>
</span>
)}
{p.projectName || p.repoFullName}
{p.features?.analyse === 'active' && <Badge>Analyse an</Badge>}
</div>
<div className="muted">
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
{c.projects.length > 0 ? (
<div className="list">
{c.projects.map((p) => (
<div key={p.id} className="list-row">
<div className="list-row-text">
<div className="cell-title">
{p.projectName || p.repoFullName}
{p.previewState && <Badge tone={previewStateTone(p.previewState)}>Preview: {p.previewState}</Badge>}
{p.features?.analyse === 'active' && <Badge>Analyse an</Badge>}
</div>
<div className="cell-sub">
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
</div>
</div>
{(p.woid || c.key === 'missing') && (
<div className="row-actions">
{p.woid && <Link className="link" to={ticketPath(p.woid)}>Ticket #{p.woid}</Link>}
{c.key === 'missing' && <Link className="link" to={`/projects/zuordnen/${p.id}`}>Zuordnen</Link>}
</div>
)}
</div>
{p.woid && <Link to={ticketPath(p.woid)}>Ticket #{p.woid}</Link>}
{c.key === 'missing' && (
<Button variant="primary" onClick={() => navigate(`/projects/zuordnen/${p.id}`)}>Zuordnen</Button>
)}
</div>
))}
{c.projects.length === 0 && <div className="muted" style={{ padding: 12 }}>Noch keine Projekte.</div>}
</div>
</Card>
))}
</div>
))}
</div>
) : (
<p className="muted pick-empty">Noch keine Projekte.</p>
)}
</div>
</Card>
))}
</div>
)
}
+63 -46
View File
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
import { PageHeader, Card, Badge, Button } from '../components/ui'
import { PageHeader, Card, Badge, Button, EmptyState } from '../components/ui'
import { listStorageTasks, markStorageDone } from '../lib/teamApi'
import { fmtDate, fmtDateTime } from '../lib/format'
import { changeText, failedText, isOverdue, orderedKind, partialText, scopeText, summaryText, taskText } from '../lib/storageFormat'
import '../styles/admin-areas.css'
import { fmtDateTime } from '../lib/format'
import { changeText, failedText, orderedKind, partialText, scopeText, storageDue, summaryText, taskText } from '../lib/storageFormat'
// E-Mail-Speicher (Kunden -> E-Mail-Speicher): Zusatzspeicher-Aufgaben in konsoleH wie im frueheren Admin-Panel
export default function StorageTasksPage() {
@@ -39,57 +37,76 @@ export default function StorageTasksPage() {
<div className="page">
<PageHeader title="E-Mail-Speicher" subtitle="Zusatzspeicher der Abos in konsoleH einrichten und senken" />
{error && (
<div className="area-error area-inline">
{error} <Button size="sm" variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
<div className="flex items-center gap-2 wrap mb-2">
<span className="msg err" role="alert">{error}</span>
<Button onClick={load}>Erneut versuchen</Button>
</div>
)}
{!data && !error && <FaSpinner className="spinner" />}
{!data && !error && <div className="loading">Wird geladen …</div>}
{data && (
<>
<div className={`area-summary ${summary.warn ? 'warn' : ''}`}>{summary.text}</div>
<Card pad={false}>
<div className="area-rows">
<div className="area-row storage-row head"><span>Fällig</span><span>Kunde</span><span>Aufgabe in konsoleH</span><span>Bestellt</span><span /></div>
{data.tasks.length === 0 && <div className="area-row"><span className="area-muted">Keine offenen Speicher-Aufgaben.</span></div>}
{data.tasks.map((t) => (
<div key={t.id} className="area-row storage-row">
<span>
{t.kind === 'einrichten'
? <><Badge tone="warn">jetzt einrichten</Badge><br /><span className="area-muted">bestellt {fmtDateTime(t.createdAt)}</span></>
: <><Badge tone={isOverdue(t) ? 'warn' : 'muted'}>zum Periodenende</Badge><br />{fmtDate(t.dueAt)}</>}
</span>
<span>
<Link to={`/customers/${t.customerId}`}><strong>{t.customerName}</strong></Link>
{t.customerCode && <><br /><span className="area-muted">Kd.-Nr. {t.customerCode}</span></>}
{t.email && <><br /><span className="area-muted">{t.email}</span></>}
<br /><span className="area-muted">{t.productLabel}</span>
</span>
<span>
<strong>{taskText(t)}</strong>
<br /><span className="area-muted">{scopeText(t)}</span>
<br /><span className="area-muted">{changeText(t)}</span>
{t.partial && <><br /><span className="area-danger">{partialText(t)}</span></>}
</span>
<span>{fmtDateTime(t.createdAt)}<br /><span className="area-muted">{orderedKind(t)}</span></span>
<span>
<Button size="sm" variant="primary" data-storage-done={t.id} onClick={() => done(t)} disabled={busyId === t.id}>
{busyId === t.id ? <FaSpinner className="spinner" /> : 'Erledigt'}
</Button>
</span>
</div>
))}
</div>
</Card>
<p className={summary.warn ? 'msg warn mb-2' : 'muted mb-2'}>{summary.text}</p>
{data.tasks.length === 0 ? (
<EmptyState title="Keine offenen Speicher-Aufgaben." />
) : (
<table className="table table-wrap">
<thead>
<tr>
<th className="hide-sm">Fällig</th>
<th className="col-name">Kunde</th>
<th>Aufgabe in konsoleH</th>
<th className="hide-md">Bestellt</th>
<th />
</tr>
</thead>
<tbody>
{data.tasks.map((t) => {
const due = storageDue(t)
return (
<tr key={t.id}>
<td className="hide-sm">
<Badge tone={due.tone}>{due.label}</Badge>
<div className={due.subMuted ? 'cell-sub' : undefined}>{due.sub}</div>
</td>
<td>
<div className="cell-title"><Link className="link" to={`/customers/${t.customerId}`}>{t.customerName}</Link></div>
{t.customerCode && <div className="cell-sub">Kd.-Nr. {t.customerCode}</div>}
{t.email && <div className="cell-sub">{t.email}</div>}
<div className="cell-sub">{t.productLabel}</div>
</td>
<td>
<div className="cell-title">{taskText(t)}</div>
<div className="cell-sub">{scopeText(t)}</div>
<div className="cell-sub">{changeText(t)}</div>
{t.partial && <div className="cell-sub"><span className="msg err">{partialText(t)}</span></div>}
</td>
<td className="hide-md nowrap">
{fmtDateTime(t.createdAt)}
<div className="cell-sub">{orderedKind(t)}</div>
</td>
<td className="nowrap">
<button type="button" className="link" data-storage-done={t.id} onClick={() => done(t)} disabled={busyId === t.id}>
{busyId === t.id ? 'Speichert …' : 'Erledigt'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{data.failed.length > 0 && (
<Card title="Gescheiterte Änderungen (letzte 30 Tage)">
<Card className="mt-3" title="Gescheiterte Änderungen (letzte 30 Tage)">
{data.failed.map((f) => (
<div key={f.id} style={{ marginBottom: 6 }}>
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="area-muted">{failedText(f)}</span>
<div key={f.id} className="list-row">
<div className="list-row-text">
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="muted">{failedText(f)}</span>
</div>
</div>
))}
</Card>
)}
<p className="area-muted" style={{ marginTop: 12 }}>
<p className="table-foot">
Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.
</p>
</>
-76
View File
@@ -1,76 +0,0 @@
/* Bereiche aus dem frueheren Admin-Panel (Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen, Abo-Status).
Zeilen als Raster; unter 720 px untereinander, damit nichts waagerecht scrollt. */
.area-rows { display: flex; flex-direction: column; container-type: inline-size; }
.area-row {
display: grid; gap: var(--sp-3); align-items: start; padding: 12px 16px;
border-bottom: 1px solid var(--border); overflow-wrap: anywhere;
}
.area-row:last-child { border-bottom: none; }
.area-row.head { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.area-row.active { background: var(--accent-soft); }
.area-muted { color: var(--text-muted); font-size: 13px; }
.area-danger { color: var(--red-fg); font-size: 13px; }
.area-warn { color: var(--orange-fg); font-size: 13px; }
.area-summary { padding: 12px 16px; border-radius: var(--r-sm); background: var(--surface-2); margin-bottom: var(--sp-3); }
.area-summary.warn { background: var(--orange-bg); color: var(--orange-fg); }
.area-message { padding: 10px 14px; border-radius: var(--r-sm); background: var(--accent-soft); margin-bottom: var(--sp-3); }
.area-error { padding: 10px 14px; border-radius: var(--r-sm); background: var(--red-bg); color: var(--red-fg); margin: var(--sp-2) 0; }
.area-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
.area-kv dt { color: var(--text-muted); }
.area-kv dd { margin: 0; overflow-wrap: anywhere; }
.area-filter { display: inline-flex; gap: 4px; margin-bottom: var(--sp-3); }
.area-inline { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.storage-row { grid-template-columns: 150px 220px minmax(0, 1fr) 150px 110px; }
.funktionen-row { grid-template-columns: minmax(0, 1.2fr) 110px 200px 110px minmax(0, 1.4fr); }
.funktionen-sitekey { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; }
.funktionen-sitekey code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text); user-select: all; }
.funktionen-sitekey-copy {
display: inline-flex; align-items: center; justify-content: center; width: 25px; height: 25px; flex: 0 0 25px;
border: 0; border-radius: 4px; background: transparent; color: var(--text-faint); cursor: pointer;
}
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
.funktionen-sitekey-ok { color: var(--ok); }
.funktionen-panel { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); background: var(--surface-2); }
.funktionen-panel-head { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); align-items: center; }
.funktionen-panel-warnungen { margin: 0; padding-left: 32px; }
.funktionen-panel .area-summary { margin-bottom: 0; }
.funktionen-panel-zeiten .area-error, .funktionen-panel-zeiten .area-message { margin: 0; }
.funktionen-panel-form { display: flex; flex-direction: column; gap: var(--sp-2); }
.funktionen-panel-feld { display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
.funktionen-panel-html { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 120px; }
.funktionen-panel-abschnitt { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-1); min-width: 0; }
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
.funktionen-panel-abschnitt pre {
margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); max-width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
}
@media (max-width: 720px) {
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); }
.area-row.head { display: none; }
.funktionen-leer { display: none; }
.area-kv { grid-template-columns: minmax(0, 1fr); }
.area-kv dt { margin-top: 6px; }
}
.area-rows a { color: var(--text); text-decoration: none; }
.area-rows a:hover { color: var(--accent); text-decoration: underline; }
.credit-row { grid-template-columns: 130px minmax(0, 1fr) minmax(0, 1fr) 110px 100px 150px 100px; }
.credit-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-5); }
.credit-cols h4 { margin: var(--sp-4) 0 var(--sp-2); font-size: 14px; }
.credit-files, .credit-checks { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.credit-checks { list-style: none; padding-left: 0; }
.credit-history { margin-bottom: 8px; }
.credit-manual { display: flex; flex-direction: column; gap: 6px; }
.credit-calc { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); }
.credit-calc label, .credit-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); margin-bottom: var(--sp-2); }
@media (max-width: 900px) { .credit-cols { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 720px) { .credit-calc { grid-template-columns: minmax(0, 1fr); } }
.credit-cols a { color: var(--accent); }
.offer-row { grid-template-columns: 130px 200px minmax(0, 1fr) 110px 210px; }
/* Nach verfuegbarer Breite stapeln (nicht nach Fensterbreite): mit ausgeklappter Sidebar sind Tablet quer oder ein
halbes Laptop-Fenster sonst zu schmal fuer die Spalten */
@container (max-width: 960px) {
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.area-row.head { display: none; }
.funktionen-leer { display: none; }
}
+106 -28
View File
@@ -1,5 +1,12 @@
/* Chat im Ticketsystem (Messenger und Reiter "Chat"). Eingabezeile, Anhaenge und Sprachnachrichten kommen aus
chat-composer.css des Kundenportals; hier Farben (--chat-*), Verlauf, Sprechblasen und die Liste. */
/* Chat im Ticketsystem (Messenger und Reiter "Chat") im weissen Design.
Eingabezeile, Anhaenge und Sprachnachrichten kommen als Baustein aus dem Kundenportal (chat-composer.css, window.ChatComposer).
Dessen CSS laedt zur Laufzeit zuletzt, bei gleicher Spezifitaet gewinnt es. Darum ist jede Regel fuer Elemente des Bausteins
strikt spezifischer als die Portal-Regel (Praefix .woms-chat, Spezifitaeten in den Kommentaren: Eigen > Fremd) und es gibt kein
!important. Einzige Ausnahme mit Absicht: .chat-tray-item, dessen rote Fehlervariante des Portals (gleiche Spezifitaet, spaeter
geladen) gewinnen soll. Das Seitenlayout (.chat-page, .chat-side, .chat-list*, .chat-main, .chat-back) hat kein Praefix.
Bildansicht und Kamera haengen am body und bleiben dunkel (Medienbetrachter, vom Praefix nicht erreichbar). */
/* Variablen des Bausteins: weisses Design statt Portal-Lila (P1), dazu Blasen und Spur der Audioleiste (P6) */
.woms-chat {
--chat-accent: var(--accent);
--chat-accent-hover: var(--accent-hover);
@@ -10,50 +17,121 @@
--chat-surface: var(--bg);
--chat-line: var(--line-strong);
--chat-muted: var(--text-2);
--chat-bubble-in: var(--bg-hover);
--chat-bubble-out: var(--grey-bg);
--chat-track: rgba(26, 31, 54, 0.2);
display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0;
}
/* Verlauf: scrollt in sich, ohne Rand und Hintergrund */
.woms-chat .chat-messages {
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto;
padding: 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg);
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto; padding: 4px 0;
}
/* Blasen: fremde hellgrau, eigene etwas dunkler und rechts, kein Rand. .chat-bubble 0,2,0 > 0,1,0 */
.woms-chat .chat-bubble {
align-self: flex-start; max-width: 78%; padding: 8px 12px; border-radius: 14px;
background: var(--surface-2); color: var(--text); overflow-wrap: anywhere;
align-self: flex-start; max-width: 78%; padding: 8px 12px; border: 0; border-radius: var(--radius-lg);
background: var(--chat-bubble-in); color: var(--text); overflow-wrap: anywhere;
}
.woms-chat .chat-bubble.mine { align-self: flex-end; background: var(--accent-soft); border: 1px solid var(--line); }
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 11.5px; color: var(--text-muted); }
.woms-chat .chat-notice { font-size: 12px; }
/* Meldungen des Eingabebausteins (Stile aus chat-composer.css des Portals): Fehler rot, Info grau, beides lesbar auf Weiss */
.woms-chat .chat-bubble.mine { align-self: flex-end; background: var(--chat-bubble-out); }
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 12px; color: var(--text-2); }
.woms-chat .chat-notice { font-size: 12px; color: var(--text-2); }
/* Datei-Chip: weiss mit Ring (die Blase ist selbst --bg-hover). .chat-att-file 0,2,0 > 0,1,0, :hover 0,3,0 > 0,2,0 */
.woms-chat .chat-att-file { border: 0; background: var(--bg); box-shadow: var(--ring); }
.woms-chat .chat-att-file:hover { background: var(--bg-hover); }
/* Audioleiste und Fokus. .chat-audio-bar 0,2,0 > 0,1,0; :focus-visible 0,3,0 > 0,2,0 (Portal: weisser Ring) */
.woms-chat .chat-audio-bar { background: var(--chat-track); }
.woms-chat .chat-att-image:focus-visible,
.woms-chat .chat-audio-bar:focus-visible { outline: 2px solid var(--accent); }
/* Abspielen in der eigenen Blase: schwarz mit weisser Schrift statt Portal-Weiss. 0,4,0 > 0,3,0 */
.woms-chat .chat-bubble.mine .chat-audio-play { background: var(--chat-accent); color: var(--chat-on-accent); }
.woms-chat .chat-audio-dl:hover { background: var(--accent-soft); }
/* Meldungen des Bausteins: Fehler rot (--red-fg), Info grau. .chat-composer-note 0,2,0 > 0,1,0; die Zeitanzeige 0,4,0 > 0,3,0 */
.woms-chat .chat-composer-note,
.woms-chat .chat-tray-item.is-error .chat-file-size,
.woms-chat .chat-audio.is-error .chat-audio-time { color: var(--red-fg); }
.woms-chat .chat-composer-note.is-info { color: var(--text-2); }
/* Eingabefeld: Grundstil wie im frueheren Admin-Panel (chat-composer.css setzt nur das Layout) */
.woms-chat .chat-input-row textarea {
resize: none; min-height: 46px; max-height: 130px; padding: 11px 14px; border-radius: 14px;
border: 1px solid var(--border); background: var(--surface-2); color: var(--text); font: inherit; line-height: 1.4;
}
.woms-chat .chat-input-row textarea:focus { outline: none; border-color: var(--accent); }
.chat-page { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: var(--sp-4); align-items: start; }
.chat-side { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
/* Eingabefeld: Ring statt Rand, Hoehe und Radius wie bisher. 0,2,1 > 0,1,1; :focus 0,3,1 */
.woms-chat .chat-input-row textarea {
resize: none; min-height: 46px; max-height: 130px; padding: 11px 14px; border: 0; border-radius: 14px;
background: var(--bg); color: var(--text); box-shadow: var(--ring); font: inherit; line-height: 1.4;
}
.woms-chat .chat-input-row textarea:focus { outline: none; box-shadow: 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-ring); }
/* "font: inherit" hebt die Portal-Regel gegen das iOS-Zoomen auf: am Handy wieder 16 px */
@media (max-width: 640px) {
.woms-chat .chat-input-row textarea { font-size: 16px; }
}
/* Icon-Knoepfe (Anhang, Mikrofon) und Senden. .chat-icon-btn 0,2,0 > 0,1,0; :hover:not(:disabled) 0,4,0 > 0,3,0;
[aria-expanded] 0,3,0 > 0,2,0; :disabled 0,3,0 > 0,2,0; .chat-send.is-busy:disabled 0,4,0 > 0,3,0.
Senden bleibt ueber --chat-accent (P1) schwarz mit weisser Schrift. */
.woms-chat .chat-icon-btn { border: 0; background: var(--bg); color: var(--text); box-shadow: var(--ring); }
.woms-chat .chat-icon-btn:hover:not(:disabled) { background: var(--bg-hover); }
.woms-chat .chat-icon-btn[aria-expanded="true"],
.woms-chat .chat-icon-btn[aria-expanded="true"]:hover:not(:disabled) { background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent); }
.woms-chat .chat-icon-btn:disabled,
.woms-chat .chat-send:disabled { opacity: 0.55; cursor: not-allowed; }
.woms-chat .chat-send.is-busy:disabled { opacity: 0.8; cursor: progress; }
/* Sprachaufnahme: Knoepfe ohne Ring und Hintergrund, Rot ueber --danger. .chat-rec .chat-icon-btn 0,3,0 > 0,2,0; :hover 0,4,0 > 0,3,0.
.chat-rec-stop steht hinter der Icon-Knopf-Regel (gleiche Spezifitaet 0,2,0), sonst gewinnt deren Farbe. */
.woms-chat .chat-rec { border-color: var(--danger-ring); background: var(--red-bg); }
.woms-chat .chat-rec .chat-icon-btn { background: transparent; box-shadow: none; }
.woms-chat .chat-rec .chat-icon-btn:hover { background: var(--accent-soft); }
.woms-chat .chat-rec-stop { color: var(--danger); }
.woms-chat .chat-rec-wave { color: var(--danger); }
.woms-chat .chat-rec-dot { background: var(--danger); }
/* Anhang-Menue: ohne Rand, mit Schatten; Hover und Tastaturfokus --bg-hover, Fokus zusaetzlich mit Umriss (Portal: outline none).
.chat-menu 0,2,0 > 0,1,0; .chat-menu button 0,2,1 > 0,1,1; :hover/:focus-visible 0,3,1 > 0,2,1 */
.woms-chat .chat-menu { border: 0; border-radius: var(--radius-lg); box-shadow: var(--shadow-pop); }
.woms-chat .chat-menu button { border-radius: var(--radius); }
.woms-chat .chat-menu button:hover,
.woms-chat .chat-menu button:focus-visible { background: var(--bg-hover); }
.woms-chat .chat-menu button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* Vorschau der Anhaenge vor dem Senden. .chat-tray-item 0,2,0 = Portal-Fehlervariante .chat-tray-item.is-error (0,2,0, spaeter
geladen): die rote Variante bleibt absichtlich. */
.woms-chat .chat-tray-item { border: 1px solid transparent; background: var(--bg-hover); }
/* Dateien ablegen. .chat-drop-active::after 0,2,1 > 0,1,1 */
.woms-chat.chat-drop-active::after { background: rgba(255, 255, 255, 0.92); color: var(--text); border-color: var(--text-3); }
/* ------------------------------------------------------------------ Seite: Liste und Verlauf (ohne Praefix) --- */
.chat-page { display: grid; grid-template-columns: 320px minmax(0, 1fr); align-items: stretch; }
.chat-side { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; padding-right: var(--sp-4); border-right: 1px solid var(--line); }
.chat-main { min-width: 0; padding-left: var(--sp-5); }
.chat-list { display: flex; flex-direction: column; max-height: 70vh; overflow-y: auto; }
.chat-list-item {
display: grid; gap: 2px; padding: 10px 14px; border: none; border-bottom: 1px solid var(--border);
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: 2px 8px;
width: 100%; padding: 10px 8px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0;
background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer;
}
.chat-list-item:hover { background: var(--surface-2); }
.chat-list-item.active { background: var(--accent-soft); }
.chat-list-top { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
.chat-list-last { color: var(--text-muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-list-time { color: var(--text-faint); font-size: 11.5px; }
.chat-main { min-width: 0; }
.chat-back { display: none; }
.chat-list-item:first-child { border-top: 1px solid var(--line); }
.chat-list-item:hover { background: var(--bg-subtle); }
.chat-list-item[aria-current="true"] { background: var(--accent-soft); }
.chat-list-top { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
.chat-list-top strong { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.chat-list-last { grid-column: 1; min-width: 0; color: var(--text-2); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-list-time { flex: none; color: var(--text-2); font-size: 12px; }
.chat-unread { grid-column: 2; color: var(--accent); font-size: 12px; font-weight: 700; }
.chat-page .chat-back { display: none; }
@media (max-width: 720px) {
@media (max-width: 1100px) {
.chat-page { grid-template-columns: 260px minmax(0, 1fr); }
}
/* Handy: eine Spalte, entweder die Liste oder der Verlauf. .chat-page .chat-back 0,2,0 > .btn 0,1,0 */
@media (max-width: 860px) {
.chat-page { grid-template-columns: minmax(0, 1fr); }
.chat-page.has-thread .chat-side { display: none; }
.chat-page:not(.has-thread) .chat-main { display: none; }
.chat-back { display: inline-flex; }
.chat-page .chat-side,
.chat-page .chat-main { padding: 0; border: 0; }
.chat-page .chat-back { display: inline-flex; margin-bottom: var(--sp-3); }
.woms-chat .chat-bubble { max-width: 90%; }
}
+408 -283
View File
@@ -500,11 +500,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
.hidden { display: none; }
.ticket-projects { margin-top: 16px; }
/* Zeile unter der Projektliste: Projekt waehlen + Zuweisen + Neues Projekt */
.ticket-projects-assign { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.ticket-projects-assign .form-control { flex: 1 1 220px; margin: 0; }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -606,225 +601,187 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
}
/* --- Rechtliches --- */
.legal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr)); gap: var(--sp-4); align-items: start; margin-top: var(--sp-4); }
.legal-grid > .legal-error { grid-column: 1 / -1; }
.legal-doc-summary { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; cursor: pointer; }
.legal-toggle { margin-left: auto; font-size: 13px; }
.legal-live { display: inline-block; margin-top: 8px; font-size: 13px; color: var(--accent); }
.legal-history { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
.legal-version { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 8px 10px; font-size: 14px; }
.legal-version-current { border-color: var(--ok); background: rgba(16, 185, 129, 0.08); }
.legal-version-draft { border-style: dashed; }
.legal-version-superseded { color: var(--text-muted); }
.legal-version-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.legal-note { white-space: pre-wrap; margin: 4px 0; color: var(--text-muted); font-size: 13px; }
.legal-files { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; }
.legal-files a { color: var(--accent); }
.legal-error { background: var(--danger); color: #fff; padding: 8px 12px; border-radius: var(--r-sm); margin: 8px 0; }
.legal-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.legal-doc-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%; padding: 12px 0; text-align: left; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.legal-toggle { margin-left: auto; font-size: 13px; color: var(--text-2); }
.legal-live { display: inline-block; margin-top: 8px; font-size: 13px; }
.legal-history { list-style: none; margin: 0; padding: 0; }
.legal-version { padding: 10px 0; border-bottom: 1px solid var(--line); }
.legal-version.is-superseded { color: var(--text-2); }
.legal-version-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.legal-version-head .link { margin-left: auto; }
.legal-note { white-space: pre-wrap; margin: 4px 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.legal-files { display: inline-flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; overflow-wrap: anywhere; }
.legal-files .link { font-weight: 400; }
.legal-files .legal-dl { color: var(--text-2); }
.legal-msg { margin: 0 0 12px; }
.legal-doc h2.section .link { white-space: nowrap; } /* "Neue Version" bleibt am Handy eine Zeile */
.legal-file { padding: 2px 1px; font-size: 13px; color: var(--text-2); } /* Platz für den Ring des Knopfs, das Dateifeld schneidet ihn sonst ab */
.legal-file::file-selector-button { height: 28px; padding: 0 10px; margin-right: 10px; border: 0; border-radius: var(--radius); background: var(--bg); box-shadow: var(--ring); font: inherit; font-weight: 500; color: var(--text); cursor: pointer; }
.legal-contracts { margin-top: var(--sp-2); }
.legal-contracts tbody tr { cursor: pointer; }
.legal-tone-red td { background: rgba(239, 68, 68, 0.10); }
.legal-tone-red .legal-deadline { color: var(--danger); font-weight: 700; }
.legal-tone-orange td { background: rgba(245, 158, 11, 0.10); }
.legal-tone-orange .legal-deadline { color: var(--orange-fg); font-weight: 600; }
.legal-tone-missing .legal-deadline { color: var(--text-faint); font-style: italic; }
.legal-tone-grey td { color: var(--text-faint); }
.legal-detail td { background: var(--surface-2); cursor: default; }
.legal-actions { margin-top: 8px; flex-wrap: wrap; }
@media (max-width: 700px) {
.legal-grid { grid-template-columns: 1fr; }
.legal-form-row { grid-template-columns: 1fr; }
.legal-contracts { display: block; overflow-x: auto; }
.legal-contracts .cell-title .link { text-align: left; } /* Knopf zentriert sonst lange Anbieternamen */
.legal-contracts tr.row:has(+ .legal-detail) td { border-bottom: 0; } /* offene Zeile und Detail bilden einen Block, die Haarlinie steht darunter */
.legal-detail td { padding: 0 0 16px; }
.legal-facts { display: none; margin-bottom: 8px; }
.legal-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 8px; }
.legal-cancel { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.legal-cancel .input { width: 160px; }
@media (max-width: 860px) {
.legal-facts { display: grid; }
}
/* ===== FINANZEN (P7) ===== */
/* --- Finanzen --- */
button.link-danger { background: none; border: 0; padding: 0; font: inherit; font-weight: 500; cursor: pointer; }
/* Allgemein: Seitengeruest, Betraege, Meldungen, Spalten. Rot gilt fuer einen Betrag, dessen Text mit dem Minuszeichen beginnt. */
.fin-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.fin-stack { display: flex; flex-direction: column; gap: var(--sp-5); }
.fin-stack .kpi-grid { margin-bottom: 0; }
.fin-spin { animation: spin 1s linear infinite; }
.fin-hint { margin: 0; font-size: 13px; color: var(--text-muted); }
.fin-hint-warn { color: var(--warn); }
.fin-flag { color: var(--danger); font-weight: 700; }
.fin-link {
background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
color: var(--accent); display: inline-flex; align-items: center; gap: 6px;
text-decoration: underline; text-underline-offset: 2px;
}
.fin-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* Block 0: Statuszeile */
.fin-status-wrap { display: flex; flex-direction: column; gap: var(--sp-2); }
.fin-status {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-3);
padding: 10px 14px; font-size: 13px; color: var(--text-muted);
background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md);
}
.fin-status-items { display: flex; flex-wrap: wrap; gap: 4px var(--sp-4); min-width: 0; }
.fin-status-item { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
.fin-status-warn { color: var(--warn); font-weight: 600; }
/* Block 1: Liquiditaetskopf */
.fin-head .kpi-value { font-variant-numeric: tabular-nums; }
.fin-breakdown { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; gap: 4px; color: var(--text-muted); }
.fin-breakdown li { display: flex; justify-content: space-between; gap: var(--sp-3); }
.fin-breakdown .fin-amount { color: var(--text); }
.fin-breakdown-total { border-top: 1px solid var(--border-strong); padding-top: 4px; font-weight: 700; color: var(--text); }
.fin-lows { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 6px 10px; align-items: baseline; font-size: 14px; font-weight: 600; }
.fin-low { display: contents; }
.fin-low-label { color: var(--text-muted); }
.fin-low .fin-amount { font-size: 20px; font-weight: 800; }
.fin-low-when { color: var(--text-muted); font-size: 13px; font-weight: 500; }
/* Block 2: Zu erledigen */
.fin-todo-card { overflow: hidden; }
.fin-todos { list-style: none; margin: 0; padding: 0; }
.fin-todo {
display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: var(--sp-3); align-items: start;
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border); border-left: 3px solid var(--border);
}
.fin-todo:last-child { border-bottom: none; }
.fin-todo-hoch { border-left-color: var(--danger); }
.fin-todo-mittel { border-left-color: var(--warn); }
.fin-todo .badge { justify-self: start; }
.fin-todo-title { font-weight: 700; }
.fin-todo-text { font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; }
.fin-todo .fin-hint { margin-top: 6px; }
.fin-todos-more { padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
@media (max-width: 720px) {
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
.fin-todo .ui-btn { justify-self: start; }
}
/* Bloecke 3-5: Vorschau, Steuer, Abos */
.fin-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.fin-neg { color: var(--danger); }
.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); align-items: start; }
.fin-neg, .fin-flag { color: var(--red-fg); }
.fin-flag { font-weight: 600; }
.fin-stack > * + * { margin-top: 36px; }
.fin-top { display: flex; flex-direction: column; gap: 20px; }
.fin-block { scroll-margin-top: 16px; }
.fin-body { container-type: inline-size; }
.fin-table-wrap { overflow-x: auto; }
.fin-notice, .fin-page-notice { margin: 0 0 8px; }
.fin-page-notice { margin-bottom: 16px; }
.fin-load-error { display: grid; gap: 12px; justify-items: start; padding: 16px 0; }
/* Die einspaltige Reihe bekommt ihren Zeilenabstand vom Abschnittsabstand (36 px). Die Regel steht mit hoeherer Spezifitaet hier, weil KOMPAT */
/* .fin-cols > section + section auf 0 setzt; ab 1100 px stehen die Spalten nebeneinander und beginnen auf gleicher Hoehe. */
.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0 56px; align-items: start; }
.fin-stack .fin-cols > section + section { margin-top: 36px; }
@media (min-width: 1100px) {
.fin-cols { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); }
.fin-cols-half { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fin-stack .fin-cols > section + section { margin-top: 0; }
}
@media (max-width: 860px) {
.fin-block { scroll-margin-top: 68px; } /* unter der 52 px hohen Mobil-Leiste 16 px Luft */
}
/* Block 3: Vorschau (Tabelle; in einer schmalen Karte wird jede Zeile zweizeilig) */
.fin-forecast-card { container-type: inline-size; overflow: hidden; }
.fin-table-wrap { overflow-x: auto; }
.fin-forecast th, .fin-forecast td { padding: 9px 10px; vertical-align: baseline; }
/* Block 0: Statuszeile (ohne Kasten) */
.fin-status-wrap > .fin-notice { margin: 8px 0 0; }
.fin-status { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 13px; color: var(--text-2); }
.fin-status-items { display: flex; flex-wrap: wrap; gap: 4px 24px; min-width: 0; }
.fin-status-item { min-width: 0; overflow-wrap: anywhere; }
.fin-status-warn { color: var(--orange-fg); font-weight: 600; }
/* Block 1: Liquiditaetskopf. Die Doppelklasse schlaegt die Handy-Regel von .stats (3 Spalten wuerden die breite dritte Kennzahl abschneiden). */
.stats.fin-head { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 24px 48px; margin: 0; }
.stats.fin-head .sub { font-size: 13px; line-height: 20px; color: var(--text-2); }
.fin-breakdown { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 4px; }
.fin-breakdown li { display: flex; justify-content: space-between; gap: 12px; }
.fin-breakdown .fin-amount { color: var(--text); }
.fin-breakdown-total { border-top: 1px solid var(--line); padding-top: 4px; font-weight: 600; color: var(--text); }
.fin-lows { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 4px 10px; align-items: baseline; }
.fin-low { display: contents; }
.fin-low-label, .fin-low-when { font-size: 13px; line-height: 20px; font-weight: 400; letter-spacing: 0; color: var(--text-2); }
.fin-low .fin-amount { font-size: 18px; line-height: 26px; font-weight: 600; }
/* Block 2: Zu erledigen */
.fin-todos { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.fin-todo { display: grid; grid-template-columns: 80px minmax(0, 1fr) auto; gap: 12px 16px; align-items: start; padding: 12px 0; border-bottom: 1px solid var(--line); }
.fin-todo .badge { justify-self: start; }
.fin-todo .link { justify-self: end; white-space: nowrap; }
.fin-todo-title { font-weight: 600; }
.fin-todo-text { font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; }
.fin-todo .msg { margin-top: 6px; }
@media (max-width: 860px) {
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.fin-todo .link { justify-self: start; }
}
/* Bloecke 3-5: Vorschau, Steuer, Abos */
/* Block 3: Vorschau. Die Tabelle liegt in .fin-body (Container); schmal wird jede Zeile ein Raster. Zellen: Abstaende wie .table, nur oben und unten 9 px. */
.fin-forecast th, .fin-forecast td { vertical-align: baseline; }
.fin-forecast td { padding-block: 9px; }
.fin-forecast th.fin-amount { text-align: right; }
.fin-forecast th:first-child, .fin-forecast td:first-child { padding-left: var(--sp-4); }
.fin-forecast th:last-child, .fin-forecast td:last-child { padding-right: var(--sp-4); }
.fin-forecast tbody tr:last-child td { border-bottom: none; }
.fin-forecast-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
.fin-forecast-label { overflow-wrap: anywhere; }
.fin-forecast-foot { display: grid; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
.fin-forecast-foot .fin-hint svg { vertical-align: -2px; }
.fin-forecast-foot { display: grid; gap: 6px; margin-top: 12px; }
@container (max-width: 560px) {
.fin-forecast thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-forecast, .fin-forecast tbody { display: block; }
.fin-forecast tr {
display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline;
grid-template-areas: "date date kind kind amount amount" "label label label label label label" "sicher sicher sicher erwartet erwartet erwartet";
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
padding: 10px 0; border-bottom: 1px solid var(--line);
}
.fin-forecast-nobal tr { grid-template-areas: "date date kind kind amount amount" "label label label label label label"; }
.fin-forecast tbody tr:last-child { border-bottom: none; }
.fin-forecast td, .fin-forecast td:first-child, .fin-forecast td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.fin-forecast tbody tr:hover td { background: transparent; }
.fin-forecast-date { grid-area: date; }
.fin-forecast-kind { grid-area: kind; }
.fin-forecast-amount { grid-area: amount; font-weight: 700; }
.fin-forecast-amount { grid-area: amount; font-weight: 600; }
.fin-forecast-label { grid-area: label; }
.fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet {
display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--sp-2); font-size: 13px;
display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; font-size: 13px;
}
.fin-forecast-sicher { grid-area: sicher; }
.fin-forecast-erwartet { grid-area: erwartet; }
.fin-forecast td.fin-forecast-sicher { padding-right: var(--sp-3); }
.fin-forecast td.fin-forecast-erwartet { padding-left: var(--sp-3); }
.fin-forecast td.fin-forecast-sicher { padding-right: 12px; }
.fin-forecast td.fin-forecast-erwartet { padding-left: 12px; }
.fin-saldo { margin-left: auto; }
.fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-size: 12px; font-weight: 500; white-space: nowrap; }
.fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-2); font-size: 12px; font-weight: 500; white-space: nowrap; }
}
/* Block 4: Steuer */
.fin-facts { display: grid; gap: var(--sp-4); }
.fin-fact { display: grid; gap: 3px; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); }
.fin-fact:last-child { padding-bottom: 0; border-bottom: none; }
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); }
.fin-fact-label { color: var(--text-muted); font-size: 13px; }
.fin-fact-big { font-size: 24px; font-weight: 800; }
.fin-fact-strong { font-size: 16px; font-weight: 700; }
.fin-fact-value { font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.fin-fact-unknown { color: var(--warn); }
.fin-fact-sub { font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
/* Block 4: Steuer (Liste mit Haarlinien; die Fussnote steht nach dem letzten Posten) */
.fin-facts { display: grid; gap: 16px; }
.fin-fact { display: grid; gap: 3px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.fin-fact:last-of-type { padding-bottom: 0; border-bottom: 0; }
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.fin-fact-label { color: var(--text-2); font-size: 13px; }
.fin-fact-big { font-size: 22px; line-height: 30px; font-weight: 600; }
.fin-fact-strong { font-size: 16px; font-weight: 600; }
.fin-fact-value { font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.fin-fact-unknown { color: var(--orange-fg); }
.fin-fact-sub { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.fin-fact-sub-strong { color: var(--text); }
/* Block 5: Abos */
.fin-abo-card { overflow: hidden; }
.fin-stats { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px var(--sp-3); align-items: baseline; padding: var(--sp-4); border-bottom: 1px solid var(--border); }
.fin-stat-value { font-size: 20px; font-weight: 800; }
.fin-stat-label { color: var(--text-muted); }
.fin-stat-label.fin-flag { color: var(--danger); }
.fin-stat-label svg { vertical-align: -2px; }
.fin-abos { list-style: none; margin: 0; padding: 0; }
.fin-abo {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-3); align-items: baseline;
padding: 11px var(--sp-4); border-bottom: 1px solid var(--border);
}
.fin-abo:last-child { border-bottom: none; }
.fin-abo-customer { font-weight: 700; overflow-wrap: anywhere; }
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); font-size: 13px; }
/* Block 5: Abos (Kennzahlen: .stats.fin-abo-stats ohne eigene Regel) */
.fin-abos { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.fin-abo { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: baseline; padding: 12px 0; border-bottom: 1px solid var(--line); }
.fin-abo-customer { font-weight: 600; overflow-wrap: anywhere; }
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; }
/* Block 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */
.fin-block { scroll-margin-top: var(--sp-4); }
.fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; }
.fin-toolbar { padding: var(--sp-3) var(--sp-4); }
.fin-toolbar .seg { flex-wrap: wrap; max-width: 100%; }
.fin-notice { padding: var(--sp-3) var(--sp-4) var(--sp-2); }
.fin-load-error { display: grid; gap: var(--sp-3); justify-items: start; padding: var(--sp-4); }
.fin-page-notice { margin-bottom: var(--sp-4); }
.fin-ages {
list-style: none; margin: 0; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3);
}
.fin-age { display: grid; gap: 2px; min-width: 0; }
.fin-age-label { color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
.fin-age-count { font-size: 13px; color: var(--text-muted); }
.fin-age-sum { text-align: left; font-size: 18px; font-weight: 800; }
.fin-pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
.fin-pager-info { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
/* Block 6 und 7: Rechnungen und Buchungen. Beide Tabellen liegen in .fin-body (Container); schmal wird jede Zeile ein Raster. */
.fin-block .seg { flex-wrap: wrap; max-width: 100%; }
.stats.fin-ages { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 32px; margin: 0 0 16px; }
.fin-ages .stat .v { font-size: 18px; line-height: 26px; }
.fin-pager { justify-content: center; flex-wrap: wrap; gap: 16px; }
.fin-pager-info { font-variant-numeric: tabular-nums; }
.fin-block button.link:disabled, .fin-block button.link-danger:disabled { cursor: not-allowed; }
.fin-invoices th, .fin-invoices td, .fin-tx th, .fin-tx td { padding: 9px 10px; vertical-align: top; }
.fin-invoices th.fin-amount, .fin-tx th.fin-amount { text-align: right; }
.fin-invoices th:first-child, .fin-invoices td:first-child, .fin-tx th:first-child, .fin-tx td:first-child { padding-left: var(--sp-4); }
.fin-invoices th:last-child, .fin-invoices td:last-child, .fin-tx th:last-child, .fin-tx td:last-child { padding-right: var(--sp-4); }
.fin-invoices tbody tr:last-child td, .fin-tx tbody tr:last-child td { border-bottom: none; }
.fin-inv-number { font-weight: 700; white-space: nowrap; }
.fin-inv-customer { overflow-wrap: anywhere; }
.fin-inv-number { font-weight: 500; white-space: nowrap; }
.fin-inv-customer, .fin-tx-counterparty { overflow-wrap: anywhere; }
.fin-tx-counterparty { font-weight: 500; }
.fin-inv-created, .fin-inv-due, .fin-tx-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
.fin-inv-statusbox { display: grid; gap: 3px; justify-items: start; }
.fin-inv-note { font-size: 12px; }
.fin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.fin-tx-counterparty { font-weight: 600; overflow-wrap: anywhere; }
.fin-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.fin-tx-remittance {
font-size: 13px; overflow-wrap: anywhere;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.fin-tx-amount { font-weight: 700; }
.fin-tx-amount { font-weight: 600; }
.fin-tx-badges { display: flex; flex-wrap: wrap; gap: 4px; }
.fin-tx-assign .badge { white-space: normal; }
.fin-tx-assign .badge { white-space: normal; height: auto; padding-block: 2px; }
.fin-tx-suggestion, .fin-tx-note { font-size: 12px; margin-top: 4px; overflow-wrap: anywhere; }
.fin-tx-action { white-space: nowrap; text-align: right; }
@media (max-width: 860px) {
.fin-block .seg button { padding: 0 9px; }
}
@container (max-width: 860px) {
.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fin-invoices thead, .fin-tx thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-invoices, .fin-invoices tbody, .fin-tx, .fin-tx tbody { display: block; }
.stats.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fin-invoices thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-invoices, .fin-invoices tbody { display: block; }
.fin-invoices tr {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px var(--sp-4); align-items: baseline;
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px 16px; align-items: baseline;
grid-template-areas: "number status" "customer customer" "created due" "total remaining" "actions actions";
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
padding: 10px 0; border-bottom: 1px solid var(--line);
}
.fin-invoices tbody tr:last-child { border-bottom: none; }
.fin-invoices td, .fin-invoices td:first-child, .fin-invoices td:last-child, .fin-tx td, .fin-tx td:first-child, .fin-tx td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.fin-invoices tbody tr:hover td, .fin-tx tbody tr:hover td { background: transparent; }
.fin-invoices td, .fin-invoices td:first-child, .fin-invoices td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.fin-inv-number { grid-area: number; }
.fin-inv-status { grid-area: status; }
.fin-inv-statusbox { justify-items: end; text-align: right; }
@@ -834,16 +791,18 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
.fin-inv-total { grid-area: total; }
.fin-inv-remaining { grid-area: remaining; }
.fin-inv-actions { grid-area: actions; padding-top: 4px; }
.fin-invoices td[data-label] { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 var(--sp-2); font-size: 13px; }
.fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-weight: 500; }
.fin-invoices td[data-label] { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 8px; font-size: 13px; }
.fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-2); font-weight: 500; }
}
@container (max-width: 640px) {
.fin-tx thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-tx, .fin-tx tbody { display: block; }
.fin-tx tr {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline;
grid-template-areas: "date amount" "what what" "assign action";
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
padding: 10px 0; border-bottom: 1px solid var(--line);
}
.fin-tx tbody tr:last-child { border-bottom: none; }
.fin-tx td, .fin-tx td:first-child, .fin-tx td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.fin-tx-date { grid-area: date; }
.fin-tx-amount { grid-area: amount; }
.fin-tx-what { grid-area: what; }
@@ -851,113 +810,84 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
.fin-tx-action { grid-area: action; align-self: start; }
}
/* Dialoge: Rechnung schreiben, Buchung zuordnen. Overlay, Box und Schliessknopf kommen aus KOMPAT (.overlay:has(> .overlay-content), */
/* .overlay-content, .overlay-close); hier nur, was dort fehlt. */
.fin-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
.fin-close:disabled { opacity: 0.4; cursor: not-allowed; }
.fin-dialog { max-width: 760px; }
.fin-dialog:focus { outline: none; }
.fin-dialog-error {
margin-bottom: var(--sp-4); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm);
border: 1px solid var(--danger); background: var(--red-bg); color: var(--red-fg); overflow-wrap: anywhere;
}
.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: var(--orange-fg); overflow-wrap: anywhere; }
.fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
.fin-fixed-customer { margin: 0; overflow-wrap: anywhere; }
.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px var(--sp-3); font-size: 13px; overflow-wrap: anywhere; }
.fin-address-warn-dialog { margin: 6px 0 0; color: var(--orange-fg); }
.fin-address-warn-page { margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); color: var(--warn); }
.fin-address-warn .fin-link { font-size: 13px; }
.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; }
.fin-items legend { padding: 0; }
.fin-item {
display: grid; grid-template-columns: minmax(0, 1fr) 90px 130px 120px 38px; gap: 8px; align-items: end; margin-bottom: 8px;
}
/* Dialoge: Rechnung schreiben, Buchung zuordnen. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
.fin-fixed-customer { overflow-wrap: anywhere; }
.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; overflow-wrap: anywhere; }
.fin-address-warn-page { margin: 0 0 16px; }
.fin-address-warn-dialog { margin: 0; }
/* Rechnung schreiben: Positionen, Summe, Zahlungsziel und Notiz */
.fin-items { border: 0; padding: 0; margin: 0; min-width: 0; }
.fin-items legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
.fin-items .msg { margin-top: 8px; overflow-wrap: anywhere; }
.fin-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
.fin-item + .fin-item { margin-top: 8px; }
.fin-item-field { min-width: 0; }
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-muted); }
.fin-item ~ .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-item .form-control.fin-amount { text-align: right; }
.fin-item-total { display: block; padding: 10px 0; font-weight: 600; }
.fin-icon-btn {
width: 38px; height: 40px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
background: transparent; border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text-muted);
}
.fin-icon-btn:hover:not(:disabled) { color: var(--red-fg); border-color: var(--red-fg); }
.fin-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.fin-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-3); }
.fin-total { display: flex; align-items: baseline; gap: var(--sp-4); font-size: 18px; font-weight: 700; }
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-4); }
.fin-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-3); margin-top: var(--sp-4); }
.fin-tx-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
.fin-tx-facts > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
.fin-tx-facts dt { color: var(--text-muted); font-size: 13px; }
.fin-tx-facts dd { margin: 0; overflow-wrap: anywhere; }
.fin-tx-facts .fin-tx-facts-amount { text-align: left; font-weight: 700; }
.fin-assigned { margin: 0 0 var(--sp-2); }
.fin-choices { border: 0; padding: 0; margin: 0 0 var(--sp-4); display: grid; gap: 8px; min-width: 0; }
.fin-choices legend { padding: 0; margin-bottom: 4px; }
.fin-choice {
display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; cursor: pointer;
border: 1px solid var(--border-strong); border-radius: var(--r-sm);
}
.fin-choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.fin-choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.fin-choice input { margin-top: 4px; accent-color: var(--accent); }
.fin-choice-off { opacity: 0.65; cursor: not-allowed; }
.fin-item .input { width: 100%; }
.fin-item .fin-amount { text-align: right; }
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-2); }
.fin-item + .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-item-sum { text-align: right; }
.fin-item-total { display: block; line-height: 32px; font-weight: 600; }
.fin-item-remove { display: flex; align-items: center; height: 32px; }
.fin-item-remove button:disabled { cursor: not-allowed; }
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.fin-total { display: flex; align-items: baseline; gap: 16px; font-size: 18px; font-weight: 600; }
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 12px; }
/* Zuordnen (Dialog): Fuss, Fakten, Auswahlzeilen, Bestaetigung. Huelle, Kopf und Schliessknopf kommen vom Baustein Dialog. */
.fin-foot { flex: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.kv .fin-tx-facts-amount { text-align: left; font-weight: 600; }
.fin-assigned { margin: 0; }
.fin-choices { border: 0; padding: 0; margin: 0; display: grid; min-width: 0; }
.fin-choices legend { padding: 0; margin-bottom: 4px; font-weight: 500; }
.fin-choice { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.fin-choice input { flex: none; margin: 3px 0 0; }
.fin-choice-off { opacity: 0.55; cursor: not-allowed; }
.fin-choice-text { display: grid; gap: 2px; min-width: 0; }
.fin-choice-label { font-weight: 600; overflow-wrap: anywhere; }
.fin-choice-detail { font-size: 13px; overflow-wrap: anywhere; }
.fin-confirm { margin-top: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); background: rgba(245, 158, 11, 0.1); }
.fin-confirm:focus { outline: 2px solid var(--warn); outline-offset: 2px; }
.fin-confirm p { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
@media (max-width: 640px) {
.fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) 38px; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
.fin-choice-label { color: var(--text); overflow-wrap: anywhere; }
.fin-choice:has(input:checked) .fin-choice-label { font-weight: 600; }
.fin-choice-detail { font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.fin-choice-detail.warn { color: var(--orange-fg); }
.fin-confirm { font-weight: 600; color: var(--orange-fg); overflow-wrap: anywhere; }
.fin-confirm p { margin: 0; }
@media (max-width: 860px) {
.fin-foot .btn { flex: 1 1 auto; }
.fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
.fin-item + .fin-item { margin-top: 16px; }
.fin-item-desc { grid-area: desc; }
.fin-item-qty { grid-area: qty; }
.fin-item-price { grid-area: price; }
.fin-item-sum { grid-area: sum; text-align: right; }
.fin-item-sum { grid-area: sum; }
.fin-item-remove { grid-area: remove; }
.fin-item ~ .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; }
.fin-item + .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; }
.fin-dialog-row { grid-template-columns: minmax(0, 1fr); }
.fin-tx-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.fin-dialog-actions .ui-btn { flex: 1 1 auto; }
.fin-toolbar .seg button { padding: 6px 9px; }
}
/* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */
.fin-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.fin-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--sp-4); }
.fin-fieldset { display: grid; gap: 16px; border: 0; padding: 0; margin: 0; min-width: 0; }
.fin-fieldset .field-row { gap: 16px; }
.fin-form-field { min-width: 0; }
.fin-form-hint { font-size: 12px; overflow-wrap: anywhere; }
.fin-field-error { font-size: 13px; font-weight: 600; color: var(--danger); overflow-wrap: anywhere; }
/* KOMPAT setzt .page .field auf 12 px Abstand unten; im Raster bestimmt allein der gap den Abstand */
.page .fin-fieldset .field, .page .fin-fixed-form .field { margin-bottom: 0; }
.fin-input-amount { text-align: right; font-variant-numeric: tabular-nums; }
.fin-counter { margin: -6px 0 var(--sp-3); text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.fin-counter-over { color: var(--danger); font-weight: 700; }
.fin-form-notice { margin: 0 0 var(--sp-3); }
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
.fin-bank-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-3); }
.fin-bank-facts > div { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
.fin-bank-facts dt { color: var(--text-muted); font-size: 13px; }
.fin-bank-facts dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.fin-fixed-form { margin-bottom: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
.fin-fixed-form-title { margin: 0 0 var(--sp-3); font-size: 15px; font-weight: 700; }
.fin-fixed-list { list-style: none; margin: 0; padding: 0; }
.fin-fixed {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
padding: var(--sp-3) 0; border-bottom: 1px solid var(--border);
}
.fin-fixed:last-child { border-bottom: none; }
.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-muted); }
.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px var(--sp-2); min-width: 0; }
.fin-fixed-name { font-weight: 700; overflow-wrap: anywhere; }
.fin-fixed-amount { font-weight: 700; }
.fin-fixed-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 2px var(--sp-4); font-size: 13px; overflow-wrap: anywhere; }
.fin-counter { margin-top: -8px; text-align: right; font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
.fin-counter-over { color: var(--danger); font-weight: 600; }
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 16px; }
.fin-kv { margin: 0 0 16px; }
.fin-result { font-size: 16px; line-height: 24px; font-weight: 600; }
.fin-fixed-form { padding-bottom: 24px; margin-bottom: 8px; border-bottom: 1px solid var(--line); }
.fin-form-title { margin: 0 0 16px; font-size: 14px; line-height: 20px; font-weight: 600; }
.fin-fixed-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.fin-fixed-form ~ .fin-fixed-list { border-top: 0; } /* das offene Formular schliesst mit seiner eigenen Linie ab */
.fin-fixed { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline; padding: 12px 0; border-bottom: 1px solid var(--line); }
.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-2); }
.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; }
.fin-fixed-name { font-weight: 500; overflow-wrap: anywhere; }
.fin-fixed-amount { font-weight: 600; }
.fin-fixed-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 2px 16px; font-size: 13px; overflow-wrap: anywhere; }
.fin-fixed-actions { grid-column: 1 / -1; padding-top: 4px; }
@media (max-width: 640px) {
.fin-form-grid { grid-template-columns: minmax(0, 1fr); }
.fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
}
.fin-fixed-actions button:disabled, .fin-notice button:disabled { cursor: not-allowed; }
/* ===== ENDE FINANZEN ===== */
/* ===== Tickets (P2) ===== */
/* Arbeitsblatt-Zeilen, Zeilenlinks, Nachladen und Unterstuetzungs-Zeile. Steht direkt vor KOMPAT: gewinnt ueber die Bausteinregeln */
@@ -1043,6 +973,210 @@ button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .
}
/* ===== ENDE Planboard (P3) ===== */
/* ===== Kunden (P4) ===== */
/* Die Prototypregel .kv .input gilt nur fuer einzeilige Felder */
.kv .textarea { width: 100%; max-width: 360px; }
/* Wertetabellen mit Feldern: Beschriftung und Feld teilen die Grundlinie (Textfeld: erste Zeile) */
.kunde-kv { align-items: baseline; }
/* Mehrzeiliges Feld: die Beschriftung steht auf Hoehe der ersten Zeile (Innenabstand des Textfelds), nicht an seiner Unterkante */
@media (min-width: 861px) {
.kunde-kv .kunde-kv-oben { align-self: start; padding-top: 8px; }
}
/* Uebersicht: unter der Wertetabelle kommen das Kaestchen "Wichtiger Kunde" und die Knopfzeile */
.kv + .check { margin-top: 16px; }
.kv ~ .toolbar { margin-top: 16px; }
.kunde-hinweis { margin-bottom: 12px; }
/* Abzeichen unter dem Namen: Abstand zum Folgenden, oben buendig (sonst waechst die Zeile um 1 px) */
.kunde-sub .badge { margin-right: 8px; vertical-align: top; }
/* Reiter "Abo & Angebote": Abstand zwischen Abo-Status und Angeboten (der Umschlag ist ein div, .page section + section greift dort nicht) */
.kunde-abo-angebote { margin-top: 36px; }
/* Reiter "Funktionen": eine Tabelle, je Projekt eine Gruppenzeile; Bestaetigung und Panels sind Zusatzzeilen direkt unter der Funktionszeile */
.funktionen-group th { text-align: left; font-size: 14px; font-weight: 600; color: var(--text); padding-top: 20px; overflow-wrap: anywhere; white-space: normal; }
.funktionen-group .link { font-size: 13px; font-weight: 400; margin-left: 8px; }
.funktionen-sitekey { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 4px; }
.funktionen-sitekey code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; user-select: all; }
.funktionen-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.funktionen-confirm td { padding-top: 0; }
.funktionen-note { width: 100%; max-width: 260px; }
/* Zeilen einer Gruppe (Funktionszeile, Bestaetigung, Panels): nur die letzte behaelt die Haarlinie */
.funktionen-offen td { border-bottom: 0; }
.funktionen-extra td { padding-top: 0; }
.funktionen-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; background: var(--bg-subtle); border-radius: var(--radius); }
.funktionen-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.funktionen-panel-warnungen { margin: 0; padding-left: 18px; }
.funktionen-panel-abschnitt { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg); min-width: 0; }
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
.funktionen-panel-abschnitt pre {
margin: 0; padding: 10px 12px; border-top: 1px solid var(--line); max-width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
}
/* Oeffnungszeiten live (Panel unter der Funktionszeile): Team-Formular und Ersatz-HTML */
.funktionen-panel-form { display: flex; flex-direction: column; gap: 8px; }
.funktionen-panel-feld { display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
.funktionen-panel-html { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 120px; }
.funktionen-zustimmung { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; }
.funktionen-zustimmung input { flex: none; margin-top: 3px; width: 15px; height: 15px; accent-color: var(--accent); }
.funktionen-vorlage summary { cursor: pointer; }
.funktionen-vorlage p { margin: 6px 0; white-space: pre-wrap; }
.funktionen-msg { margin-top: 12px; }
/* Webshop-Anfrage je Projekt unter der Tabelle: ein Block je Eintrag, dazwischen eine Haarlinie */
.funktionen-shop { display: flex; flex-direction: column; gap: 8px; margin-top: 24px; }
.funktionen-shop + .funktionen-shop { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--line); }
.funktionen-shop dd { white-space: pre-wrap; }
.funktionen-begruendung { width: 100%; max-width: 560px; }
@media (min-width: 861px) {
.funktionen-shop .kv { grid-template-columns: max-content minmax(0, 1fr); }
}
/* Handy: Kopf nur fuer Screenreader, jede Funktionszeile als zwei Spalten (Name | Status, Ticket, Aktionen untereinander) */
@media (max-width: 860px) {
.funktionen-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.funktionen-table, .funktionen-table tbody, .funktionen-table tr, .funktionen-table td, .funktionen-table th { display: block; }
.funktionen-table .funktionen-zeile { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 4px 12px; padding: 12px 0; border-bottom: 1px solid var(--line); }
.funktionen-table .funktionen-zeile.funktionen-offen { border-bottom: 0; }
.funktionen-zeile > td { grid-column: 2; min-width: 0; padding: 0; border-bottom: 0; }
.funktionen-zeile > td:first-child { grid-column: 1; grid-row: 1; }
.funktionen-zeile > td:empty, .funktionen-zeile > .funktionen-ohne-ticket { display: none; }
.funktionen-confirm td { padding-bottom: 12px; }
.funktionen-extra td { padding-bottom: 12px; }
}
/* ===== ENDE Kunden (P4) ===== */
/* ===== Projekte (P5) ===== */
/* Projektliste als Tabelle, Dialog-Inhalte, Preview-Block, Projektzeile am Ticket und beim Kunden, Zuordnen-Assistent. Steht direkt vor */
/* KOMPAT: gewinnt ueber die Bausteinregeln oberhalb durch die Reihenfolge, nie durch eine Erzwingung. */
button.link { text-align: left; } /* Reset und Gewicht von button.link stehen im Block Tickets (P2) */
/* ---------- Projektliste (Tabelle) ---------- */
.projects-table td:first-child { min-width: 140px; }
.projects-table th, .projects-table td { padding-right: 8px; }
.projects-table th:last-child, .projects-table td:last-child { padding-right: 0; }
.projects-table .cell-title .link, .projects-table .cell-sub { overflow-wrap: anywhere; }
.projects-table td.num { min-width: 64px; overflow-wrap: anywhere; } /* Ticket ohne Eintrag in der Ticketliste zeigt die lange $id: sie bricht um, die Tabelle waechst nicht ueber den Rahmen; 64 px halten 6-stellige Nummern in einer Zeile */
.projects-table .select { width: clamp(104px, 10vw, 180px); max-width: 100%; }
.projects-table td.cell-address code { display: block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.projects-table td.cell-updated { min-width: 70px; }
.projects-table tr.is-archived td { opacity: .72; }
.projects-table tr.is-archived .faint { color: var(--text-2); } /* mit opacity .72 fiele das helle Grau von .faint unter 3:1 (Muster: .table tr.row:hover .faint) */
.row-actions { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; font-size: 13px; } /* Links (a) und Textknoepfe (button) auf einer Zeile; ohne baseline sitzt ein button.link-danger in einer hoeheren Zeile mittig statt oben */
.row-actions .link { white-space: nowrap; }
.projects-table .row-actions { justify-content: flex-end; }
/* Unter 860 px steht jede Zeile als zweispaltiges Raster: Projekt ueber beide Spalten, Kunde und Status nebeneinander, Aktionen darunter. */
/* Rollen (table, row, cell) stehen im JSX, damit die Tabellensemantik beim Stapeln erhalten bleibt. */
@media (max-width: 860px) {
.projects-table, .projects-table tbody, .projects-table td { display: block; }
.projects-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } /* nur fuer Screenreader */
.projects-table tbody tr { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; padding: 14px 0; border-bottom: 1px solid var(--line); }
.projects-table td { padding: 0; border: 0; }
.projects-table td:first-child, .projects-table td.cell-actions { grid-column: 1 / -1; }
.projects-table td[data-label]::before { content: attr(data-label); display: block; font-size: 12px; color: var(--text-2); }
.projects-table .select { width: 100%; }
.projects-table .row-actions { justify-content: flex-start; }
.projects-table tr.row:hover td, .projects-table tr.row:hover td:first-child, .projects-table tr.row:hover td:last-child { background: none; box-shadow: none; }
/* (0,2,1) schlaegt .hide-sm (0,1,0) und .projects-table td { display: block } (0,1,1) ueber die Spezifitaet, ohne Erzwingung */
.projects-table td.hide-sm, .projects-table td.hide-md { display: none; }
}
.badge.wrap { white-space: normal; height: auto; min-height: 20px; padding-top: 2px; padding-bottom: 2px; overflow-wrap: anywhere; }
/* ---------- Dialog-Inhalte ---------- */
.dialog-form { display: flex; flex-direction: column; gap: 16px; }
/* Textvorlage der Analyse-Zustimmung (details im Dialog): Zeiger wie vorher, Abstaende statt Aussenabstaende im JSX, die Vorlage enthaelt lange Adressen ohne Leerzeichen */
.dialog-body details > summary { cursor: pointer; }
.dialog-body details[open] > summary { margin-bottom: 8px; }
.dialog-body details > .prose { margin-bottom: 8px; overflow-wrap: anywhere; }
/* ---------- Preview-Block (im Projekt-Dialog, am Ticket und beim Kunden) ---------- */
.pv-block { font-size: 13px; display: flex; flex-direction: column; gap: 10px; }
.pv-row, .pv-add, .pv-var { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.pv-var { padding-bottom: 8px; border-bottom: 1px solid var(--line); }
.pv-mode { width: 200px; max-width: 100%; }
.pv-title { font-size: 13px; font-weight: 600; }
.pv-block .msg { overflow-wrap: anywhere; } /* Fehlertexte des Runners enthalten lange Pfade ohne Leerzeichen */
.pv-log { background: var(--bg-subtle); max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-all; padding: 8px; border-radius: var(--radius); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 16px; }
.pv-var code, .pv-var .input, .pv-add .input { flex: 1 1 140px; min-width: 0; }
.pv-var code { overflow-wrap: anywhere; }
/* ---------- Projektzeile am Ticket und beim Kunden ---------- */
/* Spalte mit Abstand statt Aussenabstaenden an den Kindern: ein offener Dialog (fest, ausserhalb des Flusses) bekaeme sonst einen Versatz */
.proj-item { padding: 16px 0; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 8px; }
.proj-item > button.link { align-self: flex-start; } /* sonst waere der ganze Streifen klickbar */
.proj-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; }
.proj-main { min-width: 0; overflow-wrap: anywhere; }
.proj-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--text-2); }
.ticket-projects-assign { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.ticket-projects-assign .select { flex: 1 1 220px; min-width: 0; }
.ticket-projects-assign .input { flex: 1 1 160px; min-width: 0; }
/* ---------- Zuordnen-Assistent ---------- */
.seg.wrap { flex-wrap: wrap; }
.row-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.check.top { align-items: flex-start; }
.pick-list { max-height: 360px; overflow-y: auto; }
.pick-empty { padding: 12px 0; }
button.list-row { width: 100%; text-align: left; background: none; border: 0; border-bottom: 1px solid var(--line); font: inherit; color: inherit; cursor: pointer; }
button.list-row:hover, button.list-row[aria-pressed="true"] { background: var(--bg-hover); }
button.list-row .cell-sub { display: block; }
button.list-row:focus-visible { outline-offset: -2px; } /* in .pick-list (Scroll-Container) wuerde der Ring sonst abgeschnitten */
.summary { list-style: none; display: flex; flex-direction: column; gap: 6px; }
/* Inhalt eines Schritts als Spalte mit Abstand (der Reset setzt jeden Aussenabstand auf 0); Segment und Haken bleiben so breit wie ihr Inhalt */
.assign-step { display: flex; flex-direction: column; gap: 12px; }
.assign-step > .seg, .assign-step > .check { align-self: flex-start; }
.page .assign-step .field { margin-bottom: 0; } /* KOMPAT gibt .page .field 12 px unten; hier steht der Abstand der Spalte (P10 loescht beide) */
button.list-row:hover .faint, button.list-row[aria-pressed="true"] .faint { color: var(--text-2); } /* .faint hat auf --bg-hover nur 2,9:1 (Muster: .table tr.row:hover .faint) */
/* ---------- Services ---------- */
/* Beschreibung, Funktions-Badges und Zeilen einer Art als Spalte mit Abstand (der Reset setzt jeden Aussenabstand auf 0); auch Fehlermeldung plus Knopf */
.services-stack { display: flex; flex-direction: column; gap: 12px; }
.services-stack > .pick-empty { padding: 0; } /* der Abstand kommt aus gap */
/* ===== ENDE Projekte (P5) ===== */
/* ===== Kunden-Unterseiten (P6) ===== */
/* Kleine Ergaenzungen fuer Gutschriften, Angebote und E-Mail-Speicher. Steht direkt vor KOMPAT: gewinnt bei gleicher Spezifitaet */
/* ueber die Bausteinregeln oberhalb durch die Reihenfolge, nie durch eine Erzwingung. */
/* table-wrap gilt nur fuer die drei P6-Tabellen: lange Namen, Domains und Anbieter brechen statt die Seite zu verbreitern */
.table.table-wrap td { overflow-wrap: anywhere; }
/* Die aufgeklappte Gutschrift liegt auf --bg-subtle; die Zeilen sind nicht klickbar, daher kein tr.row */
.table tr[aria-current="true"] td { background: var(--bg-subtle); }
/* Aktionszeile in der letzten Zelle (button.link / .link-danger); ersetzt das gestrichene .mt-1. Nur fuer das div: P5 nutzt */
/* td.cell-actions als Zellenmarker der Projektliste (display bleibt table-cell), die Klasse darf dort kein Flex setzen. */
div.cell-actions { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6px; }
/* Dialogfuss bricht um, wenn Knoepfe und Hinweis nicht in eine Zeile passen (Handy) */
.dialog-foot { flex-wrap: wrap; }
/* Eine lange Knopfbeschriftung (Wortlaut bleibt, z. B. "Angebot anlegen und aufs laufende Abo anwenden") bricht im Dialogfuss um, */
/* statt ihn zu verbreitern: .btn ist nowrap, Umbrechen der Zeile allein reicht nicht. Einzeilig bleibt der Knopf 32 px hoch. */
.dialog-foot .btn { white-space: normal; height: auto; min-height: 32px; padding-top: 6px; padding-bottom: 6px; text-align: center; }
/* deckt auch verschachtelte Felder, P7 prueft die Regel */
.autocomplete .input { width: 100%; }
/* Formularbloecke in einer Karte (Gutschrift: Berechnung) stehen mit 16 px Abstand untereinander, wie im Dialog (.dialog-body). */
/* Der Abstand kommt vom Container, nicht vom Altabstand .page .field (KOMPAT, faellt in P10); die zweite Regel nimmt ihn dort weg. */
.form-stack { display: flex; flex-direction: column; gap: 16px; }
.page .form-stack > .field { margin-bottom: 0; }
/* Angebote: die Statusspalte (Badge, Stripe-Links, Mail-Zeile, Aktionen) wird nicht schmaler als ganze Woerter; ohne Mindestbreite */
/* gibt ihr die Tabelle (td bricht ueberall) fast nichts ab und "Abschluss" oder "Zurueckziehen" brechen mitten im Wort. */
.table-wrap th.col-status { min-width: 7rem; }
@media (min-width: 861px) { .table-wrap th.col-status { min-width: 12rem; } }
/* Kundenspalte (E-Mail-Speicher, Angebote, Gutschriften): wird nicht schmaler als ein ganzer Name ("Physiotherapie"); die Aufgaben-, */
/* Angebots- oder Statusspalte ist lang und nimmt sonst fast die ganze Breite, und td bricht ueberall: am Handy stuende "Physiothe-rapie" in 65 px. */
.table-wrap th.col-name { min-width: 7.5rem; }
/* Angebotsspalte (Angebote): braucht ebenfalls eine Mindestbreite, sonst bricht der lange Angebots- und Notiztext am Handy mitten im Wort */
/* ("Ansprechpartne-r", "Geschaeftsfuehru-ng" in 103 px). Bis 600 px passen 7,5rem (Kunde) + 8rem (Angebot) + 6rem (Status) in 358 px; */
/* die Kundenspalte braucht 7,5rem fuer "Anlagentechnik" (14 Zeichen), nur die Angebotstabelle hat col-status. */
.table-wrap th.col-offer { min-width: 8rem; }
@media (max-width: 600px) { .table-wrap th.col-status { min-width: 6rem; } }
/* ===== ENDE Kunden-Unterseiten (P6) ===== */
/* --- Rechtliches und Admin (P8) --- */
/* Zugangsdaten (CopyableCredential), Screenreader-Text und Drehanimation. Die Regeln lagen in AdminPage.css und gelten fuer */
/* alle Seiten (Admin-Tabelle, Kundenseite, Login). Steht direkt vor KOMPAT. */
.credential { display: flex; max-width: 300px; align-items: center; gap: 8px; margin: 2px 0; }
.credential .link { flex: 0 0 auto; font-size: 12px; white-space: nowrap; }
.credential-value { min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 12px; }
.credential-value.is-masked { color: var(--text-2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.08em; }
.credential-empty { color: var(--text-3); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Kein Aufrufer mehr nach P8 (der Spinner der Admin-Seite war der einzige); P10 loescht die Regel, wenn rg -w spin src --glob '!*.css' nichts findet */
.spin { animation: spin 0.8s linear infinite; }
/* ===== KOMPAT (bis P10) ===== */
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
/* und lesbar bleiben. Steht am Dateiende und gewinnt dort gegen die Altregeln und gegen die anderen Stylesheets (global.css */
@@ -1124,13 +1258,6 @@ select.form-control {
.text-grey { color: var(--text-2); }
.text-red { color: var(--red-fg); }
/* Zaehler und Initiale der Altlisten: .side-count bis P6 (Chat-Liste), .side-logo bis P4 (Kundenliste) */
.side-count { margin-left: auto; font-size: 12px; font-weight: 500; color: var(--text-2); font-variant-numeric: tabular-nums; }
.side-logo {
display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 32px; height: 32px;
border-radius: var(--radius); background: var(--grey-bg); color: var(--grey-fg); font-weight: 600;
}
/* Neutral: Karte, Raster */
.card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); }
.card-header { padding: 0 0 8px; background: none; border-bottom: 1px solid var(--line); color: var(--text); font-weight: 600; }
@@ -1147,19 +1274,17 @@ div.row { display: flex; flex-wrap: wrap; margin: 0 -8px; }
.page .page { margin: 0; padding: 0; max-width: none; }
/* Links in Bestandsklassen, die bisher --accent als Farbe nutzten: feiner Unterstrich */
.fin-link, .legal-files a, .credit-cols a, .area-rows a {
.fin-link {
text-decoration: underline; text-decoration-color: var(--line-strong); text-underline-offset: 3px; text-decoration-thickness: 1px;
}
.fin-link:hover, .legal-files a:hover, .credit-cols a:hover, .area-rows a:hover { text-decoration-color: currentColor; }
.fin-link:hover { text-decoration-color: currentColor; }
/* Abschnitte (Card = <section>) als Spalten nebeneinander: der Abstand .page section + section gilt nur fuer untereinander stehende Abschnitte */
.legal-grid > section + section, .fin-cols > section + section,
.fin-cols > section + section,
.page div[style*="display: grid"] > section + section { margin-top: 0; }
/* Altformulare mit <Field> (Kunde, Projekt zuweisen, Neuer Lead): Zeilenabstand wie frueher; neue Container bestimmen ihren Abstand selbst */
.page .field { margin-bottom: 12px; }
.dialog-body .field, .field-row .field, .field-row-3 .field, .kv .field { margin-bottom: 0; }
/* Kennzahlen nebeneinander (LiquidityHead), bis P7 sie ersetzt */
.kpi-grid { display: flex; flex-wrap: wrap; gap: 48px; }
/* ===== ENDE KOMPAT ===== */
+45 -59
View File
@@ -1,68 +1,54 @@
/* Serverpuls (Admin) */
.sp-page { display: flex; flex-direction: column; gap: var(--sp-4); }
.sp-error { background: var(--red-bg); border: 1px solid var(--danger-ring); color: var(--red-fg); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-md); }
.sp-muted { color: var(--text-muted); }
.sp-small { font-size: 12px; }
/* Serverpuls (P9): Kopf, Warnungen, Kacheln, Umschalter, Panels, Projekttabelle */
.sp-page .msg { margin: 0 0 16px; }
.sp-head { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); }
.sp-level-medium { border-color: var(--orange-fg); }
.sp-level-critical { border-color: var(--danger); }
.sp-head-top { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.sp-headline { font-size: 18px; }
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-muted); }
.sp-age-stale { color: var(--orange-fg); background: var(--orange-bg); padding: 2px 10px; border-radius: var(--r-pill); }
.sp-alerts { list-style: none; margin: var(--sp-3) 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.sp-alert { display: flex; flex-wrap: wrap; gap: var(--sp-3); align-items: baseline; padding: 8px 12px; border-radius: var(--r-sm); background: var(--surface-2); border-left: 3px solid var(--text-faint); }
.sp-alert-critical { border-left-color: var(--danger); }
.sp-alert-medium { border-left-color: var(--warn); }
.sp-alert-level { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); min-width: 56px; }
.sp-alert-title { font-weight: 600; }
.sp-alert-value { color: var(--text-muted); }
.sp-head-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.sp-headline { font-size: 16px; line-height: 24px; font-weight: 600; }
.sp-age { margin-left: auto; font-size: 13px; color: var(--text-2); }
.sp-age-stale { color: var(--orange-fg); font-weight: 500; }
.sp-alerts { list-style: none; margin: 12px 0 0; padding: 0; }
.sp-alert-main { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-width: 0; }
.sp-alert-title { font-weight: 500; overflow-wrap: anywhere; }
.sp-alert-value { color: var(--text-2); }
.sp-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--sp-3); }
@media (max-width: 1100px) { .sp-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { .sp-tiles { grid-template-columns: 1fr; } .sp-age { margin-left: 0; } }
.sp-tile { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sp-tile-label { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
.sp-tile-value { font-size: 26px; font-weight: 800; }
.sp-tile-sub { font-size: 12px; color: var(--text-muted); min-height: 16px; }
.sp-tile-delta { font-size: 12px; margin-top: 2px; }
.stats.sp-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px 32px; }
.sp-tile { min-width: 0; }
.sp-tile .sub { min-height: 16px; }
.sp-spark { display: block; max-width: 100%; margin-top: 6px; }
.sp-tile-delta { font-size: 12px; line-height: 16px; margin-top: 4px; }
.sp-delta-up { color: var(--red-fg); }
.sp-delta-down { color: var(--green-fg); }
.sp-delta-flat { color: var(--text-2); }
.sp-delta-up { color: var(--danger); }
.sp-delta-down { color: var(--ok); }
.sp-delta-flat { color: var(--text-muted); }
.sp-panel { background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-4); min-width: 0; }
.sp-panel-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-3); }
.sp-panel-head h3 { margin: 0; font-size: 16px; }
.sp-controls { display: flex; gap: var(--sp-3); flex-wrap: wrap; }
.sp-pills { display: flex; gap: 4px; flex-wrap: wrap; }
.sp-pill { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); border-radius: var(--r-pill); padding: 4px 12px; font: inherit; font-size: 13px; cursor: pointer; }
.sp-pill:hover { color: var(--text); }
.sp-pill.active { background: var(--accent-soft); border-color: var(--border-accent); color: var(--accent); font-weight: 600; }
.sp-panel { min-width: 0; }
.sp-controls { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 12px; }
.sp-controls .seg { flex-wrap: wrap; }
.sp-chart { width: 100%; min-height: 200px; }
.sp-chart .u-legend { color: var(--text-muted); font-size: 12px; }
.sp-nodata { padding: var(--sp-6, 32px) 0; text-align: center; }
.sp-chart .u-legend { font-size: 13px; color: var(--text-2); }
.sp-nodata { padding: 40px 0; text-align: center; color: var(--text-2); }
.sp-search { width: 100%; max-width: 320px; margin-bottom: var(--sp-3); background: var(--surface-2); border: 1px solid var(--border); color: var(--text); border-radius: var(--r-sm); padding: 8px 12px; font: inherit; }
.sp-table-wrap { overflow-x: auto; }
.sp-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.sp-table th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.sp-sortable { cursor: pointer; user-select: none; }
.sp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.sp-row { cursor: pointer; }
.sp-row:hover, .sp-row-active { background: var(--surface-2); }
.sp-name { font-weight: 600; }
.sp-empty { text-align: center; color: var(--text-muted); }
.sp-sort { display: block; width: 100%; text-align: inherit; white-space: nowrap; }
.sp-page .table td.num { white-space: nowrap; }
.sp-name { overflow-wrap: anywhere; }
.sp-name .link { text-align: left; overflow-wrap: anywhere; }
.sp-page .table tr.sp-row-active td { background: var(--bg-subtle); }
.sp-page .table tr.sp-row-active td:first-child { box-shadow: -12px 0 0 var(--bg-subtle); }
.sp-page .table tr.sp-row-active td:last-child { box-shadow: 12px 0 0 var(--bg-subtle); }
.sp-empty { text-align: center; color: var(--text-2); }
.sp-delta { font-variant-numeric: tabular-nums; }
.sp-detail { margin-top: var(--sp-4); padding-top: var(--sp-4); border-top: 1px solid var(--border); }
.sp-detail-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: var(--sp-4); }
.sp-chart-title { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
.sp-detail { margin-top: 28px; }
.sp-detail-title { margin: 0 0 8px; font-size: 15px; line-height: 22px; font-weight: 600; overflow-wrap: anywhere; }
.sp-detail-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: 24px; }
.sp-chart-title { font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
.sp-cleanup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--sp-3); }
.sp-cleanup-grid > div { display: flex; flex-direction: column; gap: 2px; }
.sp-cleanup-grid strong { font-size: 20px; }
.sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; }
.sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
.sp-cleanup-list { list-style: none; margin: 12px 0; padding: 0; }
@media (max-width: 1100px) {
.stats.sp-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 860px) {
.stats.sp-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 24px; }
.sp-age { margin-left: 0; }
.sp-page .sp-cleanup-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const css = readFileSync(path.join(root, 'src/styles/chat.css'), 'utf8')
const thread = readFileSync(path.join(root, 'src/components/chat/ChatThread.jsx'), 'utf8')
// Quelltext ohne Kommentare: der Kopfkommentar darf "!important" und "720 px" nennen, ohne die Prüfungen zu reißen
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '')
// Alle Selektoren (ohne @-Regeln), Kommas gesplittet
const selectors = [...rules.matchAll(/([^{}]+)\{/g)]
.map((m) => m[1].trim())
.filter((s) => s && !s.startsWith('@'))
.flatMap((s) => s.split(',').map((x) => x.trim()))
// Fremde Klassen des Portal-Bausteins (chat-composer.css): jede Regel darüber braucht das Präfix .woms-chat
const FREMD = /\.chat-(bubble|meta|messages|notice|input-row|icon-btn|send|menu|tray|rec|audio|att|file|composer|drop-active)\b/
test('chat.css: jede Regel für Klassen des Portal-Bausteins beginnt mit .woms-chat', () => {
const bad = selectors.filter((s) => FREMD.test(s) && !s.startsWith('.woms-chat'))
assert.deepEqual(bad, [])
})
test('chat.css: kein !important (Spezifität statt Gewalt, Portal-CSS lädt zuletzt)', () => {
assert.doesNotMatch(rules, /!important/)
})
test('chat.css: Umschaltung Liste/Verlauf bei 860 px, kein 720-px-Wert mehr', () => {
assert.match(rules, /@media \(max-width: 860px\)/)
assert.doesNotMatch(rules, /720px/)
})
test('ChatThread: die drei Container des Portal-Bausteins bleiben unverändert', () => {
assert.ok(thread.includes('className="woms-chat" ref={zoneRef}'))
assert.ok(thread.includes('className="chat-messages" ref={boxRef}'))
assert.ok(thread.includes('<div ref={composerRef} />'))
})
test('chat.css: .chat-page .chat-back ist außerhalb der Handyansicht verborgen (schlägt .btn aus global.css)', () => {
assert.match(rules, /\.chat-page \.chat-back\s*\{[^}]*display:\s*none/)
})
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// P6: Zeilen der Gutschriften (Liste und Detail) als reine Helfer, damit die Seiten keine Inline-Ausdruecke brauchen
const { creditStatus, creditActionLabel, creditStatusNote, checkGlyph, checkTone } = await loadModule('src/lib/creditFormat.js')
const NB = ' '
test('creditStatus: Tonnamen und Beschriftungen sind festgelegt', () => {
assert.deepEqual(creditStatus('offen'), { tone: 'warn', label: 'Zu prüfen' })
assert.deepEqual(creditStatus('rueckfrage'), { tone: 'muted', label: 'Rückfrage beim Kunden' })
assert.deepEqual(creditStatus('genehmigt'), { tone: 'ok', label: 'Genehmigt' })
assert.deepEqual(creditStatus('abgelehnt'), { tone: 'danger', label: 'Abgelehnt' })
assert.deepEqual(creditStatus('neu'), { tone: 'muted', label: 'neu' })
})
test('creditActionLabel: offene und rueckgefragte Antraege werden geprueft, alle anderen angesehen', () => {
assert.equal(creditActionLabel('offen'), 'Prüfen')
assert.equal(creditActionLabel('rueckfrage'), 'Prüfen')
for (const s of ['genehmigt', 'abgelehnt', 'neu', undefined]) assert.equal(creditActionLabel(s), 'Ansehen')
})
test('creditStatusNote: nur genehmigte Antraege tragen Betrag und Buchungsstand', () => {
assert.equal(creditStatusNote({ status: 'genehmigt', amountCents: 1250, appliedAt: '2026-10-01T10:00:00Z' }), `12,50${NB}€`)
assert.equal(creditStatusNote({ status: 'genehmigt', amountCents: 1250 }), `12,50${NB}€ · nicht gebucht`)
assert.equal(creditStatusNote({ status: 'offen', amountCents: 1250 }), '')
})
test('checkGlyph und checkTone: Zeichen und Ton der automatischen Pruefung', () => {
assert.equal(checkGlyph('ok'), '✓')
assert.equal(checkTone('ok'), 'ok')
assert.equal(checkGlyph('warn'), '!')
assert.equal(checkTone('warn'), 'warn')
assert.equal(checkGlyph('info'), 'i')
assert.equal(checkTone('info'), 'info')
assert.equal(checkGlyph(undefined), 'i')
assert.equal(checkTone(undefined), 'muted')
})
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { CUSTOMER_TABS, customerStage, isArchived, filterCustomers, stageCounts, customerBadges, stageLabel, customerEyebrow, customerTitle } =
await loadModule('src/lib/customerList.js')
const A = { $id: 'a', name: 'Alpha GmbH', code: 'K-1', location: 'Kaiserslautern', email: 'info@alpha.de', contactName: 'Anna Alt', customerStatus: 'customer' }
const B = { $id: 'b', name: 'Beta', customerStatus: 'lead' }
const C = { $id: 'c', name: 'Gamma', customerStatus: 'lost' }
const D = { $id: 'd', name: 'Delta' } // Bestandsdaten ohne Status gelten als fester Kunde
const E = { $id: 'e', name: 'Epsilon', customerStatus: 'lead', archived: true, importantCustomer: true }
const F = { $id: 'f', name: 'Zeta', customerStatus: 'customer', archived: true }
const ALL = [A, B, C, D, E, F]
const ids = (list) => list.map((c) => c.$id)
test('CUSTOMER_TABS, customerStage, isArchived', () => {
assert.deepEqual(CUSTOMER_TABS, [
{ id: 'customer', label: 'Feste Kunden' }, { id: 'lead', label: 'Leads' },
{ id: 'archived', label: 'Archiviert' }, { id: 'all', label: 'Alle' },
])
assert.equal(customerStage({ customerStatus: 'LEAD' }), 'lead')
assert.equal(customerStage({ customerStatus: 'Lost' }), 'lost')
for (const c of [{}, { customerStatus: '' }, { customerStatus: 'sonstwas' }, null, undefined]) assert.equal(customerStage(c), 'customer')
assert.equal(isArchived({ archived: true }), true)
for (const c of [{}, { archived: false }, null, undefined]) assert.equal(isArchived(c), false)
})
test('filterCustomers: jeder Reiter', () => {
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'customer' })), ['a', 'd'])
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'lead' })), ['b'])
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'archived' })), ['e', 'f'])
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'all' })), ['a', 'b', 'c', 'd'])
assert.deepEqual(ids(filterCustomers(ALL)), ['a', 'd'])
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'x' })), [])
assert.deepEqual(filterCustomers(undefined, { stage: 'all' }), [])
})
test('filterCustomers: Suche in Name, Code, Standort, E-Mail und Ansprechpartner', () => {
const find = (search, stage = 'all') => ids(filterCustomers(ALL, { stage, search }))
for (const s of ['ALPHA', 'k-1', 'kaisers', 'info@alpha', 'anna']) assert.deepEqual(find(s), ['a'], s)
assert.deepEqual(find('xyz'), [])
assert.deepEqual(find('eta', 'lead'), ['b'])
assert.deepEqual(find(''), ['a', 'b', 'c', 'd'])
assert.deepEqual(ids(filterCustomers([{ $id: 'n' }], { stage: 'all', search: 'undefined' })), [])
})
test('stageCounts: dieselbe Filterfunktion wie die Liste', () => {
assert.deepEqual(stageCounts(ALL), { customer: 2, lead: 1, archived: 2, all: 4 })
assert.deepEqual(stageCounts(ALL, 'eta'), { customer: 0, lead: 1, archived: 1, all: 1 })
assert.deepEqual(stageCounts([]), { customer: 0, lead: 0, archived: 0, all: 0 })
})
test('customerBadges: Reihenfolge, Töne, Option stage', () => {
assert.deepEqual(customerBadges(E), [
{ key: 'important', label: 'Wichtig', tone: 'warn' },
{ key: 'lead', label: 'Lead', tone: 'muted' },
{ key: 'archived', label: 'Archiviert', tone: 'plain' },
])
assert.deepEqual(customerBadges(C), [{ key: 'lost', label: 'Abgesagt', tone: 'plain' }])
for (const c of [A, D, null]) assert.deepEqual(customerBadges(c), [])
assert.deepEqual(customerBadges(E, { stage: false }).map((b) => b.key), ['important', 'archived'])
})
test('stageLabel, customerEyebrow, customerTitle', () => {
assert.deepEqual(['customer', 'lead', 'lost'].map(stageLabel), ['Fester Kunde', 'Lead', 'Abgesagt'])
assert.equal(customerEyebrow(A), 'K-1 · Fester Kunde')
assert.equal(customerEyebrow(B), 'Lead')
assert.equal(customerEyebrow({ code: ' ', customerStatus: 'lost' }), 'Abgesagt')
assert.equal(customerTitle(A), 'Alpha GmbH')
assert.equal(customerTitle({ email: 'x@y.de' }), 'x@y.de')
assert.equal(customerTitle({}), '–')
})
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { validateEmployeeForm } = await loadModule('src/lib/employeeForm.js')
const base = { displayName: 'Max Muster', email: 'max@webklar.com', password: '', shortcode: '', isAdmin: false }
const NEED = 'Name und E-Mail sind erforderlich.'
test('validateEmployeeForm: neuer Mitarbeiter', () => {
assert.equal(validateEmployeeForm({ ...base, displayName: ' ', password: '12345678' }, false), NEED)
assert.equal(validateEmployeeForm({ ...base, email: '', password: '12345678' }, false), NEED)
assert.equal(validateEmployeeForm({ ...base, password: '1234567' }, false), 'Das Passwort muss mindestens 8 Zeichen haben.')
assert.equal(validateEmployeeForm({ ...base, password: '12345678' }, false), null)
})
test('validateEmployeeForm: Mitarbeiter bearbeiten', () => {
assert.equal(validateEmployeeForm(base, true), null) // leeres Passwort = unverändert
assert.equal(validateEmployeeForm({ ...base, password: 'abc' }, true), 'Ein neues Passwort muss mindestens 8 Zeichen haben.')
assert.equal(validateEmployeeForm({ ...base, password: 'abcdefgh' }, true), null)
assert.equal(validateEmployeeForm({ ...base, email: ' ' }, true), NEED)
})
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { isNegText, noticeProps } = await loadModule('src/lib/financeUi.js')
const { euro, euroWhole, signedEuro } = await loadModule('src/lib/financeFormat.js')
test('isNegText: nur das angezeigte Minus zählt', () => {
assert.equal(isNegText(euro(-449)), true)
assert.equal(isNegText(signedEuro(-449)), true)
assert.equal(isNegText(euroWhole(-123456)), true)
assert.equal(isNegText(euroWhole(-40)), false) // „0 €“: Rundung auf 0 trägt kein Vorzeichen
assert.equal(isNegText(signedEuro(16879)), false)
assert.equal(isNegText(euro(null)), false) // „–“ (U+2013)
for (const v of ['-4,49 €', '', undefined, null, 5]) assert.equal(isNegText(v), false)
})
test('noticeProps: Klasse und Rolle je Ton', () => {
assert.deepEqual(noticeProps('ok'), { className: 'msg ok', role: 'status' })
assert.deepEqual(noticeProps('warn'), { className: 'msg warn', role: 'alert' })
assert.deepEqual(noticeProps('info'), { className: 'msg muted', role: 'status' })
assert.deepEqual(noticeProps(undefined), { className: 'msg muted', role: 'status' })
assert.deepEqual(noticeProps('warn', 'fin-notice'), { className: 'msg warn fin-notice', role: 'alert' })
})
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { costLabel, termLabel, noticeLabel, deadlineView } = await loadModule('src/lib/legalView.js')
const NB = ' '
const today = '2026-09-30'
const active = (noticeBy, periodEnd = '2027-03-12') => ({ state: 'active', noticeBy, periodEnd })
const v = (r) => deadlineView(r, today)
test('Vertragsliste: Kosten, Laufzeit, Frist wie bisher', () => {
assert.equal(costLabel({ cost: null }), '–')
assert.equal(costLabel({ cost: 12.5, costInterval: 'month' }), `12,50${NB}€/Monat`)
assert.equal(costLabel({ cost: 120, costInterval: 'year' }), `120,00${NB}€/Jahr`)
assert.equal(termLabel({}), '–')
assert.equal(termLabel({ termMonths: 0 }), 'jederzeit kündbar')
assert.equal(termLabel({ termMonths: 12, renewalMonths: 12 }), '12 Monate, dann je 12 Monate')
assert.equal(termLabel({ termMonths: 24, renewalMonths: 0 }), '24 Monate, endet danach')
assert.equal(noticeLabel({}), '–')
assert.equal(noticeLabel({ noticeValue: 1, noticeUnit: 'months' }), '1 Monate')
assert.equal(noticeLabel({ noticeValue: 14, noticeUnit: 'days' }), '14 Tage')
})
test('deadlineView: Badge nur bei rot und orange, Text wörtlich wie bisher', () => {
assert.deepEqual(v(active('2026-10-15')), {
text: 'kündbar bis 15.10.2026 (Periode endet 12.03.2027)',
lead: 'kündbar bis 15.10.2026', rest: '(Periode endet 12.03.2027)', badge: 'danger', muted: false,
})
assert.equal(v(active('2026-10-30')).badge, 'danger') // 30 Tage
assert.equal(v(active('2026-10-31')).badge, 'warn') // 31 Tage
assert.equal(v(active('2026-12-29')).badge, 'warn') // 90 Tage
const normal = v(active('2026-12-30')) // 91 Tage
assert.deepEqual([normal.badge, normal.lead === normal.text, normal.rest], [null, true, ''])
})
test('deadlineView: Text und Dämpfung der übrigen Zustände', () => {
assert.deepEqual([v({ state: 'anytime' }).text, v({ state: 'anytime' }).muted], ['jederzeit kündbar', false])
assert.equal(v({ state: 'ends', periodEnd: '2027-01-31' }).text, 'endet am 31.01.2027')
assert.deepEqual([v({ state: 'ended', periodEnd: '2026-01-31' }).text, v({ state: 'ended', periodEnd: '2026-01-31' }).muted], ['beendet seit 31.01.2026', true])
assert.equal(v({ state: 'cancelled', periodEnd: '2027-01-31' }).text, 'gekündigt zum 31.01.2027')
assert.equal(v({ state: 'cancelled', periodEnd: null }).text, 'gekündigt')
assert.deepEqual([v({ state: 'incomplete' }).text, v({ state: 'incomplete' }).muted], ['Fristen fehlen', true])
})
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// P6: Zeilen der Abo-Angebote (Filter, Aktionen, Mail-Zeile) als reine Helfer
const { offerStatus, filterOffers, offerRowActions, offerMailLine } = await loadModule('src/lib/offerFormat.js')
test('offerStatus: Tonnamen und Beschriftungen sind festgelegt', () => {
assert.deepEqual(offerStatus('offen'), { tone: 'warn', label: 'offen' })
assert.deepEqual(offerStatus('aktiv'), { tone: 'ok', label: 'aktiv' })
assert.deepEqual(offerStatus('abgelaufen'), { tone: 'muted', label: 'abgelaufen' })
assert.deepEqual(offerStatus('zurueckgezogen'), { tone: 'muted', label: 'zurückgezogen' })
})
test('filterOffers: "open" zeigt offen und aktiv, "all" alles, ohne Liste leer, Eingabe bleibt unveraendert', () => {
const offers = [
{ id: '1', status: 'offen' },
{ id: '2', status: 'aktiv' },
{ id: '3', status: 'abgelaufen' },
{ id: '4', status: 'zurueckgezogen' },
]
const copy = structuredClone(offers)
assert.deepEqual(filterOffers(offers, 'open').map((o) => o.id), ['1', '2'])
assert.deepEqual(filterOffers(offers, 'all').map((o) => o.id), ['1', '2', '3', '4'])
assert.deepEqual(filterOffers(null, 'open'), [])
assert.deepEqual(filterOffers(null, 'all'), [])
assert.deepEqual(offers, copy)
})
test('offerRowActions: E-Mail-Knopf und Zurueckziehen je nach Status', () => {
assert.deepEqual(offerRowActions({ status: 'offen', mailSendable: true }), { sendLabel: 'E-Mail senden', canWithdraw: true })
assert.deepEqual(offerRowActions({ status: 'offen', mailSendable: true, notifiedAt: '2026-09-30T10:05:00.000Z' }), { sendLabel: 'Erneut senden', canWithdraw: true })
assert.deepEqual(offerRowActions({ status: 'aktiv', mailSendable: true }), { sendLabel: 'E-Mail senden', canWithdraw: false })
assert.deepEqual(offerRowActions({ status: 'abgelaufen', mailSendable: false }), { sendLabel: '', canWithdraw: true })
assert.deepEqual(offerRowActions({ status: 'zurueckgezogen' }), { sendLabel: '', canWithdraw: false })
})
test('offerMailLine: gesendet, nicht verschickt, ohne Adresse, sonst keine Zeile', () => {
assert.deepEqual(offerMailLine({ notifiedAt: '2026-09-30T10:05:00.000Z' }), { tone: 'muted', text: 'E-Mail gesendet 30.09.26, 12:05' })
assert.deepEqual(offerMailLine({ mailSendable: true }), { tone: 'warn', text: 'E-Mail nicht verschickt' })
assert.deepEqual(offerMailLine({ status: 'offen', customerEmail: '' }), { tone: 'warn', text: 'Keine E-Mail-Adresse hinterlegt' })
assert.equal(offerMailLine({ status: 'abgelaufen', customerEmail: '' }), null)
assert.equal(offerMailLine({ status: 'offen', customerEmail: 'a@b.de' }), null)
})
+19 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches } = await loadModule('src/lib/projectList.js')
const { projectCounts, projectsInView, projectTabs, viewShowing, matchesSearch, hiddenMatches } = await loadModule('src/lib/projectList.js')
const P = [
{ $id: 'a' },
@@ -76,3 +76,21 @@ test('hiddenMatches: Treffer, die die aktuelle Ansicht ausblendet', () => {
assert.deepEqual(hiddenMatches(list, 'everything', shk, { isAdmin: true }), [], 'zeigt schon alles')
assert.deepEqual(hiddenMatches(list, 'all', (p) => matchesSearch(p, 'olz'), { isAdmin: true }), [])
})
const counts = { all: 5, unassigned: 2, assigned: 3, archived: 4, templates: 1, everything: 10 }
test('projectTabs: sechs Reiter für Admins, Wortlaut und Zähler', () => {
assert.deepEqual(projectTabs(counts, { isAdmin: true }), [
{ id: 'all', label: 'Aktive Projekte', count: 5 },
{ id: 'unassigned', label: 'Unzugeordnet', count: 2 },
{ id: 'assigned', label: 'Zugeordnet', count: 3 },
{ id: 'templates', label: 'Vorlagen', count: 1 },
{ id: 'archived', label: 'Archiviert', count: 4 },
{ id: 'everything', label: 'Alle inkl. Archiv', count: 10 },
])
})
test('projectTabs: ohne Admin keine Vorlagen, Zähler passen zur Ansicht', () => {
assert.deepEqual(projectTabs(counts).map((t) => t.id), ['all', 'unassigned', 'assigned', 'archived', 'everything'])
for (const t of projectTabs(projectCounts(P, { isAdmin: true }), { isAdmin: true })) {
assert.equal(t.count, projectsInView(P, t.id, { isAdmin: true }).length)
}
})
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { PROJECT_TYPES, typeOf, isProjectReady, projectActions, projectTicketRef, updatedLabel } = await loadModule('src/lib/projectRow.js')
test('typeOf: Vorlage vor Website vor Preview vor Projekt', () => {
assert.equal(typeOf({ isTemplate: true, isPublic: true, subdomain: 'x' }), 'template')
assert.equal(typeOf({ isPublic: true, subdomain: 'x' }), 'website')
assert.equal(typeOf({ subdomain: 'x' }), 'preview')
assert.equal(typeOf({}), 'project')
})
test('isProjectReady: nur ready/deployed, provisioningStatus hat Vorrang', () => {
assert.equal(isProjectReady({ status: 'ready' }), true)
assert.equal(isProjectReady({ status: 'DEPLOYED' }), true)
assert.equal(isProjectReady({ provisioningStatus: 'pending', status: 'ready' }), false)
assert.equal(isProjectReady({ status: 'building' }), false)
assert.equal(isProjectReady({}), false)
})
test('PROJECT_TYPES: Reihenfolge, Texte und Töne unverändert', () => {
assert.deepEqual(Object.keys(PROJECT_TYPES), ['preview', 'website', 'template', 'project'])
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.label), ['Preview (mit Login)', 'Oeffentliche Website', 'Vorlage', 'Internes Projekt (kein Hosting)'])
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.short), ['Preview', 'Website', 'Vorlage', 'Projekt'])
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.tone), ['info', 'ok', 'warn', 'muted'])
assert.equal(PROJECT_TYPES.template.adminOnly, true)
assert.equal(PROJECT_TYPES.project.hint, 'Nur Gitea-Repository, keine Website/Subdomain')
})
test('projectActions: gehostet und nicht deployt', () => {
const ready = projectActions({ subdomain: 'a', status: 'ready', previewUrl: 'https://a.project.webklar.com', giteaRepoUrl: 'https://git/x' })
assert.deepEqual(ready, { type: 'preview', hosted: true, ready: true, host: false, preview: true, refresh: true, gitea: true, hostedBadge: { text: 'gehostet', tone: 'ok' } })
const open = projectActions({ subdomain: 'a', status: 'provisioning', previewUrl: 'https://a.project.webklar.com' })
assert.deepEqual(open, { type: 'preview', hosted: true, ready: false, host: true, preview: false, refresh: false, gitea: false, hostedBadge: { text: 'nicht deployt', tone: 'warn' } })
})
test('projectActions: archiviert, ohne Website, Vorlage', () => {
const arch = projectActions({ subdomain: 'a', status: 'ready', archived: true, previewUrl: 'u' })
assert.deepEqual([arch.host, arch.preview, arch.refresh, arch.hostedBadge], [false, true, false, null])
const archOpen = projectActions({ subdomain: 'a', status: 'error', archived: true })
assert.deepEqual([archOpen.host, archOpen.refresh, archOpen.hostedBadge], [false, false, null])
const intern = projectActions({ giteaRepoUrl: 'https://git/x' })
assert.deepEqual([intern.type, intern.host, intern.preview, intern.refresh, intern.hostedBadge, intern.gitea], ['project', false, false, false, null, true])
const tpl = projectActions({ isTemplate: true, subdomain: 't', status: 'deployed' })
assert.deepEqual([tpl.type, tpl.refresh, tpl.host], ['template', true, false])
})
test('projectTicketRef: woid aus der Liste, sonst die $id, ohne Ticket leer', () => {
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: '100131' }), '100131')
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: 100131 }), '100131')
assert.equal(projectTicketRef({ ticketId: 'zzz' }, {}), 'zzz')
assert.equal(projectTicketRef({ ticketId: '' }, { abc: '1' }), '')
assert.equal(projectTicketRef({}, {}), '')
})
test('updatedLabel: deutsch relativ, Titel in Berliner Zeit, ungültig = "-"', () => {
const now = new Date('2026-10-05T10:00:00.000Z')
assert.deepEqual(updatedLabel('2026-10-05T07:00:00.000Z', now), { text: 'vor etwa 3 Stunden', title: '05.10.26, 09:00' })
assert.equal(updatedLabel('2026-10-04T10:00:00.000Z', now).text, 'vor 1 Tag')
for (const bad of ['', 'kaputt', undefined]) assert.deepEqual(updatedLabel(bad, now), { text: '-', title: '' })
})
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { previewColorTone, previewStateTone, analyseColorTone, statusValueTone } = await loadModule('src/components/projectTones.js')
test('previewColorTone', () => {
assert.deepEqual(['green', 'yellow', 'red', 'grey', undefined].map(previewColorTone), ['ok', 'warn', 'danger', 'plain', 'plain'])
})
test('previewStateTone', () => {
assert.deepEqual(['online', 'building', 'starting', 'sleeping', 'failed', 'off', undefined, 'constructor'].map(previewStateTone), ['ok', 'warn', 'warn', 'warn', 'danger', 'plain', 'plain', 'plain'])
})
test('analyseColorTone', () => {
assert.deepEqual(['green', 'red', 'grey', undefined].map(analyseColorTone), ['ok', 'danger', 'muted', 'muted'])
})
test('statusValueTone: wie die Farben in AssignedProjectCard heute', () => {
assert.deepEqual(['ready', 'LIVE', 'online', 'provisioning', 'pending', 'building', 'error', 'failed'].map(statusValueTone), ['ok', 'ok', 'ok', 'warn', 'warn', 'info', 'danger', 'danger'])
assert.deepEqual(['deployed', '', undefined, 'irgendwas'].map(statusValueTone), ['muted', 'muted', 'muted', 'muted'])
})
+14 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { formatBytes, formatDelta, ageLabel, usedPct, axisTimeKind, formatAxisTick, formatLegendTime } = await loadModule('src/lib/serverpulsFormat.js')
const { formatBytes, formatDelta, deltaDir, deltaSign, levelTone, ageLabel, usedPct, axisTimeKind, formatAxisTick, formatLegendTime } = await loadModule('src/lib/serverpulsFormat.js')
test('formatBytes: de-DE, Basis 1024', () => {
assert.equal(formatBytes(1288490189), '1,2 GB')
@@ -20,6 +20,19 @@ test('formatDelta', () => {
assert.equal(formatDelta(0).dir, 'flat')
})
test('deltaDir und deltaSign: Richtung und Vorzeichen der Kachel-Deltas', () => {
assert.deepEqual([deltaDir(0), deltaDir(5e-10), deltaDir(-5e-10)], ['flat', 'flat', 'flat'])
assert.equal(deltaDir(0.4), 'up')
assert.equal(deltaDir(-0.4), 'down')
assert.deepEqual([deltaSign('up'), deltaSign('down'), deltaSign('flat')], ['+', '−', ''])
})
test('levelTone: Badge-Ton je Stufe', () => {
assert.deepEqual(['ok', 'medium', 'critical'].map(levelTone), ['ok', 'warn', 'danger'])
assert.deepEqual([levelTone('low'), levelTone(undefined), levelTone('x')], ['muted', 'muted', 'muted'])
assert.deepEqual([levelTone('constructor'), levelTone('toString'), levelTone('__proto__')], ['muted', 'muted', 'muted'])
})
test('ageLabel: stale ab > 15 Min.', () => {
const now = 1_000_000_000_000
const a16 = ageLabel(now - 16 * 60000, now)
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// P6: Faelligkeitsspalte der E-Mail-Speicher-Aufgaben als reiner Helfer
const { storageDue, isOverdue } = await loadModule('src/lib/storageFormat.js')
const t0 = Date.parse('2026-10-05T08:00:00Z')
test('storageDue: Einrichten sofort, Verringern zum Periodenende (ueberfaellig = warn)', () => {
assert.deepEqual(
storageDue({ kind: 'einrichten', createdAt: '2026-10-01T08:30:00.000Z' }, t0),
{ tone: 'warn', label: 'jetzt einrichten', sub: 'bestellt 01.10.26, 10:30', subMuted: true },
)
assert.deepEqual(
storageDue({ kind: 'senken', dueAt: '2026-10-04T00:00:00.000Z' }, t0),
{ tone: 'warn', label: 'zum Periodenende', sub: '04.10.2026', subMuted: false },
)
const later = storageDue({ kind: 'senken', dueAt: '2026-10-20T00:00:00.000Z' }, t0)
assert.equal(later.tone, 'muted')
assert.equal(later.sub, '20.10.2026')
})
test('isOverdue: vergangenes dueAt ja, zukuenftiges oder fehlendes nein', () => {
assert.equal(isOverdue({ dueAt: '2026-10-04T00:00:00.000Z' }, t0), true)
assert.equal(isOverdue({ dueAt: '2026-10-20T00:00:00.000Z' }, t0), false)
assert.equal(isOverdue({}, t0), false)
})