Files
perinco-CRM/docs/09-quellcode.md
T
Sascha WolfandClaude Opus 5 87759a8a24 feat(ui): drei Farbwelten je hell und dunkel, gewaehlt pro Benutzer
Neben dem Design des Hauses ("perinco") gibt es "Pixel Art" und
"Outdoor", jedes in hell und dunkel. Die Wahl steht neben dem
Hell/Dunkel-Schalter im Kopf der App.

Sie gehoert dem BENUTZER und nicht dem Browser: crm_users.theme und
crm_users.theme_mode, gesetzt ueber PATCH /api/auth/me, das jeder fuer
sich selbst darf. Wer sich an einem anderen Rechner anmeldet, findet sein
Design wieder, und zwei Benutzer an derselben Maschine kommen sich nicht
in die Quere. Der localStorage bleibt als Zwischenspeicher daneben, damit
das Inline-Skript in index.html die Klassen vor dem ersten Frame setzen
kann - ohne das blitzt beim Laden das Standarddesign auf. Das hat eine
sichtbare Folge: melden sich zwei Leute an demselben Rechner an, sieht
der zweite fuer einen Augenblick das Design des ersten.

Der ThemeProvider steht deshalb in main.tsx jetzt unter dem AuthProvider
- er liest den Benutzer.

Keine Komponente wurde umgefaerbt. Im Markup steht nirgends eine feste
Farbe, sondern durchgaengig ink-*, brand-*, canvas und surface; Tailwind
v4 loest auch --radius-*, --shadow-* und die Schriften ueber Variablen
auf. Ein Theme tauscht also nur Werte, und deshalb kann Pixel Art alle
380 abgerundeten Ecken eckig machen, ohne eine Klasse anzufassen.

Zwei Dinge waren Voraussetzung:

- Der helle Block eines Themes braucht :not(.dark). html.theme-x hat
  eine Klasse mehr in der Spezifitaet als .dark und wuerde den
  Dunkelmodus sonst ueberstimmen.
- Auf brand-500/600 steht 69 Mal weisse Schrift. Jedes Theme braucht
  dort einen ausreichend dunklen Ton - ein helles Pastell waere
  unlesbar.

Die Pixelschrift (Silkscreen) sitzt nur in --font-display, also in den
Ueberschriften und den Feldbeschriftungen; labelClass tragt dafuer die
neue Klasse "field-label". Der Fliesstext bleibt Nunito Sans: die Listen
haben bis zu acht Spalten bei 11-13 px, und eine Pixelschrift waere darin
nicht mehr zu lesen. Outdoor bringt Cabin fuer beides mit.

server/test/userFields.test.mjs prueft die Werte und dass die Liste der
Farbwelten in userFields.js und src/types.ts nicht auseinanderlaeuft -
sonst zeigt die Auswahl ein Design an, dessen Speichern das Backend
abweist, und niemand weiss warum.

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

335 lines
22 KiB
Markdown

