Files
perinco-CRM/docs/05-frontend.md
T
Sascha WolfandClaude Opus 5 6f57483f33 feat(dashboard): Startansicht mit Bewerbungen, Wandlung und Gewinnen
Erster Menuepunkt und Startansicht; das Logo oben links fuehrt dorthin zurueck.

Vier Kennzahlen (Bewerbungen, daraus Opportunities samt Quote, gewonnene
Projekte, aktuell beim Kunden), ein Saeulendiagramm mit denselben drei Reihen
ueber der Zeit, die Bewerbungen je Berater, die naechsten Termine und alle
Opportunities in Kundenvorstellung bzw. Interview Kunde. Zeitraum ueber vier
Vorgaben oder frei, Einteilung nach Tag, Monat, Quartal oder Jahr.

Bewerbung heisst dabei: ein Eintrag in der Akquise - jeder Eintrag bedeutet, dass
ein Profil raus ist (so vom Nutzer festgelegt).

Zwei Dinge dafuer am Datenmodell:

- crm_opportunities.won_at haelt den Zeitpunkt des Wechsels nach "Beauftragt".
  Die Phase allein sagt nur, wie es jetzt steht, nicht wann es passiert ist.
  Beim Wechsel heraus wird das Datum geleert, sonst zaehlt das Dashboard einen
  Gewinn, den es nicht gibt.
- Die Akquise loescht nach 90 Tagen - damit waere jede Jahresauswertung falsch.
  Der Aufraeumlauf sichert deshalb vorher Tag, Berater und Anzahl in
  crm_acquisition_history, in derselben Transaktion wie das Loeschen. Gesichert
  wird nur, was verschwindet; die Auswertung addiert beide Quellen und kann sich
  damit nicht ueberschneiden. Projektdaten sind nach dem Loeschen weiterhin weg.

Termine und die Phasenlisten kommen nicht aus einem eigenen Endpunkt: Termine und
Opportunities laedt die App ohnehin komplett, das Dashboard filtert sie selbst.

Diagramme von Hand (SVG-freie Balken plus Tailwind-Uebergaenge) statt einer
Bibliothek - drei Darstellungen rechtfertigen keine weitere Abhaengigkeit, und so
kommen die Farben aus denselben Tokens wie der Rest. Die Zahlen laufen hoch, die
Saeulen wachsen; wer Bewegung im System abgeschaltet hat, bekommt sofort den
Endwert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:02:35 +02:00

157 lines
6.9 KiB
Markdown

