Files
perinco-CRM/docs/08-anleitung-entwickler.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

242 lines
12 KiB
Markdown

# 08 Anleitung für Entwickler
Für jemanden, der das Projekt zum ersten Mal öffnet. Was man braucht, wie man es
startet, wo man was ändert - und welche Fallen es gibt.
## In zwei Minuten zum Bild
* **Ein** Vite-Frontend (React 19, TypeScript, Tailwind 4), **ein** Express-Backend,
**zwei** MariaDB-Datenbanken.
* Kein Router im Frontend: `App.tsx` hält die Ansicht in einem `useState`. Die
App liegt hinter einer Anmeldung, tiefe Links braucht sie nicht.
* Kein ORM im CRM-Teil - dort schreibt das Backend SQL mit `mysql2`. Der
Scanner-Teil benutzt Sequelize, weil er 1:1 aus einer anderen Anwendung
übernommen wurde.
* Alles auf Deutsch: Bezeichner, Kommentare, Meldungen. Kommentare erklären das
**Warum**, nicht das Was.
## Voraussetzungen
| | Version |
| --- | --- |
| Node | 20 oder neuer (Server läuft 20.20) |
| MariaDB | 10.11 oder neuer, erreichbar mit Benutzer und Passwort |
| Browser | Chrome/Edge für WebGPU beim Diktat (sonst läuft es auf der CPU) |
## Lokal starten
```bash
git clone https://git.perinco.de/Sascha/perinco-CRM.git
cd perinco-CRM
npm install # Frontend
cd server && npm install # Backend
cp .env.example .env # Werte eintragen, mindestens DB_* und JWT_SECRET
npm start # Backend auf :5006
cd .. && npm run dev # Frontend auf :3100, /api wird geproxied
```
Was dabei von selbst passiert:
* `perinco_CRM` muss **existieren**; die Tabellen legt `server/src/schema.sql`
beim Start an (idempotent).
* `perinco_scanner` legt das Backend **komplett selbst** an - Datenbank, Tabellen,
Portale, Standardeinstellungen.
* Fehlt `ADMIN_PASSWORD`, wird beim ersten Start ein Zufallspasswort erzeugt und
**einmalig ins Log geschrieben**. Dort nachsehen, sonst kommt man nicht rein.
Ohne MariaDB startet das Backend nicht. Das Frontend lässt sich trotzdem
ansehen - siehe *Oberfläche prüfen ohne Datenbank* weiter unten.
## Prüfen
```bash
npm run lint # = tsc --noEmit, das ist die Wahrheit
npm run build # muss ohne Fehler durchlaufen
cd server && npm test # Postfachanbindung und Berater-Liste
```
Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines
Node und ohne Datenbank:
* `server/test/mail.test.mjs` prüft die Kontaktextraktion aus Mails und den
Graph-Client. Beides läuft unbeaufsichtigt im Hintergrund und lässt sich von
Hand kaum nachstellen - ein falsch erkannter Name landet still in der
Kontaktliste, und ein 429 vom echten Dienst kommt genau dann, wenn niemand
hinsieht.
* `server/test/acquisition.test.mjs` prüft das Zerlegen des Feldes *Berater*,
das mehrere Namen kommagetrennt hält. Es steckt im Speichern, im Verlauf, in
der Auswertung und in der Übernahme; ein Fehler darin fällt nicht auf, weil
die Liste weiter Namen zeigt und nur die Zahlen falsch wären.
Für alles andere gilt: `tsc`, ein Build und ein Blick in die Oberfläche - notfalls
mit dem Stub aus [Oberfläche prüfen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
## Aufbau
```
src/ Frontend
App.tsx Ansicht, zentrale Daten, Layout
types.ts Typen, Konstanten, Rechenhilfen (Volumen, Farben)
lib/ API-Zugriff und Kontexte
components/ je Bereich ein Ordner
server/src/ Backend
server.js Express-App, Reihenfolge der Routen
db.js Verbindungspool, Schema-Init, Admin-Seed
settings.js crm_settings lesen und schreiben (ohne Geheimnisse)
routes/ ein Router je Ressource
middleware/auth.js JWT prüfen, Adminrechte
mail/ Postfach crm@perinco.de über Microsoft Graph
scanner/ portierter Projekt Scanner (Sequelize, Playwright, OpenAI)
docs/ diese Dokumentation
deploy/ Apache-Vhosts, systemd-Unit, Deployskript
```
## Wie eine Anfrage läuft
```mermaid
sequenceDiagram
participant K as Komponente
participant A as lib/api.ts
participant E as Express
participant M as middleware/auth
participant R as routes/*.js
participant D as MariaDB
K->>A: leadsApi.update(7, { status: 'Gewonnen' })
A->>A: Token anhängen, Content-Type setzen
A->>E: PATCH /api/leads/7
E->>M: requireAuth
M->>D: Benutzer laden, aktiv?
M->>R: req.user gesetzt
R->>R: Eingaben prüfen und normalisieren
R->>D: UPDATE ... ; SELECT (angereichert)
R-->>A: 200 JSON
A-->>K: typisiertes Objekt
```
Jede Route liefert den **vollständigen, angereicherten Datensatz** zurück (mit
`created_by_name`, `owner_name`, Ansprechpartnern, Teilnehmern). Das Frontend muss
danach nichts nachladen.
## Etwas hinzufügen
### Neues Feld an einem Kontakt
1. `server/src/schema.sql`: Spalte mit `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
ergänzen - das Statement muss beim nächsten Start wiederholbar sein.
2. `server/src/routes/leads.js`: Feld in die Listen `TEXT_FIELDS` bzw. in
`INSERT`/`UPDATE` und in den `SELECT` aufnehmen.
3. `src/types.ts`: Feld ins `Lead`-Interface.
4. `src/components/leads/LeadModal.tsx`: Eingabefeld, `inputClass` benutzen.
5. Wenn es in Kachel oder Liste sichtbar sein soll: `LeadGrid.tsx`, `LeadList.tsx`.
### Neuer Menüpunkt
1. `src/types.ts`: Wert in `ViewState` ergänzen.
2. `src/components/Sidebar.tsx`: Eintrag in `CATEGORY_ENTRIES` (Name + SVG-Pfad).
3. `src/App.tsx`: Komponente rendern und einen Untertitel setzen.
Der Name in `ViewState` ist gleichzeitig die Beschriftung und der Titel in der
Kopfzeile. Wer eine abweichende Beschriftung braucht, setzt `label` am `MenuItem`
(so macht es der Unterpunkt *Postfach*, der auf die Ansicht `Mails` zeigt).
Soll der Punkt aufklappen, kommt statt eines `MenuItem` eine `MenuGroup` in
`CATEGORY_ENTRIES` - mit einer Liste ihrer Ansichten in `types.ts`
(`CONTACT_VIEWS`, `MAIL_VIEWS`, `SCANNER_VIEWS`), damit die Gruppe offen bleibt,
solange eine ihrer Ansichten aktiv ist.
### Neuer Reiter in den Einstellungen
1. `src/types.ts`: Wert in `SettingsTab`; nur für Admins zusätzlich in
`ADMIN_SETTINGS_TABS`.
2. `src/components/settings/Settings.tsx`: Eintrag in `TABS` und Rendering.
Ein Sprung dorthin aus einer beliebigen Tiefe:
`goTo('Einstellungen', { tab: 'whisper' })`.
### Neuer Endpunkt
1. Router in `server/src/routes/` anlegen, `router.use(requireAuth)` nicht
vergessen, `requireAdmin` wo nötig.
2. In `server/src/server.js` einhängen - **vor** dem `/api`-404-Fallback.
3. In `src/lib/api.ts` eine typisierte Funktion ergänzen.
### Neue Einstellung im CRM
Nur für Werte, die im Betrieb verstellbar sein sollen. **Zugangsdaten gehören
nicht dazu** - die stehen in `server/.env` und werden beim Start gelesen, damit ein
Datenbank-Dump keine Geheimnisse enthält.
1. Schlüssel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster:
`MAIL_SETTING_DEFAULTS` in `server/src/mail/config.js`). Es ist gleichzeitig die
Positivliste des Speicher-Endpunkts.
2. Lesen mit `getSettings(defaults)` aus `server/src/settings.js`, schreiben mit
`setSetting()`. Zahlen und Schalter mit `asInt`/`asBool` wandeln.
3. Feld im passenden Einstellungsreiter ergänzen.
Die Tabelle `crm_settings` legt `schema.sql` an; ein neuer Schlüssel braucht keine
Migration.
## Fallen
Diese Punkte haben real Zeit gekostet:
| Falle | Was passiert |
| --- | --- |
| **Reihenfolge in `server.js`** | Der 404-Fallback `app.use('/api', ...)` fängt alles, was danach registriert wird. Neue Router davor einhängen. |
| **`Content-Type` bei FormData** | `apiRequest` setzt JSON nur, wenn der Body **keine** `FormData` ist - sonst fehlt die multipart-Grenze und der Upload scheitert. |
| **Dateien in neuen Tabs** | Ein `<a href>` schickt kein Bearer-Token. Deshalb holt `openAgentCv()` das PDF per `fetch` und öffnet einen Blob. |
| **Leerzeichen in Pfaden** | Der alte Scanner liegt unter `/var/www/Projekt Scanner/…`. In der `.env` muss der Wert gequotet sein, sonst schneidet systemd ihn am Leerzeichen ab. |
| **`.env` ist gleichzeitig `EnvironmentFile`** | Also kein `export`, keine Shell-Ausdrücke, Werte mit Leerzeichen in Anführungszeichen. |
| **Zeitstempel können nur vorziehen** | Ein „vorgealterter" `lastRunTime` verzögert keinen Cronlauf. Wer verzögern will, braucht eine eigene Frist. |
| **Sperrfristen bei jedem Start neu** | Früher schob jedes Deployment den fälligen Scan um 30 Minuten - er fand nie statt. Deshalb liegt `last_scan_at` in der Datenbank. |
| **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. |
| **Kaskaden beim Löschen** | Einen Suchagenten löschen löscht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| **Doppelte Scanner-Läufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. |
| **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehört in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshöhe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Höhe. |
| **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprüft wurde. |
| **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. |
| **Aufzählungen in der Datenbank sind deutsch** | `crm_mail_links.origin` (`manuell`/`automatisch`), Vorschlagsstatus (`offen`, `uebernommen`, `verworfen`, `dublette`). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung. |
## Oberfläche prüfen ohne Datenbank
Für Bildschirmfotos und Klickwege genügt ein Stub: ein kleiner `node:http`-Server,
der `dist/` ausliefert und auf `/api/...` erfundene Daten zurückgibt. Token vorab
setzen, dann ist die Anmeldung übersprungen:
```js
await page.goto(BASE);
await page.evaluate(() => localStorage.setItem('perinco_crm_token', 'stub-token'));
await page.goto(BASE);
```
Playwright startet dabei am besten das **installierte Chrome**
(`chromium.launch({ channel: 'chrome' })`) - dann braucht es keinen
Browser-Download.
## Konventionen
* **Deutsch** in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben
englisch (`Match`, `Opportunity`, `Lead`).
* **Kommentare erklären das Warum.** Was der Code tut, steht im Code.
* **Kein `dark:` und keine Theme-Klasse im Markup.** Nur die Skalen `ink-*`,
`brand-*`, `canvas`, `surface` benutzen, den Rest macht `index.css`. Das ist
nicht Kosmetik: daran hängen drei Farbwelten in je zwei Modi, und eine feste
Farbe im Markup bricht fünf davon. Die Rollen der Skalen und die Regeln für ein
neues Theme stehen in [05 Frontend](05-frontend.md#themes-ohne-dark-varianten) -
wichtigste davon: auf `brand-500`/`brand-600` steht weisse Schrift.
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
`primaryButtonClass` aus `components/scanner/scannerUi.tsx`.
* **Meldungen als Toast**, Rückfragen über `ConfirmDialog` - nie `alert()`
oder `confirm()`. Rot (`tone="danger"`, Standard) nur, wenn etwas verloren
geht; sonst `tone="neutral"`, damit die Farbe ihre Bedeutung behält.
* **Rechte immer doppelt**: Oberfläche blendet aus, Backend antwortet mit 403.
## Deployment
Siehe [06 Betrieb](06-betrieb.md). Kurzform: lokal bauen, `deploy/deploy.ps1`
lädt `dist/` und `server/src` hoch und startet den Dienst neu. Auf dem Server
wird **nicht** gebaut - dort sind RAM und Swap knapp.