Im Dialog einer Opportunity aus der Akquise nimmt "Zurueck in die Akquise" eine versehentliche Uebernahme zurueck: alle Opportunities aus dem Eintrag wandern in den Papierkorb, der Eintrag steht mit seinem Funddatum wieder in Eingang bzw. Archiv. Neue Spalte crm_opportunities.acquisition_id, damit bei mehreren Beratern alle gefunden werden. Wiederherstellen haengt den Eintrag wieder an die Opportunity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
384 lines
27 KiB
Markdown
384 lines
27 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.
|
|
|
|
Die Spaltenliste ist ausgeschrieben (kein `SELECT *`), damit das Passwort-Hash
|
|
nicht am Request hängt. Sie muss dafür alles enthalten, was `publicUser()`
|
|
ausliefert. Fehlte dort `theme` bzw. `theme_mode`, antwortete
|
|
`GET /api/auth/me` mit `null`, und die Oberfläche fiel beim Neuladen auf das
|
|
Standarddesign zurück - obwohl die Wahl in der Datenbank stand.
|
|
`server/test/sessionUser.test.mjs` vergleicht beide Listen, damit ein neues Feld
|
|
nicht wieder still verloren geht.
|
|
|
|
### `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 /:id/acquisition`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist |
|
|
| `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/linkedinPhoto.js`
|
|
|
|
Holt das Profilfoto zu einer LinkedIn-Adresse. Bedient wird damit
|
|
`POST /api/leads/linkedin-photo`.
|
|
|
|
| Export | Zweck |
|
|
| --- | --- |
|
|
| `normalizeProfileUrl(eingabe)` | macht aus `linkedin.com/in/name` eine `https`-URL, wirft bei fremden Hosts |
|
|
| `fetchLinkedInPhoto(eingabe)` | Profilseite lesen, `og:image` ziehen, Bild laden, als Data-URL zurückgeben |
|
|
| `PhotoError` | Fehler mit `status`; die Route reicht Status und Text unverändert durch |
|
|
|
|
Der Weg in Stichpunkten und die Gründe dafür:
|
|
|
|
* **Über das Backend, nicht aus dem Browser.** Ein `fetch` auf linkedin.com aus
|
|
der Seite heraus scheitert an der Same-Origin-Policy.
|
|
* **Browser-Kennung im Kopf.** Ohne `User-Agent` antwortet LinkedIn oft gar
|
|
nicht erst.
|
|
* **Nur `og:image`/`twitter:image`** - und sonst nichts. Im übrigen Quelltext
|
|
wird bewusst *nicht* gesucht: eine Profilseite enthält Dutzende Adressen des
|
|
LinkedIn-CDN (Firmenlogos, Beitragsbilder, die Fotos der „ähnlichen Profile"
|
|
am Rand). Die erste davon zu nehmen, hängt einem Kontakt irgendwann das
|
|
Gesicht einer fremden Person an. Es wird nichts angemeldet und nichts
|
|
Geschütztes gelesen.
|
|
* **Herkunft wird geprüft.** Das Bild muss über `https` von `licdn.com` kommen
|
|
und `/dms/image/` im Pfad haben - sonst könnte eine manipulierte Seite eine
|
|
beliebige Adresse unterschieben.
|
|
* **Platzhalter werden abgewiesen.** Statt eines Fotos liefert LinkedIn dann die
|
|
graue Silhouette (`ghost` im Pfad) oder eine feste Grafik von
|
|
`static.licdn.com` (`/aero-v1/sc/h/...`). Das passiert in zwei Fällen, die von
|
|
außen nicht unterscheidbar sind: Das Profil hat kein Foto, **oder** dessen
|
|
Sichtbarkeit steht auf „Meine Kontakte" statt „Alle LinkedIn-Mitglieder". Im
|
|
zweiten Fall sieht man das Foto angemeldet sehr wohl - der Abruf hier ist aber
|
|
nicht angemeldet. Die Meldung nennt deshalb beide Möglichkeiten.
|
|
* **Grenzen:** 12 s Zeitlimit je Anfrage, höchstens 5 MB Bild.
|
|
|
|
Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen
|
|
Anfragen von einer Adresse), kommt eine Meldung im Klartext zurück - das ist
|
|
der Normalfall, mit dem die Oberfläche rechnet, kein Serverfehler.
|
|
|
|
### `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()` |
|
|
|
|
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 |
|
|
| `leadsApi.linkedinPhoto(url)` | Profilfoto zu einer LinkedIn-Adresse als Data-URL; speichert nichts, das erledigt das Formular beim Sichern |
|
|
|
|
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/Cyberpunk, Hintergrundgrafiken in `public/themes/`) 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. Die Titelspalte ist auf 28rem festgelegt und bricht um (`wrap-anywhere`), lange Titel liefen sonst in die Spalte „Berater“ |
|
|
| `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` (Beschriftung als eigene Zeile), `labelTextClass` + `labelRowClass` (Zeile mit Knopf rechts, z. B. Diktat - `min-h-7` haelt sie so hoch wie den Knopf, damit Felder in Nachbarspalten auf einer Linie beginnen), `KindBadge`, `Avatar`, Farben der Kontaktart |
|
|
| `components/leads/LeadModal.tsx` | Kontaktformular. Beim Neuanlegen ist der angemeldete Benutzer *Zuständig* (`useAuth()`, Rückfallebene `users[0]`) - beim Bearbeiten bleibt die gespeicherte Zuständigkeit stehen. Neben dem LinkedIn-Feld holt *Foto* das Profilbild; beim Verlassen des Feldes geschieht das einmal je Adresse von selbst, aber nur solange noch kein Bild gesetzt ist (`triedProfiles`) - ein hochgeladenes Foto wird nie überschrieben. Fehler des automatischen Versuchs stehen als Hinweis unter dem Feld, nur der Klick auf *Foto* meldet sich als Toast. In der Liste der Verbindungen ist die **Art** ein Auswahlfeld: die Änderung geht als `PATCH /api/connections/:id` sofort raus, ohne den Speichern-Knopf - eine Verbindung ist ein eigener Datensatz und hängt nicht am Formular |
|
|
| `components/leads/LeadNetwork.tsx` | Netzwerkdiagramm (d3-force). Ein Klick auf leere Fläche hebt die Auswahl auf, das Diagramm ist danach wieder so hell wie beim Wechsel in die Ansicht; ein Ziehen des Hintergrunds (mehr als 4 px) verschiebt nur und lässt die Auswahl stehen. Knoten stoppen das `pointerdown`, ihre Klicks erreichen den Hintergrund also nicht. In der Seitenleiste des gewählten Kontakts ist die Art jeder Verbindung umstellbar, genau wie im Kontaktdialog |
|
|
| `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(datei)` auf 256 px, WebP → JPEG → PNG; `compressImageSource(dataUrl)` geht denselben Weg für ein Bild, das nicht aus einer Datei kommt (LinkedIn-Foto) |
|
|
|
|
### `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.
|