# 09 Quellcode-Referenz
Was in welcher Datei steckt und welche Zusagen sie einhält. Gedacht zum
Nachschlagen, nicht zum Durchlesen.
## Backend
### `server/src/server.js`
Baut die Express-App. Die **Reihenfolge** ist bedeutsam:
```mermaid
flowchart TD
J["express.json({ limit: '6mb' })<br/>Avatare kommen als Data-URL"] --> C["cors (nur für den Dev-Server)"]
C --> H["/api/health"]
H --> R["/api/auth, /users, /leads,<br/>/connections, /events, /opportunities"]
R --> S["/api/scanner (requireAuth + Router)"]
S --> F["/api → 404 JSON<br/>fängt alles Unbekannte"]
F --> E["Fehlerbehandlung:<br/>400 kaputtes JSON, 413 zu groß, 500 Rest"]
```
Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()`
(darf fehlschlagen, dann läuft das CRM ohne Scanner) → Scheduler, sofern
`SCANNER_SCHEDULER !== 'false'`.
### `server/src/db.js`
| Export | Zweck |
| --- | --- |
| `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` |
| `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `migrateLeadKind()`, `migrateMailPool()`, `seedAcquisitionOptions()`, `dropProviderOptions()`, `ensureAdminUser()` |
`dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als
`YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Die Terminansicht rechnet mit
Wandzeit; ein `Date`-Objekt aus dem Treiber würde beim Serialisieren nach UTC
verschieben.
Die beiden Einmal-Umstellungen laufen bei jedem Start, tun aber nur beim ersten
Mal etwas:
* `migrateLeadKind()` macht aus dem Vertriebsstatus eines Kontakts seine Art.
Erkannt wird der Altstand an der Spalte `crm_leads.status` selbst - liegt schon
`kind` vor, passiert nichts. Die Auswahl wird erst auf alte plus neue Werte
erweitert, dann werden alle Altwerte auf `Recruiter` gesetzt, dann wird
umbenannt und auf die drei neuen Werte verengt. Direkt zu verengen würde jeden
Altwert zu `''` machen.
* `migrateMailPool()` schiebt die vom Postfachabgleich angelegten Kontakte in den
Vorbereich. Sie merkt sich das per Schalter in `crm_settings`, weil an den
Daten selbst nicht zu erkennen ist, ob sie schon gelaufen ist.
* `seedAcquisitionOptions()` legt den Startbestand der Akquise-Dropdowns an: die
Kanäle, die vorher im Quelltext standen, plus alles, was in vorhandenen
Einträgen als Freitext steht. Auch hier ein Schalter in `crm_settings` - ohne
ihn wäre jeder Neustart ein Wiederherstellen von Werten, die jemand bewusst
entfernt hat.
* `dropProviderOptions()` nimmt *Anbieter* wieder aus den Auswahllisten heraus -
das Feld ist Freitext mit Vorschlägen. Erkannt am Spaltentyp: steht `provider`
noch im ENUM, ist die Umstellung offen. Die Zeilen müssen vor dem Verengen weg,
sonst weist MariaDB den `ALTER` ab.
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
im Quelltext steht kein Passwort.
### `server/src/middleware/auth.js`
| Export | Zweck |
| --- | --- |
| `signToken(user)` | JWT mit `sub` und `role`, Laufzeit aus `JWT_EXPIRES_IN` (12 h) |
| `requireAuth` | Token prüfen **und** Benutzer frisch aus der DB laden |
| `requireAdmin` | 403, wenn `req.user.role !== 'admin'` |
Dass `requireAuth` bei jeder Anfrage die Datenbank liest, ist Absicht: ein
deaktivierter oder gelöschter Benutzer ist sofort draußen und nicht erst, wenn
sein Token abläuft. `secret()` wirft, wenn `JWT_SECRET` fehlt oder kürzer als 16
Zeichen ist - lieber ein harter Startfehler als eine schwache Signatur.
### `server/src/userFields.js`
`normalizeColor` und `normalizeAvatar`, benutzt von `routes/auth.js` und
`routes/users.js`. Farben nur als `#rrggbb`, Avatare nur als Bild-Data-URL oder
`http(s)`-Adresse, jeweils längenbegrenzt.
### Die CRM-Router
Alle unter `router.use(requireAuth)`.
| Router | Endpunkte | Rechte |
| --- | --- | --- |
| `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password` | `/login` offen, Rest angemeldet |
| `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` für alle, Rest Admin |
| `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete |
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete |
| `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist |
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) |
| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin |
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin |
Wiederkehrende Muster in diesen Routern:
* `emptyToNull()` - leere Zeichenketten werden `NULL`, damit „nicht gesetzt" und
„leer" in der Datenbank nicht zwei Zustände sind.
* `normalizeDate()` prüft echte Kalendertage; der 31. Februar wird abgelehnt und
nicht stillschweigend verschoben.
* `normalizeNumber()` akzeptiert Komma als Dezimaltrennzeichen und begrenzt nach
oben - ein Tagessatz von 10 Millionen ist ein Tippfehler.
* `parseTags()` liest die als Text gespeicherten JSON-Arrays defensiv: kaputter
Inhalt ergibt eine leere Liste, keinen Absturz.
* `GET /login` antwortet bei unbekanntem Benutzer und falschem Passwort
**identisch**, damit sich über die API keine Benutzernamen aufzählen lassen.
* Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder
gelöscht werden.
### `server/src/settings.js`
`getSetting`, `getSettings`, `setSetting` auf `crm_settings`, dazu `asBool` und
`asInt`. Alle Werte liegen als Text in der Datenbank. `getSettings(defaults)`
nimmt die Vorgaben gleichzeitig als Positivliste der erlaubten Schlüssel - ein
unbekannter Name kommt so nie in die Datenbank.
Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
**ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`.
### `server/src/mail/`
Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
| Datei | Inhalt |
| --- | --- |
| `config.js` | `.env` lesen, `MAIL_CONFIGURED`, `missingCredentials()`, verstellbare Einstellungen mit Vorgaben (`mailSettings()`) |
| `graph.js` | Token mit Zwischenspeicher, `graphRequest()` mit Wiederholung bei 429/5xx und Beachtung von `Retry-After`, `testConnection()` |
| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()`, `findLeadByEmail()` je Bestand |
| `contacts.js` | `adoptCandidate()`: Eintrag in „Aus Mails" (`target: 'pool'`) oder richtiger Kontakt (`'contacts'`), sonst ergänzen; `markCandidate()` |
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
| `scheduler.js` | Minutentakt, Fälligkeit aus `last_run_at`, kein Zeitfenster |
Eigenheiten, die man kennen muss:
* `graphRequest()` nimmt Pfade **und** vollständige URLs - Delta-Links kommen
absolut von Graph zurück.
* Ein `401` verwirft das Token genau einmal und wiederholt; so überlebt ein Lauf
ein serverseitig zurückgezogenes Token.
* `upsertMessage()` lässt die Textspalten unberührt, wenn `bodyText` im
übergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon
geholten Mailtext nicht löschen.
* `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen.
Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener
Wörter als Namen.
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
kommt sofort mit `skipped` zurück, statt parallel zu laufen.
### `server/src/routes/scanner.js`
Die portierte Scanner-API unter `/api/scanner`, vollständig in
[03 Projekt Scanner](03-scanner.md#endpunkte) tabelliert. Eigenheiten:
* `multer` schreibt CV-PDFs nach `UPLOAD_DIR`, Grenze 20 MB.
* `resolveCvPath()` findet eine Datei auch dann, wenn in der Datenbank noch ein
relativer Pfad aus der alten App steht - es zählt der Dateiname.
* `GET /agents` lässt `cvText` weg (mehrere Hundert KB pro Agent).
* `SECRET_KEYS` (`openai_api_key`, `smtp_pass`) werden als `********`
ausgeliefert; kommt dieser Wert oder ein leeres Feld zurück, bleibt der
gespeicherte Wert stehen.
* `normalizeCookie()` biegt Exporte von Cookie-Editor auf das zurecht, was
Playwright akzeptiert (`sameSite`, `expirationDate` → `expires`).
### `server/src/scanner/`
| Datei | Inhalt |
| --- | --- |
| `db/config.js` | Sequelize-Instanz, eigene Datenbank, `underscored: true` |
| `db/ensureDatabase.js` | `CREATE DATABASE IF NOT EXISTS`, Name gegen Zeichenklasse geprüft |
| `models/index.js` | `initScannerDatabase()`: verbinden, `sync()`, Portale und Standardeinstellungen seeden |
| `models/Agent.js` | `cvText`, `cvFilePath`, `keywords`, `excludeKeywords`, `isActive` |
| `models/Project.js` | Unique auf `(portal, portal_project_id)`, `updatedAt: false` |
| `models/Match.js` | Unique auf `(agent_id, project_id)`, `pros`/`cons` als JSON-Text mit Getter/Setter |
| `models/Portal.js` | Zugangsdaten, `sessionState`, `connectionStatus` |
| `models/Setting.js` | Schlüssel-Wert mit `getVal`/`setVal` |
| `services/scraper.js` | Playwright je Portal; unverändert aus der alten App |
| `services/matchingEngine.js` | OpenAI `gpt-4o-mini`, `response_format: json_object`, `temperature: 0.1` |
| `services/pipeline.js` | ein Lauf: scrapen, speichern, bewerten, benachrichtigen |
| `services/scheduler.js` | node-cron jede Minute, Zeitfenster, Sperrfrist, `last_scan_at` |
| `services/cleanup.js` | Projekte älter als 7 Tage löschen (Treffer kaskadieren) |
| `services/pdfParser.js` | `pdf-parse`, doppelte Leerzeilen raus |
| `utils/crypto.js` | AES über `crypto-js`; `decryptWith()` für fremde Schlüssel |
| `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` |
| `importLegacy.js` | einmalige Übernahme aus der alten Datenbank |
Zusagen, auf die sich der Rest verlässt:
* `runMatchingPipeline()` läuft **nie zweimal gleichzeitig** und gibt `false`
zurück, wenn schon einer läuft. `isPipelineRunning()` fragt den Zustand ab.
* Der **Beginn** des Laufs landet im `finally` in `settings.last_scan_at` - im
`finally`, damit sich ein gescheiterter Lauf nicht sofort wiederholt, aber mit
dem Startzeitpunkt, weil das Intervall ein Takt ist und keine Pause zwischen
zwei Läufen. Mit dem Endzeitpunkt wurden aus 15 Minuten 19.
* `cleanup.js` benutzt `sequelize.col('created_at')` statt des Attributnamens:
`Project` benennt die Spalte um, und in `where` greift die Zuordnung nicht.
## Frontend
### `src/lib/api.ts`
| Export | Zweck |
| --- | --- |
| `apiRequest<T>(path, options)` | zentraler Fetch: Token, JSON-Header, 401-Behandlung, Fehlermeldung aus dem Body |
| `getToken` / `setToken` / `clearToken` | Token im `localStorage` (`perinco_crm_token`) |
| `setUnauthorizedHandler` | wird bei 401 gerufen; `AuthProvider` hängt sich hier ein |
| `ApiError` | Fehler mit `status` |
| `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte |
| `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt übernehmen |
Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body
`FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit
„Server nicht erreichbar" - das Frontend muss `fetch`-Ausnahmen nicht kennen.
### `src/lib/scannerApi.ts`
Typen (`ScannerAgent`, `ScannerPortal`, `ScannerProject`, `ScannerMatch`,
`ScannerSettings`, `ScannerStatus`), das Objekt `scannerApi` mit allen Aufrufen,
`SCANNER_SETTINGS_DEFAULTS`, `SECRET_MASK` und `openAgentCv()`.
Die Feldnamen sind absichtlich die der Scanner-Datenbank (camelCase aus
Sequelize), inklusive der Grossbuchstaben `Agent` und `Project` an den Joins -
damit bleibt der Vergleich mit der alten App möglich. Nur `ScannerProject`
trägt beide Schreibweisen für `created_at`, weil das Modell die Spalte selbst
umbenennt.
### `src/lib/mailApi.ts`
Typen (`MailMessageSummary`, `MailMessageDetail`, `MailCandidate`, `MailLink`,
`MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das
Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
`parseMailDate()` hängt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein
`Date` wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige
wäre je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der
Datenbank, anders als die Termine unter Interviews. `formatMailDate()` zeigt heute nur die
Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
### Kontexte
| Datei | Hält | Eigenheit |
| --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` |
| `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor) und Modus (hell/dunkel/System) | Beides gehört dem Benutzer und steht in `crm_users`; `localStorage` ist nur der Zwischenspeicher fürs Inline-Skript in `index.html`. Doppelklick auf den Modus-Schalter folgt wieder dem System |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |
### Whisper
| Datei | Inhalt |
| --- | --- |
| `whisper.tsx` | Konstanten (`WHISPER_MODEL`, `WHISPER_DTYPE`, `WHISPER_LANGUAGE`), Provider, Fristen, Hänger-Erkennung |
| `whisper.worker.ts` | `WorkerRequest`/`WorkerResponse`, lädt und erkennt |
| `whisperModels.ts` | `fetchModelSize()` holt die echte Größe von Hugging Face |
| `audio.ts` | `toWhisperAudio()` - Blob zu 16-kHz-Mono-PCM |
| `dictation.tsx` | `useRecorder()` - Mikrofon, Dauer, Abbruch |
Warum genau diese Konstanten festgelegt sind, steht in
[04 Diktat](04-diktat.md#warum-keine-auswahl).
### Gemeinsame Bausteine
| Datei | Inhalt |
| --- | --- |
| `components/opportunities/OpportunityTrash.tsx` | Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag |
| `components/opportunities/opportunityUi.tsx` | `STAGE_LABELS` (Anzeige, mit Umlaut), `STAGE_HINTS` (Erläuterung als Titelhinweis), `STAGE_STYLES`, `STAGE_COLORS`, `StageBadge` |
| `components/dashboard/Dashboard.tsx` | Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". `buildAxis()` erzeugt auch leere Abschnitte |
| `components/dashboard/dashboardUi.tsx` | `useCountUp()` (Zahl läuft hoch, respektiert `prefers-reduced-motion`), `StatCard`, `SeriesChart`, `BarList`, `Panel` - von Hand statt Diagrammbibliothek |
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen, Wiederherstellen. Das Formular steht im Archiv nur beim Bearbeiten - ohne diesen Zweig blieb der Stift dort ohne Wirkung |
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden. Der Pfeil zurück in den Eingang erscheint nur, wenn `onReopen` gesetzt ist - im Eingang gibt es nichts zurückzuholen |
| `components/acquisition/AcquisitionTrash.tsx` | Papierkorb der Akquise: zurückholen, endgültig löschen, leeren. Die Meldung nach dem Zurückholen nennt das Ziel (Eingang oder Archiv) - das Funddatum bleibt, also landet ein alter Fund im Archiv |
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>`. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts |
| `components/leads/MailingListDialog.tsx` | Adressen der Kontakte mit Einverständnis: Zwischenablage und `mailto:` mit BCC, Längengrenze 1900 Zeichen |
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
| `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG |
### `src/types.ts`
Neben den Interfaces stehen hier die Rechenregeln, damit Frontend und Anzeige
nicht auseinanderlaufen:
| Export | Zweck |
| --- | --- |
| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS`, `MAIL_VIEWS`, `ACQUISITION_VIEWS`, `OPPORTUNITY_VIEWS`, `CONTACT_VIEWS` | Ansichten, Reiter und aufklappbare Gruppen |
| `VIEW_TITLES`, `viewTitle()` | abweichende Beschriftung, wo der Ansichtsname nur intern eindeutig sein muss (`Opportunity-Papierkorb` → Papierkorb) |
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `opportunityVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; formatiert. Ein gewichtetes Volumen gibt es bewusst nicht |
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
| `ACQUISITION_OPTION_FIELDS`, `ACQUISITION_FIELD_LABELS`, `ACQUISITION_FIELD_HINTS` | die vier Auswahllisten der Akquise samt Beschriftung |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
## Datenfluss im Frontend
```mermaid
flowchart TD
A["App.tsx: loadData()"] -->|"Promise.all"| B["5 Endpunkte parallel"]
B --> C["leads, connections, events, users, opportunities"]
C --> D["als Props in die Ansichten"]
D --> E["Dialog speichert"]
E --> F["reload() = loadData(true)"]
F -->|"still, ohne Ladeanzeige"| C
G["Scanner-Ansichten"] -->|"eigener 30-s-Takt"| H["/api/scanner"]
I["Mailansicht"] -->|"blättert, sucht, filtert"| J["/api/mail"]
D --> I
I -->|"nach Übernahme eines Vorschlags"| F
```
Die Mailansicht lädt ihre Mails selbst, bekommt Kontakte und Opportunities aber
als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer
Kontaktübernahme ruft sie `reload()`, damit der neue Kontakt in der Kontaktliste
der App auftaucht.
Die fünf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren
sich fünf Rundreisen beim Anmelden. Die Scanner-Ansichten hängen bewusst nicht
an `loadData`: andere Datenbank, andere Auffrischung.