feat: Interviews, Mail-Papierkorb, Kontaktart

Drei Aenderungen aus dem Alltagsgebrauch:

- Der Kalender heisst jetzt "Interviews" - dafuer wird er benutzt. Der Wert in
  ViewState wandert mit, damit Menue und Kopfzeile nicht auseinanderlaufen.
  Sichtbare Reste ("Kalenderfarbe") heissen jetzt Terminfarbe.

- Mails haben einen Papierkorb, der unter dem Menuepunkt aufklappt. Loeschen
  heisst dabei ausschliesslich: im CRM. Im Postfach crm@perinco.de bleibt die
  Mail unberuehrt - Schreibrechte darauf hat die Anwendung nicht. Damit der
  Delta-Abgleich geloeschte Mails nicht wieder einliest, bleibt nach dem Leeren
  eine leere Zeile als Merker stehen (crm_purged); runMailSync() ueberspringt
  alles, was im CRM geloescht ist. Zurueckholen und Leeren darf jeder
  Angemeldete, Leeren nach einer Warnung.

- Der Kontaktstatus (Neu .. Verloren) wird zur Kontaktart: Recruiter, Partner,
  Endkunde. Die Spalte heisst jetzt kind, weil "Status" das Feld falsch
  benannte. migrateLeadKind() stellt bestehende Datenbanken um und setzt alle
  vorhandenen Kontakte auf Recruiter - welche Art jemand ist, sagt der alte
  Status nicht (so mit dem Nutzer entschieden).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 11:12:49 +02:00
