Files
perinco-CRM/docs/05-frontend.md
T
Sascha WolfandClaude Opus 5 6e66bd39d7 feat(akquise): Papierkorb fuer geloeschte Eintraege
Loeschen in der Akquise war ein echtes DELETE: der Eintrag stand danach
in keiner Liste, auch nicht im Archiv, und war nicht zurueckholbar. Jetzt
setzt es `deleted_at`/`deleted_by`, die Zeile bleibt 90 Tage stehen.

Der Papierkorb ist die eine Ausnahme von der Regel, dass diese Tabelle
keinen Zustand fuehrt: Loeschen von Hand ist eine Entscheidung, kein
Ablauf. Was dort liegt, blenden alle anderen Abfragen ueber NOT_DELETED
aus - Eingang, Archiv, Menuezahlen und Dashboard. Der Aufraeumlauf ueber
`found_at` laesst geloeschte Zeilen ausdruecklich liegen, sonst waere ein
alter Fund im Papierkorb sofort weg; der Papierkorb raeumt ab
`deleted_at`.

Zwei Rueckwege, deshalb zwei Routen. `/reopen` (bisher `/restore`) holt
aus dem Archiv in den Eingang und setzt das Funddatum auf heute.
`/restore` holt aus dem Papierkorb zurueck und laesst das Funddatum in
Ruhe - der Eintrag soll dort auftauchen, wo er hingehoert, also im
Archiv, wenn er alt ist. Die Meldung nennt das Ziel.

Dazu `DELETE /:id/permanent` und `POST /trash/empty`. Im Papierkorb wird
nicht gepflegt: Bearbeiten und Uebernehmen antworten 409.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 10:17:21 +02:00

7.1 KiB

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

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"]
    AQ --> AQ3["Papierkorb<br/>von Hand gelöscht; 90 Tage"]
    AQ2 -. "Zurück in den Eingang<br/>(Funddatum = heute)" .-> AQ1
    AQ3 -. "Wiederherstellen<br/>(Funddatum bleibt)" .-> AQ2
    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

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.

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.