diff --git a/docs/01-architektur.md b/docs/01-architektur.md
index 327749a..efa1acf 100644
--- a/docs/01-architektur.md
+++ b/docs/01-architektur.md
@@ -90,8 +90,8 @@ sofort draußen, ohne auf den Ablauf des Tokens zu warten.
```mermaid
flowchart TD
L["Anmeldung"] --> R{"Rolle"}
- R -->|admin| A["Opportunities, Kalender, Kontakte, Mails, Scanner Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
- R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner Einstellungen: Mein Konto, Whisper"]
+ R -->|admin| A["Opportunities, Interviews, Kontakte, Mails, Scanner Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
+ R -->|user| U["Opportunities, Interviews, Kontakte, Mails, Scanner Einstellungen: Mein Konto, Whisper"]
```
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`).
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
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
degradiert, deaktiviert oder gelöscht wird.
diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md
index ee0ef5c..89e6dd6 100644
--- a/docs/02-datenmodell.md
+++ b/docs/02-datenmodell.md
@@ -30,7 +30,7 @@ erDiagram
varchar email UK
varchar name
enum role "admin | user"
- varchar color "Kalenderfarbe, NULL = aus Id abgeleitet"
+ varchar color "Terminfarbe, NULL = aus Id abgeleitet"
longtext avatar "Data-URL"
varchar password_hash "bcrypt"
tinyint is_active
@@ -40,7 +40,7 @@ erDiagram
varchar first_name
varchar last_name
varchar company
- enum status "Neu .. Verloren"
+ enum kind "Recruiter | Partner | Endkunde"
varchar source "z.B. 'Mail' bei Herkunft Postfach"
tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt"
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
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
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
`crm_mail_contact_candidates` für beide Bestände gelten
([11 Postfachanbindung](11-postfach.md)).
diff --git a/docs/05-frontend.md b/docs/05-frontend.md
index 0d34b5d..1f2eafd 100644
--- a/docs/05-frontend.md
+++ b/docs/05-frontend.md
@@ -10,13 +10,13 @@ sitzt und keine tiefen Links braucht.
flowchart TD
SB["Seitenleiste"] --> K["Kategorien"]
K --> O["Opportunities (Startansicht)"]
- K --> C["Kalender"]
+ K --> C["Interviews"]
K --> L["Kontakte"]
L --> L1["Alle Kontakte"]
L --> L2["Aus Mails Vorbereich, Zähler im Menü"]
K --> M["Mails"]
- M --> M1["Postfach"]
- M --> M2["Kontakt-Vorschläge"]
+ M --> M1["Postfach Reiter: Postfach, Kontakt-Vorschläge"]
+ M --> M2["Papierkorb im CRM gelöschte Mails"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
@@ -61,7 +61,7 @@ flowchart TB
APP --> NAV
APP --> DATA
DATA --> V1["Opportunities"]
- DATA --> V2["Kalender"]
+ DATA --> V2["Interviews"]
DATA --> V3["Kontakte mail_pool = false"]
DATA --> V5["Aus Mails mail_pool = true + Kontakte zum Abgleich"]
DATA --> V4["Mails leads + opportunities für den Zuordnungsdialog"]
diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md
index 3b24ae8..a4504e6 100644
--- a/docs/07-anleitung-benutzer.md
+++ b/docs/07-anleitung-benutzer.md
@@ -10,7 +10,7 @@ Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen:
| Frage | Bereich |
| --- | --- |
| 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** |
| 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] │
│ Opportunities├──────────────────────────────────────────┤
-│ Kalender │ │
+│ Interviews │ │
│ Kontakte ▾ │ Inhalt │
│ Alle Kont. │ │
│ Aus Mails 5│ │
-│ Mails │ │
+│ Mails ▾ │ │
+│ Postfach │ │
+│ Papierkorb │ │
│ Scanner ▾ │ │
│ Dashboard │ │
│ 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
Auswahlfeld direkt auf der Karte ändern.
-## Kalender
+## Interviews
-Ein **gemeinsamer** Teamkalender - alle sehen dieselben Termine, jeder darf
-eintragen.
+Der gemeinsame Terminkalender des Teams - alle sehen dieselben Termine, jeder
+darf eintragen. Genutzt wird er vor allem für Gesprächstermine, deshalb heißt
+der Menüpunkt **Interviews** und nicht „Kalender".
-
+
* **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blättern.
* 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
darf nur, wer den Termin auch ändern darf.
* **Farben** gehören zum Ersteller. Die eigene Farbe ändert man unter
- *Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche
- Farbe zu wem gehört.
+ *Einstellungen → Mein Konto* („Meine Terminfarbe"); die Legende unter dem
+ Raster zeigt, welche Farbe zu wem gehört.
* **Teilnehmer** erscheinen mit Foto oder Initiale und sind anklickbar.
* **@-Erwähnungen**: In der Beschreibung `@` tippen, dann Kontakte *und*
Benutzer aus der Vorschlagsliste wählen (Pfeiltasten, Enter). In der
Leseansicht sind sie klickbar - Kontakte öffnen die Kontaktkarte, Benutzer ein
Detailfenster mit nächsten Terminen.
* 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.
-
+
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.
Ändern und löschen darf nur der **Ersteller** oder ein **Administrator**.
@@ -154,18 +157,25 @@ Drei Ansichten, davon eine besondere:
| Ansicht | wofür |
| --- | --- |
-| **Kachel** | Überblick mit Foto, Firma, Status |
+| **Kachel** | Überblick mit Foto, Firma, Art |
| **Liste** | sortierbar, viele Einträge |
| **Netzwerk** | wer steht mit wem in Kontakt |

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.

-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
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
Kontakt.
@@ -175,10 +185,10 @@ Kontakt.
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt, einen Knoten ziehen
setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung
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
-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.
### 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
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.

@@ -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
ö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
@@ -310,6 +321,36 @@ zeigt die Rohfassung ohne Formatierung.
Steht an einer Mail **Im Postfach gelöscht**, wurde sie in Outlook entfernt. Im
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
Aus Absender, Empfängerliste und Signatur jeder Mail liest die Anwendung
@@ -442,7 +483,7 @@ Datenbank.
| Reiter | Wer | Inhalt |
| --- | --- | --- |
| **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 |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
| **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
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 -
das verhindert der Server.
diff --git a/docs/08-anleitung-entwickler.md b/docs/08-anleitung-entwickler.md
index 2602b6c..9ab9a7e 100644
--- a/docs/08-anleitung-entwickler.md
+++ b/docs/08-anleitung-entwickler.md
@@ -128,12 +128,17 @@ danach nichts nachladen.
### Neuer Menüpunkt
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.
Der Name in `ViewState` ist gleichzeitig die Beschriftung und der Titel in der
Kopfzeile. Wer eine abweichende Beschriftung braucht, setzt `label` am `MenuItem`
-(so macht es der Unterpunkt *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
diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md
index d27640b..a8359a6 100644
--- a/docs/09-quellcode.md
+++ b/docs/09-quellcode.md
@@ -28,13 +28,26 @@ Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()
| Export | Zweck |
| --- | --- |
| `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
-`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
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
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
im Quelltext steht kein Passwort.
@@ -70,7 +83,7 @@ Alle unter `router.use(requireAuth)`.
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /`, `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:
@@ -213,7 +226,7 @@ Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
`parseMailDate()` hängt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein
`Date` wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige
wäre je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der
-Datenbank, anders als die 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.
### Kontexte
@@ -242,7 +255,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| 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/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `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 |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `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 |
## Datenfluss im Frontend
diff --git a/docs/10-glossar.md b/docs/10-glossar.md
index d697492..69a8a64 100644
--- a/docs/10-glossar.md
+++ b/docs/10-glossar.md
@@ -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. |
| **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`. |
+| **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. |
| **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. |
diff --git a/docs/11-postfach.md b/docs/11-postfach.md
index 075ecec..17baaed 100644
--- a/docs/11-postfach.md
+++ b/docs/11-postfach.md
@@ -124,7 +124,8 @@ sequenceDiagram
end
G-->>Y: Seite mit Metadaten + nextLink/deltaLink
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
opt Text fehlt
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*.
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
das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand
entscheidet.
@@ -288,13 +289,16 @@ Alle unter `/api/mail`, alle hinter `requireAuth`.
| Methode | Pfad | Rolle | Zweck |
| --- | --- | --- | --- |
| 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 |
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity hängen |
| DELETE | `/links/:id` | alle | Zuordnung lösen |
| 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/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
`/api/leads`:
@@ -319,6 +323,43 @@ vorbehalten.
`filter` in der Liste: `unlinked` (noch nirgends zugeordnet - die Arbeitsliste),
`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
diff --git a/server/src/db.js b/server/src/db.js
index 84daef5..21b8908 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -48,10 +48,56 @@ export async function initDatabase() {
conn.release();
}
+ await migrateLeadKind();
await migrateMailPool();
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
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
diff --git a/server/src/mail/contacts.js b/server/src/mail/contacts.js
index 3d0026d..0ad2bb4 100644
--- a/server/src/mail/contacts.js
+++ b/server/src/mail/contacts.js
@@ -113,7 +113,9 @@ export async function adoptCandidate(
const fields = candidateToLeadFields(candidate);
const columns = {
...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,
mail_pool: mailPool,
tags: '[]',
diff --git a/server/src/mail/store.js b/server/src/mail/store.js
index f206dc3..b9862ad 100644
--- a/server/src/mail/store.js
+++ b/server/src/mail/store.js
@@ -48,15 +48,24 @@ export async function resetDelta() {
* 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,
* 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) {
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`,
[graphIdHash]
);
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)),
+ };
}
/**
diff --git a/server/src/mail/sync.js b/server/src/mail/sync.js
index 0c26c45..7d4150b 100644
--- a/server/src/mail/sync.js
+++ b/server/src/mail/sync.js
@@ -166,6 +166,7 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
let newMessages = 0;
let updatedMessages = 0;
let deletedMessages = 0;
+ let skippedTrashed = 0;
let newCandidates = 0;
let adoptedContacts = 0;
let pages = 0;
@@ -201,6 +202,17 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
const message = normalizeMessage(raw);
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
* hat. Ein Erstabgleich ohne Filter zieht sich ueber mehrere Laeufe; in
@@ -298,13 +310,15 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
deletedMessages,
newCandidates,
adoptedContacts,
+ skippedTrashed,
hasMore: hasMore || truncated,
};
if (newMessages || newCandidates || deletedMessages) {
console.log(
`[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) {
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
index f45fa03..5e03c34 100644
--- a/server/src/routes/auth.js
+++ b/server/src/routes/auth.js
@@ -60,7 +60,7 @@ router.get('/me', requireAuth, (req, res) => {
});
// 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
// aus der Palette, Foto: Initiale).
router.patch('/me', requireAuth, async (req, res, next) => {
diff --git a/server/src/routes/events.js b/server/src/routes/events.js
index e5628eb..f2ee733 100644
--- a/server/src/routes/events.js
+++ b/server/src/routes/events.js
@@ -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) => {
try {
const [rows] = await pool.query(`${SELECT_EVENTS} ORDER BY e.starts_at ASC, e.id ASC`);
diff --git a/server/src/routes/leads.js b/server/src/routes/leads.js
index 37098d3..4bd4e45 100644
--- a/server/src/routes/leads.js
+++ b/server/src/routes/leads.js
@@ -5,14 +5,10 @@ import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
-const STATUSES = new Set([
- 'Neu',
- 'Kontaktiert',
- 'Qualifiziert',
- 'Angebot',
- 'Gewonnen',
- 'Verloren',
-]);
+// Art des Kontakts. Vorgabe ist 'Recruiter' - auch fuer die Kontakte, die vor
+// der Umstellung noch einen Vertriebsstatus trugen (siehe migrateLeadKind()).
+const KINDS = new Set(['Recruiter', 'Partner', 'Endkunde']);
+const DEFAULT_KIND = 'Recruiter';
// Felder, die per API gesetzt werden duerfen. owner_id/created_by werden
// 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.' });
}
- 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 =
req.body?.owner_id === undefined || req.body.owner_id === null || req.body.owner_id === ''
? req.user.id
@@ -134,12 +130,12 @@ router.post('/', async (req, res, next) => {
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 = [
...TEXT_FIELDS.map((field) =>
field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field])
),
- status,
+ kind,
serializeTags(req.body?.tags) ?? '[]',
ownerId,
req.user.id,
@@ -169,7 +165,7 @@ router.post('/', async (req, res, next) => {
/*
* Felder, die beim Uebernehmen verglichen und uebertragen werden koennen.
* `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
* haben in einem Mailfund kein Gegenstueck.
*/
@@ -217,7 +213,7 @@ async function moveMailRelations(fromLeadId, toLeadId) {
async function copyPoolLeadToContacts(poolLead, { userId, ownerId }) {
const columns = {};
for (const field of TEXT_FIELDS) columns[field] = poolLead[field] ?? null;
- columns.status = poolLead.status;
+ columns.kind = poolLead.kind;
columns.tags = poolLead.tags ?? '[]';
columns.mail_pool = 0;
columns.owner_id = ownerId;
@@ -435,10 +431,10 @@ router.patch('/:id', async (req, res, next) => {
values.push(value);
}
- if (req.body?.status !== undefined) {
- if (!STATUSES.has(req.body.status)) return res.status(400).json({ error: 'Ungültiger Status.' });
- fields.push('status = ?');
- values.push(req.body.status);
+ if (req.body?.kind !== undefined) {
+ if (!KINDS.has(req.body.kind)) return res.status(400).json({ error: 'Ungültige Kontaktart.' });
+ fields.push('kind = ?');
+ values.push(req.body.kind);
}
if (req.body?.tags !== undefined) {
diff --git a/server/src/routes/mail.js b/server/src/routes/mail.js
index dd25acd..5b30d08 100644
--- a/server/src/routes/mail.js
+++ b/server/src/routes/mail.js
@@ -53,6 +53,8 @@ function mapMessageRow(row) {
body_preview: row.body_preview,
has_attachments: Boolean(row.has_attachments),
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,
link_count: Number(row.link_count ?? 0),
open_candidates: Number(row.open_candidates ?? 0),
@@ -71,8 +73,13 @@ router.get('/status', async (req, res, next) => {
const [[counts]] = await pool.query(
`SELECT
- (SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
- (SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates,
+ (SELECT COUNT(*) FROM crm_mail_messages
+ 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_leads WHERE mail_pool = 1) AS pool_leads,
(SELECT COUNT(*) FROM crm_mail_links) AS links`
@@ -98,6 +105,7 @@ router.get('/status', async (req, res, next) => {
last_candidates: state.last_candidates,
counts: {
messages: Number(counts.messages),
+ trash: Number(counts.trash),
open_candidates: Number(counts.open_candidates),
adopted_candidates: Number(counts.adopted_candidates),
pool_leads: Number(counts.pool_leads),
@@ -200,7 +208,7 @@ router.post('/sync', requireAdmin, async (req, res, next) => {
// 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) => {
try {
const limit = clampInt(req.query.limit, 50, 1, 200);
@@ -209,6 +217,17 @@ router.get('/messages', async (req, res, next) => {
const where = [];
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');
const search = String(req.query.search ?? '').trim();
@@ -235,10 +254,12 @@ router.get('/messages', async (req, res, next) => {
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,
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_contact_candidates c
WHERE c.message_id = m.id AND c.status = 'offen') AS open_candidates
FROM crm_mail_messages m
+ LEFT JOIN crm_users du ON du.id = m.crm_deleted_by
${whereSql}
ORDER BY m.received_at DESC, m.id DESC
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.' });
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];
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(
`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
router.post('/messages/:id/links', async (req, res, next) => {
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 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 = [];
if (status !== 'alle') {
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
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
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
LIMIT ?`,
[...params, limit]
diff --git a/server/src/routes/users.js b/server/src/routes/users.js
index 22f084f..5d2a211 100644
--- a/server/src/routes/users.js
+++ b/server/src/routes/users.js
@@ -24,7 +24,7 @@ function normalizeEmail(value) {
// GET /api/users/directory
// Benutzerliste ohne Adminrechte - jeder angemeldete Benutzer braucht sie fuer
// 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
// Zuordnungen und Mentions weiter aufloesbar sind - die Auswahlfelder im
// Frontend filtern sie heraus.
diff --git a/server/src/schema.sql b/server/src/schema.sql
index 2afbb97..a7137dd 100644
--- a/server/src/schema.sql
+++ b/server/src/schema.sql
@@ -40,8 +40,9 @@ CREATE TABLE IF NOT EXISTS crm_leads (
zip VARCHAR(24) NULL,
city VARCHAR(120) NULL,
country VARCHAR(120) NULL,
- status ENUM('Neu','Kontaktiert','Qualifiziert','Angebot','Gewonnen','Verloren')
- NOT NULL DEFAULT 'Neu',
+ -- Art des Kontakts. Frueher ein Vertriebsstatus (Neu .. Verloren); die
+ -- Umstellung bestehender Datenbanken macht migrateLeadKind() in db.js.
+ kind ENUM('Recruiter','Partner','Endkunde') NOT NULL DEFAULT 'Recruiter',
source VARCHAR(120) NULL,
tags LONGTEXT NULL, -- JSON-Array als Text (MariaDB-kompatibel)
notes TEXT NULL,
@@ -53,7 +54,7 @@ CREATE TABLE IF NOT EXISTS crm_leads (
PRIMARY KEY (id),
KEY idx_crm_leads_name (last_name, first_name),
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_creator (created_by),
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
is_read_in_mailbox TINYINT(1) NOT NULL DEFAULT 0,
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,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_graph (graph_id_hash),
KEY idx_crm_mail_received (received_at),
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;
+/*
+ * 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
* eigene Tabelle statt eines Textanhangs im Notizfeld: so laesst sich eine
diff --git a/src/App.tsx b/src/App.tsx
index 13e67bd..a4cf8a8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -87,7 +87,7 @@ const App: React.FC = () => {
if (!silent) setIsDataLoading(true);
try {
// 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.
const [leadRows, connectionRows, eventRows, userRows, opportunityRows] = await Promise.all([
leadsApi.list(),
@@ -118,7 +118,7 @@ const App: React.FC = () => {
if (!user) return ;
/*
- * 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
* 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.
const STATIC_SUBTITLES: Partial> = {
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',
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
@@ -159,8 +160,8 @@ const App: React.FC = () => {
? `${mailPoolLeads.length} ${
mailPoolLeads.length === 1 ? 'Fund' : 'Funde'
} aus dem Postfach · noch nicht in den Kontakten`
- : currentView === 'Kalender'
- ? `Gemeinsamer Teamkalender · ${events.length} ${
+ : currentView === 'Interviews'
+ ? `Gemeinsame Termine im Team · ${events.length} ${
events.length === 1 ? 'Termin' : 'Termine'
} · ${upcomingCount} anstehend`
: STATIC_SUBTITLES[currentView];
@@ -193,7 +194,7 @@ const App: React.FC = () => {
reload={() => loadData(true)}
/>
)}
- {currentView === 'Kalender' && (
+ {currentView === 'Interviews' && (
{
{currentView === 'Mails' && (
loadData(true)} />
)}
+ {currentView === 'Papierkorb' && (
+ loadData(true)}
+ mode="papierkorb"
+ />
+ )}
{currentView === 'Scanner' && }
{currentView === 'Unpassend' && }
{currentView === 'Suchagenten' && }
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 98db61d..c152bd3 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -1,5 +1,5 @@
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 { 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 -
* "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',
},
{
- 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',
},
CONTACTS_GROUP,
- { name: 'Mails', icon: MAIL_ICON },
+ MAILS_GROUP,
SCANNER_GROUP,
];
diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx
index 7e81641..b976151 100644
--- a/src/components/calendar/Calendar.tsx
+++ b/src/components/calendar/Calendar.tsx
@@ -884,7 +884,7 @@ export const Calendar: React.FC = ({
- Nichts geplant. Lege links im Kalender einen Termin an.
+ Nichts geplant. Lege links im Terminraster einen Termin an.
) : (
diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx
index a2ce78a..b39c74c 100644
--- a/src/components/calendar/EventModal.tsx
+++ b/src/components/calendar/EventModal.tsx
@@ -131,7 +131,7 @@ export const EventModal: React.FC = ({
subtitle={
event
? `Angelegt von ${event.created_by_name ?? 'unbekannt'}`
- : 'Sichtbar für alle Benutzer im Teamkalender'
+ : 'Sichtbar für alle Benutzer unter Interviews'
}
onClose={onClose}
size="lg"
@@ -247,7 +247,7 @@ export const EventModal: React.FC = ({
Teilnehmer
- Mehrfachauswahl möglich. Die Farbe des Termins im Kalender ist die des Erstellers.
+ Mehrfachauswahl möglich. Die Farbe des Termins ist die des Erstellers.
{users.map((user) => {
diff --git a/src/components/calendar/UserDetailModal.tsx b/src/components/calendar/UserDetailModal.tsx
index a6e404f..c351b5a 100644
--- a/src/components/calendar/UserDetailModal.tsx
+++ b/src/components/calendar/UserDetailModal.tsx
@@ -18,7 +18,7 @@ interface UserDetailModalProps {
onOpenLead: (leadId: number) => void;
}
-/** Detailfenster fuer einen im Kalender markierten Benutzer. */
+/** Detailfenster fuer einen in einem Termin markierten Benutzer. */
export const UserDetailModal: React.FC = ({
user,
leads,
@@ -78,7 +78,7 @@ export const UserDetailModal: React.FC = ({
{user.email || 'Keine E-Mail hinterlegt'}
- Kalenderfarbe
+ Terminfarbe
diff --git a/src/components/leads/LeadGrid.tsx b/src/components/leads/LeadGrid.tsx
index 837f5b4..b22646c 100644
--- a/src/components/leads/LeadGrid.tsx
+++ b/src/components/leads/LeadGrid.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Avatar, StatusBadge } from './leadUi';
+import { Avatar, KindBadge } from './leadUi';
import { leadFullName, leadInitials, type Lead } from '../../types';
interface LeadGridProps {
@@ -77,7 +77,7 @@ export const LeadGrid: React.FC = ({ leads, connectionCount, onEd
{lead.position &&
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.{' '}
Gold markierte Einträge gibt es dort schon – bei
ihnen lassen sich abweichende Angaben einzeln in den vorhandenen Kontakt übernehmen.
diff --git a/src/components/leads/leadUi.tsx b/src/components/leads/leadUi.tsx
index b9dd2ce..3bbc16e 100644
--- a/src/components/leads/leadUi.tsx
+++ b/src/components/leads/leadUi.tsx
@@ -1,34 +1,28 @@
import React from 'react';
-import type { LeadStatus } from '../../types';
+import type { LeadKind } from '../../types';
-/** Statusfarben: Gewonnen im CI-Gruen, der Rest bewusst zurueckhaltend. */
-export const STATUS_STYLES: Record = {
- Neu: 'bg-ink-100 text-ink-600 border-ink-200',
- Kontaktiert: 'bg-sky-50 text-sky-700 border-sky-200',
- Qualifiziert: 'bg-amber-50 text-amber-700 border-amber-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',
+/** Farben der Kontaktart: der Endkunde im CI-Gruen, der Rest daneben. */
+export const KIND_STYLES: Record = {
+ Recruiter: 'bg-sky-50 text-sky-700 border-sky-200',
+ Partner: 'bg-violet-50 text-violet-700 border-violet-200',
+ Endkunde: 'bg-brand-50 text-brand-700 border-brand-200',
};
/** Knotenfarbe im Netzwerkdiagramm - gleiche Logik wie das Badge. */
-export const STATUS_COLORS: Record = {
- Neu: '#9a9a9a',
- Kontaktiert: '#0284c7',
- Qualifiziert: '#d97706',
- Angebot: '#7c3aed',
- Gewonnen: '#6ba04a',
- Verloren: '#dc2626',
+export const KIND_COLORS: Record = {
+ Recruiter: '#0284c7',
+ Partner: '#7c3aed',
+ Endkunde: '#6ba04a',
};
-export const StatusBadge: React.FC<{ status: LeadStatus; className?: string }> = ({
- status,
+export const KindBadge: React.FC<{ kind: LeadKind; className?: string }> = ({
+ kind,
className = '',
}) => (
- {status}
+ {kind}
);
diff --git a/src/components/mail/MailDetail.tsx b/src/components/mail/MailDetail.tsx
index f73a5ad..ed20650 100644
--- a/src/components/mail/MailDetail.tsx
+++ b/src/components/mail/MailDetail.tsx
@@ -21,9 +21,14 @@ import { mailApi, formatMailDateTime, type MailCandidate, type MailMessageDetail
interface MailDetailProps {
message: MailMessageDetail;
isLoading: boolean;
- onLink: () => void;
+ /** Fehlt im Papierkorb: was geloescht ist, wird nicht zugeordnet. */
+ onLink?: () => void;
onReload: () => 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 }) => (
@@ -39,6 +44,8 @@ export const MailDetail: React.FC = ({
onLink,
onReload,
onOpenLead,
+ onTrash,
+ onRestore,
}) => {
const { addToast } = useToast();
@@ -82,12 +89,31 @@ export const MailDetail: React.FC = ({