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". -![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. * 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. -![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 -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 | ![Kontakte in der Kachelansicht](bilder/kontakte-kacheln.png) 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) -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. ![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 ö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 = ({
- {/* Kalender */} + {/* Terminraster */}
{isLoading && events.length === 0 ? (
@@ -940,7 +940,7 @@ export const Calendar: React.FC = ({
{upcomingGroups.length === 0 ? (

- 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 &&

{lead.position}

} {lead.company &&

{lead.company}

}
- +
diff --git a/src/components/leads/LeadList.tsx b/src/components/leads/LeadList.tsx index 6d63823..7472a52 100644 --- a/src/components/leads/LeadList.tsx +++ b/src/components/leads/LeadList.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { Avatar, StatusBadge } from './leadUi'; +import { Avatar, KindBadge } from './leadUi'; 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 { key: SortKey; @@ -21,7 +21,7 @@ interface LeadListProps { const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [ { key: 'name', label: 'Kontakt' }, { key: 'company', label: 'Unternehmen' }, - { key: 'status', label: 'Status' }, + { key: 'kind', label: 'Art' }, { key: null, label: 'Erreichbarkeit' }, { key: 'connections', label: 'Verbindungen' }, { key: null, label: 'Angelegt von' }, @@ -92,7 +92,7 @@ export const LeadList: React.FC = ({ {lead.city &&

{lead.city}

} - +
diff --git a/src/components/leads/LeadModal.tsx b/src/components/leads/LeadModal.tsx index ac2945d..4a77ae5 100644 --- a/src/components/leads/LeadModal.tsx +++ b/src/components/leads/LeadModal.tsx @@ -6,14 +6,14 @@ import { connectionsApi, leadsApi } from '../../lib/api'; import { useToast } from '../Toast'; import { CONNECTION_TYPES, - LEAD_STATUSES, + LEAD_KINDS, leadFullName, leadInitials, type AppUser, type ConnectionType, type Lead, type LeadConnection, - type LeadStatus, + type LeadKind, } from '../../types'; interface LeadModalProps { @@ -46,7 +46,7 @@ interface FormState { zip: string; city: string; country: string; - status: LeadStatus; + kind: LeadKind; source: string; notes: string; avatar: string; @@ -72,7 +72,7 @@ function toFormState(lead: Lead | null, fallbackOwnerId: number | null): FormSta zip: lead?.zip ?? '', city: lead?.city ?? '', country: lead?.country ?? '', - status: lead?.status ?? 'Neu', + kind: lead?.kind ?? 'Recruiter', source: lead?.source ?? '', notes: lead?.notes ?? '', avatar: lead?.avatar ?? '', @@ -250,15 +250,18 @@ export const LeadModal: React.FC = ({
- + diff --git a/src/components/leads/LeadNetwork.tsx b/src/components/leads/LeadNetwork.tsx index 67f7f86..ee20a77 100644 --- a/src/components/leads/LeadNetwork.tsx +++ b/src/components/leads/LeadNetwork.tsx @@ -11,7 +11,7 @@ import { type SimulationLinkDatum, type SimulationNodeDatum, } from 'd3-force'; -import { STATUS_COLORS } from './leadUi'; +import { KIND_COLORS } from './leadUi'; import { connectionsApi } from '../../lib/api'; import { useToast } from '../Toast'; import { @@ -134,7 +134,7 @@ export const LeadNetwork: React.FC = ({ leadId: lead.id, label: leadFullName(lead), sublabel: lead.company || lead.position || '', - color: STATUS_COLORS[lead.status], + color: KIND_COLORS[lead.kind], degree: deg, radius: 14 + Math.min(deg, 8) * 1.8, avatar: lead.avatar, @@ -875,7 +875,7 @@ export const LeadNetwork: React.FC = ({

{leadFullName(selectedLead)}

diff --git a/src/components/leads/Leads.tsx b/src/components/leads/Leads.tsx index 034de8f..0ae1d17 100644 --- a/src/components/leads/Leads.tsx +++ b/src/components/leads/Leads.tsx @@ -8,14 +8,14 @@ import { ConfirmDialog } from '../ui/Modal'; import { useToast } from '../Toast'; import { leadsApi } from '../../lib/api'; import { - LEAD_STATUSES, + LEAD_KINDS, MAIL_LEAD_SOURCE, leadFullName, type AppUser, type Lead, type LeadConnection, type LeadOriginFilter, - type LeadStatus, + type LeadKind, type LeadViewMode, } from '../../types'; @@ -68,7 +68,7 @@ export const Leads: React.FC = ({ const { addToast } = useToast(); const [viewMode, setViewMode] = useState('grid'); const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState('Alle'); + const [kindFilter, setKindFilter] = useState('Alle'); const [originFilter, setOriginFilter] = useState('Alle'); const [sortConfig, setSortConfig] = useState({ key: 'updated_at', direction: 'desc' }); @@ -112,7 +112,7 @@ export const Leads: React.FC = ({ .filter(Boolean); 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 === 'Manuell' && lead.source === MAIL_LEAD_SOURCE) return false; if (terms.length === 0) return true; @@ -140,10 +140,8 @@ export const Leads: React.FC = ({ return leadFullName(a).localeCompare(leadFullName(b), 'de') * direction; case 'company': return (a.company ?? '').localeCompare(b.company ?? '', 'de') * direction; - case 'status': - return ( - (LEAD_STATUSES.indexOf(a.status) - LEAD_STATUSES.indexOf(b.status)) * direction - ); + case 'kind': + return (LEAD_KINDS.indexOf(a.kind) - LEAD_KINDS.indexOf(b.kind)) * direction; case 'connections': return (connectionCount(a.id) - connectionCount(b.id)) * direction; case 'updated_at': @@ -154,7 +152,7 @@ export const Leads: React.FC = ({ } } }); - }, [leads, searchTerm, statusFilter, originFilter, sortConfig, connectionCounts]); + }, [leads, searchTerm, kindFilter, originFilter, sortConfig, connectionCounts]); // Zaehler fuer den Herkunftsfilter - nur anzeigen, wenn es solche Kontakte gibt. const mailLeadCount = useMemo( @@ -231,14 +229,15 @@ export const Leads: React.FC = ({
diff --git a/src/components/leads/MailPoolLeads.tsx b/src/components/leads/MailPoolLeads.tsx index da1a9cd..508f09b 100644 --- a/src/components/leads/MailPoolLeads.tsx +++ b/src/components/leads/MailPoolLeads.tsx @@ -280,7 +280,7 @@ export const MailPoolLeads: React.FC = ({

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 = ({
- + {onLink && ( + + )} + {onRestore && ( + + )} + {onTrash && ( + + )} {message.web_link && ( = ({ {message.bcc.length > 0 && }
- {(message.has_attachments || message.is_deleted || message.importance === 'high') && ( + {(message.has_attachments || + message.is_deleted || + message.crm_deleted_at !== null || + message.importance === 'high') && (
{message.has_attachments && ( @@ -125,6 +154,16 @@ export const MailDetail: React.FC = ({ Im Postfach gelöscht )} + {message.crm_deleted_at !== null && ( + + Im Papierkorb + + )}
)} diff --git a/src/components/mail/Mails.tsx b/src/components/mail/Mails.tsx index d9ae653..ba32f28 100644 --- a/src/components/mail/Mails.tsx +++ b/src/components/mail/Mails.tsx @@ -3,6 +3,7 @@ import { MailList } from './MailList'; import { MailDetail } from './MailDetail'; import { MailLinkDialog } from './MailLinkDialog'; import { CandidateRow } from './CandidateRow'; +import { ConfirmDialog } from '../ui/Modal'; import { Badge, emptyStateClass } from './mailUi'; import { useToast } from '../Toast'; 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 * Kontakt-Vorschlaege. Letztere ist die eigentliche Arbeitsliste - dort wird * 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 { @@ -31,6 +36,8 @@ interface MailsProps { opportunities: Opportunity[]; /** Kontakte im CRM neu laden - nach Uebernahme eines Vorschlags. */ reload: () => void; + /** 'papierkorb' zeigt die im CRM geloeschten Mails. */ + mode?: 'postfach' | 'papierkorb'; } type Tab = 'postfach' | 'vorschlaege'; @@ -117,7 +124,8 @@ const StatusBanner: React.FC<{ status: MailStatus; isAdmin: boolean; onOpenSetti return null; }; -export const Mails: React.FC = ({ leads, opportunities, reload }) => { +export const Mails: React.FC = ({ leads, opportunities, reload, mode = 'postfach' }) => { + const isTrash = mode === 'papierkorb'; const { addToast } = useToast(); const { isAdmin } = useAuth(); const { goTo } = useNavigation(); @@ -141,6 +149,7 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) => const [candidates, setCandidates] = useState([]); const [isCandidatesLoading, setIsCandidatesLoading] = useState(false); const [isSyncing, setIsSyncing] = useState(false); + const [isEmptyTrashOpen, setIsEmptyTrashOpen] = useState(false); // Die Suche soll nicht bei jedem Tastendruck eine Abfrage aufloesen. useEffect(() => { @@ -165,6 +174,7 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) => filter, limit: PAGE_SIZE, offset: nextOffset, + trash: isTrash, }); setMessages(data.messages); setTotal(data.total); @@ -175,7 +185,7 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) => setIsListLoading(false); } }, - [debouncedSearch, filter, addToast] + [debouncedSearch, filter, isTrash, addToast] ); const loadCandidates = useCallback(async () => { @@ -259,6 +269,53 @@ export const Mails: React.FC = ({ 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 openCandidateCount = status?.counts.open_candidates ?? candidates.length; @@ -282,6 +339,7 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) => {/* Steuerleiste */}
+ {!isTrash && (
+ )} {tab === 'postfach' && ( <> @@ -337,18 +396,20 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) =>
- + {!isTrash && ( + + )} )} @@ -358,7 +419,16 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) => Stand {formatMailDateTime(status.last_success_at)} )} - {isAdmin && status?.configured && ( + {isTrash && ( + + )} + {!isTrash && isAdmin && status?.configured && (
) : messages.length === 0 ? (
- {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.'} + {isTrash ? ( + debouncedSearch ? ( + 'Keine Mails im Papierkorb passen zur Suche.' + ) : ( + <> + Der Papierkorb ist leer. +

+ Gelöschte Mails landen hier und lassen sich zurückholen. Im Postfach + crm@perinco.de bleiben sie in jedem Fall erhalten. +

+ + ) + ) : 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.' + )}
) : (
@@ -429,9 +515,11 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) => setIsLinkOpen(true)} + onLink={isTrash ? undefined : () => setIsLinkOpen(true)} onReload={refreshAll} onOpenLead={openLead} + onTrash={isTrash ? undefined : () => trashMessage(detail.id)} + onRestore={isTrash ? () => restoreMessage(detail.id) : undefined} /> ) : (
Eine Mail auswählen.
@@ -483,6 +571,18 @@ export const Mails: React.FC = ({ leads, opportunities, reload }) => )} + {isEmptyTrashOpen && ( + setIsEmptyTrashOpen(false)} + /> + )} + {isLinkOpen && detail && ( = ({ value, onChange, fallback }) => (
diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 674d11b..a118a92 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -111,7 +111,7 @@ const AccountTab: React.FC = () => {
-

Meine Kalenderfarbe

+

Meine Terminfarbe

Mit dieser Farbe erscheinen die von dir angelegten Termine im Teamkalender.

@@ -119,7 +119,7 @@ const AccountTab: React.FC = () => { saveProfile({ color }, 'Kalenderfarbe gespeichert.')} + onChange={(color) => saveProfile({ color }, 'Terminfarbe gespeichert.')} />
diff --git a/src/components/settings/UserManagement.tsx b/src/components/settings/UserManagement.tsx index 55980af..d27d828 100644 --- a/src/components/settings/UserManagement.tsx +++ b/src/components/settings/UserManagement.tsx @@ -330,7 +330,7 @@ export const UserManagement: React.FC = () => { />
- + = ({ user, diff --git a/src/lib/api.ts b/src/lib/api.ts index 73261ab..c9e4e33 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -85,7 +85,7 @@ export const authApi = { }), 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. */ updateMe: (input: { color?: string | null; avatar?: string | null }) => @@ -113,7 +113,7 @@ export interface UserInput { export const usersApi = { list: () => request('/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". */ directory: () => request('/users/directory'), @@ -197,7 +197,7 @@ export const opportunitiesApi = { remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }), }; -// ---------- Kalender ---------- +// ---------- Interviews (Termine) ---------- export interface EventInput { title: string; diff --git a/src/lib/mailApi.ts b/src/lib/mailApi.ts index 06063db..6199663 100644 --- a/src/lib/mailApi.ts +++ b/src/lib/mailApi.ts @@ -23,7 +23,11 @@ export interface MailMessageSummary { received_at: string; body_preview: string | null; has_attachments: boolean; + /** Im Postfach geloescht - nicht zu verwechseln mit `crm_deleted_at`. */ 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; link_count: number; open_candidates: number; @@ -115,6 +119,8 @@ export interface MailStatus { last_candidates: number; counts: { messages: number; + /** Mails im Papierkorb des CRM. */ + trash: number; open_candidates: number; adopted_candidates: number; links: number; @@ -133,6 +139,8 @@ export interface MailSyncResult { deletedMessages?: number; newCandidates?: number; adoptedContacts?: number; + /** Im CRM geloeschte Mails, die der Abgleich deshalb nicht angefasst hat. */ + skippedTrashed?: number; hasMore?: boolean; error?: string; } @@ -171,12 +179,22 @@ export type MailListFilter = 'alle' | 'unlinked' | 'linked' | 'candidates'; export const mailApi = { status: () => apiRequest('/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(); if (params.search) query.set('search', params.search); if (params.filter && params.filter !== 'alle') query.set('filter', params.filter); if (params.limit) query.set('limit', String(params.limit)); if (params.offset) query.set('offset', String(params.offset)); + if (params.trash) query.set('trash', 'true'); const suffix = query.toString() ? `?${query}` : ''; return apiRequest<{ total: number; limit: number; offset: number; messages: MailMessageSummary[] }>( `/mail/messages${suffix}` @@ -193,6 +211,16 @@ export const mailApi = { 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 } = {}) => { const query = new URLSearchParams(); query.set('status', params.status ?? 'offen'); diff --git a/src/types.ts b/src/types.ts index 1421fbd..ea9cb4d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,10 @@ export type ViewState = | 'Opportunities' - | 'Kalender' + | 'Interviews' | 'Kontakte' | 'Aus Mails' | 'Mails' + | 'Papierkorb' | 'Scanner' | 'Unpassend' | 'Suchagenten' @@ -37,6 +38,13 @@ export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten' */ 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 interface AppUser { @@ -45,7 +53,7 @@ export interface AppUser { email: string | null; name: string; role: Role; - /** Persoenliche Kalenderfarbe als Hex; null = automatisch aus der Palette. */ + /** Persoenliche Terminfarbe als Hex; null = automatisch aus der Palette. */ color?: string | null; /** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */ 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. */ 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]; } -export const LEAD_STATUSES = [ - 'Neu', - 'Kontaktiert', - 'Qualifiziert', - 'Angebot', - 'Gewonnen', - 'Verloren', -] as const; +/** + * Art eines Kontakts - in der Oberflaeche "Art". Frueher stand hier ein + * Vertriebsstatus (Neu .. Verloren); der sagte nichts darueber, mit wem man es + * zu tun hat. Bestehende Kontakte wurden bei der Umstellung auf 'Recruiter' + * gesetzt (siehe migrateLeadKind() im Backend). + */ +export const LEAD_KINDS = ['Recruiter', 'Partner', 'Endkunde'] 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 @@ -115,12 +122,12 @@ export interface Lead { zip: string | null; city: string | null; country: string | null; - status: LeadStatus; + kind: LeadKind; source: string | null; /** * true = Eintrag im Vorbereich "Aus Mails". Solche Kontakte stammen aus dem * 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; /** @@ -262,7 +269,7 @@ export function contactFullName(contact: Pick