# 05 Frontend
Eine einzige React-Anwendung ohne Router: `App.tsx` hält die aktuelle Ansicht in
einem Zustand und rendert sie. Das genügt, weil die App hinter einer Anmeldung
sitzt und keine tiefen Links braucht.
## Menü und Ansichten
```mermaid
flowchart TD
SB["Seitenleiste"] --> K["Kategorien"]
K --> D["Dashboard<br/>(Startansicht, auch über das Logo)"]
K --> AQ["Akquise"]
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
K --> O["Opportunities"]
K --> C["Interviews"]
K --> L["Kontakte"]
L --> L1["Alle Kontakte"]
L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
K --> M["Mails"]
M --> M1["Postfach<br/>Reiter: Postfach, Kontakt-Vorschläge"]
M --> M2["Papierkorb<br/>im CRM gelöschte Mails"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
SC --> SC3["Suchagenten"]
SC --> SC4["Börsen-Konten"]
SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
E --> E3["Postfach (admin)"]
E --> E4["Scanner (admin)"]
E --> E5["Whisper"]
```
*Postfach* und *Kontakt-Vorschläge* sind Reiter **innerhalb** der Ansicht
*Mails*, keine Menüpunkte - sie gehören zusammen und wechseln oft hin und her.
Die Menüpunkte *Kontakte* und *Scanner* klappen ihre Unterpunkte auf; der
Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard).
Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, die
Unterpunkte kommen mit den Beschriftungen zurück. Beide Gruppen entstehen aus
derselben Struktur `MenuGroup` in `Sidebar.tsx`.
*Aus Mails* trägt einen Zähler mit der Anzahl der offenen Funde - er kommt aus
`App.tsx`, das die Kontakte ohnehin geladen hat.
## Zustand und Datenfluss
```mermaid
flowchart TB
subgraph Kontexte["Kontexte in main.tsx"]
TH["ThemeProvider<br/>hell/dunkel/System"]
TO["ToastProvider"]
AU["AuthProvider<br/>JWT, Benutzer"]
WH["WhisperProvider<br/>Modell, Worker"]
end
APP["App.tsx<br/>currentView, settingsTab, focusLeadId"]
NAV["NavigationProvider<br/>goTo(view, {tab, leadId})"]
DATA["loadData()<br/>leads, connections, events, users, opportunities"]
Kontexte --> APP
APP --> NAV
APP --> DATA
DATA --> V1["Opportunities"]
NOTE["Akquise lädt selbst<br/>acquisitionsApi, nicht über loadData()"]
DATA --> V2["Interviews"]
DATA --> V3["Kontakte<br/>mail_pool = false"]
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
NAV --> DF["DictationField<br/>springt nach Whisper"]
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
MV["Mailansicht"] -->|"lädt selbst"| MAPI["/api/mail"]
```
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er
eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails,
weil sie blättern, suchen und filtern und dabei nicht die ganze Liste im Speicher
halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
`/api/leads` liefert beide Bestände in einem Aufruf; `App.tsx` teilt sie in
`leads` (Kontakte) und `mailPoolLeads` (Vorbereich „Aus Mails") auf. Alle
anderen Ansichten bekommen ausschließlich `leads` - ein Fund aus einer Mail soll
in keiner Auswahlliste auftauchen. Die Ansicht *Aus Mails* braucht beides: sie
vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten.
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
Kontakt. `App.tsx` hält die Id in `focusLeadId`, die Kontaktansicht öffnet den
Kontakt und meldet mit `onFocusHandled()` zurück - der Effekt hängt an `leads`
und wartet damit auf einen gerade erst angelegten Kontakt.
`reload={() => loadData(true)}` lädt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern lässt.
## Komponentenkarte
```
src/
App.tsx Layout, Datenladen, Ansichtswechsel
types.ts Gemeinsame Typen, Farbpalette, Rechenhilfen
lib/
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
scannerApi.ts Typisierte Endpunkte unter /api/scanner
mailApi.ts Endpunkte unter /api/mail, UTC-Datumshelfer
auth.tsx Anmeldung, Benutzer, Rolle
theme.tsx Hell/Dunkel/System
navigation.tsx Ansichtswechsel für tiefe Komponenten
whisper.tsx Modell, Worker-Lebenszyklus
dictation.tsx Aufnahme-Hook
audio.ts Aufnahme zu 16-kHz-Mono-PCM
imageCompress.ts Bilder vor dem Upload verkleinern
components/
Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force),
Vorbereich "Aus Mails" mit Dublettenabgleich
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschläge
scanner/ Dashboard, Unpassend, Suchagenten, Börsen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
whisper/ Einrichtung der Spracherkennung
ui/ Modal, Avatare, Diktatfeld
```
## Dark Mode ohne `dark:`-Varianten
Die Klasse `dark` auf `<html>` tauscht in `src/index.css` nur die Werte der
Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben.
Die Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch -
kein `dark:` im Markup.
```mermaid
flowchart LR
T["Umschalter im Header"] --> P["localStorage<br/>perinco_crm_theme"]
P --> H["class 'dark' auf html"]
H --> V["Variablen in index.css<br/>canvas, surface, ink-*, brand-*"]
V --> U["Utility-Klassen der Komponenten"]
SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H
```
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
farbigen Flächen) und `brand-500` (das CI-Grün der Buttons).
Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
darunter macht schwarze Schrift auf schwarzer Fläche. Der Rahmen liest sich damit
wie ein Blatt Papier im dunklen Fenster - gewollt.