co-authored by Claude Opus 5
parent 025390e18a
commit 8741a9ae0c
39 changed files with 719 additions and 199 deletions
+3 -3
View File
@@ -90,8 +90,8 @@ sofort draußen, ohne auf den Ablauf des Tokens zu warten.
```mermaid ```mermaid
flowchart TD flowchart TD
L["Anmeldung"] --> R{"Rolle"} L["Anmeldung"] --> R{"Rolle"}
R -->|admin| A["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"] R -->|admin| A["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"] R -->|user| U["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
``` ```
Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
@@ -102,7 +102,7 @@ Die Rollenprüfung sitzt **doppelt**: das Menü blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusätzlich mit `403` ab (`requireAdmin`). ist, und das Backend weist Zugriffe zusätzlich mit `403` ab (`requireAdmin`).
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
für Netzwerkdiagramm, Zuständigkeiten, @-Mentions, Teilnehmerauswahl und für Netzwerkdiagramm, Zuständigkeiten, @-Mentions, Teilnehmerauswahl und
Kalenderfarben. Passwort-Hashes liefert der Endpunkt nie. Terminfarben. Passwort-Hashes liefert der Endpunkt nie.
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
degradiert, deaktiviert oder gelöscht wird. degradiert, deaktiviert oder gelöscht wird.
+3 -3
View File
@@ -30,7 +30,7 @@ erDiagram
varchar email UK varchar email UK
varchar name varchar name
enum role "admin | user" enum role "admin | user"
varchar color "Kalenderfarbe, NULL = aus Id abgeleitet" varchar color "Terminfarbe, NULL = aus Id abgeleitet"
longtext avatar "Data-URL" longtext avatar "Data-URL"
varchar password_hash "bcrypt" varchar password_hash "bcrypt"
tinyint is_active tinyint is_active
@@ -40,7 +40,7 @@ erDiagram
varchar first_name varchar first_name
varchar last_name varchar last_name
varchar company varchar company
enum status "Neu .. Verloren" enum kind "Recruiter | Partner | Endkunde"
varchar source "z.B. 'Mail' bei Herkunft Postfach" varchar source "z.B. 'Mail' bei Herkunft Postfach"
tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt" tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt"
tinyint mail_consent "Einverständnis für Werbemails" tinyint mail_consent "Einverständnis für Werbemails"
@@ -94,7 +94,7 @@ Vier Eigenheiten, die man kennen muss:
- **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte - **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste, Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste,
Opportunities, Kalender und Netzwerk; der Vorbereich hat seine eigene Ansicht. Opportunities, Interviews und Netzwerk; der Vorbereich hat seine eigene Ansicht.
Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und
`crm_mail_contact_candidates` für beide Bestände gelten `crm_mail_contact_candidates` für beide Bestände gelten
([11 Postfachanbindung](11-postfach.md)). ([11 Postfachanbindung](11-postfach.md)).
+4 -4
View File
@@ -10,13 +10,13 @@ sitzt und keine tiefen Links braucht.
flowchart TD flowchart TD
SB["Seitenleiste"] --> K["Kategorien"] SB["Seitenleiste"] --> K["Kategorien"]
K --> O["Opportunities<br/>(Startansicht)"] K --> O["Opportunities<br/>(Startansicht)"]
K --> C["Kalender"] K --> C["Interviews"]
K --> L["Kontakte"] K --> L["Kontakte"]
L --> L1["Alle Kontakte"] L --> L1["Alle Kontakte"]
L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"] L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
K --> M["Mails"] K --> M["Mails"]
M --> M1["Postfach"] M --> M1["Postfach<br/>Reiter: Postfach, Kontakt-Vorschläge"]
M --> M2["Kontakt-Vorschläge"] M --> M2["Papierkorb<br/>im CRM gelöschte Mails"]
K --> SC["Scanner"] K --> SC["Scanner"]
SC --> SC1["Dashboard"] SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"] SC --> SC2["Unpassend"]
@@ -61,7 +61,7 @@ flowchart TB
APP --> NAV APP --> NAV
APP --> DATA APP --> DATA
DATA --> V1["Opportunities"] DATA --> V1["Opportunities"]
DATA --> V2["Kalender"] DATA --> V2["Interviews"]
DATA --> V3["Kontakte<br/>mail_pool = false"] DATA --> V3["Kontakte<br/>mail_pool = false"]
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"] DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"] DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
+62 -21
View File
@@ -10,7 +10,7 @@ Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen:
| Frage | Bereich | | Frage | Bereich |
| --- | --- | | --- | --- |
| Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** | | Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** |
| Wer ist wann wo, und wer ist beteiligt? | **Kalender** | | Wer ist wann wo, und wer ist beteiligt? | **Interviews** |
| Wen kennen wir, und wer kennt wen? | **Kontakte** | | Wen kennen wir, und wer kennt wen? | **Kontakte** |
| Welche Projekte auf den Börsen passen zu unseren Beratern? | **Scanner** | | Welche Projekte auf den Börsen passen zu unseren Beratern? | **Scanner** |
@@ -32,11 +32,13 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
┌──────────────┬───────────────────────────────────────────┐ ┌──────────────┬───────────────────────────────────────────┐
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │ │ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
│ Opportunities├──────────────────────────────────────────┤ │ Opportunities├──────────────────────────────────────────┤
│ Kalender │ │ │ Interviews │ │
│ Kontakte ▾ │ Inhalt │ │ Kontakte ▾ │ Inhalt │
│ Alle Kont. │ │ │ Alle Kont. │ │
│ Aus Mails 5│ │ │ Aus Mails 5│ │
│ Mails │ │ │ Mails ▾ │ │
│ Postfach │ │
│ Papierkorb │ │
│ Scanner ▾ │ │ │ Scanner ▾ │ │
│ Dashboard │ │ │ Dashboard │ │
│ Unpassend │ │ │ Unpassend │ │
@@ -112,12 +114,13 @@ der Server, nicht nur die Oberfläche.
Auf dem Tablet oder ohne Lust auf Ziehen: die Phase lässt sich auch über das Auf dem Tablet oder ohne Lust auf Ziehen: die Phase lässt sich auch über das
Auswahlfeld direkt auf der Karte ändern. Auswahlfeld direkt auf der Karte ändern.
## Kalender ## Interviews
Ein **gemeinsamer** Teamkalender - alle sehen dieselben Termine, jeder darf Der gemeinsame Terminkalender des Teams - alle sehen dieselben Termine, jeder
eintragen. darf eintragen. Genutzt wird er vor allem für Gesprächstermine, deshalb heißt
der Menüpunkt **Interviews** und nicht „Kalender".
![Monatsansicht des Kalenders](bilder/kalender-monat.png) ![Monatsansicht der Termine](bilder/kalender-monat.png)
* **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blättern. * **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blättern.
* Klick auf einen Monatstag oder eine Stundenzelle legt sofort einen Termin an. * Klick auf einen Monatstag oder eine Stundenzelle legt sofort einen Termin an.
@@ -129,20 +132,20 @@ eintragen.
gleich, und der Punkt, an dem man den Termin angefasst hat, wandert mit. Ziehen gleich, und der Punkt, an dem man den Termin angefasst hat, wandert mit. Ziehen
darf nur, wer den Termin auch ändern darf. darf nur, wer den Termin auch ändern darf.
* **Farben** gehören zum Ersteller. Die eigene Farbe ändert man unter * **Farben** gehören zum Ersteller. Die eigene Farbe ändert man unter
*Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche *Einstellungen → Mein Konto* („Meine Terminfarbe"); die Legende unter dem
Farbe zu wem gehört. Raster zeigt, welche Farbe zu wem gehört.
* **Teilnehmer** erscheinen mit Foto oder Initiale und sind anklickbar. * **Teilnehmer** erscheinen mit Foto oder Initiale und sind anklickbar.
* **@-Erwähnungen**: In der Beschreibung `@` tippen, dann Kontakte *und* * **@-Erwähnungen**: In der Beschreibung `@` tippen, dann Kontakte *und*
Benutzer aus der Vorschlagsliste wählen (Pfeiltasten, Enter). In der Benutzer aus der Vorschlagsliste wählen (Pfeiltasten, Enter). In der
Leseansicht sind sie klickbar - Kontakte öffnen die Kontaktkarte, Benutzer ein Leseansicht sind sie klickbar - Kontakte öffnen die Kontaktkarte, Benutzer ein
Detailfenster mit nächsten Terminen. Detailfenster mit nächsten Terminen.
* Rechts stehen die **nächsten Termine**, nach Tagen gruppiert. Vergangenes * Rechts stehen die **nächsten Termine**, nach Tagen gruppiert. Vergangenes
fliegt aus der Liste, bleibt im Kalender aber erhalten. fliegt aus der Liste, bleibt im Raster aber erhalten.
![Wochenansicht des Kalenders mit Terminen dreier Benutzer](bilder/kalender-woche.png) ![Wochenansicht mit Terminen dreier Benutzer](bilder/kalender-woche.png)
Die Farbe eines Termins gehört zu seinem Ersteller - die Legende unter dem Die Farbe eines Termins gehört zu seinem Ersteller - die Legende unter dem
Kalender löst auf, welche Farbe zu wem gehört. Rechts stehen die nächsten Raster löst auf, welche Farbe zu wem gehört. Rechts stehen die nächsten
Termine mit Beschreibung, Teilnehmern und den markierten Personen. Termine mit Beschreibung, Teilnehmern und den markierten Personen.
Ändern und löschen darf nur der **Ersteller** oder ein **Administrator**. Ändern und löschen darf nur der **Ersteller** oder ein **Administrator**.
@@ -154,18 +157,25 @@ Drei Ansichten, davon eine besondere:
| Ansicht | wofür | | Ansicht | wofür |
| --- | --- | | --- | --- |
| **Kachel** | Überblick mit Foto, Firma, Status | | **Kachel** | Überblick mit Foto, Firma, Art |
| **Liste** | sortierbar, viele Einträge | | **Liste** | sortierbar, viele Einträge |
| **Netzwerk** | wer steht mit wem in Kontakt | | **Netzwerk** | wer steht mit wem in Kontakt |
![Kontakte in der Kachelansicht](bilder/kontakte-kacheln.png) ![Kontakte in der Kachelansicht](bilder/kontakte-kacheln.png)
Ein Klick auf eine Karte öffnet den Kontakt zum Bearbeiten - mit Foto, Anschrift, Ein Klick auf eine Karte öffnet den Kontakt zum Bearbeiten - mit Foto, Anschrift,
Status, Zuständigkeit, Tags, Notizen und den Verbindungen zu anderen Kontakten. Art, Zuständigkeit, Tags, Notizen und den Verbindungen zu anderen Kontakten.
Die **Art** sagt, mit wem man es zu tun hat: **Recruiter**, **Partner** oder
**Endkunde**. Früher stand an dieser Stelle ein Vertriebsstatus (Neu,
Kontaktiert, …); der sagte über den Kontakt selbst nichts. Bei der Umstellung am
18.08.2026 wurden alle vorhandenen Kontakte auf *Recruiter* gesetzt - welche Art
jemand wirklich ist, war aus dem alten Status nicht ableitbar. Wer die Angabe
braucht, pflegt sie im Kontakt nach.
![Dialog "Neuer Kontakt" mit allen Feldern](bilder/kontakt-dialog.png) ![Dialog "Neuer Kontakt" mit allen Feldern](bilder/kontakt-dialog.png)
Im **Netzwerk** sind Kreise Kontakte (Farbe = Status, Größe = Anzahl Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
Kontakt. Kontakt.
@@ -175,10 +185,10 @@ Kontakt.
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt, einen Knoten ziehen Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt, einen Knoten ziehen
setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab. erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
Suche und Statusfilter wirken auch hier. Suche und Artfilter wirken auch hier.
Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus Artfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten. dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
### Einverständnis für Mails ### Einverständnis für Mails
@@ -240,7 +250,7 @@ Der Menüpunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und
In *Aus Mails* stehen die Personen, die der Postfachabgleich in Mails gefunden In *Aus Mails* stehen die Personen, die der Postfachabgleich in Mails gefunden
hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste, hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste,
nicht in Opportunities und nicht im Kalender. Jede Karte zeigt alle gefundenen nicht in Opportunities und nicht unter Interviews. Jede Karte zeigt alle gefundenen
Angaben und darunter, aus welcher Mail sie stammen. Angaben und darunter, aus welcher Mail sie stammen.
![Vorbereich "Aus Mails" mit zwei neuen Personen und einer gold markierten Dublette](bilder/aus-mails.png) ![Vorbereich "Aus Mails" mit zwei neuen Personen und einer gold markierten Dublette](bilder/aus-mails.png)
@@ -278,7 +288,8 @@ Das gemeinsame Postfach `crm@perinco.de` liegt hier im CRM. Der Zugriff ist
markiert, nichts gelöscht. Antworten geht weiter in Outlook; der Link *In Outlook markiert, nichts gelöscht. Antworten geht weiter in Outlook; der Link *In Outlook
öffnen* springt direkt zur Mail. öffnen* springt direkt zur Mail.
Zwei Reiter oben: Der Menüpunkt **Mails** klappt zwei Unterpunkte auf: *Postfach* und
*Papierkorb*. Im Postfach selbst gibt es oben zwei Reiter:
### Postfach ### Postfach
@@ -310,6 +321,36 @@ zeigt die Rohfassung ohne Formatierung.
Steht an einer Mail **Im Postfach gelöscht**, wurde sie in Outlook entfernt. Im Steht an einer Mail **Im Postfach gelöscht**, wurde sie in Outlook entfernt. Im
CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen. CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen.
**Löschen** legt die Mail in den Papierkorb des CRM. Im Postfach
`crm@perinco.de` bleibt sie unangetastet - Schreibzugriff darauf hat das CRM
nicht.
### Papierkorb
Der Unterpunkt **Papierkorb** unter *Mails* zeigt alles, was im CRM gelöscht
wurde. Aufbau und Suche sind dieselben wie im Postfach, nur die Knöpfe wechseln:
* **Wiederherstellen** holt die Mail zurück in das Postfach des CRM - samt ihrer
Zuordnungen und Kontakt-Vorschlägen.
* **Papierkorb leeren** entfernt alle Mails darin **endgültig**, zusammen mit
ihren Zuordnungen zu Kontakten und Opportunities und ihren noch offenen
Kontakt-Vorschlägen. Bereits übernommene Vorschläge bleiben als Nachweis
erhalten, woher ein Kontakt stammt. Vorher fragt eine Warnung nach; danach ist
nichts mehr zurückzuholen. Das darf jeder angemeldete Benutzer.
Zwei Dinge sind wichtig zu wissen:
1. Gelöscht heißt **nur im CRM gelöscht**. In `crm@perinco.de` liegt die Mail
weiter, dort ändert das CRM nichts.
2. Der Abgleich holt gelöschte Mails **nicht wieder herein** - weder die im
Papierkorb noch die endgültig gelöschten. Dafür bleibt zu jeder endgültig
gelöschten Mail ein leerer Merker in der Datenbank stehen: ohne ihn würde der
nächste Abgleich sie im Postfach wiederfinden und erneut einlesen.
Offene Kontakt-Vorschläge einer Mail im Papierkorb verlangen keine Entscheidung
mehr - sie verschwinden aus dem Reiter *Kontakt-Vorschläge* und sind nach dem
Wiederherstellen wieder da.
### Kontakt-Vorschläge ### Kontakt-Vorschläge
Aus Absender, Empfängerliste und Signatur jeder Mail liest die Anwendung Aus Absender, Empfängerliste und Signatur jeder Mail liest die Anwendung
@@ -442,7 +483,7 @@ Datenbank.
| Reiter | Wer | Inhalt | | Reiter | Wer | Inhalt |
| --- | --- | --- | | --- | --- | --- |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen | | **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen |
| **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort ändern | | **Mein Konto** | alle | Foto, Terminfarbe, Passwort ändern |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme | | **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail | | **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung für das Diktat einrichten | | **Whisper** | alle | Spracherkennung für das Diktat einrichten |
@@ -467,7 +508,7 @@ Die Zugangsdaten selbst stehen **nicht** in der Oberfläche, sondern in der
Serverkonfiguration - sie werden beim Start gelesen. Fehlt einer, sagt die Seite Serverkonfiguration - sie werden beim Start gelesen. Fehlt einer, sagt die Seite
welcher. welcher.
Foto und Kalenderfarbe werden **sofort** gespeichert, ohne Speichern-Knopf. Der Foto und Terminfarbe werden **sofort** gespeichert, ohne Speichern-Knopf. Der
letzte aktive Administrator kann sich nicht selbst degradieren oder löschen - letzte aktive Administrator kann sich nicht selbst degradieren oder löschen -
das verhindert der Server. das verhindert der Server.
+7 -2
View File
@@ -128,12 +128,17 @@ danach nichts nachladen.
### Neuer Menüpunkt ### Neuer Menüpunkt
1. `src/types.ts`: Wert in `ViewState` ergänzen. 1. `src/types.ts`: Wert in `ViewState` ergänzen.
2. `src/components/Sidebar.tsx`: Eintrag in `CATEGORY_ITEMS` (Name + SVG-Pfad). 2. `src/components/Sidebar.tsx`: Eintrag in `CATEGORY_ENTRIES` (Name + SVG-Pfad).
3. `src/App.tsx`: Komponente rendern und einen Untertitel setzen. 3. `src/App.tsx`: Komponente rendern und einen Untertitel setzen.
Der Name in `ViewState` ist gleichzeitig die Beschriftung und der Titel in der Der Name in `ViewState` ist gleichzeitig die Beschriftung und der Titel in der
Kopfzeile. Wer eine abweichende Beschriftung braucht, setzt `label` am `MenuItem` Kopfzeile. Wer eine abweichende Beschriftung braucht, setzt `label` am `MenuItem`
(so macht es der Unterpunkt *Dashboard*, der auf die Ansicht `Scanner` zeigt). (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 ### Neuer Reiter in den Einstellungen
+19 -6
View File
@@ -28,13 +28,26 @@ Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()
| Export | Zweck | | Export | Zweck |
| --- | --- | | --- | --- |
| `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` | | `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` |
| `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `ensureAdminUser()` | | `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `migrateLeadKind()`, `migrateMailPool()`, `ensureAdminUser()` |
`dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als `dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als
`YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Der Kalender rechnet mit `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 Wandzeit; ein `Date`-Objekt aus dem Treiber würde beim Serialisieren nach UTC
verschieben. 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.
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne `ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log - `ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
im Quelltext steht kein Passwort. im Quelltext steht kein Passwort.
@@ -70,7 +83,7 @@ Alle unter `router.use(requireAuth)`.
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | 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 | | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | ändern/löschen nur Ersteller, Zuständiger oder Admin | | `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | ändern/löschen nur Ersteller, Zuständiger oder Admin |
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/candidates/:id/adopt`, `/candidates/:id/reject`; `DELETE /links/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen und zuordnen jeder; einrichten Admin | | `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: Wiederkehrende Muster in diesen Routern:
@@ -213,7 +226,7 @@ Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
`parseMailDate()` hängt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein `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 `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 wäre je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der
Datenbank, anders als die Kalendertermine. `formatMailDate()` zeigt heute nur die Datenbank, anders als die Termine unter Interviews. `formatMailDate()` zeigt heute nur die
Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr. Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
### Kontexte ### Kontexte
@@ -242,7 +255,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| Datei | Inhalt | | Datei | Inhalt |
| --- | --- | | --- | --- |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `StatusBadge`, `Avatar`, Statusfarben | | `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/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/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts | | `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts |
@@ -266,7 +279,7 @@ nicht auseinanderlaufen:
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter | | `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id | | `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert | | `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert |
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_STATUSES`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank | | `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik | | `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
## Datenfluss im Frontend ## Datenfluss im Frontend
+2
View File
@@ -21,6 +21,8 @@ Quelltext.
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. | | **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. |
| **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. | | **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
| **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. | | **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
| **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. |
| **Papierkorb** | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. |
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. | | **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
| **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). | | **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). |
| **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. | | **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. |
+44 -3
View File
@@ -124,7 +124,8 @@ sequenceDiagram
end end
G-->>Y: Seite mit Metadaten + nextLink/deltaLink G-->>Y: Seite mit Metadaten + nextLink/deltaLink
loop je Nachricht loop je Nachricht
Y->>D: bekannt? Text vorhanden? Y->>D: bekannt? Text vorhanden? im CRM gelöscht?
Note over Y: im Papierkorb oder endgültig gelöscht? dann weiter
Note over Y: unbekannt und älter als die Grenze? dann weiter Note over Y: unbekannt und älter als die Grenze? dann weiter
opt Text fehlt opt Text fehlt
Y->>G: /messages/{id}?$select=body Y->>G: /messages/{id}?$select=body
@@ -224,7 +225,7 @@ nur `mail_pool = 0`, der Vorbereich steht als Unterpunkt *Aus Mails* unter dem
Menüpunkt *Kontakte*. Menüpunkt *Kontakte*.
Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der
Opportunities, Kalender und Netzwerk hängen. Ein Abgleich, der ungefragt jede Opportunities, Interviews und Netzwerk hängen. Ein Abgleich, der ungefragt jede
Adresse aus jeder Mail hineinschreibt, macht sie unbrauchbar - genau daran ist Adresse aus jeder Mail hineinschreibt, macht sie unbrauchbar - genau daran ist
das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand
entscheidet. entscheidet.
@@ -288,13 +289,16 @@ Alle unter `/api/mail`, alle hinter `requireAuth`.
| Methode | Pfad | Rolle | Zweck | | Methode | Pfad | Rolle | Zweck |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/status` | alle | Betriebszustand, Zähler, letzter Fehler | | GET | `/status` | alle | Betriebszustand, Zähler, letzter Fehler |
| GET | `/messages` | alle | Liste; `search`, `filter`, `limit`, `offset` | | GET | `/messages` | alle | Liste; `search`, `filter`, `limit`, `offset`, `trash` |
| GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlägen | | GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlägen |
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity hängen | | POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity hängen |
| DELETE | `/links/:id` | alle | Zuordnung lösen | | DELETE | `/links/:id` | alle | Zuordnung lösen |
| GET | `/candidates` | alle | Kontakt-Vorschläge, `status` wählbar | | GET | `/candidates` | alle | Kontakt-Vorschläge, `status` wählbar |
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergänzen; `target: 'pool'` legt in „Aus Mails" ab | | POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergänzen; `target: 'pool'` legt in „Aus Mails" ab |
| POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen | | POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen |
| DELETE | `/messages/:id` | alle | Mail in den Papierkorb legen |
| POST | `/messages/:id/restore` | alle | Mail aus dem Papierkorb zurückholen |
| POST | `/trash/empty` | alle | Papierkorb endgültig leeren |
Der Vorbereich hängt an den Kontakten und hat seine Endpunkte deshalb unter Der Vorbereich hängt an den Kontakten und hat seine Endpunkte deshalb unter
`/api/leads`: `/api/leads`:
@@ -319,6 +323,43 @@ vorbehalten.
`filter` in der Liste: `unlinked` (noch nirgends zugeordnet - die Arbeitsliste), `filter` in der Liste: `unlinked` (noch nirgends zugeordnet - die Arbeitsliste),
`candidates` (offene Kontakt-Vorschläge), `linked` (bereits zugeordnet). `candidates` (offene Kontakt-Vorschläge), `linked` (bereits zugeordnet).
`trash=true` zeigt genau den Papierkorb; ohne den Parameter ist er ausgeblendet.
## Papierkorb
Löschen heißt im CRM: aus dem CRM verschwinden lassen. Das Postfach bleibt
unberührt - Schreibrechte darauf hat die Anwendung nicht und soll sie nicht
haben. Zwei Spalten in `crm_mail_messages` tragen das:
| Spalte | Bedeutung |
| --- | --- |
| `crm_deleted_at` (+ `crm_deleted_by`) | liegt im Papierkorb, lässt sich zurückholen |
| `crm_purged` | Papierkorb geleert: Zeile bleibt als leerer Merker stehen |
Der Merker ist der Kern der Sache. Im Postfach liegt die Mail weiter, der
Delta-Abgleich liefert sie also erneut aus. `runMailSync()` prüft deshalb bei
jeder Nachricht zuerst, ob sie im CRM gelöscht ist, und lässt sie dann in Ruhe -
kein Text wird nachgeladen, keine Zeile aktualisiert. Ohne den Merker käme jede
endgültig gelöschte Mail beim nächsten Lauf zurück. Der Lauf zählt die
übersprungenen Mails in `skippedTrashed` und schreibt sie ins Protokoll.
Beim Leeren fällt zusätzlich weg:
* alle `crm_mail_links` der betroffenen Mails - was es nicht mehr gibt, kann an
keiner Opportunity und keinem Kontakt hängen,
* deren **offene** Kontakt-Vorschläge. Entschiedene bleiben: sie sind der
Nachweis, woher ein Kontakt stammt, und tragen die Herkunftsanzeige in
„Aus Mails".
Vom Inhalt bleibt nichts: Betreff, Absender, Empfänger, Vorschautext und Text
werden geleert. In der Zeile stehen danach nur noch die Graph-Kennung, ihr Hash,
der Empfangszeitpunkt und der Löschvermerk.
In den Papierkorb legen und leeren darf **jeder angemeldete Benutzer** - beim
Leeren nach einer Warnung. Der erste Schritt ist umkehrbar, der zweite nicht.
`crm_deleted_by` trägt bewusst keinen Fremdschlüssel auf `crm_users`: Spalten
lassen sich mit `ADD COLUMN IF NOT EXISTS` idempotent nachziehen, ein
Fremdschlüssel nicht.
## Was anders ist als im Altsystem ## Was anders ist als im Altsystem
+46
View File
@@ -48,10 +48,56 @@ export async function initDatabase() {
conn.release(); conn.release();
} }
await migrateLeadKind();
await migrateMailPool(); await migrateMailPool();
await ensureAdminUser(); await ensureAdminUser();
} }
/*
* Einmalige Umstellung: aus dem Vertriebsstatus eines Kontakts
* (Neu, Kontaktiert, Qualifiziert, Angebot, Gewonnen, Verloren) wird seine Art
* (Recruiter, Partner, Endkunde). Erkannt wird der Altstand an der Spalte
* selbst - dafuer braucht es keinen Merker in crm_settings.
*
* Die Reihenfolge ist wichtig: erst die Auswahl auf alte plus neue Werte
* erweitern, dann die Altwerte auf 'Recruiter' setzen, dann umbenennen und auf
* die drei neuen Werte verengen. Direkt zu verengen wuerde jeden Altwert zu ''
* machen - welcher Kontakt welche Art ist, sagt der alte Status nicht, deshalb
* die einheitliche Vorgabe (so entschieden am 2026-08-18).
*/
async function migrateLeadKind() {
const [rows] = await pool.query(
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_leads'
AND COLUMN_NAME IN ('status', 'kind')`
);
const columns = new Set(rows.map((row) => row.COLUMN_NAME));
if (columns.has('kind') || !columns.has('status')) return;
await pool.query(
`ALTER TABLE crm_leads MODIFY status
ENUM('Neu','Kontaktiert','Qualifiziert','Angebot','Gewonnen','Verloren',
'Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter'`
);
const [result] = await pool.query(
`UPDATE crm_leads SET status = 'Recruiter'
WHERE status NOT IN ('Recruiter', 'Partner', 'Endkunde')`
);
await pool.query(
`ALTER TABLE crm_leads CHANGE COLUMN status kind
ENUM('Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter'`
);
// Der Index zeigt nach dem Umbenennen weiter auf dieselbe Spalte, traegt aber
// noch den alten Namen. Neu anlegen, alten entfernen.
await pool.query('ALTER TABLE crm_leads ADD INDEX IF NOT EXISTS idx_crm_leads_kind (kind)');
await pool.query('ALTER TABLE crm_leads DROP INDEX IF EXISTS idx_crm_leads_status');
console.log(
`[perinco CRM] Kontaktstatus auf Kontaktart umgestellt - ${result.affectedRows} Kontakt(e) auf "Recruiter" gesetzt.`
);
}
/* /*
* Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte * Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1). * (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
+3 -1
View File
@@ -113,7 +113,9 @@ export async function adoptCandidate(
const fields = candidateToLeadFields(candidate); const fields = candidateToLeadFields(candidate);
const columns = { const columns = {
...fields, ...fields,
status: 'Neu', // Art des Kontakts bleibt auf der Vorgabe der Spalte ('Recruiter'): welche
// Art jemand ist, sagt eine Mail nicht - das entscheidet der Mensch beim
// Uebernehmen aus "Aus Mails".
source: MAIL_LEAD_SOURCE, source: MAIL_LEAD_SOURCE,
mail_pool: mailPool, mail_pool: mailPool,
tags: '[]', tags: '[]',
+11 -2
View File
@@ -48,15 +48,24 @@ export async function resetDelta() {
* Ist die Mail schon bekannt, und liegt ihr Text vor? Der Delta-Abgleich holt * Ist die Mail schon bekannt, und liegt ihr Text vor? Der Delta-Abgleich holt
* absichtlich nur Metadaten; der Mailtext wird einzeln nachgeladen und nur dann, * absichtlich nur Metadaten; der Mailtext wird einzeln nachgeladen und nur dann,
* wenn er wirklich fehlt. * wenn er wirklich fehlt.
*
* `inTrash` und `purged` sagen dem Abgleich, dass er die Mail in Ruhe lassen
* soll: im Postfach steht sie weiter, im CRM ist sie geloescht.
*/ */
export async function getMessageStateByHash(graphIdHash) { export async function getMessageStateByHash(graphIdHash) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT id, (body_text IS NOT NULL AND body_text <> '') AS has_body `SELECT id, crm_deleted_at, crm_purged,
(body_text IS NOT NULL AND body_text <> '') AS has_body
FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1`, FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1`,
[graphIdHash] [graphIdHash]
); );
if (!rows[0]) return null; if (!rows[0]) return null;
return { id: rows[0].id, hasBody: Boolean(Number(rows[0].has_body)) }; return {
id: rows[0].id,
hasBody: Boolean(Number(rows[0].has_body)),
inTrash: rows[0].crm_deleted_at !== null,
purged: Boolean(Number(rows[0].crm_purged)),
};
} }
/** /**
+15 -1
View File
@@ -166,6 +166,7 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
let newMessages = 0; let newMessages = 0;
let updatedMessages = 0; let updatedMessages = 0;
let deletedMessages = 0; let deletedMessages = 0;
let skippedTrashed = 0;
let newCandidates = 0; let newCandidates = 0;
let adoptedContacts = 0; let adoptedContacts = 0;
let pages = 0; let pages = 0;
@@ -201,6 +202,17 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
const message = normalizeMessage(raw); const message = normalizeMessage(raw);
const existing = await getMessageStateByHash(message.graphIdHash); const existing = await getMessageStateByHash(message.graphIdHash);
/*
* Im CRM geloescht - Papierkorb oder endgueltig. Im Postfach steht die
* Mail weiter, der Delta-Abgleich liefert sie also wieder aus. Sie hier
* zu ueberspringen ist der ganze Grund, warum die Zeile nach dem Leeren
* des Papierkorbs als Merker stehen bleibt.
*/
if (existing && (existing.inTrash || existing.purged)) {
skippedTrashed += 1;
continue;
}
/* /*
* Zeitgrenze - immer geprueft, nicht nur wenn der Serverfilter versagt * Zeitgrenze - immer geprueft, nicht nur wenn der Serverfilter versagt
* hat. Ein Erstabgleich ohne Filter zieht sich ueber mehrere Laeufe; in * hat. Ein Erstabgleich ohne Filter zieht sich ueber mehrere Laeufe; in
@@ -298,13 +310,15 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
deletedMessages, deletedMessages,
newCandidates, newCandidates,
adoptedContacts, adoptedContacts,
skippedTrashed,
hasMore: hasMore || truncated, hasMore: hasMore || truncated,
}; };
if (newMessages || newCandidates || deletedMessages) { if (newMessages || newCandidates || deletedMessages) {
console.log( console.log(
`[Mail] Abgleich (${trigger}): ${newMessages} neu, ${updatedMessages} aktualisiert, ` + `[Mail] Abgleich (${trigger}): ${newMessages} neu, ${updatedMessages} aktualisiert, ` +
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Eintraege in "Aus Mails".` `${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Eintraege in "Aus Mails"` +
`${skippedTrashed ? `, ${skippedTrashed} im CRM geloescht und daher uebersprungen` : ''}.`
); );
} }
if (hasMore || truncated) { if (hasMore || truncated) {
+1 -1
View File
@@ -60,7 +60,7 @@ router.get('/me', requireAuth, (req, res) => {
}); });
// PATCH /api/auth/me { color?, avatar? } // PATCH /api/auth/me { color?, avatar? }
// Jeder darf seine eigene Kalenderfarbe und sein Foto setzen - dafuer braucht // Jeder darf seine eigene Terminfarbe und sein Foto setzen - dafuer braucht
// es keine Adminrechte. null bzw. "" setzt das Feld zurueck (Farbe: automatisch // es keine Adminrechte. null bzw. "" setzt das Feld zurueck (Farbe: automatisch
// aus der Palette, Foto: Initiale). // aus der Palette, Foto: Initiale).
router.patch('/me', requireAuth, async (req, res, next) => { router.patch('/me', requireAuth, async (req, res, next) => {
+1 -1
View File
@@ -123,7 +123,7 @@ async function replaceParticipants(conn, eventId, ids) {
); );
} }
// GET /api/events - der Kalender ist geteilt, jeder sieht alle Termine // GET /api/events - die Termine sind geteilt, jeder sieht alle
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const [rows] = await pool.query(`${SELECT_EVENTS} ORDER BY e.starts_at ASC, e.id ASC`); const [rows] = await pool.query(`${SELECT_EVENTS} ORDER BY e.starts_at ASC, e.id ASC`);
+13 -17
View File
@@ -5,14 +5,10 @@ import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
const STATUSES = new Set([ // Art des Kontakts. Vorgabe ist 'Recruiter' - auch fuer die Kontakte, die vor
'Neu', // der Umstellung noch einen Vertriebsstatus trugen (siehe migrateLeadKind()).
'Kontaktiert', const KINDS = new Set(['Recruiter', 'Partner', 'Endkunde']);
'Qualifiziert', const DEFAULT_KIND = 'Recruiter';
'Angebot',
'Gewonnen',
'Verloren',
]);
// Felder, die per API gesetzt werden duerfen. owner_id/created_by werden // Felder, die per API gesetzt werden duerfen. owner_id/created_by werden
// separat behandelt, alles andere ist nicht beschreibbar. // separat behandelt, alles andere ist nicht beschreibbar.
@@ -122,7 +118,7 @@ router.post('/', async (req, res, next) => {
return res.status(400).json({ error: 'Vorname und Nachname sind erforderlich.' }); return res.status(400).json({ error: 'Vorname und Nachname sind erforderlich.' });
} }
const status = STATUSES.has(req.body?.status) ? req.body.status : 'Neu'; const kind = KINDS.has(req.body?.kind) ? req.body.kind : DEFAULT_KIND;
const ownerId = const ownerId =
req.body?.owner_id === undefined || req.body.owner_id === null || req.body.owner_id === '' req.body?.owner_id === undefined || req.body.owner_id === null || req.body.owner_id === ''
? req.user.id ? req.user.id
@@ -134,12 +130,12 @@ router.post('/', async (req, res, next) => {
const consent = consentUpdate(req.body?.mail_consent, null); const consent = consentUpdate(req.body?.mail_consent, null);
const columns = [...TEXT_FIELDS, 'status', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)]; const columns = [...TEXT_FIELDS, 'kind', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)];
const values = [ const values = [
...TEXT_FIELDS.map((field) => ...TEXT_FIELDS.map((field) =>
field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field]) field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field])
), ),
status, kind,
serializeTags(req.body?.tags) ?? '[]', serializeTags(req.body?.tags) ?? '[]',
ownerId, ownerId,
req.user.id, req.user.id,
@@ -169,7 +165,7 @@ router.post('/', async (req, res, next) => {
/* /*
* Felder, die beim Uebernehmen verglichen und uebertragen werden koennen. * Felder, die beim Uebernehmen verglichen und uebertragen werden koennen.
* `source` fehlt absichtlich - die Herkunft 'Mail' wandert unveraendert mit und * `source` fehlt absichtlich - die Herkunft 'Mail' wandert unveraendert mit und
* ist keine Entscheidung des Benutzers. `notes`, `avatar`, `tags` und `status` * ist keine Entscheidung des Benutzers. `notes`, `avatar`, `tags` und `kind`
* bleiben ebenfalls aussen vor: sie sind gepflegte Angaben des Kontakts und * bleiben ebenfalls aussen vor: sie sind gepflegte Angaben des Kontakts und
* haben in einem Mailfund kein Gegenstueck. * haben in einem Mailfund kein Gegenstueck.
*/ */
@@ -217,7 +213,7 @@ async function moveMailRelations(fromLeadId, toLeadId) {
async function copyPoolLeadToContacts(poolLead, { userId, ownerId }) { async function copyPoolLeadToContacts(poolLead, { userId, ownerId }) {
const columns = {}; const columns = {};
for (const field of TEXT_FIELDS) columns[field] = poolLead[field] ?? null; for (const field of TEXT_FIELDS) columns[field] = poolLead[field] ?? null;
columns.status = poolLead.status; columns.kind = poolLead.kind;
columns.tags = poolLead.tags ?? '[]'; columns.tags = poolLead.tags ?? '[]';
columns.mail_pool = 0; columns.mail_pool = 0;
columns.owner_id = ownerId; columns.owner_id = ownerId;
@@ -435,10 +431,10 @@ router.patch('/:id', async (req, res, next) => {
values.push(value); values.push(value);
} }
if (req.body?.status !== undefined) { if (req.body?.kind !== undefined) {
if (!STATUSES.has(req.body.status)) return res.status(400).json({ error: 'Ungültiger Status.' }); if (!KINDS.has(req.body.kind)) return res.status(400).json({ error: 'Ungültige Kontaktart.' });
fields.push('status = ?'); fields.push('kind = ?');
values.push(req.body.status); values.push(req.body.kind);
} }
if (req.body?.tags !== undefined) { if (req.body?.tags !== undefined) {
+136 -6
View File
@@ -53,6 +53,8 @@ function mapMessageRow(row) {
body_preview: row.body_preview, body_preview: row.body_preview,
has_attachments: Boolean(row.has_attachments), has_attachments: Boolean(row.has_attachments),
is_deleted: Boolean(row.is_deleted), is_deleted: Boolean(row.is_deleted),
crm_deleted_at: row.crm_deleted_at ?? null,
crm_deleted_by_name: row.crm_deleted_by_name ?? null,
web_link: row.web_link, web_link: row.web_link,
link_count: Number(row.link_count ?? 0), link_count: Number(row.link_count ?? 0),
open_candidates: Number(row.open_candidates ?? 0), open_candidates: Number(row.open_candidates ?? 0),
@@ -71,8 +73,13 @@ router.get('/status', async (req, res, next) => {
const [[counts]] = await pool.query( const [[counts]] = await pool.query(
`SELECT `SELECT
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages, (SELECT COUNT(*) FROM crm_mail_messages
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates, WHERE is_deleted = 0 AND crm_deleted_at IS NULL) AS messages,
(SELECT COUNT(*) FROM crm_mail_messages
WHERE crm_deleted_at IS NOT NULL AND crm_purged = 0) AS trash,
(SELECT COUNT(*) FROM crm_mail_contact_candidates c
JOIN crm_mail_messages m ON m.id = c.message_id
WHERE c.status = 'offen' AND m.crm_deleted_at IS NULL) AS open_candidates,
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'uebernommen') AS adopted_candidates, (SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'uebernommen') AS adopted_candidates,
(SELECT COUNT(*) FROM crm_leads WHERE mail_pool = 1) AS pool_leads, (SELECT COUNT(*) FROM crm_leads WHERE mail_pool = 1) AS pool_leads,
(SELECT COUNT(*) FROM crm_mail_links) AS links` (SELECT COUNT(*) FROM crm_mail_links) AS links`
@@ -98,6 +105,7 @@ router.get('/status', async (req, res, next) => {
last_candidates: state.last_candidates, last_candidates: state.last_candidates,
counts: { counts: {
messages: Number(counts.messages), messages: Number(counts.messages),
trash: Number(counts.trash),
open_candidates: Number(counts.open_candidates), open_candidates: Number(counts.open_candidates),
adopted_candidates: Number(counts.adopted_candidates), adopted_candidates: Number(counts.adopted_candidates),
pool_leads: Number(counts.pool_leads), pool_leads: Number(counts.pool_leads),
@@ -200,7 +208,7 @@ router.post('/sync', requireAdmin, async (req, res, next) => {
// Nachrichten // Nachrichten
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /api/mail/messages?search=&limit=&offset=&filter= // GET /api/mail/messages?search=&limit=&offset=&filter=&trash=
router.get('/messages', async (req, res, next) => { router.get('/messages', async (req, res, next) => {
try { try {
const limit = clampInt(req.query.limit, 50, 1, 200); const limit = clampInt(req.query.limit, 50, 1, 200);
@@ -209,6 +217,17 @@ router.get('/messages', async (req, res, next) => {
const where = []; const where = [];
const params = []; const params = [];
/*
* `trash=true` zeigt genau den Papierkorb, sonst ist er ausgeblendet.
* Endgueltig geloeschte Mails (crm_purged) tauchen nirgends auf - ihre Zeile
* ist nur noch ein Merker fuer den Abgleich.
*/
if (req.query.trash === 'true') {
where.push('m.crm_deleted_at IS NOT NULL AND m.crm_purged = 0');
} else {
where.push('m.crm_deleted_at IS NULL');
}
if (req.query.include_deleted !== 'true') where.push('m.is_deleted = 0'); if (req.query.include_deleted !== 'true') where.push('m.is_deleted = 0');
const search = String(req.query.search ?? '').trim(); const search = String(req.query.search ?? '').trim();
@@ -235,10 +254,12 @@ router.get('/messages', async (req, res, next) => {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT m.id, m.subject, m.from_name, m.from_email, m.to_json, m.cc_json, m.received_at, `SELECT m.id, m.subject, m.from_name, m.from_email, m.to_json, m.cc_json, m.received_at,
m.body_preview, m.has_attachments, m.is_deleted, m.web_link, m.body_preview, m.has_attachments, m.is_deleted, m.web_link,
m.crm_deleted_at, du.name AS crm_deleted_by_name,
(SELECT COUNT(*) FROM crm_mail_links l WHERE l.message_id = m.id) AS link_count, (SELECT COUNT(*) FROM crm_mail_links l WHERE l.message_id = m.id) AS link_count,
(SELECT COUNT(*) FROM crm_mail_contact_candidates c (SELECT COUNT(*) FROM crm_mail_contact_candidates c
WHERE c.message_id = m.id AND c.status = 'offen') AS open_candidates WHERE c.message_id = m.id AND c.status = 'offen') AS open_candidates
FROM crm_mail_messages m FROM crm_mail_messages m
LEFT JOIN crm_users du ON du.id = m.crm_deleted_by
${whereSql} ${whereSql}
ORDER BY m.received_at DESC, m.id DESC ORDER BY m.received_at DESC, m.id DESC
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
@@ -262,9 +283,16 @@ router.get('/messages/:id', async (req, res, next) => {
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
try { try {
const [rows] = await pool.query('SELECT * FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]); const [rows] = await pool.query(
`SELECT m.*, du.name AS crm_deleted_by_name
FROM crm_mail_messages m
LEFT JOIN crm_users du ON du.id = m.crm_deleted_by
WHERE m.id = ? LIMIT 1`,
[id]
);
const row = rows[0]; const row = rows[0];
if (!row) return res.status(404).json({ error: 'Mail nicht gefunden.' }); if (!row) return res.status(404).json({ error: 'Mail nicht gefunden.' });
if (Number(row.crm_purged) === 1) return res.status(404).json({ error: 'Mail nicht gefunden.' });
const [links] = await pool.query( const [links] = await pool.query(
`SELECT l.id, l.lead_id, l.opportunity_id, l.origin, l.note, l.created_at, `SELECT l.id, l.lead_id, l.opportunity_id, l.origin, l.note, l.created_at,
@@ -320,6 +348,105 @@ router.get('/messages/:id', async (req, res, next) => {
} }
}); });
// ---------------------------------------------------------------------------
// Papierkorb
//
// Loeschen heisst hier: aus dem CRM verschwinden lassen. Im Postfach
// crm@perinco.de bleibt die Mail unberuehrt - Schreibzugriff darauf hat das CRM
// bewusst nicht. Der Abgleich ueberspringt geloeschte Mails, sie kommen also
// nicht wieder (siehe sync.js).
//
// In den Papierkorb darf jeder Angemeldete: der Schritt ist umkehrbar. Das
// Leeren ist es nicht - davor warnt die Oberflaeche.
// ---------------------------------------------------------------------------
// POST /api/mail/trash/empty - Papierkorb endgueltig leeren
router.post('/trash/empty', async (req, res, next) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Offene Vorschlaege verschwinden mit der Mail. Entschiedene bleiben: sie
// sind der Nachweis, woher ein Kontakt stammt.
await conn.query(
`DELETE c FROM crm_mail_contact_candidates c
JOIN crm_mail_messages m ON m.id = c.message_id
WHERE m.crm_deleted_at IS NOT NULL AND m.crm_purged = 0 AND c.status = 'offen'`
);
// Eine Mail, die es nicht mehr gibt, kann an nichts mehr haengen.
await conn.query(
`DELETE l FROM crm_mail_links l
JOIN crm_mail_messages m ON m.id = l.message_id
WHERE m.crm_deleted_at IS NOT NULL AND m.crm_purged = 0`
);
/*
* Die Zeile bleibt stehen, ihr Inhalt geht. Nur so weiss der naechste
* Abgleich, dass diese Mail im CRM nicht mehr erwuenscht ist - im Postfach
* liegt sie ja weiter und wuerde sonst erneut eingelesen.
*/
const [result] = await conn.query(
`UPDATE crm_mail_messages
SET crm_purged = 1,
subject = NULL, body_preview = NULL, body_text = NULL, body_html = NULL,
to_json = NULL, cc_json = NULL, bcc_json = NULL,
from_name = NULL, from_email = NULL, web_link = NULL
WHERE crm_deleted_at IS NOT NULL AND crm_purged = 0`
);
await conn.commit();
res.json({ ok: true, purged: result.affectedRows });
} catch (err) {
await conn.rollback().catch(() => {});
next(err);
} finally {
conn.release();
}
});
// DELETE /api/mail/messages/:id - Mail in den Papierkorb legen
router.delete('/messages/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
try {
const [result] = await pool.query(
`UPDATE crm_mail_messages
SET crm_deleted_at = UTC_TIMESTAMP(), crm_deleted_by = ?
WHERE id = ? AND crm_deleted_at IS NULL`,
[req.user.id, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'Mail nicht gefunden oder schon im Papierkorb.' });
}
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// POST /api/mail/messages/:id/restore - Mail aus dem Papierkorb zurueckholen
router.post('/messages/:id/restore', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
try {
const [result] = await pool.query(
`UPDATE crm_mail_messages
SET crm_deleted_at = NULL, crm_deleted_by = NULL
WHERE id = ? AND crm_deleted_at IS NOT NULL AND crm_purged = 0`,
[id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'Mail liegt nicht im Papierkorb.' });
}
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// POST /api/mail/messages/:id/links - Mail an Kontakt und/oder Opportunity haengen // POST /api/mail/messages/:id/links - Mail an Kontakt und/oder Opportunity haengen
router.post('/messages/:id/links', async (req, res, next) => { router.post('/messages/:id/links', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
@@ -413,7 +540,10 @@ router.get('/candidates', async (req, res, next) => {
const limit = clampInt(req.query.limit, 100, 1, 500); const limit = clampInt(req.query.limit, 100, 1, 500);
const status = String(req.query.status ?? 'offen'); const status = String(req.query.status ?? 'offen');
const where = []; // Vorschlaege aus Mails im Papierkorb bleiben aussen vor: eine geloeschte
// Mail soll keine Entscheidung mehr verlangen. Beim Zuruecknehmen der
// Loeschung sind sie wieder da.
const where = ['m.crm_deleted_at IS NULL'];
const params = []; const params = [];
if (status !== 'alle') { if (status !== 'alle') {
if (!CANDIDATE_STATUSES.has(status)) return res.status(400).json({ error: 'Ungültiger Status.' }); if (!CANDIDATE_STATUSES.has(status)) return res.status(400).json({ error: 'Ungültiger Status.' });
@@ -439,7 +569,7 @@ router.get('/candidates', async (req, res, next) => {
JOIN crm_mail_messages m ON m.id = c.message_id JOIN crm_mail_messages m ON m.id = c.message_id
LEFT JOIN crm_leads lead ON lead.id = c.lead_id LEFT JOIN crm_leads lead ON lead.id = c.lead_id
LEFT JOIN crm_users u ON u.id = c.decided_by LEFT JOIN crm_users u ON u.id = c.decided_by
${where.length ? `WHERE ${where.join(' AND ')}` : ''} WHERE ${where.join(' AND ')}
ORDER BY c.confidence DESC, m.received_at DESC, c.id DESC ORDER BY c.confidence DESC, m.received_at DESC, c.id DESC
LIMIT ?`, LIMIT ?`,
[...params, limit] [...params, limit]
+1 -1
View File
@@ -24,7 +24,7 @@ function normalizeEmail(value) {
// GET /api/users/directory // GET /api/users/directory
// Benutzerliste ohne Adminrechte - jeder angemeldete Benutzer braucht sie fuer // Benutzerliste ohne Adminrechte - jeder angemeldete Benutzer braucht sie fuer
// das Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, Teilnehmerauswahl und // das Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, Teilnehmerauswahl und
// Farben im Kalender. Bewusst vor dem Admin-Guard registriert; Passwort-Hashes // Terminfarben. Bewusst vor dem Admin-Guard registriert; Passwort-Hashes
// tauchen hier nie auf. Deaktivierte Konten bleiben enthalten, damit alte // tauchen hier nie auf. Deaktivierte Konten bleiben enthalten, damit alte
// Zuordnungen und Mentions weiter aufloesbar sind - die Auswahlfelder im // Zuordnungen und Mentions weiter aufloesbar sind - die Auswahlfelder im
// Frontend filtern sie heraus. // Frontend filtern sie heraus.
+28 -4
View File
@@ -40,8 +40,9 @@ CREATE TABLE IF NOT EXISTS crm_leads (
zip VARCHAR(24) NULL, zip VARCHAR(24) NULL,
city VARCHAR(120) NULL, city VARCHAR(120) NULL,
country VARCHAR(120) NULL, country VARCHAR(120) NULL,
status ENUM('Neu','Kontaktiert','Qualifiziert','Angebot','Gewonnen','Verloren') -- Art des Kontakts. Frueher ein Vertriebsstatus (Neu .. Verloren); die
NOT NULL DEFAULT 'Neu', -- Umstellung bestehender Datenbanken macht migrateLeadKind() in db.js.
kind ENUM('Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter',
source VARCHAR(120) NULL, source VARCHAR(120) NULL,
tags LONGTEXT NULL, -- JSON-Array als Text (MariaDB-kompatibel) tags LONGTEXT NULL, -- JSON-Array als Text (MariaDB-kompatibel)
notes TEXT NULL, notes TEXT NULL,
@@ -53,7 +54,7 @@ CREATE TABLE IF NOT EXISTS crm_leads (
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_crm_leads_name (last_name, first_name), KEY idx_crm_leads_name (last_name, first_name),
KEY idx_crm_leads_company (company), KEY idx_crm_leads_company (company),
KEY idx_crm_leads_status (status), KEY idx_crm_leads_kind (kind),
KEY idx_crm_leads_owner (owner_id), KEY idx_crm_leads_owner (owner_id),
KEY idx_crm_leads_creator (created_by), KEY idx_crm_leads_creator (created_by),
CONSTRAINT fk_crm_leads_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL, CONSTRAINT fk_crm_leads_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL,
@@ -231,15 +232,38 @@ CREATE TABLE IF NOT EXISTS crm_mail_messages (
web_link VARCHAR(1024) NULL, -- Outlook-Web-Link web_link VARCHAR(1024) NULL, -- Outlook-Web-Link
is_read_in_mailbox TINYINT(1) NOT NULL DEFAULT 0, is_read_in_mailbox TINYINT(1) NOT NULL DEFAULT 0,
is_deleted TINYINT(1) NOT NULL DEFAULT 0, -- im Postfach geloescht is_deleted TINYINT(1) NOT NULL DEFAULT 0, -- im Postfach geloescht
crm_deleted_at DATETIME NULL, -- im CRM in den Papierkorb gelegt (UTC)
crm_deleted_by INT UNSIGNED NULL, -- wer; bewusst ohne Fremdschluessel, s.u.
crm_purged TINYINT(1) NOT NULL DEFAULT 0, -- Papierkorb geleert
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_graph (graph_id_hash), UNIQUE KEY uq_crm_mail_graph (graph_id_hash),
KEY idx_crm_mail_received (received_at), KEY idx_crm_mail_received (received_at),
KEY idx_crm_mail_from (from_email), KEY idx_crm_mail_from (from_email),
KEY idx_crm_mail_conversation (conversation_id(191)) KEY idx_crm_mail_conversation (conversation_id(191)),
KEY idx_crm_mail_crm_deleted (crm_deleted_at)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Papierkorb des CRM. Zwei Stufen, weil sie zwei verschiedene Dinge bedeuten:
*
* crm_deleted_at - liegt im Papierkorb, laesst sich zurueckholen.
* crm_purged - Papierkorb geleert. Die Zeile bleibt als Merker stehen,
* ihr Inhalt wird geleert. Ohne diesen Merker wuerde der
* naechste Abgleich die Mail aus dem Postfach - dort ist sie
* ja weiterhin vorhanden - sofort wieder einlesen.
*
* crm_deleted_by traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
* ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
* Verschwindet der Benutzer, bleibt hier eine Id ohne Namen stehen - der Merker
* ist die Loeschung selbst, nicht die Person.
*/
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_deleted_at DATETIME NULL AFTER is_deleted;
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_deleted_by INT UNSIGNED NULL AFTER crm_deleted_at;
ALTER TABLE crm_mail_messages ADD COLUMN IF NOT EXISTS crm_purged TINYINT(1) NOT NULL DEFAULT 0 AFTER crm_deleted_by;
ALTER TABLE crm_mail_messages ADD INDEX IF NOT EXISTS idx_crm_mail_crm_deleted (crm_deleted_at);
/* /*
* Verknuepfung einer Mail mit Kontakt und/oder Opportunity. Absichtlich eine * Verknuepfung einer Mail mit Kontakt und/oder Opportunity. Absichtlich eine
* eigene Tabelle statt eines Textanhangs im Notizfeld: so laesst sich eine * eigene Tabelle statt eines Textanhangs im Notizfeld: so laesst sich eine
+14 -5
View File
@@ -87,7 +87,7 @@ const App: React.FC = () => {
if (!silent) setIsDataLoading(true); if (!silent) setIsDataLoading(true);
try { try {
// Die Benutzerliste braucht jeder: Netzwerkdiagramm, Zustaendigkeit, // Die Benutzerliste braucht jeder: Netzwerkdiagramm, Zustaendigkeit,
// @-Mentions, Teilnehmer und Farben im Kalender. /users/directory ist // @-Mentions, Teilnehmer und Farben der Termine. /users/directory ist
// deshalb - anders als /users - fuer alle Rollen freigegeben. // deshalb - anders als /users - fuer alle Rollen freigegeben.
const [leadRows, connectionRows, eventRows, userRows, opportunityRows] = await Promise.all([ const [leadRows, connectionRows, eventRows, userRows, opportunityRows] = await Promise.all([
leadsApi.list(), leadsApi.list(),
@@ -118,7 +118,7 @@ const App: React.FC = () => {
if (!user) return <LoginScreen />; if (!user) return <LoginScreen />;
/* /*
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Kalender, * Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Interviews,
* Netzwerk, Mailzuordnung - gilt nur der gepflegte Bestand; die Funde aus dem * Netzwerk, Mailzuordnung - gilt nur der gepflegte Bestand; die Funde aus dem
* Postfach sind Rohmaterial und sollen in keiner Auswahlliste auftauchen. * Postfach sind Rohmaterial und sollen in keiner Auswahlliste auftauchen.
*/ */
@@ -139,6 +139,7 @@ const App: React.FC = () => {
// mit, deshalb steht hier nur, worum es auf der Seite geht. // mit, deshalb steht hier nur, worum es auf der Seite geht.
const STATIC_SUBTITLES: Partial<Record<ViewState, string>> = { const STATIC_SUBTITLES: Partial<Record<ViewState, string>> = {
Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen', Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen',
Papierkorb: 'Im CRM gelöschte Mails - im Postfach crm@perinco.de bleiben sie erhalten',
Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen', Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen',
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer', Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen', Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
@@ -159,8 +160,8 @@ const App: React.FC = () => {
? `${mailPoolLeads.length} ${ ? `${mailPoolLeads.length} ${
mailPoolLeads.length === 1 ? 'Fund' : 'Funde' mailPoolLeads.length === 1 ? 'Fund' : 'Funde'
} aus dem Postfach · noch nicht in den Kontakten` } aus dem Postfach · noch nicht in den Kontakten`
: currentView === 'Kalender' : currentView === 'Interviews'
? `Gemeinsamer Teamkalender · ${events.length} ${ ? `Gemeinsame Termine im Team · ${events.length} ${
events.length === 1 ? 'Termin' : 'Termine' events.length === 1 ? 'Termin' : 'Termine'
} · ${upcomingCount} anstehend` } · ${upcomingCount} anstehend`
: STATIC_SUBTITLES[currentView]; : STATIC_SUBTITLES[currentView];
@@ -193,7 +194,7 @@ const App: React.FC = () => {
reload={() => loadData(true)} reload={() => loadData(true)}
/> />
)} )}
{currentView === 'Kalender' && ( {currentView === 'Interviews' && (
<Calendar <Calendar
events={events} events={events}
leads={leads} leads={leads}
@@ -226,6 +227,14 @@ const App: React.FC = () => {
{currentView === 'Mails' && ( {currentView === 'Mails' && (
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} /> <Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
)} )}
{currentView === 'Papierkorb' && (
<Mails
leads={leads}
opportunities={opportunities}
reload={() => loadData(true)}
mode="papierkorb"
/>
)}
{currentView === 'Scanner' && <ScannerDashboard />} {currentView === 'Scanner' && <ScannerDashboard />}
{currentView === 'Unpassend' && <ScannerRejected />} {currentView === 'Unpassend' && <ScannerRejected />}
{currentView === 'Suchagenten' && <ScannerAgents />} {currentView === 'Suchagenten' && <ScannerAgents />}
+21 -3
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { CONTACT_VIEWS, SCANNER_VIEWS, type ViewState } from '../types'; import { CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import { UserAvatar } from './ui/UserAvatar'; import { UserAvatar } from './ui/UserAvatar';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
@@ -51,6 +51,22 @@ const CONTACTS_GROUP: MenuGroup = {
], ],
}; };
const TRASH_ICON =
'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16';
/**
* Mails. Darunter klappt der Papierkorb auf: Mails, die im CRM geloescht wurden.
* Im Postfach bleiben sie stehen - der Abgleich holt sie aber nicht wieder.
*/
const MAILS_GROUP: MenuGroup = {
item: { name: 'Mails', icon: MAIL_ICON },
views: MAIL_VIEWS,
children: [
{ name: 'Mails', label: 'Postfach', icon: MAIL_ICON },
{ name: 'Papierkorb', icon: TRASH_ICON },
],
};
/** /**
* Projekt Scanner. Wie bei den Kontakten klappen die Reiter darunter auf - * Projekt Scanner. Wie bei den Kontakten klappen die Reiter darunter auf -
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners * "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
@@ -88,11 +104,13 @@ const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z', icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
}, },
{ {
name: 'Kalender', // Der Terminkalender heisst im CRM "Interviews": genutzt wird er fuer
// Gespraechstermine, nicht als allgemeiner Kalender.
name: 'Interviews',
icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z', icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
}, },
CONTACTS_GROUP, CONTACTS_GROUP,
{ name: 'Mails', icon: MAIL_ICON }, MAILS_GROUP,
SCANNER_GROUP, SCANNER_GROUP,
]; ];
+2 -2
View File
@@ -884,7 +884,7 @@ export const Calendar: React.FC<CalendarProps> = ({
</div> </div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
{/* Kalender */} {/* Terminraster */}
<div className="space-y-3"> <div className="space-y-3">
{isLoading && events.length === 0 ? ( {isLoading && events.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24"> <div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
@@ -940,7 +940,7 @@ export const Calendar: React.FC<CalendarProps> = ({
<div className="scrollbar-slim max-h-[70vh] flex-1 overflow-y-auto p-3"> <div className="scrollbar-slim max-h-[70vh] flex-1 overflow-y-auto p-3">
{upcomingGroups.length === 0 ? ( {upcomingGroups.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-10 text-center text-xs font-semibold text-ink-400"> <p className="rounded-xl border border-dashed border-ink-200 px-4 py-10 text-center text-xs font-semibold text-ink-400">
Nichts geplant. Lege links im Kalender einen Termin an. Nichts geplant. Lege links im Terminraster einen Termin an.
</p> </p>
) : ( ) : (
<ul className="space-y-4"> <ul className="space-y-4">
+2 -2
View File
@@ -131,7 +131,7 @@ export const EventModal: React.FC<EventModalProps> = ({
subtitle={ subtitle={
event event
? `Angelegt von ${event.created_by_name ?? 'unbekannt'}` ? `Angelegt von ${event.created_by_name ?? 'unbekannt'}`
: 'Sichtbar für alle Benutzer im Teamkalender' : 'Sichtbar für alle Benutzer unter Interviews'
} }
onClose={onClose} onClose={onClose}
size="lg" size="lg"
@@ -247,7 +247,7 @@ export const EventModal: React.FC<EventModalProps> = ({
Teilnehmer Teilnehmer
</h3> </h3>
<p className="mb-3 text-xs text-ink-400"> <p className="mb-3 text-xs text-ink-400">
Mehrfachauswahl möglich. Die Farbe des Termins im Kalender ist die des Erstellers. Mehrfachauswahl möglich. Die Farbe des Termins ist die des Erstellers.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{users.map((user) => { {users.map((user) => {
+2 -2
View File
@@ -18,7 +18,7 @@ interface UserDetailModalProps {
onOpenLead: (leadId: number) => void; onOpenLead: (leadId: number) => void;
} }
/** Detailfenster fuer einen im Kalender markierten Benutzer. */ /** Detailfenster fuer einen in einem Termin markierten Benutzer. */
export const UserDetailModal: React.FC<UserDetailModalProps> = ({ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
user, user,
leads, leads,
@@ -78,7 +78,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
<p className="truncate text-sm text-ink-400">{user.email || 'Keine E-Mail hinterlegt'}</p> <p className="truncate text-sm text-ink-400">{user.email || 'Keine E-Mail hinterlegt'}</p>
<p className="mt-1 flex items-center gap-2 text-xs font-bold text-ink-400"> <p className="mt-1 flex items-center gap-2 text-xs font-bold text-ink-400">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} /> <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
Kalenderfarbe Terminfarbe
</p> </p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Avatar, StatusBadge } from './leadUi'; import { Avatar, KindBadge } from './leadUi';
import { leadFullName, leadInitials, type Lead } from '../../types'; import { leadFullName, leadInitials, type Lead } from '../../types';
interface LeadGridProps { interface LeadGridProps {
@@ -77,7 +77,7 @@ export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEd
{lead.position && <p className="truncate text-xs font-semibold text-ink-500">{lead.position}</p>} {lead.position && <p className="truncate text-xs font-semibold text-ink-500">{lead.position}</p>}
{lead.company && <p className="truncate text-xs text-ink-400">{lead.company}</p>} {lead.company && <p className="truncate text-xs text-ink-400">{lead.company}</p>}
<div className="mt-2"> <div className="mt-2">
<StatusBadge status={lead.status} /> <KindBadge kind={lead.kind} />
</div> </div>
</div> </div>
</div> </div>
+4 -4
View File
@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { Avatar, StatusBadge } from './leadUi'; import { Avatar, KindBadge } from './leadUi';
import { leadFullName, leadInitials, type Lead } from '../../types'; import { leadFullName, leadInitials, type Lead } from '../../types';
export type SortKey = 'name' | 'company' | 'status' | 'connections' | 'updated_at'; export type SortKey = 'name' | 'company' | 'kind' | 'connections' | 'updated_at';
export interface SortConfig { export interface SortConfig {
key: SortKey; key: SortKey;
@@ -21,7 +21,7 @@ interface LeadListProps {
const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [ const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [
{ key: 'name', label: 'Kontakt' }, { key: 'name', label: 'Kontakt' },
{ key: 'company', label: 'Unternehmen' }, { key: 'company', label: 'Unternehmen' },
{ key: 'status', label: 'Status' }, { key: 'kind', label: 'Art' },
{ key: null, label: 'Erreichbarkeit' }, { key: null, label: 'Erreichbarkeit' },
{ key: 'connections', label: 'Verbindungen' }, { key: 'connections', label: 'Verbindungen' },
{ key: null, label: 'Angelegt von' }, { key: null, label: 'Angelegt von' },
@@ -92,7 +92,7 @@ export const LeadList: React.FC<LeadListProps> = ({
{lead.city && <p className="truncate text-xs text-ink-400">{lead.city}</p>} {lead.city && <p className="truncate text-xs text-ink-400">{lead.city}</p>}
</td> </td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<StatusBadge status={lead.status} /> <KindBadge kind={lead.kind} />
</td> </td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<div className="space-y-0.5 text-xs"> <div className="space-y-0.5 text-xs">
+13 -10
View File
@@ -6,14 +6,14 @@ import { connectionsApi, leadsApi } from '../../lib/api';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { import {
CONNECTION_TYPES, CONNECTION_TYPES,
LEAD_STATUSES, LEAD_KINDS,
leadFullName, leadFullName,
leadInitials, leadInitials,
type AppUser, type AppUser,
type ConnectionType, type ConnectionType,
type Lead, type Lead,
type LeadConnection, type LeadConnection,
type LeadStatus, type LeadKind,
} from '../../types'; } from '../../types';
interface LeadModalProps { interface LeadModalProps {
@@ -46,7 +46,7 @@ interface FormState {
zip: string; zip: string;
city: string; city: string;
country: string; country: string;
status: LeadStatus; kind: LeadKind;
source: string; source: string;
notes: string; notes: string;
avatar: string; avatar: string;
@@ -72,7 +72,7 @@ function toFormState(lead: Lead | null, fallbackOwnerId: number | null): FormSta
zip: lead?.zip ?? '', zip: lead?.zip ?? '',
city: lead?.city ?? '', city: lead?.city ?? '',
country: lead?.country ?? '', country: lead?.country ?? '',
status: lead?.status ?? 'Neu', kind: lead?.kind ?? 'Recruiter',
source: lead?.source ?? '', source: lead?.source ?? '',
notes: lead?.notes ?? '', notes: lead?.notes ?? '',
avatar: lead?.avatar ?? '', avatar: lead?.avatar ?? '',
@@ -250,15 +250,18 @@ export const LeadModal: React.FC<LeadModalProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className={labelClass}>Status</label> <label className={labelClass} htmlFor="lead-kind">
Art
</label>
<select <select
value={form.status} id="lead-kind"
onChange={(e) => set('status', e.target.value as LeadStatus)} value={form.kind}
onChange={(e) => set('kind', e.target.value as LeadKind)}
className={inputClass} className={inputClass}
> >
{LEAD_STATUSES.map((status) => ( {LEAD_KINDS.map((kind) => (
<option key={status} value={status}> <option key={kind} value={kind}>
{status} {kind}
</option> </option>
))} ))}
</select> </select>
+3 -3
View File
@@ -11,7 +11,7 @@ import {
type SimulationLinkDatum, type SimulationLinkDatum,
type SimulationNodeDatum, type SimulationNodeDatum,
} from 'd3-force'; } from 'd3-force';
import { STATUS_COLORS } from './leadUi'; import { KIND_COLORS } from './leadUi';
import { connectionsApi } from '../../lib/api'; import { connectionsApi } from '../../lib/api';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { import {
@@ -134,7 +134,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
leadId: lead.id, leadId: lead.id,
label: leadFullName(lead), label: leadFullName(lead),
sublabel: lead.company || lead.position || '', sublabel: lead.company || lead.position || '',
color: STATUS_COLORS[lead.status], color: KIND_COLORS[lead.kind],
degree: deg, degree: deg,
radius: 14 + Math.min(deg, 8) * 1.8, radius: 14 + Math.min(deg, 8) * 1.8,
avatar: lead.avatar, avatar: lead.avatar,
@@ -875,7 +875,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span <span
className="mt-1 h-3 w-3 shrink-0 rounded-full" className="mt-1 h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: STATUS_COLORS[selectedLead.status] }} style={{ backgroundColor: KIND_COLORS[selectedLead.kind] }}
/> />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate text-base text-ink-900">{leadFullName(selectedLead)}</h3> <h3 className="truncate text-base text-ink-900">{leadFullName(selectedLead)}</h3>
+14 -15
View File
@@ -8,14 +8,14 @@ import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { leadsApi } from '../../lib/api'; import { leadsApi } from '../../lib/api';
import { import {
LEAD_STATUSES, LEAD_KINDS,
MAIL_LEAD_SOURCE, MAIL_LEAD_SOURCE,
leadFullName, leadFullName,
type AppUser, type AppUser,
type Lead, type Lead,
type LeadConnection, type LeadConnection,
type LeadOriginFilter, type LeadOriginFilter,
type LeadStatus, type LeadKind,
type LeadViewMode, type LeadViewMode,
} from '../../types'; } from '../../types';
@@ -68,7 +68,7 @@ export const Leads: React.FC<LeadsProps> = ({
const { addToast } = useToast(); const { addToast } = useToast();
const [viewMode, setViewMode] = useState<LeadViewMode>('grid'); const [viewMode, setViewMode] = useState<LeadViewMode>('grid');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<LeadStatus | 'Alle'>('Alle'); const [kindFilter, setKindFilter] = useState<LeadKind | 'Alle'>('Alle');
const [originFilter, setOriginFilter] = useState<LeadOriginFilter>('Alle'); const [originFilter, setOriginFilter] = useState<LeadOriginFilter>('Alle');
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' }); const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' });
@@ -112,7 +112,7 @@ export const Leads: React.FC<LeadsProps> = ({
.filter(Boolean); .filter(Boolean);
const matches = leads.filter((lead) => { const matches = leads.filter((lead) => {
if (statusFilter !== 'Alle' && lead.status !== statusFilter) return false; if (kindFilter !== 'Alle' && lead.kind !== kindFilter) return false;
if (originFilter === 'Mail' && lead.source !== MAIL_LEAD_SOURCE) return false; if (originFilter === 'Mail' && lead.source !== MAIL_LEAD_SOURCE) return false;
if (originFilter === 'Manuell' && lead.source === MAIL_LEAD_SOURCE) return false; if (originFilter === 'Manuell' && lead.source === MAIL_LEAD_SOURCE) return false;
if (terms.length === 0) return true; if (terms.length === 0) return true;
@@ -140,10 +140,8 @@ export const Leads: React.FC<LeadsProps> = ({
return leadFullName(a).localeCompare(leadFullName(b), 'de') * direction; return leadFullName(a).localeCompare(leadFullName(b), 'de') * direction;
case 'company': case 'company':
return (a.company ?? '').localeCompare(b.company ?? '', 'de') * direction; return (a.company ?? '').localeCompare(b.company ?? '', 'de') * direction;
case 'status': case 'kind':
return ( return (LEAD_KINDS.indexOf(a.kind) - LEAD_KINDS.indexOf(b.kind)) * direction;
(LEAD_STATUSES.indexOf(a.status) - LEAD_STATUSES.indexOf(b.status)) * direction
);
case 'connections': case 'connections':
return (connectionCount(a.id) - connectionCount(b.id)) * direction; return (connectionCount(a.id) - connectionCount(b.id)) * direction;
case 'updated_at': case 'updated_at':
@@ -154,7 +152,7 @@ export const Leads: React.FC<LeadsProps> = ({
} }
} }
}); });
}, [leads, searchTerm, statusFilter, originFilter, sortConfig, connectionCounts]); }, [leads, searchTerm, kindFilter, originFilter, sortConfig, connectionCounts]);
// Zaehler fuer den Herkunftsfilter - nur anzeigen, wenn es solche Kontakte gibt. // Zaehler fuer den Herkunftsfilter - nur anzeigen, wenn es solche Kontakte gibt.
const mailLeadCount = useMemo( const mailLeadCount = useMemo(
@@ -231,14 +229,15 @@ export const Leads: React.FC<LeadsProps> = ({
</div> </div>
<select <select
value={statusFilter} value={kindFilter}
onChange={(event) => setStatusFilter(event.target.value as LeadStatus | 'Alle')} onChange={(event) => setKindFilter(event.target.value as LeadKind | 'Alle')}
aria-label="Nach Art des Kontakts filtern"
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500" className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
> >
<option value="Alle">Alle Status</option> <option value="Alle">Alle Arten</option>
{LEAD_STATUSES.map((status) => ( {LEAD_KINDS.map((kind) => (
<option key={status} value={status}> <option key={kind} value={kind}>
{status} {kind}
</option> </option>
))} ))}
</select> </select>
+1 -1
View File
@@ -280,7 +280,7 @@ export const MailPoolLeads: React.FC<MailPoolLeadsProps> = ({
<p className="rounded-xl border border-ink-200 bg-surface px-4 py-3 text-xs leading-relaxed text-ink-500"> <p className="rounded-xl border border-ink-200 bg-surface px-4 py-3 text-xs leading-relaxed text-ink-500">
Diese Personen hat der Postfachabgleich in Mails gefunden. Sie stehen noch nicht in den Kontakten Diese Personen hat der Postfachabgleich in Mails gefunden. Sie stehen noch nicht in den Kontakten
und tauchen weder in Opportunities noch im Kalender auf.{' '} und tauchen weder in Opportunities noch unter Interviews auf.{' '}
<span className="font-bold text-amber-700">Gold markierte Einträge</span> gibt es dort schon – bei <span className="font-bold text-amber-700">Gold markierte Einträge</span> gibt es dort schon – bei
ihnen lassen sich abweichende Angaben einzeln in den vorhandenen Kontakt übernehmen. ihnen lassen sich abweichende Angaben einzeln in den vorhandenen Kontakt übernehmen.
</p> </p>
+14 -20
View File
@@ -1,34 +1,28 @@
import React from 'react'; import React from 'react';
import type { LeadStatus } from '../../types'; import type { LeadKind } from '../../types';
/** Statusfarben: Gewonnen im CI-Gruen, der Rest bewusst zurueckhaltend. */ /** Farben der Kontaktart: der Endkunde im CI-Gruen, der Rest daneben. */
export const STATUS_STYLES: Record<LeadStatus, string> = { export const KIND_STYLES: Record<LeadKind, string> = {
Neu: 'bg-ink-100 text-ink-600 border-ink-200', Recruiter: 'bg-sky-50 text-sky-700 border-sky-200',
Kontaktiert: 'bg-sky-50 text-sky-700 border-sky-200', Partner: 'bg-violet-50 text-violet-700 border-violet-200',
Qualifiziert: 'bg-amber-50 text-amber-700 border-amber-200', Endkunde: 'bg-brand-50 text-brand-700 border-brand-200',
Angebot: 'bg-violet-50 text-violet-700 border-violet-200',
Gewonnen: 'bg-brand-50 text-brand-700 border-brand-200',
Verloren: 'bg-red-50 text-red-700 border-red-200',
}; };
/** Knotenfarbe im Netzwerkdiagramm - gleiche Logik wie das Badge. */ /** Knotenfarbe im Netzwerkdiagramm - gleiche Logik wie das Badge. */
export const STATUS_COLORS: Record<LeadStatus, string> = { export const KIND_COLORS: Record<LeadKind, string> = {
Neu: '#9a9a9a', Recruiter: '#0284c7',
Kontaktiert: '#0284c7', Partner: '#7c3aed',
Qualifiziert: '#d97706', Endkunde: '#6ba04a',
Angebot: '#7c3aed',
Gewonnen: '#6ba04a',
Verloren: '#dc2626',
}; };
export const StatusBadge: React.FC<{ status: LeadStatus; className?: string }> = ({ export const KindBadge: React.FC<{ kind: LeadKind; className?: string }> = ({
status, kind,
className = '', className = '',
}) => ( }) => (
<span <span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${STATUS_STYLES[status]} ${className}`} className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${KIND_STYLES[kind]} ${className}`}
> >
{status} {kind}
</span> </span>
); );
+41 -2
View File
@@ -21,9 +21,14 @@ import { mailApi, formatMailDateTime, type MailCandidate, type MailMessageDetail
interface MailDetailProps { interface MailDetailProps {
message: MailMessageDetail; message: MailMessageDetail;
isLoading: boolean; isLoading: boolean;
onLink: () => void; /** Fehlt im Papierkorb: was geloescht ist, wird nicht zugeordnet. */
onLink?: () => void;
onReload: () => void; onReload: () => void;
onOpenLead: (leadId: number) => void; onOpenLead: (leadId: number) => void;
/** Mail in den Papierkorb legen - nur im Postfach angeboten. */
onTrash?: () => void;
/** Mail aus dem Papierkorb zurueckholen. */
onRestore?: () => void;
} }
const AddressRow: React.FC<{ label: string; value: string }> = ({ label, value }) => ( const AddressRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
@@ -39,6 +44,8 @@ export const MailDetail: React.FC<MailDetailProps> = ({
onLink, onLink,
onReload, onReload,
onOpenLead, onOpenLead,
onTrash,
onRestore,
}) => { }) => {
const { addToast } = useToast(); const { addToast } = useToast();
@@ -82,12 +89,31 @@ export const MailDetail: React.FC<MailDetailProps> = ({
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-2"> <div className="flex shrink-0 flex-col items-end gap-2">
{onLink && (
<button <button
onClick={onLink} onClick={onLink}
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600" className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
> >
Zuordnen Zuordnen
</button> </button>
)}
{onRestore && (
<button
onClick={onRestore}
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
Wiederherstellen
</button>
)}
{onTrash && (
<button
onClick={onTrash}
title="Aus dem CRM entfernen. Im Postfach crm@perinco.de bleibt die Mail erhalten."
className="whitespace-nowrap rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
>
Löschen
</button>
)}
{message.web_link && ( {message.web_link && (
<a <a
href={message.web_link} href={message.web_link}
@@ -107,7 +133,10 @@ export const MailDetail: React.FC<MailDetailProps> = ({
{message.bcc.length > 0 && <AddressRow label="Bcc" value={formatAddresses(message.bcc, 6)} />} {message.bcc.length > 0 && <AddressRow label="Bcc" value={formatAddresses(message.bcc, 6)} />}
</div> </div>
{(message.has_attachments || message.is_deleted || message.importance === 'high') && ( {(message.has_attachments ||
message.is_deleted ||
message.crm_deleted_at !== null ||
message.importance === 'high') && (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{message.has_attachments && ( {message.has_attachments && (
<Badge className="border-ink-200 bg-ink-100 text-ink-600" title="Anhänge werden in Phase 1 nicht übernommen"> <Badge className="border-ink-200 bg-ink-100 text-ink-600" title="Anhänge werden in Phase 1 nicht übernommen">
@@ -125,6 +154,16 @@ export const MailDetail: React.FC<MailDetailProps> = ({
Im Postfach gelöscht Im Postfach gelöscht
</Badge> </Badge>
)} )}
{message.crm_deleted_at !== null && (
<Badge
className="border-red-200 bg-red-50 text-red-700"
title={`Liegt im Papierkorb des CRM${
message.crm_deleted_by_name ? ` - gelöscht von ${message.crm_deleted_by_name}` : ''
}. Im Postfach ist die Mail unberührt.`}
>
Im Papierkorb
</Badge>
)}
</div> </div>
)} )}
</section> </section>
+109 -9
View File
@@ -3,6 +3,7 @@ import { MailList } from './MailList';
import { MailDetail } from './MailDetail'; import { MailDetail } from './MailDetail';
import { MailLinkDialog } from './MailLinkDialog'; import { MailLinkDialog } from './MailLinkDialog';
import { CandidateRow } from './CandidateRow'; import { CandidateRow } from './CandidateRow';
import { ConfirmDialog } from '../ui/Modal';
import { Badge, emptyStateClass } from './mailUi'; import { Badge, emptyStateClass } from './mailUi';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth'; import { useAuth } from '../../lib/auth';
@@ -24,6 +25,10 @@ import type { Lead, Opportunity } from '../../types';
* Zwei Bereiche: die Mailliste mit Detailansicht und die Sammelliste der offenen * Zwei Bereiche: die Mailliste mit Detailansicht und die Sammelliste der offenen
* Kontakt-Vorschlaege. Letztere ist die eigentliche Arbeitsliste - dort wird * Kontakt-Vorschlaege. Letztere ist die eigentliche Arbeitsliste - dort wird
* entschieden, was Kontakt wird und was nicht. * entschieden, was Kontakt wird und was nicht.
*
* Derselbe Aufbau traegt den Papierkorb (`mode = 'papierkorb'`): Liste und
* Detailansicht sind dieselben, nur die Bedienelemente wechseln. Ein eigener
* Nachbau waere doppelte Pflege bei jeder Aenderung an der Mailansicht.
*/ */
interface MailsProps { interface MailsProps {
@@ -31,6 +36,8 @@ interface MailsProps {
opportunities: Opportunity[]; opportunities: Opportunity[];
/** Kontakte im CRM neu laden - nach Uebernahme eines Vorschlags. */ /** Kontakte im CRM neu laden - nach Uebernahme eines Vorschlags. */
reload: () => void; reload: () => void;
/** 'papierkorb' zeigt die im CRM geloeschten Mails. */
mode?: 'postfach' | 'papierkorb';
} }
type Tab = 'postfach' | 'vorschlaege'; type Tab = 'postfach' | 'vorschlaege';
@@ -117,7 +124,8 @@ const StatusBanner: React.FC<{ status: MailStatus; isAdmin: boolean; onOpenSetti
return null; return null;
}; };
export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) => { export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload, mode = 'postfach' }) => {
const isTrash = mode === 'papierkorb';
const { addToast } = useToast(); const { addToast } = useToast();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
@@ -141,6 +149,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
const [candidates, setCandidates] = useState<MailCandidate[]>([]); const [candidates, setCandidates] = useState<MailCandidate[]>([]);
const [isCandidatesLoading, setIsCandidatesLoading] = useState(false); const [isCandidatesLoading, setIsCandidatesLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [isEmptyTrashOpen, setIsEmptyTrashOpen] = useState(false);
// Die Suche soll nicht bei jedem Tastendruck eine Abfrage aufloesen. // Die Suche soll nicht bei jedem Tastendruck eine Abfrage aufloesen.
useEffect(() => { useEffect(() => {
@@ -165,6 +174,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
filter, filter,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: nextOffset, offset: nextOffset,
trash: isTrash,
}); });
setMessages(data.messages); setMessages(data.messages);
setTotal(data.total); setTotal(data.total);
@@ -175,7 +185,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
setIsListLoading(false); setIsListLoading(false);
} }
}, },
[debouncedSearch, filter, addToast] [debouncedSearch, filter, isTrash, addToast]
); );
const loadCandidates = useCallback(async () => { const loadCandidates = useCallback(async () => {
@@ -259,6 +269,53 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
} }
}; };
/*
* Nach dem Loeschen, Zurueckholen oder Leeren faellt die Mail aus der gerade
* gezeigten Liste. Die Auswahl wird deshalb aufgehoben und die Liste von vorn
* geladen - bewusst nicht ueber refreshAll(), das wuerde die Detailansicht der
* eben entfernten Mail nachladen und in einen 404 laufen.
*/
const refreshAfterTrashChange = useCallback(async () => {
setSelectedId(null);
setDetail(null);
await Promise.all([loadStatus(), loadCandidates(), loadMessages(0, true)]);
reload();
}, [loadStatus, loadCandidates, loadMessages, reload]);
const trashMessage = async (id: number) => {
try {
await mailApi.trashMessage(id);
addToast('Mail in den Papierkorb gelegt.', 'success');
await refreshAfterTrashChange();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
}
};
const restoreMessage = async (id: number) => {
try {
await mailApi.restoreMessage(id);
addToast('Mail wiederhergestellt.', 'success');
await refreshAfterTrashChange();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Wiederherstellen fehlgeschlagen.', 'error');
}
};
const emptyTrash = async () => {
setIsEmptyTrashOpen(false);
try {
const result = await mailApi.emptyTrash();
addToast(
result.purged === 1 ? '1 Mail endgültig gelöscht.' : `${result.purged} Mails endgültig gelöscht.`,
'success'
);
await refreshAfterTrashChange();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Leeren fehlgeschlagen.', 'error');
}
};
const openLead = (leadId: number) => goTo('Kontakte', { leadId }); const openLead = (leadId: number) => goTo('Kontakte', { leadId });
const openCandidateCount = status?.counts.open_candidates ?? candidates.length; const openCandidateCount = status?.counts.open_candidates ?? candidates.length;
@@ -282,6 +339,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
{/* Steuerleiste */} {/* Steuerleiste */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
{!isTrash && (
<div className="flex rounded-xl border border-ink-200 bg-surface p-1"> <div className="flex rounded-xl border border-ink-200 bg-surface p-1">
<button <button
onClick={() => setTab('postfach')} onClick={() => setTab('postfach')}
@@ -311,6 +369,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
)} )}
</button> </button>
</div> </div>
)}
{tab === 'postfach' && ( {tab === 'postfach' && (
<> <>
@@ -337,6 +396,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
</svg> </svg>
</div> </div>
{!isTrash && (
<select <select
value={filter} value={filter}
onChange={(event) => setFilter(event.target.value as MailListFilter)} onChange={(event) => setFilter(event.target.value as MailListFilter)}
@@ -349,6 +409,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
</option> </option>
))} ))}
</select> </select>
)}
</> </>
)} )}
@@ -358,7 +419,16 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
Stand {formatMailDateTime(status.last_success_at)} Stand {formatMailDateTime(status.last_success_at)}
</span> </span>
)} )}
{isAdmin && status?.configured && ( {isTrash && (
<button
onClick={() => setIsEmptyTrashOpen(true)}
disabled={messages.length === 0}
className="whitespace-nowrap rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700 disabled:opacity-40"
>
Papierkorb leeren
</button>
)}
{!isTrash && isAdmin && status?.configured && (
<button <button
onClick={runSync} onClick={runSync}
disabled={isSyncing || status.running} disabled={isSyncing || status.running}
@@ -390,11 +460,27 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
</div> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className={emptyStateClass}> <div className={emptyStateClass}>
{status?.configured {isTrash ? (
? debouncedSearch || filter !== 'alle' debouncedSearch ? (
? 'Keine Mails passen zur Auswahl.' 'Keine Mails im Papierkorb passen zur Suche.'
: 'Noch keine Mails abgeglichen.' ) : (
: 'Sobald die Anbindung eingerichtet ist, erscheinen hier die Mails aus crm@perinco.de.'} <>
Der Papierkorb ist leer.
<p className="mx-auto mt-2 max-w-md text-xs font-normal text-ink-400">
Gelöschte Mails landen hier und lassen sich zurückholen. Im Postfach
crm@perinco.de bleiben sie in jedem Fall erhalten.
</p>
</>
)
) : status?.configured ? (
debouncedSearch || filter !== 'alle' ? (
'Keine Mails passen zur Auswahl.'
) : (
'Noch keine Mails abgeglichen.'
)
) : (
'Sobald die Anbindung eingerichtet ist, erscheinen hier die Mails aus crm@perinco.de.'
)}
</div> </div>
) : ( ) : (
<div className="grid gap-4 xl:grid-cols-[380px_1fr]"> <div className="grid gap-4 xl:grid-cols-[380px_1fr]">
@@ -429,9 +515,11 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
<MailDetail <MailDetail
message={detail} message={detail}
isLoading={isDetailLoading} isLoading={isDetailLoading}
onLink={() => setIsLinkOpen(true)} onLink={isTrash ? undefined : () => setIsLinkOpen(true)}
onReload={refreshAll} onReload={refreshAll}
onOpenLead={openLead} onOpenLead={openLead}
onTrash={isTrash ? undefined : () => trashMessage(detail.id)}
onRestore={isTrash ? () => restoreMessage(detail.id) : undefined}
/> />
) : ( ) : (
<div className={emptyStateClass}>Eine Mail auswählen.</div> <div className={emptyStateClass}>Eine Mail auswählen.</div>
@@ -483,6 +571,18 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
</section> </section>
)} )}
{isEmptyTrashOpen && (
<ConfirmDialog
title="Papierkorb endgültig leeren?"
message={`${total} ${
total === 1 ? 'Mail wird' : 'Mails werden'
} unwiderruflich aus dem CRM entfernt - samt ihrer Zuordnungen zu Kontakten und Opportunities. Im Postfach crm@perinco.de bleiben die Mails erhalten, das CRM holt sie aber nicht wieder herein.`}
confirmLabel="Endgültig löschen"
onConfirm={emptyTrash}
onCancel={() => setIsEmptyTrashOpen(false)}
/>
)}
{isLinkOpen && detail && ( {isLinkOpen && detail && (
<MailLinkDialog <MailLinkDialog
message={detail} message={detail}
+1 -1
View File
@@ -9,7 +9,7 @@ interface ColorPickerProps {
fallback: string; fallback: string;
} }
/** Auswahl der persoenlichen Kalenderfarbe. */ /** Auswahl der persoenlichen Terminfarbe. */
export const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange, fallback }) => ( export const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange, fallback }) => (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
+2 -2
View File
@@ -111,7 +111,7 @@ const AccountTab: React.FC = () => {
</dl> </dl>
<div className="mt-6 border-t border-ink-100 pt-5"> <div className="mt-6 border-t border-ink-100 pt-5">
<h4 className="text-sm font-extrabold text-ink-900">Meine Kalenderfarbe</h4> <h4 className="text-sm font-extrabold text-ink-900">Meine Terminfarbe</h4>
<p className="mb-3 text-xs text-ink-400"> <p className="mb-3 text-xs text-ink-400">
Mit dieser Farbe erscheinen die von dir angelegten Termine im Teamkalender. Mit dieser Farbe erscheinen die von dir angelegten Termine im Teamkalender.
</p> </p>
@@ -119,7 +119,7 @@ const AccountTab: React.FC = () => {
<ColorPicker <ColorPicker
value={user?.color ?? null} value={user?.color ?? null}
fallback={userColor(user ? { id: user.id, color: null } : null)} fallback={userColor(user ? { id: user.id, color: null } : null)}
onChange={(color) => saveProfile({ color }, 'Kalenderfarbe gespeichert.')} onChange={(color) => saveProfile({ color }, 'Terminfarbe gespeichert.')}
/> />
</div> </div>
</div> </div>
+1 -1
View File
@@ -330,7 +330,7 @@ export const UserManagement: React.FC = () => {
/> />
</div> </div>
<div> <div>
<label className={labelClass}>Kalenderfarbe</label> <label className={labelClass}>Terminfarbe</label>
<ColorPicker <ColorPicker
value={form.color} value={form.color}
fallback={userColor(editingUser ? { id: editingUser.id, color: null } : { id: 0 })} fallback={userColor(editingUser ? { id: editingUser.id, color: null } : { id: 0 })}
+2 -2
View File
@@ -21,8 +21,8 @@ interface UserAvatarProps {
} }
/** /**
* Foto des Benutzers, sonst die Initiale auf seiner Kalenderfarbe. Damit sehen * Foto des Benutzers, sonst die Initiale auf seiner Terminfarbe. Damit sehen
* Benutzer ueberall gleich aus - Sidebar, Kalender, Nutzerverwaltung. * Benutzer ueberall gleich aus - Sidebar, Interviews, Nutzerverwaltung.
*/ */
export const UserAvatar: React.FC<UserAvatarProps> = ({ export const UserAvatar: React.FC<UserAvatarProps> = ({
user, user,
+3 -3
View File
@@ -85,7 +85,7 @@ export const authApi = {
}), }),
me: () => request<{ user: AppUser }>('/auth/me'), me: () => request<{ user: AppUser }>('/auth/me'),
/** /**
* Eigene Kalenderfarbe und eigenes Foto setzen - ohne Adminrechte. * Eigene Terminfarbe und eigenes Foto setzen - ohne Adminrechte.
* null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale. * null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale.
*/ */
updateMe: (input: { color?: string | null; avatar?: string | null }) => updateMe: (input: { color?: string | null; avatar?: string | null }) =>
@@ -113,7 +113,7 @@ export interface UserInput {
export const usersApi = { export const usersApi = {
list: () => request<AppUser[]>('/users'), list: () => request<AppUser[]>('/users'),
/** /**
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Der Kalender braucht * Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Die Terminansicht braucht
* die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user". * die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user".
*/ */
directory: () => request<AppUser[]>('/users/directory'), directory: () => request<AppUser[]>('/users/directory'),
@@ -197,7 +197,7 @@ export const opportunitiesApi = {
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }), remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
}; };
// ---------- Kalender ---------- // ---------- Interviews (Termine) ----------
export interface EventInput { export interface EventInput {
title: string; title: string;
+29 -1
View File
@@ -23,7 +23,11 @@ export interface MailMessageSummary {
received_at: string; received_at: string;
body_preview: string | null; body_preview: string | null;
has_attachments: boolean; has_attachments: boolean;
/** Im Postfach geloescht - nicht zu verwechseln mit `crm_deleted_at`. */
is_deleted: boolean; is_deleted: boolean;
/** Gesetzt = liegt im Papierkorb des CRM (UTC). */
crm_deleted_at: string | null;
crm_deleted_by_name: string | null;
web_link: string | null; web_link: string | null;
link_count: number; link_count: number;
open_candidates: number; open_candidates: number;
@@ -115,6 +119,8 @@ export interface MailStatus {
last_candidates: number; last_candidates: number;
counts: { counts: {
messages: number; messages: number;
/** Mails im Papierkorb des CRM. */
trash: number;
open_candidates: number; open_candidates: number;
adopted_candidates: number; adopted_candidates: number;
links: number; links: number;
@@ -133,6 +139,8 @@ export interface MailSyncResult {
deletedMessages?: number; deletedMessages?: number;
newCandidates?: number; newCandidates?: number;
adoptedContacts?: number; adoptedContacts?: number;
/** Im CRM geloeschte Mails, die der Abgleich deshalb nicht angefasst hat. */
skippedTrashed?: number;
hasMore?: boolean; hasMore?: boolean;
error?: string; error?: string;
} }
@@ -171,12 +179,22 @@ export type MailListFilter = 'alle' | 'unlinked' | 'linked' | 'candidates';
export const mailApi = { export const mailApi = {
status: () => apiRequest<MailStatus>('/mail/status'), status: () => apiRequest<MailStatus>('/mail/status'),
messages: (params: { search?: string; filter?: MailListFilter; limit?: number; offset?: number } = {}) => { messages: (
params: {
search?: string;
filter?: MailListFilter;
limit?: number;
offset?: number;
/** true = nur der Papierkorb, sonst alles ausser dem Papierkorb. */
trash?: boolean;
} = {}
) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params.search) query.set('search', params.search); if (params.search) query.set('search', params.search);
if (params.filter && params.filter !== 'alle') query.set('filter', params.filter); if (params.filter && params.filter !== 'alle') query.set('filter', params.filter);
if (params.limit) query.set('limit', String(params.limit)); if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset)); if (params.offset) query.set('offset', String(params.offset));
if (params.trash) query.set('trash', 'true');
const suffix = query.toString() ? `?${query}` : ''; const suffix = query.toString() ? `?${query}` : '';
return apiRequest<{ total: number; limit: number; offset: number; messages: MailMessageSummary[] }>( return apiRequest<{ total: number; limit: number; offset: number; messages: MailMessageSummary[] }>(
`/mail/messages${suffix}` `/mail/messages${suffix}`
@@ -193,6 +211,16 @@ export const mailApi = {
unlink: (linkId: number) => apiRequest<{ ok: true }>(`/mail/links/${linkId}`, { method: 'DELETE' }), unlink: (linkId: number) => apiRequest<{ ok: true }>(`/mail/links/${linkId}`, { method: 'DELETE' }),
/**
* Papierkorb. Geloescht wird ausschliesslich im CRM - das Postfach
* crm@perinco.de bleibt unberuehrt, der Abgleich holt geloeschte Mails aber
* nicht wieder herein.
*/
trashMessage: (id: number) => apiRequest<{ ok: true }>(`/mail/messages/${id}`, { method: 'DELETE' }),
restoreMessage: (id: number) =>
apiRequest<{ ok: true }>(`/mail/messages/${id}/restore`, { method: 'POST' }),
emptyTrash: () => apiRequest<{ ok: true; purged: number }>('/mail/trash/empty', { method: 'POST' }),
candidates: (params: { status?: CandidateStatus | 'alle'; limit?: number } = {}) => { candidates: (params: { status?: CandidateStatus | 'alle'; limit?: number } = {}) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set('status', params.status ?? 'offen'); query.set('status', params.status ?? 'offen');
+22 -15
View File
@@ -1,9 +1,10 @@
export type ViewState = export type ViewState =
| 'Opportunities' | 'Opportunities'
| 'Kalender' | 'Interviews'
| 'Kontakte' | 'Kontakte'
| 'Aus Mails' | 'Aus Mails'
| 'Mails' | 'Mails'
| 'Papierkorb'
| 'Scanner' | 'Scanner'
| 'Unpassend' | 'Unpassend'
| 'Suchagenten' | 'Suchagenten'
@@ -37,6 +38,13 @@ export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten'
*/ */
export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails']; export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails'];
/**
* Mailbereich. "Mails" ist das gespiegelte Postfach, "Papierkorb" enthaelt die
* im CRM geloeschten Mails. Geloescht heisst nur: das CRM listet sie nicht mehr -
* im Postfach crm@perinco.de bleiben sie unberuehrt.
*/
export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
export type Role = 'admin' | 'user'; export type Role = 'admin' | 'user';
export interface AppUser { export interface AppUser {
@@ -45,7 +53,7 @@ export interface AppUser {
email: string | null; email: string | null;
name: string; name: string;
role: Role; role: Role;
/** Persoenliche Kalenderfarbe als Hex; null = automatisch aus der Palette. */ /** Persoenliche Terminfarbe als Hex; null = automatisch aus der Palette. */
color?: string | null; color?: string | null;
/** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */ /** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */
avatar?: string | null; avatar?: string | null;
@@ -55,7 +63,7 @@ export interface AppUser {
} }
/** /**
* Farbpalette fuer Benutzer im Kalender. Bewusst kontraststark untereinander * Farbpalette fuer Benutzer in den Terminen. Bewusst kontraststark untereinander
* und dunkel genug fuer weisse Schrift; das CI-Gruen steht an erster Stelle. * und dunkel genug fuer weisse Schrift; das CI-Gruen steht an erster Stelle.
*/ */
export const USER_COLORS = [ export const USER_COLORS = [
@@ -78,16 +86,15 @@ export function userColor(user?: { id: number; color?: string | null } | null):
return USER_COLORS[Math.abs(user.id) % USER_COLORS.length]; return USER_COLORS[Math.abs(user.id) % USER_COLORS.length];
} }
export const LEAD_STATUSES = [ /**
'Neu', * Art eines Kontakts - in der Oberflaeche "Art". Frueher stand hier ein
'Kontaktiert', * Vertriebsstatus (Neu .. Verloren); der sagte nichts darueber, mit wem man es
'Qualifiziert', * zu tun hat. Bestehende Kontakte wurden bei der Umstellung auf 'Recruiter'
'Angebot', * gesetzt (siehe migrateLeadKind() im Backend).
'Gewonnen', */
'Verloren', export const LEAD_KINDS = ['Recruiter', 'Partner', 'Endkunde'] as const;
] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number]; export type LeadKind = (typeof LEAD_KINDS)[number];
/** /**
* Herkunft von Kontakten, die aus dem Postfach entstanden sind. Steht in * Herkunft von Kontakten, die aus dem Postfach entstanden sind. Steht in
@@ -115,12 +122,12 @@ export interface Lead {
zip: string | null; zip: string | null;
city: string | null; city: string | null;
country: string | null; country: string | null;
status: LeadStatus; kind: LeadKind;
source: string | null; source: string | null;
/** /**
* true = Eintrag im Vorbereich "Aus Mails". Solche Kontakte stammen aus dem * true = Eintrag im Vorbereich "Aus Mails". Solche Kontakte stammen aus dem
* Postfachabgleich und erscheinen nicht in der Kontaktliste, in Opportunities * Postfachabgleich und erscheinen nicht in der Kontaktliste, in Opportunities
* oder im Kalender - erst die Uebernahme macht daraus einen Kontakt. * oder in Terminen - erst die Uebernahme macht daraus einen Kontakt.
*/ */
mail_pool: boolean; mail_pool: boolean;
/** /**
@@ -262,7 +269,7 @@ export function contactFullName(contact: Pick<OpportunityContact, 'first_name' |
return `${contact.first_name} ${contact.last_name}`.trim(); return `${contact.first_name} ${contact.last_name}`.trim();
} }
// ---------- Kalender ---------- // ---------- Interviews (Termine) ----------
export interface EventParticipant { export interface EventParticipant {
id: number; id: number;