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
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
L["Anmeldung"] --> R{"Rolle"}
|
L["Anmeldung"] --> R{"Rolle"}
|
||||||
R -->|admin| A["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
|
R -->|admin| A["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
|
||||||
R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
|
R -->|user| U["Opportunities, Interviews, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
|
Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
|
||||||
@@ -102,7 +102,7 @@ Die Rollenprüfung sitzt **doppelt**: das Menü blendet aus, was nicht erlaubt
|
|||||||
ist, und das Backend weist Zugriffe zusätzlich mit `403` ab (`requireAdmin`).
|
ist, und das Backend weist Zugriffe zusätzlich mit `403` ab (`requireAdmin`).
|
||||||
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
|
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
|
||||||
für Netzwerkdiagramm, Zuständigkeiten, @-Mentions, Teilnehmerauswahl und
|
für Netzwerkdiagramm, Zuständigkeiten, @-Mentions, Teilnehmerauswahl und
|
||||||
Kalenderfarben. Passwort-Hashes liefert der Endpunkt nie.
|
Terminfarben. Passwort-Hashes liefert der Endpunkt nie.
|
||||||
|
|
||||||
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
|
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
|
||||||
degradiert, deaktiviert oder gelöscht wird.
|
degradiert, deaktiviert oder gelöscht wird.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ erDiagram
|
|||||||
varchar email UK
|
varchar email UK
|
||||||
varchar name
|
varchar name
|
||||||
enum role "admin | user"
|
enum role "admin | user"
|
||||||
varchar color "Kalenderfarbe, NULL = aus Id abgeleitet"
|
varchar color "Terminfarbe, NULL = aus Id abgeleitet"
|
||||||
longtext avatar "Data-URL"
|
longtext avatar "Data-URL"
|
||||||
varchar password_hash "bcrypt"
|
varchar password_hash "bcrypt"
|
||||||
tinyint is_active
|
tinyint is_active
|
||||||
@@ -40,7 +40,7 @@ erDiagram
|
|||||||
varchar first_name
|
varchar first_name
|
||||||
varchar last_name
|
varchar last_name
|
||||||
varchar company
|
varchar company
|
||||||
enum status "Neu .. Verloren"
|
enum kind "Recruiter | Partner | Endkunde"
|
||||||
varchar source "z.B. 'Mail' bei Herkunft Postfach"
|
varchar source "z.B. 'Mail' bei Herkunft Postfach"
|
||||||
tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt"
|
tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt"
|
||||||
tinyint mail_consent "Einverständnis für Werbemails"
|
tinyint mail_consent "Einverständnis für Werbemails"
|
||||||
@@ -94,7 +94,7 @@ Vier Eigenheiten, die man kennen muss:
|
|||||||
- **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte
|
- **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte
|
||||||
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
|
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
|
||||||
Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste,
|
Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste,
|
||||||
Opportunities, Kalender und Netzwerk; der Vorbereich hat seine eigene Ansicht.
|
Opportunities, Interviews und Netzwerk; der Vorbereich hat seine eigene Ansicht.
|
||||||
Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und
|
Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und
|
||||||
`crm_mail_contact_candidates` für beide Bestände gelten
|
`crm_mail_contact_candidates` für beide Bestände gelten
|
||||||
([11 Postfachanbindung](11-postfach.md)).
|
([11 Postfachanbindung](11-postfach.md)).
|
||||||
|
|||||||
+4
-4
@@ -10,13 +10,13 @@ sitzt und keine tiefen Links braucht.
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
SB["Seitenleiste"] --> K["Kategorien"]
|
SB["Seitenleiste"] --> K["Kategorien"]
|
||||||
K --> O["Opportunities<br/>(Startansicht)"]
|
K --> O["Opportunities<br/>(Startansicht)"]
|
||||||
K --> C["Kalender"]
|
K --> C["Interviews"]
|
||||||
K --> L["Kontakte"]
|
K --> L["Kontakte"]
|
||||||
L --> L1["Alle Kontakte"]
|
L --> L1["Alle Kontakte"]
|
||||||
L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
|
L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
|
||||||
K --> M["Mails"]
|
K --> M["Mails"]
|
||||||
M --> M1["Postfach"]
|
M --> M1["Postfach<br/>Reiter: Postfach, Kontakt-Vorschläge"]
|
||||||
M --> M2["Kontakt-Vorschläge"]
|
M --> M2["Papierkorb<br/>im CRM gelöschte Mails"]
|
||||||
K --> SC["Scanner"]
|
K --> SC["Scanner"]
|
||||||
SC --> SC1["Dashboard"]
|
SC --> SC1["Dashboard"]
|
||||||
SC --> SC2["Unpassend"]
|
SC --> SC2["Unpassend"]
|
||||||
@@ -61,7 +61,7 @@ flowchart TB
|
|||||||
APP --> NAV
|
APP --> NAV
|
||||||
APP --> DATA
|
APP --> DATA
|
||||||
DATA --> V1["Opportunities"]
|
DATA --> V1["Opportunities"]
|
||||||
DATA --> V2["Kalender"]
|
DATA --> V2["Interviews"]
|
||||||
DATA --> V3["Kontakte<br/>mail_pool = false"]
|
DATA --> V3["Kontakte<br/>mail_pool = false"]
|
||||||
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
|
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
|
||||||
DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
|
DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen:
|
|||||||
| Frage | Bereich |
|
| Frage | Bereich |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** |
|
| Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** |
|
||||||
| Wer ist wann wo, und wer ist beteiligt? | **Kalender** |
|
| Wer ist wann wo, und wer ist beteiligt? | **Interviews** |
|
||||||
| Wen kennen wir, und wer kennt wen? | **Kontakte** |
|
| Wen kennen wir, und wer kennt wen? | **Kontakte** |
|
||||||
| Welche Projekte auf den Börsen passen zu unseren Beratern? | **Scanner** |
|
| Welche Projekte auf den Börsen passen zu unseren Beratern? | **Scanner** |
|
||||||
|
|
||||||
@@ -32,11 +32,13 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
|||||||
┌──────────────┬───────────────────────────────────────────┐
|
┌──────────────┬───────────────────────────────────────────┐
|
||||||
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
|
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
|
||||||
│ Opportunities├──────────────────────────────────────────┤
|
│ Opportunities├──────────────────────────────────────────┤
|
||||||
│ Kalender │ │
|
│ Interviews │ │
|
||||||
│ Kontakte ▾ │ Inhalt │
|
│ Kontakte ▾ │ Inhalt │
|
||||||
│ Alle Kont. │ │
|
│ Alle Kont. │ │
|
||||||
│ Aus Mails 5│ │
|
│ Aus Mails 5│ │
|
||||||
│ Mails │ │
|
│ Mails ▾ │ │
|
||||||
|
│ Postfach │ │
|
||||||
|
│ Papierkorb │ │
|
||||||
│ Scanner ▾ │ │
|
│ Scanner ▾ │ │
|
||||||
│ Dashboard │ │
|
│ Dashboard │ │
|
||||||
│ Unpassend │ │
|
│ Unpassend │ │
|
||||||
@@ -112,12 +114,13 @@ der Server, nicht nur die Oberfläche.
|
|||||||
Auf dem Tablet oder ohne Lust auf Ziehen: die Phase lässt sich auch über das
|
Auf dem Tablet oder ohne Lust auf Ziehen: die Phase lässt sich auch über das
|
||||||
Auswahlfeld direkt auf der Karte ändern.
|
Auswahlfeld direkt auf der Karte ändern.
|
||||||
|
|
||||||
## Kalender
|
## Interviews
|
||||||
|
|
||||||
Ein **gemeinsamer** Teamkalender - alle sehen dieselben Termine, jeder darf
|
Der gemeinsame Terminkalender des Teams - alle sehen dieselben Termine, jeder
|
||||||
eintragen.
|
darf eintragen. Genutzt wird er vor allem für Gesprächstermine, deshalb heißt
|
||||||
|
der Menüpunkt **Interviews** und nicht „Kalender".
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
* **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blättern.
|
* **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blättern.
|
||||||
* Klick auf einen Monatstag oder eine Stundenzelle legt sofort einen Termin an.
|
* Klick auf einen Monatstag oder eine Stundenzelle legt sofort einen Termin an.
|
||||||
@@ -129,20 +132,20 @@ eintragen.
|
|||||||
gleich, und der Punkt, an dem man den Termin angefasst hat, wandert mit. Ziehen
|
gleich, und der Punkt, an dem man den Termin angefasst hat, wandert mit. Ziehen
|
||||||
darf nur, wer den Termin auch ändern darf.
|
darf nur, wer den Termin auch ändern darf.
|
||||||
* **Farben** gehören zum Ersteller. Die eigene Farbe ändert man unter
|
* **Farben** gehören zum Ersteller. Die eigene Farbe ändert man unter
|
||||||
*Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche
|
*Einstellungen → Mein Konto* („Meine Terminfarbe"); die Legende unter dem
|
||||||
Farbe zu wem gehört.
|
Raster zeigt, welche Farbe zu wem gehört.
|
||||||
* **Teilnehmer** erscheinen mit Foto oder Initiale und sind anklickbar.
|
* **Teilnehmer** erscheinen mit Foto oder Initiale und sind anklickbar.
|
||||||
* **@-Erwähnungen**: In der Beschreibung `@` tippen, dann Kontakte *und*
|
* **@-Erwähnungen**: In der Beschreibung `@` tippen, dann Kontakte *und*
|
||||||
Benutzer aus der Vorschlagsliste wählen (Pfeiltasten, Enter). In der
|
Benutzer aus der Vorschlagsliste wählen (Pfeiltasten, Enter). In der
|
||||||
Leseansicht sind sie klickbar - Kontakte öffnen die Kontaktkarte, Benutzer ein
|
Leseansicht sind sie klickbar - Kontakte öffnen die Kontaktkarte, Benutzer ein
|
||||||
Detailfenster mit nächsten Terminen.
|
Detailfenster mit nächsten Terminen.
|
||||||
* Rechts stehen die **nächsten Termine**, nach Tagen gruppiert. Vergangenes
|
* Rechts stehen die **nächsten Termine**, nach Tagen gruppiert. Vergangenes
|
||||||
fliegt aus der Liste, bleibt im Kalender aber erhalten.
|
fliegt aus der Liste, bleibt im Raster aber erhalten.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Die Farbe eines Termins gehört zu seinem Ersteller - die Legende unter dem
|
Die Farbe eines Termins gehört zu seinem Ersteller - die Legende unter dem
|
||||||
Kalender löst auf, welche Farbe zu wem gehört. Rechts stehen die nächsten
|
Raster löst auf, welche Farbe zu wem gehört. Rechts stehen die nächsten
|
||||||
Termine mit Beschreibung, Teilnehmern und den markierten Personen.
|
Termine mit Beschreibung, Teilnehmern und den markierten Personen.
|
||||||
|
|
||||||
Ändern und löschen darf nur der **Ersteller** oder ein **Administrator**.
|
Ändern und löschen darf nur der **Ersteller** oder ein **Administrator**.
|
||||||
@@ -154,18 +157,25 @@ Drei Ansichten, davon eine besondere:
|
|||||||
|
|
||||||
| Ansicht | wofür |
|
| Ansicht | wofür |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Kachel** | Überblick mit Foto, Firma, Status |
|
| **Kachel** | Überblick mit Foto, Firma, Art |
|
||||||
| **Liste** | sortierbar, viele Einträge |
|
| **Liste** | sortierbar, viele Einträge |
|
||||||
| **Netzwerk** | wer steht mit wem in Kontakt |
|
| **Netzwerk** | wer steht mit wem in Kontakt |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Ein Klick auf eine Karte öffnet den Kontakt zum Bearbeiten - mit Foto, Anschrift,
|
Ein Klick auf eine Karte öffnet den Kontakt zum Bearbeiten - mit Foto, Anschrift,
|
||||||
Status, Zuständigkeit, Tags, Notizen und den Verbindungen zu anderen Kontakten.
|
Art, Zuständigkeit, Tags, Notizen und den Verbindungen zu anderen Kontakten.
|
||||||
|
|
||||||
|
Die **Art** sagt, mit wem man es zu tun hat: **Recruiter**, **Partner** oder
|
||||||
|
**Endkunde**. Früher stand an dieser Stelle ein Vertriebsstatus (Neu,
|
||||||
|
Kontaktiert, …); der sagte über den Kontakt selbst nichts. Bei der Umstellung am
|
||||||
|
18.08.2026 wurden alle vorhandenen Kontakte auf *Recruiter* gesetzt - welche Art
|
||||||
|
jemand wirklich ist, war aus dem alten Status nicht ableitbar. Wer die Angabe
|
||||||
|
braucht, pflegt sie im Kontakt nach.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Im **Netzwerk** sind Kreise Kontakte (Farbe = Status, Größe = Anzahl
|
Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl
|
||||||
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
|
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
|
||||||
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
|
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
|
||||||
Kontakt.
|
Kontakt.
|
||||||
@@ -175,10 +185,10 @@ Kontakt.
|
|||||||
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt, einen Knoten ziehen
|
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt, einen Knoten ziehen
|
||||||
setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung
|
setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung
|
||||||
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
|
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
|
||||||
Suche und Statusfilter wirken auch hier.
|
Suche und Artfilter wirken auch hier.
|
||||||
|
|
||||||
Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
|
Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
|
||||||
Statusfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
|
Artfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
|
||||||
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
|
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
|
||||||
|
|
||||||
### Einverständnis für Mails
|
### Einverständnis für Mails
|
||||||
@@ -240,7 +250,7 @@ Der Menüpunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und
|
|||||||
|
|
||||||
In *Aus Mails* stehen die Personen, die der Postfachabgleich in Mails gefunden
|
In *Aus Mails* stehen die Personen, die der Postfachabgleich in Mails gefunden
|
||||||
hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste,
|
hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste,
|
||||||
nicht in Opportunities und nicht im Kalender. Jede Karte zeigt alle gefundenen
|
nicht in Opportunities und nicht unter Interviews. Jede Karte zeigt alle gefundenen
|
||||||
Angaben und darunter, aus welcher Mail sie stammen.
|
Angaben und darunter, aus welcher Mail sie stammen.
|
||||||
|
|
||||||

|

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