feat: Interviews, Mail-Papierkorb, Kontaktart
Drei Aenderungen aus dem Alltagsgebrauch:
- Der Kalender heisst jetzt "Interviews" - dafuer wird er benutzt. Der Wert in
ViewState wandert mit, damit Menue und Kopfzeile nicht auseinanderlaufen.
Sichtbare Reste ("Kalenderfarbe") heissen jetzt Terminfarbe.
- Mails haben einen Papierkorb, der unter dem Menuepunkt aufklappt. Loeschen
heisst dabei ausschliesslich: im CRM. Im Postfach crm@perinco.de bleibt die
Mail unberuehrt - Schreibrechte darauf hat die Anwendung nicht. Damit der
Delta-Abgleich geloeschte Mails nicht wieder einliest, bleibt nach dem Leeren
eine leere Zeile als Merker stehen (crm_purged); runMailSync() ueberspringt
alles, was im CRM geloescht ist. Zurueckholen und Leeren darf jeder
Angemeldete, Leeren nach einer Warnung.
- Der Kontaktstatus (Neu .. Verloren) wird zur Kontaktart: Recruiter, Partner,
Endkunde. Die Spalte heisst jetzt kind, weil "Status" das Feld falsch
benannte. migrateLeadKind() stellt bestehende Datenbanken um und setzt alle
vorhandenen Kontakte auf Recruiter - welche Art jemand ist, sagt der alte
Status nicht (so mit dem Nutzer entschieden).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
025390e18a
commit
8741a9ae0c
@@ -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<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
|
||||
R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
|
||||
R -->|admin| A["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
|
||||
R -->|user| U["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
|
||||
```
|
||||
|
||||
Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
|
||||
@@ -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.
|
||||
|
||||
@@ -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)).
|
||||
|
||||
+4
-4
@@ -10,13 +10,13 @@ sitzt und keine tiefen Links braucht.
|
||||
flowchart TD
|
||||
SB["Seitenleiste"] --> K["Kategorien"]
|
||||
K --> O["Opportunities<br/>(Startansicht)"]
|
||||
K --> C["Kalender"]
|
||||
K --> C["Interviews"]
|
||||
K --> L["Kontakte"]
|
||||
L --> L1["Alle Kontakte"]
|
||||
L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
|
||||
K --> M["Mails"]
|
||||
M --> M1["Postfach"]
|
||||
M --> M2["Kontakt-Vorschläge"]
|
||||
M --> M1["Postfach<br/>Reiter: Postfach, Kontakt-Vorschläge"]
|
||||
M --> M2["Papierkorb<br/>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<br/>mail_pool = false"]
|
||||
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
|
||||
DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+19
-6
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+44
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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: '[]',
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-1
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
+13
-17
@@ -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) {
|
||||
|
||||
+136
-6
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
+28
-4
@@ -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
|
||||
|
||||
+14
-5
@@ -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 <LoginScreen />;
|
||||
|
||||
/*
|
||||
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Kalender,
|
||||
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Interviews,
|
||||
* Netzwerk, Mailzuordnung - gilt nur der gepflegte Bestand; die Funde aus dem
|
||||
* 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<Record<ViewState, string>> = {
|
||||
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' && (
|
||||
<Calendar
|
||||
events={events}
|
||||
leads={leads}
|
||||
@@ -226,6 +227,14 @@ const App: React.FC = () => {
|
||||
{currentView === 'Mails' && (
|
||||
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
|
||||
)}
|
||||
{currentView === 'Papierkorb' && (
|
||||
<Mails
|
||||
leads={leads}
|
||||
opportunities={opportunities}
|
||||
reload={() => loadData(true)}
|
||||
mode="papierkorb"
|
||||
/>
|
||||
)}
|
||||
{currentView === 'Scanner' && <ScannerDashboard />}
|
||||
{currentView === 'Unpassend' && <ScannerRejected />}
|
||||
{currentView === 'Suchagenten' && <ScannerAgents />}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -884,7 +884,7 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
{/* Kalender */}
|
||||
{/* Terminraster */}
|
||||
<div className="space-y-3">
|
||||
{isLoading && events.length === 0 ? (
|
||||
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
|
||||
@@ -940,7 +940,7 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||
<div className="scrollbar-slim max-h-[70vh] flex-1 overflow-y-auto p-3">
|
||||
{upcomingGroups.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-10 text-center text-xs font-semibold text-ink-400">
|
||||
Nichts geplant. Lege links im Kalender einen Termin an.
|
||||
Nichts geplant. Lege links im Terminraster einen Termin an.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
|
||||
@@ -131,7 +131,7 @@ export const EventModal: React.FC<EventModalProps> = ({
|
||||
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<EventModalProps> = ({
|
||||
Teilnehmer
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-ink-400">
|
||||
Mehrfachauswahl möglich. Die Farbe des Termins im Kalender ist die des Erstellers.
|
||||
Mehrfachauswahl möglich. Die Farbe des Termins ist die des Erstellers.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{users.map((user) => {
|
||||
|
||||
@@ -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<UserDetailModalProps> = ({
|
||||
user,
|
||||
leads,
|
||||
@@ -78,7 +78,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
|
||||
<p className="truncate text-sm text-ink-400">{user.email || 'Keine E-Mail hinterlegt'}</p>
|
||||
<p className="mt-1 flex items-center gap-2 text-xs font-bold text-ink-400">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
Kalenderfarbe
|
||||
Terminfarbe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<LeadGridProps> = ({ leads, connectionCount, onEd
|
||||
{lead.position && <p className="truncate text-xs font-semibold text-ink-500">{lead.position}</p>}
|
||||
{lead.company && <p className="truncate text-xs text-ink-400">{lead.company}</p>}
|
||||
<div className="mt-2">
|
||||
<StatusBadge status={lead.status} />
|
||||
<KindBadge kind={lead.kind} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<LeadListProps> = ({
|
||||
{lead.city && <p className="truncate text-xs text-ink-400">{lead.city}</p>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<StatusBadge status={lead.status} />
|
||||
<KindBadge kind={lead.kind} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="space-y-0.5 text-xs">
|
||||
|
||||
@@ -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<LeadModalProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<label className={labelClass} htmlFor="lead-kind">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set('status', e.target.value as LeadStatus)}
|
||||
id="lead-kind"
|
||||
value={form.kind}
|
||||
onChange={(e) => set('kind', e.target.value as LeadKind)}
|
||||
className={inputClass}
|
||||
>
|
||||
{LEAD_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
{LEAD_KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{kind}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -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<LeadNetworkProps> = ({
|
||||
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<LeadNetworkProps> = ({
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="mt-1 h-3 w-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: STATUS_COLORS[selectedLead.status] }}
|
||||
style={{ backgroundColor: KIND_COLORS[selectedLead.kind] }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base text-ink-900">{leadFullName(selectedLead)}</h3>
|
||||
|
||||
@@ -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<LeadsProps> = ({
|
||||
const { addToast } = useToast();
|
||||
const [viewMode, setViewMode] = useState<LeadViewMode>('grid');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<LeadStatus | 'Alle'>('Alle');
|
||||
const [kindFilter, setKindFilter] = useState<LeadKind | 'Alle'>('Alle');
|
||||
const [originFilter, setOriginFilter] = useState<LeadOriginFilter>('Alle');
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' });
|
||||
|
||||
@@ -112,7 +112,7 @@ export const Leads: React.FC<LeadsProps> = ({
|
||||
.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<LeadsProps> = ({
|
||||
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<LeadsProps> = ({
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [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<LeadsProps> = ({
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LeadStatus | 'Alle')}
|
||||
value={kindFilter}
|
||||
onChange={(event) => setKindFilter(event.target.value as LeadKind | 'Alle')}
|
||||
aria-label="Nach Art des Kontakts filtern"
|
||||
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||
>
|
||||
<option value="Alle">Alle Status</option>
|
||||
{LEAD_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
<option value="Alle">Alle Arten</option>
|
||||
{LEAD_KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{kind}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -280,7 +280,7 @@ export const MailPoolLeads: React.FC<MailPoolLeadsProps> = ({
|
||||
|
||||
<p className="rounded-xl border border-ink-200 bg-surface px-4 py-3 text-xs leading-relaxed text-ink-500">
|
||||
Diese Personen hat der Postfachabgleich in Mails gefunden. Sie stehen noch nicht in den Kontakten
|
||||
und tauchen weder in Opportunities noch im Kalender auf.{' '}
|
||||
und tauchen weder in Opportunities noch unter Interviews auf.{' '}
|
||||
<span className="font-bold text-amber-700">Gold markierte Einträge</span> gibt es dort schon – bei
|
||||
ihnen lassen sich abweichende Angaben einzeln in den vorhandenen Kontakt übernehmen.
|
||||
</p>
|
||||
|
||||
@@ -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<LeadStatus, string> = {
|
||||
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<LeadKind, string> = {
|
||||
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<LeadStatus, string> = {
|
||||
Neu: '#9a9a9a',
|
||||
Kontaktiert: '#0284c7',
|
||||
Qualifiziert: '#d97706',
|
||||
Angebot: '#7c3aed',
|
||||
Gewonnen: '#6ba04a',
|
||||
Verloren: '#dc2626',
|
||||
export const KIND_COLORS: Record<LeadKind, string> = {
|
||||
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 = '',
|
||||
}) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${STATUS_STYLES[status]} ${className}`}
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${KIND_STYLES[kind]} ${className}`}
|
||||
>
|
||||
{status}
|
||||
{kind}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<MailDetailProps> = ({
|
||||
onLink,
|
||||
onReload,
|
||||
onOpenLead,
|
||||
onTrash,
|
||||
onRestore,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -82,12 +89,31 @@ export const MailDetail: React.FC<MailDetailProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<button
|
||||
onClick={onLink}
|
||||
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
Zuordnen
|
||||
</button>
|
||||
{onLink && (
|
||||
<button
|
||||
onClick={onLink}
|
||||
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
Zuordnen
|
||||
</button>
|
||||
)}
|
||||
{onRestore && (
|
||||
<button
|
||||
onClick={onRestore}
|
||||
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
Wiederherstellen
|
||||
</button>
|
||||
)}
|
||||
{onTrash && (
|
||||
<button
|
||||
onClick={onTrash}
|
||||
title="Aus dem CRM entfernen. Im Postfach crm@perinco.de bleibt die Mail erhalten."
|
||||
className="whitespace-nowrap rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
{message.web_link && (
|
||||
<a
|
||||
href={message.web_link}
|
||||
@@ -107,7 +133,10 @@ export const MailDetail: React.FC<MailDetailProps> = ({
|
||||
{message.bcc.length > 0 && <AddressRow label="Bcc" value={formatAddresses(message.bcc, 6)} />}
|
||||
</div>
|
||||
|
||||
{(message.has_attachments || message.is_deleted || message.importance === 'high') && (
|
||||
{(message.has_attachments ||
|
||||
message.is_deleted ||
|
||||
message.crm_deleted_at !== null ||
|
||||
message.importance === 'high') && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{message.has_attachments && (
|
||||
<Badge className="border-ink-200 bg-ink-100 text-ink-600" title="Anhänge werden in Phase 1 nicht übernommen">
|
||||
@@ -125,6 +154,16 @@ export const MailDetail: React.FC<MailDetailProps> = ({
|
||||
Im Postfach gelöscht
|
||||
</Badge>
|
||||
)}
|
||||
{message.crm_deleted_at !== null && (
|
||||
<Badge
|
||||
className="border-red-200 bg-red-50 text-red-700"
|
||||
title={`Liegt im Papierkorb des CRM${
|
||||
message.crm_deleted_by_name ? ` - gelöscht von ${message.crm_deleted_by_name}` : ''
|
||||
}. Im Postfach ist die Mail unberührt.`}
|
||||
>
|
||||
Im Papierkorb
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
+121
-21
@@ -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<MailsProps> = ({ leads, opportunities, reload }) => {
|
||||
export const Mails: React.FC<MailsProps> = ({ 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<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
const [candidates, setCandidates] = useState<MailCandidate[]>([]);
|
||||
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<MailsProps> = ({ 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<MailsProps> = ({ 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<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Nach dem Loeschen, Zurueckholen oder Leeren faellt die Mail aus der gerade
|
||||
* gezeigten Liste. Die Auswahl wird deshalb aufgehoben und die Liste von vorn
|
||||
* geladen - bewusst nicht ueber refreshAll(), das wuerde die Detailansicht der
|
||||
* eben entfernten Mail nachladen und in einen 404 laufen.
|
||||
*/
|
||||
const refreshAfterTrashChange = useCallback(async () => {
|
||||
setSelectedId(null);
|
||||
setDetail(null);
|
||||
await Promise.all([loadStatus(), loadCandidates(), loadMessages(0, true)]);
|
||||
reload();
|
||||
}, [loadStatus, loadCandidates, loadMessages, reload]);
|
||||
|
||||
const trashMessage = async (id: number) => {
|
||||
try {
|
||||
await mailApi.trashMessage(id);
|
||||
addToast('Mail in den Papierkorb gelegt.', 'success');
|
||||
await refreshAfterTrashChange();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const restoreMessage = async (id: number) => {
|
||||
try {
|
||||
await mailApi.restoreMessage(id);
|
||||
addToast('Mail wiederhergestellt.', 'success');
|
||||
await refreshAfterTrashChange();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Wiederherstellen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const emptyTrash = async () => {
|
||||
setIsEmptyTrashOpen(false);
|
||||
try {
|
||||
const result = await mailApi.emptyTrash();
|
||||
addToast(
|
||||
result.purged === 1 ? '1 Mail endgültig gelöscht.' : `${result.purged} Mails endgültig gelöscht.`,
|
||||
'success'
|
||||
);
|
||||
await refreshAfterTrashChange();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Leeren fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const openLead = (leadId: number) => goTo('Kontakte', { leadId });
|
||||
|
||||
const openCandidateCount = status?.counts.open_candidates ?? candidates.length;
|
||||
@@ -282,6 +339,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
|
||||
{/* Steuerleiste */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
{!isTrash && (
|
||||
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
|
||||
<button
|
||||
onClick={() => setTab('postfach')}
|
||||
@@ -311,6 +369,7 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'postfach' && (
|
||||
<>
|
||||
@@ -337,18 +396,20 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value as MailListFilter)}
|
||||
title={FILTERS.find((entry) => entry.id === filter)?.hint}
|
||||
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||
>
|
||||
{FILTERS.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!isTrash && (
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value as MailListFilter)}
|
||||
title={FILTERS.find((entry) => entry.id === filter)?.hint}
|
||||
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||
>
|
||||
{FILTERS.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -358,7 +419,16 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
Stand {formatMailDateTime(status.last_success_at)}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && status?.configured && (
|
||||
{isTrash && (
|
||||
<button
|
||||
onClick={() => setIsEmptyTrashOpen(true)}
|
||||
disabled={messages.length === 0}
|
||||
className="whitespace-nowrap rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700 disabled:opacity-40"
|
||||
>
|
||||
Papierkorb leeren
|
||||
</button>
|
||||
)}
|
||||
{!isTrash && isAdmin && status?.configured && (
|
||||
<button
|
||||
onClick={runSync}
|
||||
disabled={isSyncing || status.running}
|
||||
@@ -390,11 +460,27 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className={emptyStateClass}>
|
||||
{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.
|
||||
<p className="mx-auto mt-2 max-w-md text-xs font-normal text-ink-400">
|
||||
Gelöschte Mails landen hier und lassen sich zurückholen. Im Postfach
|
||||
crm@perinco.de bleiben sie in jedem Fall erhalten.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
) : status?.configured ? (
|
||||
debouncedSearch || filter !== 'alle' ? (
|
||||
'Keine Mails passen zur Auswahl.'
|
||||
) : (
|
||||
'Noch keine Mails abgeglichen.'
|
||||
)
|
||||
) : (
|
||||
'Sobald die Anbindung eingerichtet ist, erscheinen hier die Mails aus crm@perinco.de.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-[380px_1fr]">
|
||||
@@ -429,9 +515,11 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
<MailDetail
|
||||
message={detail}
|
||||
isLoading={isDetailLoading}
|
||||
onLink={() => setIsLinkOpen(true)}
|
||||
onLink={isTrash ? undefined : () => setIsLinkOpen(true)}
|
||||
onReload={refreshAll}
|
||||
onOpenLead={openLead}
|
||||
onTrash={isTrash ? undefined : () => trashMessage(detail.id)}
|
||||
onRestore={isTrash ? () => restoreMessage(detail.id) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className={emptyStateClass}>Eine Mail auswählen.</div>
|
||||
@@ -483,6 +571,18 @@ export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) =>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isEmptyTrashOpen && (
|
||||
<ConfirmDialog
|
||||
title="Papierkorb endgültig leeren?"
|
||||
message={`${total} ${
|
||||
total === 1 ? 'Mail wird' : 'Mails werden'
|
||||
} unwiderruflich aus dem CRM entfernt - samt ihrer Zuordnungen zu Kontakten und Opportunities. Im Postfach crm@perinco.de bleiben die Mails erhalten, das CRM holt sie aber nicht wieder herein.`}
|
||||
confirmLabel="Endgültig löschen"
|
||||
onConfirm={emptyTrash}
|
||||
onCancel={() => setIsEmptyTrashOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLinkOpen && detail && (
|
||||
<MailLinkDialog
|
||||
message={detail}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ColorPickerProps {
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/** Auswahl der persoenlichen Kalenderfarbe. */
|
||||
/** Auswahl der persoenlichen Terminfarbe. */
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange, fallback }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -111,7 +111,7 @@ const AccountTab: React.FC = () => {
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 border-t border-ink-100 pt-5">
|
||||
<h4 className="text-sm font-extrabold text-ink-900">Meine Kalenderfarbe</h4>
|
||||
<h4 className="text-sm font-extrabold text-ink-900">Meine Terminfarbe</h4>
|
||||
<p className="mb-3 text-xs text-ink-400">
|
||||
Mit dieser Farbe erscheinen die von dir angelegten Termine im Teamkalender.
|
||||
</p>
|
||||
@@ -119,7 +119,7 @@ const AccountTab: React.FC = () => {
|
||||
<ColorPicker
|
||||
value={user?.color ?? null}
|
||||
fallback={userColor(user ? { id: user.id, color: null } : null)}
|
||||
onChange={(color) => saveProfile({ color }, 'Kalenderfarbe gespeichert.')}
|
||||
onChange={(color) => saveProfile({ color }, 'Terminfarbe gespeichert.')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +330,7 @@ export const UserManagement: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Kalenderfarbe</label>
|
||||
<label className={labelClass}>Terminfarbe</label>
|
||||
<ColorPicker
|
||||
value={form.color}
|
||||
fallback={userColor(editingUser ? { id: editingUser.id, color: null } : { id: 0 })}
|
||||
|
||||
@@ -21,8 +21,8 @@ interface UserAvatarProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Foto des Benutzers, sonst die Initiale auf seiner Kalenderfarbe. Damit sehen
|
||||
* Benutzer ueberall gleich aus - Sidebar, Kalender, Nutzerverwaltung.
|
||||
* Foto des Benutzers, sonst die Initiale auf seiner Terminfarbe. Damit sehen
|
||||
* Benutzer ueberall gleich aus - Sidebar, Interviews, Nutzerverwaltung.
|
||||
*/
|
||||
export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
user,
|
||||
|
||||
+3
-3
@@ -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<AppUser[]>('/users'),
|
||||
/**
|
||||
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Der Kalender braucht
|
||||
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Die Terminansicht braucht
|
||||
* die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user".
|
||||
*/
|
||||
directory: () => request<AppUser[]>('/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;
|
||||
|
||||
+29
-1
@@ -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<MailStatus>('/mail/status'),
|
||||
|
||||
messages: (params: { search?: string; filter?: MailListFilter; limit?: number; offset?: number } = {}) => {
|
||||
messages: (
|
||||
params: {
|
||||
search?: string;
|
||||
filter?: MailListFilter;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** true = nur der Papierkorb, sonst alles ausser dem Papierkorb. */
|
||||
trash?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const query = new URLSearchParams();
|
||||
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');
|
||||
|
||||
+22
-15
@@ -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<OpportunityContact, 'first_name' |
|
||||
return `${contact.first_name} ${contact.last_name}`.trim();
|
||||
}
|
||||
|
||||
// ---------- Kalender ----------
|
||||
// ---------- Interviews (Termine) ----------
|
||||
|
||||
export interface EventParticipant {
|
||||
id: number;
|
||||
|
||||
Reference in New Issue
Block a user