feat(kontakte): Vorbereich "Aus Mails" mit Dublettenabgleich
Der Postfachabgleich hat gefundene Personen bisher direkt in den Kontaktbestand geschrieben. Damit wächst eine gepflegte Liste ungefragt mit jeder Adresse aus jeder Mail - genau daran ist das Altsystem gescheitert. Funde landen jetzt in einem Vorbereich (crm_leads.mail_pool = 1), der als Unterpunkt "Aus Mails" unter dem Menüpunkt Kontakte aufklappt. Der Kontaktbestand bleibt unberührt, bis dort jemand entscheidet; in Opportunities, Kalender und Netzwerk tauchen die Funde nicht auf. Eine Spalte statt einer zweiten Tabelle, weil crm_mail_links und crm_mail_contact_candidates per Fremdschlüssel auf crm_leads zeigen - so behält der Vorbereich seine Mailbezüge. Übernehmen kennt zwei Wege: - neue Person: Kopie mit mail_pool = 0, die Mailverknüpfungen wandern mit - Dublette (Adresse, sonst voller Name): gold markiert, abweichende Felder mit altem und neuem Wert; die Feldauswahl schreibt sie in den vorhandenen Kontakt. Vorausgewählt sind nur Lücken - ein Wert aus einer alten Signatur soll nichts Gepflegtes stillschweigend ersetzen. db.js verschiebt beim ersten Start die vorhandenen Kontakte mit source = 'Mail' in den Vorbereich, einmalig über den Merker leads_mail_pool_migrated in crm_settings. Die Sidebar-Gruppe ist dabei generisch geworden (MenuGroup) - Kontakte und Scanner benutzen jetzt denselben Code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8090d0aae1
commit
ea55acdb5c
@@ -74,7 +74,7 @@ den Apache-Proxy erreichbar.
|
|||||||
| Tabelle | Inhalt |
|
| Tabelle | Inhalt |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) |
|
| `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) |
|
||||||
| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zustaendig) |
|
| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zustaendig); `mail_pool = 1` ist der Vorbereich „Aus Mails" aus dem Postfachabgleich |
|
||||||
| `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit |
|
| `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit |
|
||||||
| `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle |
|
| `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle |
|
||||||
| `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
|
| `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
|
||||||
|
|||||||
+10
-1
@@ -41,6 +41,8 @@ erDiagram
|
|||||||
varchar last_name
|
varchar last_name
|
||||||
varchar company
|
varchar company
|
||||||
enum status "Neu .. Verloren"
|
enum status "Neu .. Verloren"
|
||||||
|
varchar source "z.B. 'Mail' bei Herkunft Postfach"
|
||||||
|
tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt"
|
||||||
longtext tags "JSON-Array als Text"
|
longtext tags "JSON-Array als Text"
|
||||||
longtext avatar
|
longtext avatar
|
||||||
int owner_id FK
|
int owner_id FK
|
||||||
@@ -84,8 +86,15 @@ erDiagram
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Drei Eigenheiten, die man kennen muss:
|
Vier Eigenheiten, die man kennen muss:
|
||||||
|
|
||||||
|
- **`crm_leads` fuehrt zwei Bestaende.** `mail_pool = 0` ist der gepflegte
|
||||||
|
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
|
||||||
|
Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste,
|
||||||
|
Opportunities, Kalender und Netzwerk; der Vorbereich hat seine eigene Ansicht.
|
||||||
|
Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und
|
||||||
|
`crm_mail_contact_candidates` fuer beide Bestaende gelten
|
||||||
|
([11 Postfachanbindung](11-postfach.md)).
|
||||||
- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf aelteren
|
- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf aelteren
|
||||||
MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert.
|
MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert.
|
||||||
- **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in
|
- **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in
|
||||||
|
|||||||
+21
-6
@@ -12,6 +12,8 @@ flowchart TD
|
|||||||
K --> O["Opportunities<br/>(Startansicht)"]
|
K --> O["Opportunities<br/>(Startansicht)"]
|
||||||
K --> C["Kalender"]
|
K --> C["Kalender"]
|
||||||
K --> L["Kontakte"]
|
K --> L["Kontakte"]
|
||||||
|
L --> L1["Alle Kontakte"]
|
||||||
|
L --> L2["Aus Mails<br/>Vorbereich, Zaehler im Menue"]
|
||||||
K --> M["Mails"]
|
K --> M["Mails"]
|
||||||
M --> M1["Postfach"]
|
M --> M1["Postfach"]
|
||||||
M --> M2["Kontakt-Vorschlaege"]
|
M --> M2["Kontakt-Vorschlaege"]
|
||||||
@@ -31,9 +33,14 @@ flowchart TD
|
|||||||
*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht
|
*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht
|
||||||
*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her.
|
*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her.
|
||||||
|
|
||||||
Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das
|
Die Menuepunkte *Kontakte* und *Scanner* klappen ihre Unterpunkte auf; der
|
||||||
Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole,
|
Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard).
|
||||||
die Unterpunkte kommen mit den Beschriftungen zurueck.
|
Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, die
|
||||||
|
Unterpunkte kommen mit den Beschriftungen zurueck. Beide Gruppen entstehen aus
|
||||||
|
derselben Struktur `MenuGroup` in `Sidebar.tsx`.
|
||||||
|
|
||||||
|
*Aus Mails* traegt einen Zaehler mit der Anzahl der offenen Funde - er kommt aus
|
||||||
|
`App.tsx`, das die Kontakte ohnehin geladen hat.
|
||||||
|
|
||||||
## Zustand und Datenfluss
|
## Zustand und Datenfluss
|
||||||
|
|
||||||
@@ -55,7 +62,8 @@ flowchart TB
|
|||||||
APP --> DATA
|
APP --> DATA
|
||||||
DATA --> V1["Opportunities"]
|
DATA --> V1["Opportunities"]
|
||||||
DATA --> V2["Kalender"]
|
DATA --> V2["Kalender"]
|
||||||
DATA --> V3["Kontakte"]
|
DATA --> V3["Kontakte<br/>mail_pool = false"]
|
||||||
|
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
|
||||||
DATA --> V4["Mails<br/>leads + opportunities fuer den Zuordnungsdialog"]
|
DATA --> V4["Mails<br/>leads + opportunities fuer den Zuordnungsdialog"]
|
||||||
NAV --> DF["DictationField<br/>springt nach Whisper"]
|
NAV --> DF["DictationField<br/>springt nach Whisper"]
|
||||||
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
|
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
|
||||||
@@ -71,6 +79,12 @@ weil sie blaettern, suchen und filtern und dabei nicht die ganze Liste im Speich
|
|||||||
halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
|
halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
|
||||||
Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
|
Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
|
||||||
|
|
||||||
|
`/api/leads` liefert beide Bestaende in einem Aufruf; `App.tsx` teilt sie in
|
||||||
|
`leads` (Kontakte) und `mailPoolLeads` (Vorbereich „Aus Mails") auf. Alle
|
||||||
|
anderen Ansichten bekommen ausschliesslich `leads` - ein Fund aus einer Mail soll
|
||||||
|
in keiner Auswahlliste auftauchen. Die Ansicht *Aus Mails* braucht beides: sie
|
||||||
|
vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten.
|
||||||
|
|
||||||
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
|
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
|
||||||
Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den
|
Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den
|
||||||
Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads`
|
Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads`
|
||||||
@@ -97,11 +111,12 @@ src/
|
|||||||
audio.ts Aufnahme zu 16-kHz-Mono-PCM
|
audio.ts Aufnahme zu 16-kHz-Mono-PCM
|
||||||
imageCompress.ts Bilder vor dem Upload verkleinern
|
imageCompress.ts Bilder vor dem Upload verkleinern
|
||||||
components/
|
components/
|
||||||
Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe
|
Sidebar.tsx Menue inkl. aufklappbarer Gruppen (Kontakte, Scanner)
|
||||||
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
|
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
|
||||||
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
|
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
|
||||||
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
|
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
|
||||||
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
|
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force),
|
||||||
|
Vorbereich "Aus Mails" mit Dublettenabgleich
|
||||||
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschlaege
|
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschlaege
|
||||||
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
|
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
|
||||||
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
|
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
|
||||||
|
|||||||
@@ -128,8 +128,42 @@ erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht a
|
|||||||
Suche und Statusfilter wirken auch hier.
|
Suche und Statusfilter wirken auch hier.
|
||||||
|
|
||||||
Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem
|
Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem
|
||||||
Statusfilter ein **Herkunftsfilter**: *Aus Mails* zeigt nur die automatisch
|
Statusfilter ein **Herkunftsfilter**: *Aus Mails uebernommen* zeigt nur die aus
|
||||||
entstandenen, *Selbst angelegt* nur die von Hand erfassten.
|
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
|
||||||
|
|
||||||
|
### Aus Mails
|
||||||
|
|
||||||
|
Der Menuepunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und
|
||||||
|
*Aus Mails*. Der Zaehler am Menuepunkt sagt, wie viele Funde warten.
|
||||||
|
|
||||||
|
In *Aus Mails* stehen die Personen, die der Postfachabgleich in Mails gefunden
|
||||||
|
hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste,
|
||||||
|
nicht in Opportunities und nicht im Kalender. Jede Karte zeigt alle gefundenen
|
||||||
|
Angaben und darunter, aus welcher Mail sie stammen.
|
||||||
|
|
||||||
|
Zwei Faelle:
|
||||||
|
|
||||||
|
* **Weisse Karte** - die Person ist neu. *In Kontakte übernehmen* legt sie als
|
||||||
|
Kontakt an, mit Herkunft „Mail".
|
||||||
|
* **Gold markierte Karte** - es gibt sie in den Kontakten schon, erkannt an der
|
||||||
|
E-Mail-Adresse oder am Namen. Gold hinterlegte Felder weichen ab; darunter steht
|
||||||
|
durchgestrichen, was bisher im Kontakt stand. *Kontakt aktualisieren* oeffnet
|
||||||
|
die Feldauswahl: angehakt sind zunaechst nur die Luecken, also Felder, die im
|
||||||
|
Kontakt leer sind. Ein Wert, der etwas Gepflegtes ersetzen wuerde, ist als
|
||||||
|
*ueberschreibt* gekennzeichnet und muss bewusst angehakt werden - die Angabe
|
||||||
|
kann aus einer alten Signatur stammen. *Neu anlegen* erzeugt trotz Dublette
|
||||||
|
einen zweiten Kontakt, etwa wenn es zwei Personen mit demselben Namen gibt.
|
||||||
|
|
||||||
|
Weitere Bedienelemente:
|
||||||
|
|
||||||
|
* **Alle neuen übernehmen** legt alle weissen Karten auf einmal als Kontakte an;
|
||||||
|
gold markierte bleiben unberuehrt.
|
||||||
|
* **Eintrag hier behalten** - normalerweise verschwindet ein Eintrag aus *Aus
|
||||||
|
Mails*, sobald er als Kontakt gefuehrt wird. Mit dem Haken bleibt er stehen.
|
||||||
|
* Das **Papierkorbsymbol** entfernt einen Fund. Die Mails bleiben erhalten; taucht
|
||||||
|
die Person in einer neuen Mail auf, erscheint sie hier wieder.
|
||||||
|
* Der Filter waehlt zwischen allen Eintraegen, nur neuen Personen und nur
|
||||||
|
Dubletten.
|
||||||
|
|
||||||
## Mails
|
## Mails
|
||||||
|
|
||||||
@@ -172,7 +206,8 @@ CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen.
|
|||||||
|
|
||||||
Aus Absender, Empfaengerliste und Signatur jeder Mail liest die Anwendung
|
Aus Absender, Empfaengerliste und Signatur jeder Mail liest die Anwendung
|
||||||
Kontaktdaten. Wer eine echte Mailadresse hat und aus Absender oder
|
Kontaktdaten. Wer eine echte Mailadresse hat und aus Absender oder
|
||||||
Empfaengerliste stammt, wird **automatisch** Kontakt. Alles Unsichere landet
|
Empfaengerliste stammt, landet **automatisch** im Vorbereich
|
||||||
|
[Aus Mails](#aus-mails) - nicht direkt in den Kontakten. Alles Unsichere landet
|
||||||
hier - typischerweise Personen, die nur mit Telefonnummer im Text genannt werden.
|
hier - typischerweise Personen, die nur mit Telefonnummer im Text genannt werden.
|
||||||
|
|
||||||
Der Balken rechts ist die Zuversicht: je voller und gruener, je verlaesslicher die
|
Der Balken rechts ist die Zuversicht: je voller und gruener, je verlaesslicher die
|
||||||
@@ -181,7 +216,9 @@ Angaben. Das Kennzeichen darunter sagt, woher sie stammen (*Absender*,
|
|||||||
|
|
||||||
Zwei Knoepfe:
|
Zwei Knoepfe:
|
||||||
|
|
||||||
* **Als Kontakt anlegen** - es entsteht ein neuer Kontakt mit Herkunft „Mail".
|
* **Als Kontakt anlegen** - es entsteht sofort ein richtiger Kontakt mit Herkunft
|
||||||
|
„Mail", nicht nur ein Eintrag in *Aus Mails*: hier hat ja schon jemand
|
||||||
|
hingesehen und entschieden.
|
||||||
* **Ergaenzen** (statt „anlegen", wenn **Bereits vorhanden** dransteht) - der
|
* **Ergaenzen** (statt „anlegen", wenn **Bereits vorhanden** dransteht) - der
|
||||||
vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt
|
vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt
|
||||||
ist, bleibt unangetastet.
|
ist, bleibt unangetastet.
|
||||||
@@ -342,4 +379,4 @@ nicht zum Benutzerkonto.
|
|||||||
| Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen → Postfach* die Verbindung zu testen |
|
| Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen → Postfach* die Verbindung zu testen |
|
||||||
| „Erstabgleich laeuft noch" | aeltere Mails werden in mehreren Durchlaeufen nachgeholt, die Liste waechst noch |
|
| „Erstabgleich laeuft noch" | aeltere Mails werden in mehreren Durchlaeufen nachgeholt, die Liste waechst noch |
|
||||||
| Bilder in einer Mail fehlen | *Externe Bilder laden* klicken - sie sind absichtlich gesperrt |
|
| Bilder in einer Mail fehlen | *Externe Bilder laden* klicken - sie sind absichtlich gesperrt |
|
||||||
| Kontakt-Vorschlaege bleiben leer | Normalfall: sichere Vorschlaege werden automatisch uebernommen und tauchen hier nicht auf |
|
| Kontakt-Vorschlaege bleiben leer | Normalfall: sichere Vorschlaege wandern automatisch nach *Kontakte › Aus Mails* und tauchen hier nicht auf |
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Alle unter `router.use(requireAuth)`.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password` | `/login` offen, Rest angemeldet |
|
| `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password` | `/login` offen, Rest angemeldet |
|
||||||
| `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` fuer alle, Rest Admin |
|
| `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` fuer alle, Rest Admin |
|
||||||
| `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
|
| `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete |
|
||||||
| `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; aendern/loeschen nur Ersteller oder Admin |
|
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; aendern/loeschen nur Ersteller oder Admin |
|
||||||
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | aendern/loeschen nur Ersteller, Zustaendiger oder Admin |
|
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | aendern/loeschen nur Ersteller, Zustaendiger oder Admin |
|
||||||
@@ -107,8 +107,8 @@ Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
|
|||||||
| `graph.js` | Token mit Zwischenspeicher, `graphRequest()` mit Wiederholung bei 429/5xx und Beachtung von `Retry-After`, `testConnection()` |
|
| `graph.js` | Token mit Zwischenspeicher, `graphRequest()` mit Wiederholung bei 429/5xx und Beachtung von `Retry-After`, `testConnection()` |
|
||||||
| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
|
| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
|
||||||
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
|
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
|
||||||
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()` |
|
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()`, `findLeadByEmail()` je Bestand |
|
||||||
| `contacts.js` | `adoptCandidate()`: anlegen oder vorhandenen Kontakt ergaenzen, `markCandidate()` |
|
| `contacts.js` | `adoptCandidate()`: Eintrag in „Aus Mails" (`target: 'pool'`) oder richtiger Kontakt (`'contacts'`), sonst ergaenzen; `markCandidate()` |
|
||||||
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
|
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
|
||||||
| `scheduler.js` | Minutentakt, Faelligkeit aus `last_run_at`, kein Zeitfenster |
|
| `scheduler.js` | Minutentakt, Faelligkeit aus `last_run_at`, kein Zeitfenster |
|
||||||
|
|
||||||
@@ -184,6 +184,7 @@ Zusagen, auf die sich der Rest verlaesst:
|
|||||||
| `setUnauthorizedHandler` | wird bei 401 gerufen; `AuthProvider` haengt sich hier ein |
|
| `setUnauthorizedHandler` | wird bei 401 gerufen; `AuthProvider` haengt sich hier ein |
|
||||||
| `ApiError` | Fehler mit `status` |
|
| `ApiError` | Fehler mit `status` |
|
||||||
| `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte |
|
| `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte |
|
||||||
|
| `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt uebernehmen |
|
||||||
|
|
||||||
Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body
|
Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body
|
||||||
`FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit
|
`FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit
|
||||||
@@ -240,6 +241,9 @@ 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`, `StatusBadge`, `Avatar`, Statusfarben |
|
||||||
|
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
|
||||||
|
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Uebernehmen, Sammeluebernahme |
|
||||||
|
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergaenzen eines vorhandenen Kontakts |
|
||||||
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
|
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
|
||||||
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
|
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
|
||||||
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
|
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
|
||||||
|
|||||||
+6
-4
@@ -13,7 +13,9 @@ Quelltext.
|
|||||||
| **App-only** | Zugriff auf Microsoft 365 im Namen der Anwendung, nicht eines Benutzers (OAuth-Ablauf *Client Credentials*). Braucht Anwendungsberechtigungen und Administratorzustimmung, aber keinen Login. |
|
| **App-only** | Zugriff auf Microsoft 365 im Namen der Anwendung, nicht eines Benutzers (OAuth-Ablauf *Client Credentials*). Braucht Anwendungsberechtigungen und Administratorzustimmung, aber keinen Login. |
|
||||||
| **Cookie-Import** | Uebertragen einer angemeldeten Browsersitzung auf den Scraper, per JSON-Export der Erweiterung *Cookie-Editor*. Landet als `session_state` am Portal. |
|
| **Cookie-Import** | Uebertragen einer angemeldeten Browsersitzung auf den Scraper, per JSON-Export der Erweiterung *Cookie-Editor*. Landet als `session_state` am Portal. |
|
||||||
| **Delta Query** | Graph-Abfrage, die beim naechsten Aufruf nur noch die Aenderungen liefert. Der Fortschritt steckt in einem Verweis (`delta_link`), der in `crm_mail_sync_state` liegt. |
|
| **Delta Query** | Graph-Abfrage, die beim naechsten Aufruf nur noch die Aenderungen liefert. Der Fortschritt steckt in einem Verweis (`delta_link`), der in `crm_mail_sync_state` liegt. |
|
||||||
| **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads`. Im Code durchgaengig `lead`. |
|
| **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads` mit `mail_pool = 0`. Im Code durchgaengig `lead`. |
|
||||||
|
| **Aus Mails** / Vorbereich | Zeilen in `crm_leads` mit `mail_pool = 1`: Personen, die der Postfachabgleich gefunden hat. Eigener Menuepunkt unter *Kontakte*, taucht in keiner Auswahlliste auf. Erst das Uebernehmen macht daraus einen Kontakt. |
|
||||||
|
| **Dublette** (Aus Mails) | Ein Fund, zu dem es in den Kontakten schon eine Person gibt - erkannt an der E-Mail-Adresse, sonst am vollen Namen. In der Liste **gold** markiert, abweichende Felder hervorgehoben. |
|
||||||
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird uebernommen, ergaenzt oder verworfen. |
|
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird uebernommen, ergaenzt oder verworfen. |
|
||||||
| **Match** / Treffer | Die Bewertung **eines** Projekts fuer **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 fuer **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
|
||||||
| **Opportunity** | Geschaeft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
|
| **Opportunity** | Geschaeft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
|
||||||
@@ -31,7 +33,7 @@ Quelltext.
|
|||||||
| **Whisper** | Das Spracherkennungsmodell fuer das Diktat. Laeuft im Browser, nicht auf dem Server. |
|
| **Whisper** | Das Spracherkennungsmodell fuer das Diktat. Laeuft im Browser, nicht auf dem Server. |
|
||||||
| **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfaecher - ohne sie gilt sie tenant-weit. |
|
| **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfaecher - ohne sie gilt sie tenant-weit. |
|
||||||
| **Zustaendiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie aendern, auch wenn er sie nicht angelegt hat. |
|
| **Zustaendiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie aendern, auch wenn er sie nicht angelegt hat. |
|
||||||
| **Zuversicht** / Confidence | Wert von 0 bis 100 an einem Kontakt-Vorschlag. Ab 70 und mit echter Mailadresse wird automatisch uebernommen. |
|
| **Zuversicht** / Confidence | Wert von 0 bis 100 an einem Kontakt-Vorschlag. Ab 70 und mit echter Mailadresse landet er automatisch im Vorbereich *Aus Mails*. |
|
||||||
|
|
||||||
## Herkunft eines Kontakt-Vorschlags
|
## Herkunft eines Kontakt-Vorschlags
|
||||||
|
|
||||||
@@ -47,8 +49,8 @@ Quelltext.
|
|||||||
| Wert | in der Oberflaeche | Bedeutung |
|
| Wert | in der Oberflaeche | Bedeutung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `offen` | Offen | wartet auf eine Entscheidung |
|
| `offen` | Offen | wartet auf eine Entscheidung |
|
||||||
| `uebernommen` | Uebernommen | es wurde ein Kontakt angelegt |
|
| `uebernommen` | Uebernommen | es entstand ein Eintrag - im Automatiklauf in *Aus Mails*, von Hand ein Kontakt |
|
||||||
| `dublette` | Ergaenzt | Adresse war vorhanden, der Kontakt wurde erweitert |
|
| `dublette` | Ergaenzt | die Adresse war im Zielbestand vorhanden, der Eintrag wurde erweitert |
|
||||||
| `verworfen` | Verworfen | von Hand abgelehnt |
|
| `verworfen` | Verworfen | von Hand abgelehnt |
|
||||||
|
|
||||||
## Statuswerte im Scanner
|
## Statuswerte im Scanner
|
||||||
|
|||||||
+61
-6
@@ -192,8 +192,8 @@ flowchart TD
|
|||||||
F -->|"Person"| C["Vorschlag mit Zuversicht"]
|
F -->|"Person"| C["Vorschlag mit Zuversicht"]
|
||||||
|
|
||||||
C --> Z{"E-Mail vorhanden<br/>und Zuversicht ≥ 70?"}
|
C --> Z{"E-Mail vorhanden<br/>und Zuversicht ≥ 70?"}
|
||||||
Z -->|ja, Automatik an| N["Kontakt anlegen<br/>source = 'Mail'"]
|
Z -->|ja, Automatik an| N["Eintrag in 'Aus Mails'<br/>mail_pool = 1"]
|
||||||
Z -->|Adresse existiert| G["vorhandenen Kontakt ergaenzen"]
|
Z -->|"Adresse dort schon"| G["Eintrag ergaenzen"]
|
||||||
Z -->|nein| R["bleibt zur Durchsicht"]
|
Z -->|nein| R["bleibt zur Durchsicht"]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -216,9 +216,51 @@ Zwei Entscheidungen, die die Trefferqualitaet tragen:
|
|||||||
`k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus
|
`k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus
|
||||||
dem Anzeigenamen - dort stehen die Umlaute richtig.
|
dem Anzeigenamen - dort stehen die Umlaute richtig.
|
||||||
|
|
||||||
Uebernommene Kontakte tragen `crm_leads.source = 'Mail'`. Das ist die
|
## Vorbereich „Aus Mails"
|
||||||
„Unterkategorie": die Kontaktliste hat dafuer einen Herkunftsfilter, ein eigenes
|
|
||||||
Feld war nicht noetig.
|
Der Hintergrundlauf schreibt **nicht** in den Kontaktbestand. Was er findet,
|
||||||
|
landet in einem Vorbereich: `crm_leads.mail_pool = 1`. Die Kontaktansicht zeigt
|
||||||
|
nur `mail_pool = 0`, der Vorbereich steht als Unterpunkt *Aus Mails* unter dem
|
||||||
|
Menuepunkt *Kontakte*.
|
||||||
|
|
||||||
|
Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der
|
||||||
|
Opportunities, Kalender und Netzwerk haengen. Ein Abgleich, der ungefragt jede
|
||||||
|
Adresse aus jeder Mail hineinschreibt, macht sie unbrauchbar - genau daran ist
|
||||||
|
das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand
|
||||||
|
entscheidet.
|
||||||
|
|
||||||
|
Eine Spalte statt einer zweiten Tabelle, weil `crm_mail_links` und
|
||||||
|
`crm_mail_contact_candidates` per Fremdschluessel auf `crm_leads` zeigen: der
|
||||||
|
Vorbereich behaelt damit seine Mailbezuege, ohne dass es zwei Wege gibt, eine
|
||||||
|
Person mit einer Mail zu verbinden.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
P["Aus Mails<br/>mail_pool = 1"] --> D{"Person schon<br/>in den Kontakten?"}
|
||||||
|
D -->|nein| NEU["In Kontakte uebernehmen<br/>Kopie mit mail_pool = 0"]
|
||||||
|
D -->|"ja - Adresse oder Name"| GOLD["gold markiert<br/>Abweichungen hervorgehoben"]
|
||||||
|
GOLD --> M["Felder einzeln in den<br/>vorhandenen Kontakt uebernehmen"]
|
||||||
|
GOLD --> N2["trotzdem neu anlegen"]
|
||||||
|
P --> W["entfernen"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Abgleich gegen die Kontakte laeuft im Browser (`mailPool.ts`): erst ueber die
|
||||||
|
E-Mail-Adresse, sonst ueber den vollen Namen. Telefonnummern werden dabei ueber
|
||||||
|
ihre Ziffern verglichen, damit „0221 123-4" und „02211234" nicht als Abweichung
|
||||||
|
erscheinen.
|
||||||
|
|
||||||
|
Beim Uebernehmen sind nur die **Luecken** vorausgewaehlt - Felder, in denen der
|
||||||
|
Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen wuerden,
|
||||||
|
sind als *ueberschreibt* gekennzeichnet und muessen bewusst angehakt werden: der
|
||||||
|
Mailfund kann aus einer alten Signatur stammen.
|
||||||
|
|
||||||
|
Uebernommene Kontakte tragen weiterhin `crm_leads.source = 'Mail'` - die
|
||||||
|
Kontaktliste hat dafuer den Herkunftsfilter *Aus Mails uebernommen*.
|
||||||
|
|
||||||
|
Bestandsumstellung: `db.js` verschiebt beim ersten Start alle vorhandenen
|
||||||
|
Kontakte mit `source = 'Mail'` in den Vorbereich. Das laeuft genau einmal,
|
||||||
|
festgehalten im Merker `leads_mail_pool_migrated` in `crm_settings` - sonst
|
||||||
|
wanderten uebernommene Kontakte bei jedem Neustart zurueck.
|
||||||
|
|
||||||
## Anzeige des Mailtexts
|
## Anzeige des Mailtexts
|
||||||
|
|
||||||
@@ -251,8 +293,21 @@ Alle unter `/api/mail`, alle hinter `requireAuth`.
|
|||||||
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity haengen |
|
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity haengen |
|
||||||
| DELETE | `/links/:id` | alle | Zuordnung loesen |
|
| DELETE | `/links/:id` | alle | Zuordnung loesen |
|
||||||
| GET | `/candidates` | alle | Kontakt-Vorschlaege, `status` waehlbar |
|
| GET | `/candidates` | alle | Kontakt-Vorschlaege, `status` waehlbar |
|
||||||
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergaenzen |
|
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergaenzen; `target: 'pool'` legt in „Aus Mails" ab |
|
||||||
| POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen |
|
| POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen |
|
||||||
|
|
||||||
|
Der Vorbereich haengt an den Kontakten und hat seine Endpunkte deshalb unter
|
||||||
|
`/api/leads`:
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/leads/mail-pool/sources` | Herkunft je Eintrag: Mailanzahl, letzter Betreff, Zuversicht |
|
||||||
|
| POST | `/leads/mail-pool/promote` | mehrere Eintraege als neue Kontakte anlegen |
|
||||||
|
| POST | `/leads/:id/promote` | einen Eintrag uebernehmen; mit `target_lead_id` und `fields` in einen vorhandenen Kontakt |
|
||||||
|
|
||||||
|
`keep_pool: true` behaelt den Eintrag in „Aus Mails"; ohne das verschwindet er
|
||||||
|
dort, sobald er als Kontakt gefuehrt wird. In beiden Faellen wandern die
|
||||||
|
Mailverknuepfungen mit an den Zielkontakt.
|
||||||
| GET | `/settings` | admin | verstellbare Einstellungen |
|
| GET | `/settings` | admin | verstellbare Einstellungen |
|
||||||
| POST | `/settings` | admin | Einstellungen speichern |
|
| POST | `/settings` | admin | Einstellungen speichern |
|
||||||
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff pruefen |
|
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff pruefen |
|
||||||
|
|||||||
@@ -48,9 +48,39 @@ export async function initDatabase() {
|
|||||||
conn.release();
|
conn.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await migrateMailPool();
|
||||||
await ensureAdminUser();
|
await ensureAdminUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
|
||||||
|
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
|
||||||
|
*
|
||||||
|
* Das laeuft absichtlich nur ein einziges Mal und wird per Merker in
|
||||||
|
* crm_settings festgehalten - ein Kontakt, der aus dem Vorbereich uebernommen
|
||||||
|
* wurde, behaelt die Herkunft 'Mail' und wuerde bei jedem Start wieder
|
||||||
|
* zurueckwandern. Direkter SQL-Zugriff statt settings.js, weil das Modul
|
||||||
|
* seinerseits db.js braucht.
|
||||||
|
*/
|
||||||
|
async function migrateMailPool() {
|
||||||
|
const flag = 'leads_mail_pool_migrated';
|
||||||
|
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
|
||||||
|
if (rows[0]?.value === 'true') return;
|
||||||
|
|
||||||
|
const [result] = await pool.query("UPDATE crm_leads SET mail_pool = 1 WHERE source = 'Mail'");
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO crm_settings (key_name, value) VALUES (?, 'true')
|
||||||
|
ON DUPLICATE KEY UPDATE value = 'true'`,
|
||||||
|
[flag]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
console.log(
|
||||||
|
`[perinco CRM] ${result.affectedRows} Kontakt(e) aus Mails in den Vorbereich "Aus Mails" verschoben.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureAdminUser() {
|
async function ensureAdminUser() {
|
||||||
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
|
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
|
||||||
if (rows[0].count > 0) return;
|
if (rows[0].count > 0) return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { findLeadByEmail, linkMessage } from './store.js';
|
|||||||
/*
|
/*
|
||||||
* Aus einem Kontakt-Vorschlag einen Kontakt machen.
|
* Aus einem Kontakt-Vorschlag einen Kontakt machen.
|
||||||
*
|
*
|
||||||
* Zwei Dinge macht das bewusst anders als das Altsystem:
|
* Drei Dinge macht das bewusst anders als das Altsystem:
|
||||||
*
|
*
|
||||||
* 1. Es werden keine Platzhalter-Adressen erfunden. Wer keine E-Mail-Adresse hat,
|
* 1. Es werden keine Platzhalter-Adressen erfunden. Wer keine E-Mail-Adresse hat,
|
||||||
* wird angelegt ohne - das Feld ist in crm_leads ohnehin optional. Das
|
* wird angelegt ohne - das Feld ist in crm_leads ohnehin optional. Das
|
||||||
@@ -13,6 +13,13 @@ import { findLeadByEmail, linkMessage } from './store.js';
|
|||||||
*
|
*
|
||||||
* 2. Ein bereits vorhandener Kontakt wird nicht ein zweites Mal angelegt, sondern
|
* 2. Ein bereits vorhandener Kontakt wird nicht ein zweites Mal angelegt, sondern
|
||||||
* ergaenzt: leere Felder werden gefuellt, gepflegte Werte bleiben unangetastet.
|
* ergaenzt: leere Felder werden gefuellt, gepflegte Werte bleiben unangetastet.
|
||||||
|
*
|
||||||
|
* 3. Der Hintergrundlauf schreibt nicht in den Kontaktbestand, sondern in den
|
||||||
|
* Vorbereich "Aus Mails" (mail_pool = 1). Der Kontaktbestand ist gepflegt und
|
||||||
|
* soll nicht ohne Zutun wachsen; wer aus einer Mail auftaucht, wartet dort auf
|
||||||
|
* Sichtung. `target` waehlt den Bestand:
|
||||||
|
* 'pool' - Vorbereich, Standard des Hintergrundlaufs
|
||||||
|
* 'contacts' - richtiger Kontakt, so wie beim Uebernehmen aus der Mailansicht
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Herkunftskennzeichen in crm_leads.source - traegt die Unterkategorie. */
|
/** Herkunftskennzeichen in crm_leads.source - traegt die Unterkategorie. */
|
||||||
@@ -73,13 +80,22 @@ async function enrichLead(leadId, candidate) {
|
|||||||
*
|
*
|
||||||
* `mode` steuert nur die Protokollierung: 'automatisch' kommt aus dem
|
* `mode` steuert nur die Protokollierung: 'automatisch' kommt aus dem
|
||||||
* Hintergrundlauf, 'manuell' aus der Oberflaeche. `userId` ist bei automatischer
|
* Hintergrundlauf, 'manuell' aus der Oberflaeche. `userId` ist bei automatischer
|
||||||
* Uebernahme null.
|
* Uebernahme null. `target` waehlt den Zielbestand (siehe Kopf der Datei).
|
||||||
|
*
|
||||||
|
* Die Dublettenpruefung laeuft immer nur im Zielbestand: ein Eintrag im
|
||||||
|
* Vorbereich soll die Uebernahme in den Kontaktbestand nicht blockieren, sonst
|
||||||
|
* wuerde die Uebernahme aus der Mailansicht den Vorbereichseintrag ergaenzen
|
||||||
|
* statt einen Kontakt anzulegen.
|
||||||
*
|
*
|
||||||
* Rueckgabe: { status, leadId, enrichedFields } - status ist 'uebernommen' bei
|
* Rueckgabe: { status, leadId, enrichedFields } - status ist 'uebernommen' bei
|
||||||
* einem neuen Kontakt und 'dublette', wenn die Adresse schon vorhanden war.
|
* einem neuen Eintrag und 'dublette', wenn die Adresse dort schon vorhanden war.
|
||||||
*/
|
*/
|
||||||
export async function adoptCandidate(candidate, { userId = null, ownerId = null, mode = 'manuell' } = {}) {
|
export async function adoptCandidate(
|
||||||
const existing = candidate.email ? await findLeadByEmail(candidate.email) : null;
|
candidate,
|
||||||
|
{ userId = null, ownerId = null, mode = 'manuell', target = 'pool' } = {}
|
||||||
|
) {
|
||||||
|
const mailPool = target === 'contacts' ? 0 : 1;
|
||||||
|
const existing = candidate.email ? await findLeadByEmail(candidate.email, { mailPool }) : null;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const enrichedFields = await enrichLead(existing.id, candidate);
|
const enrichedFields = await enrichLead(existing.id, candidate);
|
||||||
@@ -95,7 +111,15 @@ export async function adoptCandidate(candidate, { userId = null, ownerId = null,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fields = candidateToLeadFields(candidate);
|
const fields = candidateToLeadFields(candidate);
|
||||||
const columns = { ...fields, status: 'Neu', source: MAIL_LEAD_SOURCE, tags: '[]', owner_id: ownerId, created_by: userId };
|
const columns = {
|
||||||
|
...fields,
|
||||||
|
status: 'Neu',
|
||||||
|
source: MAIL_LEAD_SOURCE,
|
||||||
|
mail_pool: mailPool,
|
||||||
|
tags: '[]',
|
||||||
|
owner_id: ownerId,
|
||||||
|
created_by: userId,
|
||||||
|
};
|
||||||
const keys = Object.keys(columns);
|
const keys = Object.keys(columns);
|
||||||
|
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
|
|||||||
@@ -200,12 +200,21 @@ export async function insertCandidates(messageId, candidates) {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal). */
|
/**
|
||||||
export async function findLeadByEmail(email) {
|
* Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal).
|
||||||
|
*
|
||||||
|
* `mailPool` waehlt den Bestand: 0 sind die richtigen Kontakte, 1 der Vorbereich
|
||||||
|
* "Aus Mails". Beide werden getrennt durchsucht - ein Eintrag im Vorbereich darf
|
||||||
|
* die Uebernahme in den Kontaktbestand nicht als Dublette erscheinen lassen und
|
||||||
|
* umgekehrt. Ohne Angabe wird ueber beide Bestaende gesucht.
|
||||||
|
*/
|
||||||
|
export async function findLeadByEmail(email, { mailPool = null } = {}) {
|
||||||
if (!email) return null;
|
if (!email) return null;
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'SELECT id, first_name, last_name FROM crm_leads WHERE LOWER(email) = LOWER(?) ORDER BY id LIMIT 1',
|
`SELECT id, first_name, last_name, mail_pool FROM crm_leads
|
||||||
[email]
|
WHERE LOWER(email) = LOWER(?)${mailPool === null ? '' : ' AND mail_pool = ?'}
|
||||||
|
ORDER BY id LIMIT 1`,
|
||||||
|
mailPool === null ? [email] : [email, mailPool]
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,13 +240,18 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
|
|||||||
* Automatisch uebernommen wird nur, wer eine echte Adresse hat und aus
|
* Automatisch uebernommen wird nur, wer eine echte Adresse hat und aus
|
||||||
* Absender oder Empfaengerliste stammt. Signatur- und Textfunde liegen
|
* Absender oder Empfaengerliste stammt. Signatur- und Textfunde liegen
|
||||||
* unter der Schwelle und warten auf eine Entscheidung.
|
* unter der Schwelle und warten auf eine Entscheidung.
|
||||||
|
*
|
||||||
|
* Ziel ist der Vorbereich "Aus Mails" (mail_pool = 1), nicht der
|
||||||
|
* Kontaktbestand: der Hintergrundlauf soll die gepflegte Kontaktliste
|
||||||
|
* nicht von selbst fuellen. Der Weg in die Kontakte fuehrt ueber die
|
||||||
|
* Ansicht "Aus Mails".
|
||||||
*/
|
*/
|
||||||
if (settings.autoContacts) {
|
if (settings.autoContacts) {
|
||||||
for (const candidate of created) {
|
for (const candidate of created) {
|
||||||
if (!candidate.email) continue;
|
if (!candidate.email) continue;
|
||||||
if (candidate.confidence < AUTO_ADOPT_MIN_CONFIDENCE) continue;
|
if (candidate.confidence < AUTO_ADOPT_MIN_CONFIDENCE) continue;
|
||||||
try {
|
try {
|
||||||
const result = await adoptCandidate(candidate, { mode: 'automatisch' });
|
const result = await adoptCandidate(candidate, { mode: 'automatisch', target: 'pool' });
|
||||||
if (result.status === 'uebernommen') adoptedContacts += 1;
|
if (result.status === 'uebernommen') adoptedContacts += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[Mail] Kontakt-Vorschlag konnte nicht uebernommen werden: ${err.message}`);
|
console.warn(`[Mail] Kontakt-Vorschlag konnte nicht uebernommen werden: ${err.message}`);
|
||||||
@@ -299,7 +304,7 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
|
|||||||
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} Kontakte angelegt.`
|
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Eintraege in "Aus Mails".`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hasMore || truncated) {
|
if (hasMore || truncated) {
|
||||||
|
|||||||
+267
-1
@@ -59,7 +59,14 @@ function serializeTags(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapLead(row) {
|
function mapLead(row) {
|
||||||
return { ...row, tags: parseTags(row.tags), owner_id: row.owner_id, created_by: row.created_by };
|
return {
|
||||||
|
...row,
|
||||||
|
tags: parseTags(row.tags),
|
||||||
|
owner_id: row.owner_id,
|
||||||
|
created_by: row.created_by,
|
||||||
|
// TINYINT(1) kommt als 0/1 - im Frontend soll es ein Schalter sein.
|
||||||
|
mail_pool: Boolean(row.mail_pool),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECT_LEADS = `
|
const SELECT_LEADS = `
|
||||||
@@ -134,6 +141,265 @@ router.post('/', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vorbereich "Aus Mails" (mail_pool = 1)
|
||||||
|
//
|
||||||
|
// Der Postfachabgleich legt gefundene Personen hier ab, nicht im Kontaktbestand.
|
||||||
|
// Von hier fuehren zwei Wege in die Kontakte: als neuer Kontakt (Kopie) oder als
|
||||||
|
// Ergaenzung eines vorhandenen Kontakts mit ausgewaehlten Feldern.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Felder, die beim Uebernehmen verglichen und uebertragen werden koennen.
|
||||||
|
* `source` fehlt absichtlich - die Herkunft 'Mail' wandert unveraendert mit und
|
||||||
|
* ist keine Entscheidung des Benutzers. `notes`, `avatar`, `tags` und `status`
|
||||||
|
* bleiben ebenfalls aussen vor: sie sind gepflegte Angaben des Kontakts und
|
||||||
|
* haben in einem Mailfund kein Gegenstueck.
|
||||||
|
*/
|
||||||
|
const POOL_MERGE_FIELDS = [
|
||||||
|
'salutation',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'company',
|
||||||
|
'position',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'mobile',
|
||||||
|
'linkedin',
|
||||||
|
'website',
|
||||||
|
'street',
|
||||||
|
'zip',
|
||||||
|
'city',
|
||||||
|
'country',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadPoolLead(id) {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM crm_leads WHERE id = ? AND mail_pool = 1 LIMIT 1', [id]);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailbezuege des Vorbereichseintrags auf den Zielkontakt uebertragen: die
|
||||||
|
* Mails, aus denen die Person stammt, haengen danach am richtigen Kontakt.
|
||||||
|
* Die Vorschlaege werden umgehaengt statt kopiert - beim Entfernen des
|
||||||
|
* Vorbereichseintrags wuerde ihr Verweis sonst auf NULL fallen.
|
||||||
|
*/
|
||||||
|
async function moveMailRelations(fromLeadId, toLeadId) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, origin, note, created_by)
|
||||||
|
SELECT message_id, ?, origin, note, created_by FROM crm_mail_links WHERE lead_id = ?`,
|
||||||
|
[toLeadId, fromLeadId]
|
||||||
|
);
|
||||||
|
await pool.query('UPDATE crm_mail_contact_candidates SET lead_id = ? WHERE lead_id = ?', [
|
||||||
|
toLeadId,
|
||||||
|
fromLeadId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorbereichseintrag als neuen Kontakt anlegen; gibt die neue ID zurueck. */
|
||||||
|
async function copyPoolLeadToContacts(poolLead, { userId, ownerId }) {
|
||||||
|
const columns = {};
|
||||||
|
for (const field of TEXT_FIELDS) columns[field] = poolLead[field] ?? null;
|
||||||
|
columns.status = poolLead.status;
|
||||||
|
columns.tags = poolLead.tags ?? '[]';
|
||||||
|
columns.mail_pool = 0;
|
||||||
|
columns.owner_id = ownerId;
|
||||||
|
columns.created_by = userId;
|
||||||
|
|
||||||
|
const keys = Object.keys(columns);
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO crm_leads (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||||
|
keys.map((key) => columns[key])
|
||||||
|
);
|
||||||
|
|
||||||
|
await moveMailRelations(poolLead.id, result.insertId);
|
||||||
|
return result.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/leads/mail-pool/sources - woher stammt welcher Eintrag
|
||||||
|
router.get('/mail-pool/sources', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
* Betreff der juengsten Mail ohne Unterabfrage je Zeile: CONCAT stellt das
|
||||||
|
* Datum voran (als Text immer 19 Zeichen), MAX waehlt damit die juengste
|
||||||
|
* Zeile aus, SUBSTRING schneidet das Datum wieder ab.
|
||||||
|
*/
|
||||||
|
const [messages] = await pool.query(
|
||||||
|
`SELECT ml.lead_id,
|
||||||
|
COUNT(DISTINCT ml.message_id) AS message_count,
|
||||||
|
MAX(m.received_at) AS last_received_at,
|
||||||
|
SUBSTRING(MAX(CONCAT(m.received_at, COALESCE(m.subject, ''))), 20) AS last_subject
|
||||||
|
FROM crm_mail_links ml
|
||||||
|
JOIN crm_mail_messages m ON m.id = ml.message_id
|
||||||
|
JOIN crm_leads l ON l.id = ml.lead_id
|
||||||
|
WHERE l.mail_pool = 1 AND ml.lead_id IS NOT NULL
|
||||||
|
GROUP BY ml.lead_id`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fundstelle und Verlaesslichkeit: derselbe Trick, hier mit der auf drei
|
||||||
|
// Stellen aufgefuellten Trefferguete als Sortierpraefix.
|
||||||
|
const [candidates] = await pool.query(
|
||||||
|
`SELECT c.lead_id,
|
||||||
|
MAX(c.confidence) AS confidence,
|
||||||
|
SUBSTRING(MAX(CONCAT(LPAD(c.confidence, 3, '0'), c.origin)), 4) AS origin
|
||||||
|
FROM crm_mail_contact_candidates c
|
||||||
|
JOIN crm_leads l ON l.id = c.lead_id
|
||||||
|
WHERE l.mail_pool = 1
|
||||||
|
GROUP BY c.lead_id`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sources = {};
|
||||||
|
for (const row of messages) {
|
||||||
|
sources[row.lead_id] = {
|
||||||
|
message_count: Number(row.message_count),
|
||||||
|
last_received_at: row.last_received_at,
|
||||||
|
last_subject: row.last_subject || null,
|
||||||
|
confidence: null,
|
||||||
|
origin: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const row of candidates) {
|
||||||
|
const entry = (sources[row.lead_id] ??= {
|
||||||
|
message_count: 0,
|
||||||
|
last_received_at: null,
|
||||||
|
last_subject: null,
|
||||||
|
confidence: null,
|
||||||
|
origin: null,
|
||||||
|
});
|
||||||
|
entry.confidence = Number(row.confidence);
|
||||||
|
entry.origin = row.origin || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(sources);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* POST /api/leads/mail-pool/promote - mehrere Eintraege auf einen Schlag als
|
||||||
|
* neue Kontakte anlegen. Muss vor /:id/promote stehen, sonst faengt die
|
||||||
|
* Einzelroute "mail-pool" als ID ab.
|
||||||
|
*/
|
||||||
|
router.post('/mail-pool/promote', async (req, res, next) => {
|
||||||
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isInteger) : [];
|
||||||
|
if (ids.length === 0) return res.status(400).json({ error: 'Keine Eintraege uebergeben.' });
|
||||||
|
|
||||||
|
const keepPool = req.body?.keep_pool === true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = [];
|
||||||
|
const skipped = [];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const poolLead = await loadPoolLead(id);
|
||||||
|
if (!poolLead) {
|
||||||
|
skipped.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const leadId = await copyPoolLeadToContacts(poolLead, {
|
||||||
|
userId: req.user.id,
|
||||||
|
ownerId: poolLead.owner_id ?? req.user.id,
|
||||||
|
});
|
||||||
|
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
|
||||||
|
created.push(leadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, created, skipped });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* POST /api/leads/:id/promote - einen Eintrag uebernehmen.
|
||||||
|
*
|
||||||
|
* ohne target_lead_id : neuer Kontakt als Kopie
|
||||||
|
* mit target_lead_id : die in `fields` genannten Werte in den vorhandenen
|
||||||
|
* Kontakt schreiben (Dublettenfall)
|
||||||
|
*
|
||||||
|
* `keep_pool` behaelt den Eintrag in "Aus Mails"; ohne das verschwindet er dort,
|
||||||
|
* sobald er in den Kontakten steht.
|
||||||
|
*/
|
||||||
|
router.post('/:id/promote', async (req, res, next) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' });
|
||||||
|
|
||||||
|
const keepPool = req.body?.keep_pool === true;
|
||||||
|
const targetLeadId =
|
||||||
|
req.body?.target_lead_id === undefined || req.body.target_lead_id === null
|
||||||
|
? null
|
||||||
|
: Number(req.body.target_lead_id);
|
||||||
|
if (targetLeadId !== null && !Number.isInteger(targetLeadId)) {
|
||||||
|
return res.status(400).json({ error: 'Ungueltige Ziel-Kontakt-ID.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const poolLead = await loadPoolLead(id);
|
||||||
|
if (!poolLead) return res.status(404).json({ error: 'Eintrag in "Aus Mails" nicht gefunden.' });
|
||||||
|
|
||||||
|
// Fall 1: neuer Kontakt.
|
||||||
|
if (targetLeadId === null) {
|
||||||
|
const ownerId =
|
||||||
|
req.body?.owner_id === undefined || req.body.owner_id === null
|
||||||
|
? poolLead.owner_id ?? req.user.id
|
||||||
|
: Number(req.body.owner_id);
|
||||||
|
if (!Number.isInteger(ownerId) || !(await userExists(ownerId))) {
|
||||||
|
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadId = await copyPoolLeadToContacts(poolLead, { userId: req.user.id, ownerId });
|
||||||
|
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
merged: false,
|
||||||
|
fields: 0,
|
||||||
|
removed_pool: !keepPool,
|
||||||
|
lead: await fetchLead(leadId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall 2: vorhandenen Kontakt ergaenzen.
|
||||||
|
const [targets] = await pool.query(
|
||||||
|
'SELECT id FROM crm_leads WHERE id = ? AND mail_pool = 0 LIMIT 1',
|
||||||
|
[targetLeadId]
|
||||||
|
);
|
||||||
|
if (!targets[0]) return res.status(400).json({ error: 'Zielkontakt existiert nicht.' });
|
||||||
|
|
||||||
|
const requested = Array.isArray(req.body?.fields) ? req.body.fields : [];
|
||||||
|
const fields = requested.filter((field) => POOL_MERGE_FIELDS.includes(field));
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = emptyToNull(poolLead[field]);
|
||||||
|
// Ein leerer Wert aus dem Vorbereich darf einen gepflegten Kontakt nicht
|
||||||
|
// ausraeumen - und Namen duerfen ohnehin nicht leer werden.
|
||||||
|
if (value === null) continue;
|
||||||
|
updates[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(updates);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE crm_leads SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
|
||||||
|
[...keys.map((key) => updates[key]), targetLeadId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveMailRelations(poolLead.id, targetLeadId);
|
||||||
|
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
merged: true,
|
||||||
|
fields: keys.length,
|
||||||
|
removed_pool: !keepPool,
|
||||||
|
lead: await fetchLead(targetLeadId),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /api/leads/:id
|
// PATCH /api/leads/:id
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', async (req, res, next) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ router.get('/status', async (req, res, next) => {
|
|||||||
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
|
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
|
||||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates,
|
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') 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_mail_links) AS links`
|
(SELECT COUNT(*) FROM crm_mail_links) AS links`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ router.get('/status', async (req, res, next) => {
|
|||||||
messages: Number(counts.messages),
|
messages: Number(counts.messages),
|
||||||
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),
|
||||||
links: Number(counts.links),
|
links: Number(counts.links),
|
||||||
},
|
},
|
||||||
last_result: lastMailSyncResult(),
|
last_result: lastMailSyncResult(),
|
||||||
@@ -283,8 +285,12 @@ router.get('/messages/:id', async (req, res, next) => {
|
|||||||
const [candidates] = await pool.query(
|
const [candidates] = await pool.query(
|
||||||
`SELECT c.*,
|
`SELECT c.*,
|
||||||
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
||||||
|
/* Nur richtige Kontakte zaehlen (mail_pool = 0). Ein Eintrag im
|
||||||
|
Vorbereich "Aus Mails" ist keine Dublette - das Uebernehmen von
|
||||||
|
Hand legt trotzdem einen Kontakt an. */
|
||||||
(SELECT lx.id FROM crm_leads lx
|
(SELECT lx.id FROM crm_leads lx
|
||||||
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
|
WHERE c.email IS NOT NULL AND lx.mail_pool = 0
|
||||||
|
AND LOWER(lx.email) = LOWER(c.email)
|
||||||
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
||||||
FROM crm_mail_contact_candidates c
|
FROM crm_mail_contact_candidates c
|
||||||
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
|
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
|
||||||
@@ -423,9 +429,11 @@ router.get('/candidates', async (req, res, next) => {
|
|||||||
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
||||||
u.name AS decided_by_name,
|
u.name AS decided_by_name,
|
||||||
/* Gibt es diese Adresse schon als Kontakt? Dann ist es keine
|
/* Gibt es diese Adresse schon als Kontakt? Dann ist es keine
|
||||||
Neuanlage, sondern eine Ergaenzung. */
|
Neuanlage, sondern eine Ergaenzung. Der Vorbereich "Aus Mails"
|
||||||
|
(mail_pool = 1) zaehlt hier nicht mit. */
|
||||||
(SELECT lx.id FROM crm_leads lx
|
(SELECT lx.id FROM crm_leads lx
|
||||||
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
|
WHERE c.email IS NOT NULL AND lx.mail_pool = 0
|
||||||
|
AND LOWER(lx.email) = LOWER(c.email)
|
||||||
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
||||||
FROM crm_mail_contact_candidates c
|
FROM crm_mail_contact_candidates c
|
||||||
JOIN crm_mail_messages m ON m.id = c.message_id
|
JOIN crm_mail_messages m ON m.id = c.message_id
|
||||||
@@ -473,10 +481,19 @@ router.post('/candidates/:id/adopt', async (req, res, next) => {
|
|||||||
: Number(req.body.owner_id);
|
: Number(req.body.owner_id);
|
||||||
if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' });
|
if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Von Hand uebernommen heisst: richtiger Kontakt. Der Vorbereich "Aus Mails"
|
||||||
|
* ist fuer den Hintergrundlauf gedacht - wer hier klickt, hat den Vorschlag
|
||||||
|
* schon gesehen und entschieden. Mit target: 'pool' laesst sich das im Bedarf
|
||||||
|
* umstellen, etwa aus einer Sammelaktion.
|
||||||
|
*/
|
||||||
|
const target = req.body?.target === 'pool' ? 'pool' : 'contacts';
|
||||||
|
|
||||||
const result = await adoptCandidate(candidate, {
|
const result = await adoptCandidate(candidate, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
ownerId,
|
ownerId,
|
||||||
mode: 'manuell',
|
mode: 'manuell',
|
||||||
|
target,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ CREATE TABLE IF NOT EXISTS crm_leads (
|
|||||||
CONSTRAINT fk_crm_leads_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
CONSTRAINT fk_crm_leads_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kontakte aus dem Postfach landen nicht direkt im Kontaktbestand, sondern in
|
||||||
|
* einem Vorbereich: mail_pool = 1. Die Kontaktansicht zeigt nur mail_pool = 0,
|
||||||
|
* der Vorbereich steht als eigener Menuepunkt "Aus Mails" daneben. Erst die
|
||||||
|
* Uebernahme dort erzeugt einen richtigen Kontakt (mail_pool = 0).
|
||||||
|
*
|
||||||
|
* Bewusst eine Spalte statt einer zweiten Tabelle: Mailverknuepfungen
|
||||||
|
* (crm_mail_links) und Vorschlaege (crm_mail_contact_candidates) zeigen per
|
||||||
|
* Fremdschluessel auf crm_leads und bleiben damit auch fuer den Vorbereich
|
||||||
|
* gueltig. Die Einmal-Umstellung bestehender Zeilen macht db.js.
|
||||||
|
*/
|
||||||
|
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_pool TINYINT(1) NOT NULL DEFAULT 0 AFTER source;
|
||||||
|
ALTER TABLE crm_leads ADD INDEX IF NOT EXISTS idx_crm_leads_mail_pool (mail_pool);
|
||||||
|
|
||||||
-- Wer steht mit wem in Kontakt. Ungerichtet gedacht; Duplikate werden ueber
|
-- Wer steht mit wem in Kontakt. Ungerichtet gedacht; Duplikate werden ueber
|
||||||
-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend).
|
-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend).
|
||||||
CREATE TABLE IF NOT EXISTS crm_lead_connections (
|
CREATE TABLE IF NOT EXISTS crm_lead_connections (
|
||||||
|
|||||||
+30
-2
@@ -3,6 +3,7 @@ import { Sidebar } from './components/Sidebar';
|
|||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { LoginScreen } from './components/LoginScreen';
|
import { LoginScreen } from './components/LoginScreen';
|
||||||
import { Leads } from './components/leads/Leads';
|
import { Leads } from './components/leads/Leads';
|
||||||
|
import { MailPoolLeads } from './components/leads/MailPoolLeads';
|
||||||
import { Opportunities } from './components/opportunities/Opportunities';
|
import { Opportunities } from './components/opportunities/Opportunities';
|
||||||
import { Calendar } from './components/calendar/Calendar';
|
import { Calendar } from './components/calendar/Calendar';
|
||||||
import { parseSqlDateTime } from './components/calendar/calendarUtils';
|
import { parseSqlDateTime } from './components/calendar/calendarUtils';
|
||||||
@@ -57,7 +58,12 @@ const App: React.FC = () => {
|
|||||||
*/
|
*/
|
||||||
const [focusLeadId, setFocusLeadId] = useState<number | null>(null);
|
const [focusLeadId, setFocusLeadId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [leads, setLeads] = useState<Lead[]>([]);
|
/**
|
||||||
|
* Alle Kontakte inklusive Vorbereich - getrennt wird direkt darunter. Der
|
||||||
|
* Server liefert absichtlich beide Bestaende in einem Aufruf: die Ansicht
|
||||||
|
* "Aus Mails" braucht ohnehin beide, um Dubletten zu erkennen.
|
||||||
|
*/
|
||||||
|
const [allLeads, setAllLeads] = useState<Lead[]>([]);
|
||||||
const [connections, setConnections] = useState<LeadConnection[]>([]);
|
const [connections, setConnections] = useState<LeadConnection[]>([]);
|
||||||
const [users, setUsers] = useState<AppUser[]>([]);
|
const [users, setUsers] = useState<AppUser[]>([]);
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
@@ -90,7 +96,7 @@ const App: React.FC = () => {
|
|||||||
usersApi.directory(),
|
usersApi.directory(),
|
||||||
opportunitiesApi.list(),
|
opportunitiesApi.list(),
|
||||||
]);
|
]);
|
||||||
setLeads(leadRows);
|
setAllLeads(leadRows);
|
||||||
setConnections(connectionRows);
|
setConnections(connectionRows);
|
||||||
setEvents(eventRows);
|
setEvents(eventRows);
|
||||||
setUsers(userRows);
|
setUsers(userRows);
|
||||||
@@ -111,6 +117,14 @@ const App: React.FC = () => {
|
|||||||
if (isLoading) return <Splash />;
|
if (isLoading) return <Splash />;
|
||||||
if (!user) return <LoginScreen />;
|
if (!user) return <LoginScreen />;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Kalender,
|
||||||
|
* Netzwerk, Mailzuordnung - gilt nur der gepflegte Bestand; die Funde aus dem
|
||||||
|
* Postfach sind Rohmaterial und sollen in keiner Auswahlliste auftauchen.
|
||||||
|
*/
|
||||||
|
const leads = allLeads.filter((lead) => !lead.mail_pool);
|
||||||
|
const mailPoolLeads = allLeads.filter((lead) => lead.mail_pool);
|
||||||
|
|
||||||
const upcomingCount = events.filter(
|
const upcomingCount = events.filter(
|
||||||
(event) => parseSqlDateTime(event.ends_at).getTime() >= Date.now()
|
(event) => parseSqlDateTime(event.ends_at).getTime() >= Date.now()
|
||||||
).length;
|
).length;
|
||||||
@@ -141,6 +155,10 @@ const App: React.FC = () => {
|
|||||||
? `${leads.length} ${leads.length === 1 ? 'Kontakt' : 'Kontakte'} · ${connections.length} ${
|
? `${leads.length} ${leads.length === 1 ? 'Kontakt' : 'Kontakte'} · ${connections.length} ${
|
||||||
connections.length === 1 ? 'Verbindung' : 'Verbindungen'
|
connections.length === 1 ? 'Verbindung' : 'Verbindungen'
|
||||||
}`
|
}`
|
||||||
|
: currentView === 'Aus Mails'
|
||||||
|
? `${mailPoolLeads.length} ${
|
||||||
|
mailPoolLeads.length === 1 ? 'Fund' : 'Funde'
|
||||||
|
} aus dem Postfach · noch nicht in den Kontakten`
|
||||||
: currentView === 'Kalender'
|
: currentView === 'Kalender'
|
||||||
? `Gemeinsamer Teamkalender · ${events.length} ${
|
? `Gemeinsamer Teamkalender · ${events.length} ${
|
||||||
events.length === 1 ? 'Termin' : 'Termine'
|
events.length === 1 ? 'Termin' : 'Termine'
|
||||||
@@ -155,6 +173,7 @@ const App: React.FC = () => {
|
|||||||
onViewChange={goTo}
|
onViewChange={goTo}
|
||||||
isOpen={isSidebarOpen}
|
isOpen={isSidebarOpen}
|
||||||
onToggle={() => setIsSidebarOpen((open) => !open)}
|
onToggle={() => setIsSidebarOpen((open) => !open)}
|
||||||
|
mailPoolCount={mailPoolLeads.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
@@ -195,6 +214,15 @@ const App: React.FC = () => {
|
|||||||
onFocusHandled={() => setFocusLeadId(null)}
|
onFocusHandled={() => setFocusLeadId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{currentView === 'Aus Mails' && (
|
||||||
|
<MailPoolLeads
|
||||||
|
poolLeads={mailPoolLeads}
|
||||||
|
leads={leads}
|
||||||
|
isLoading={isDataLoading}
|
||||||
|
reload={() => loadData(true)}
|
||||||
|
onOpenLead={(leadId) => goTo('Kontakte', { leadId })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{currentView === 'Mails' && (
|
{currentView === 'Mails' && (
|
||||||
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
|
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+131
-67
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { SCANNER_VIEWS, type ViewState } from '../types';
|
import { CONTACT_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';
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ interface SidebarProps {
|
|||||||
onViewChange: (view: ViewState) => void;
|
onViewChange: (view: ViewState) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
/** Anzahl der Eintraege in "Aus Mails" - als Zaehler am Unterpunkt. */
|
||||||
|
mailPoolCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@@ -17,9 +19,70 @@ interface MenuItem {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kategorien-Reiter in Anzeigereihenfolge. Weitere Kategorien lassen sich hier
|
/**
|
||||||
// ergaenzen - Einstellungen bleibt separat am unteren Rand.
|
* Aufklappbarer Menuepunkt: der Kopf wechselt auf `item.name` und zeigt die
|
||||||
const CATEGORY_ITEMS: MenuItem[] = [
|
* Unterpunkte. `views` sind alle Ansichten der Gruppe - liegt die aktuelle
|
||||||
|
* darunter, bleibt die Gruppe offen und hervorgehoben.
|
||||||
|
*/
|
||||||
|
interface MenuGroup {
|
||||||
|
item: MenuItem;
|
||||||
|
children: MenuItem[];
|
||||||
|
views: ViewState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroup = (entry: MenuItem | MenuGroup): entry is MenuGroup => 'children' in entry;
|
||||||
|
|
||||||
|
const CONTACTS_ICON =
|
||||||
|
'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z';
|
||||||
|
const MAIL_ICON =
|
||||||
|
'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontakte. Der Menuepunkt klappt den gepflegten Bestand und den Vorbereich
|
||||||
|
* "Aus Mails" darunter auf - dort sammelt der Postfachabgleich Personen, die
|
||||||
|
* noch niemand gesichtet hat.
|
||||||
|
*/
|
||||||
|
const CONTACTS_GROUP: MenuGroup = {
|
||||||
|
item: { name: 'Kontakte', icon: CONTACTS_ICON },
|
||||||
|
views: CONTACT_VIEWS,
|
||||||
|
children: [
|
||||||
|
{ name: 'Kontakte', label: 'Alle Kontakte', icon: CONTACTS_ICON },
|
||||||
|
{ name: 'Aus Mails', icon: MAIL_ICON },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projekt Scanner. Wie bei den Kontakten klappen die Reiter darunter auf -
|
||||||
|
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
|
||||||
|
* stehen nicht hier, sondern als Reiter in den CRM-Einstellungen.
|
||||||
|
*/
|
||||||
|
const SCANNER_GROUP: MenuGroup = {
|
||||||
|
item: { name: 'Scanner', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
|
||||||
|
views: SCANNER_VIEWS,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'Scanner',
|
||||||
|
label: 'Dashboard',
|
||||||
|
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: 'Unpassend',
|
||||||
|
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Suchagenten',
|
||||||
|
icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Börsen-Konten',
|
||||||
|
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kategorien in Anzeigereihenfolge, einzelne Punkte und Gruppen gemischt.
|
||||||
|
// Einstellungen bleibt separat am unteren Rand.
|
||||||
|
const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [
|
||||||
{
|
{
|
||||||
name: 'Opportunities',
|
name: 'Opportunities',
|
||||||
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',
|
||||||
@@ -28,44 +91,9 @@ const CATEGORY_ITEMS: MenuItem[] = [
|
|||||||
name: 'Kalender',
|
name: 'Kalender',
|
||||||
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,
|
||||||
name: 'Kontakte',
|
{ name: 'Mails', icon: MAIL_ICON },
|
||||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
SCANNER_GROUP,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mails',
|
|
||||||
icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Projekt Scanner. Der Menuepunkt klappt die Reiter des Scanners darunter auf -
|
|
||||||
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
|
|
||||||
* stehen nicht hier, sondern als Reiter in den CRM-Einstellungen.
|
|
||||||
*/
|
|
||||||
const SCANNER_ITEM: MenuItem = {
|
|
||||||
name: 'Scanner',
|
|
||||||
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SCANNER_CHILDREN: MenuItem[] = [
|
|
||||||
{
|
|
||||||
name: 'Scanner',
|
|
||||||
label: 'Dashboard',
|
|
||||||
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: 'Unpassend',
|
|
||||||
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Suchagenten',
|
|
||||||
icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Börsen-Konten',
|
|
||||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -93,20 +121,35 @@ function useIsMobile() {
|
|||||||
return isMobile;
|
return isMobile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isOpen, onToggle }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
currentView,
|
||||||
|
onViewChange,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
mailPoolCount = 0,
|
||||||
|
}) => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const showLabels = isOpen || isMobile;
|
const showLabels = isOpen || isMobile;
|
||||||
|
|
||||||
const isScannerView = SCANNER_VIEWS.includes(currentView);
|
/*
|
||||||
const [isScannerOpen, setIsScannerOpen] = useState(isScannerView);
|
* Offene Gruppen, nach dem Namen des Kopfeintrags. Wer per Klick in einer
|
||||||
|
* Gruppenansicht landet, soll die Unterpunkte auch sehen - deshalb oeffnet der
|
||||||
|
* Effekt die Gruppe, zu der die aktuelle Ansicht gehoert.
|
||||||
|
*/
|
||||||
|
const [openGroups, setOpenGroups] = useState<ViewState[]>(() =>
|
||||||
|
CATEGORY_ENTRIES.filter(isGroup)
|
||||||
|
.filter((group) => group.views.includes(currentView))
|
||||||
|
.map((group) => group.item.name)
|
||||||
|
);
|
||||||
|
|
||||||
// Wer per Klick im Scanner landet, soll die Unterpunkte auch sehen.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isScannerView) setIsScannerOpen(true);
|
const active = CATEGORY_ENTRIES.filter(isGroup).find((group) => group.views.includes(currentView));
|
||||||
}, [isScannerView]);
|
if (!active) return;
|
||||||
|
setOpenGroups((open) => (open.includes(active.item.name) ? open : [...open, active.item.name]));
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
const renderItem = (item: MenuItem, options: { indented?: boolean } = {}) => {
|
const renderItem = (item: MenuItem, options: { indented?: boolean; badge?: number } = {}) => {
|
||||||
const isActive = currentView === item.name;
|
const isActive = currentView === item.name;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -145,34 +188,51 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
|
|||||||
{item.label ?? item.name}
|
{item.label ?? item.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showLabels && options.badge ? (
|
||||||
|
<span
|
||||||
|
className={`ml-auto shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${
|
||||||
|
isActive ? 'bg-white/25 text-white' : 'border border-amber-200 bg-amber-50 text-amber-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{options.badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scanner-Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich auf
|
* Aufklappbare Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich
|
||||||
* das Dashboard. Bei eingeklappter Leiste gibt es nur das Symbol, die
|
* auf die Hauptansicht der Gruppe. Bei eingeklappter Leiste gibt es nur das
|
||||||
* Unterpunkte erscheinen erst mit den Beschriftungen.
|
* Symbol, die Unterpunkte erscheinen erst mit den Beschriftungen.
|
||||||
*/
|
*/
|
||||||
const renderScannerGroup = () => {
|
const renderGroup = (group: MenuGroup) => {
|
||||||
const isActive = currentView === 'Scanner';
|
const { item, children, views } = group;
|
||||||
|
const isActive = currentView === item.name;
|
||||||
|
const isInGroup = views.includes(currentView);
|
||||||
|
const isGroupOpen = openGroups.includes(item.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key="scanner-group">
|
<div key={`group-${item.name}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsScannerOpen((open) => (isScannerView ? !open : true));
|
setOpenGroups((open) =>
|
||||||
onViewChange('Scanner');
|
isInGroup && open.includes(item.name)
|
||||||
|
? open.filter((name) => name !== item.name)
|
||||||
|
: [...open.filter((name) => name !== item.name), item.name]
|
||||||
|
);
|
||||||
|
onViewChange(item.name);
|
||||||
if (isMobile) onToggle();
|
if (isMobile) onToggle();
|
||||||
}}
|
}}
|
||||||
title={!showLabels ? 'Scanner' : undefined}
|
title={!showLabels ? item.label ?? item.name : undefined}
|
||||||
aria-expanded={isScannerOpen}
|
aria-expanded={isGroupOpen}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={`group flex w-full items-center rounded-xl transition ${
|
className={`group flex w-full items-center rounded-xl transition ${
|
||||||
showLabels ? 'gap-3 px-3 py-2.5' : 'justify-center px-0 py-2.5'
|
showLabels ? 'gap-3 px-3 py-2.5' : 'justify-center px-0 py-2.5'
|
||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-500 text-white shadow-card'
|
? 'bg-brand-500 text-white shadow-card'
|
||||||
: isScannerView
|
: isInGroup
|
||||||
? 'bg-brand-50 text-brand-700'
|
? 'bg-brand-50 text-brand-700'
|
||||||
: 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
|
: 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
|
||||||
}`}
|
}`}
|
||||||
@@ -182,15 +242,15 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={isActive ? 2.2 : 1.7}
|
strokeWidth={isActive ? 2.2 : 1.7}
|
||||||
d={SCANNER_ITEM.icon}
|
d={item.icon}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{showLabels && (
|
{showLabels && (
|
||||||
<>
|
<>
|
||||||
<span className="truncate text-sm font-bold">Scanner</span>
|
<span className="truncate text-sm font-bold">{item.label ?? item.name}</span>
|
||||||
<svg
|
<svg
|
||||||
className={`ml-auto h-4 w-4 shrink-0 transition-transform ${
|
className={`ml-auto h-4 w-4 shrink-0 transition-transform ${
|
||||||
isScannerOpen ? 'rotate-180' : ''
|
isGroupOpen ? 'rotate-180' : ''
|
||||||
}`}
|
}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -202,9 +262,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showLabels && isScannerOpen && (
|
{showLabels && isGroupOpen && (
|
||||||
<div className="mt-1 space-y-1">
|
<div className="mt-1 space-y-1">
|
||||||
{SCANNER_CHILDREN.map((child) => renderItem(child, { indented: true }))}
|
{children.map((child) =>
|
||||||
|
renderItem(child, {
|
||||||
|
indented: true,
|
||||||
|
badge: child.name === 'Aus Mails' ? mailPoolCount : undefined,
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -244,8 +309,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
|
|||||||
Kategorien
|
Kategorien
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{CATEGORY_ITEMS.map((item) => renderItem(item))}
|
{CATEGORY_ENTRIES.map((entry) => (isGroup(entry) ? renderGroup(entry) : renderItem(entry)))}
|
||||||
{renderScannerGroup()}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Einstellungen unten - Inhalt richtet sich nach der Rolle */}
|
{/* Einstellungen unten - Inhalt richtet sich nach der Rolle */}
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ interface LeadsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Herkunft der Kontakte. "Aus Mails" sind die aus dem Postfach uebernommenen -
|
* Herkunft der Kontakte. `source = 'Mail'` tragen die Kontakte, die aus dem
|
||||||
* sie tragen `source = 'Mail'` und brauchen deshalb kein eigenes Feld.
|
* Vorbereich "Aus Mails" uebernommen wurden - die Herkunft wandert bei der
|
||||||
|
* Uebernahme mit. Die noch nicht uebernommenen Funde stehen nicht hier, sondern
|
||||||
|
* im eigenen Menuepunkt "Aus Mails".
|
||||||
*/
|
*/
|
||||||
const ORIGIN_FILTERS: { id: LeadOriginFilter; label: string }[] = [
|
const ORIGIN_FILTERS: { id: LeadOriginFilter; label: string }[] = [
|
||||||
{ id: 'Alle', label: 'Alle Herkunft' },
|
{ id: 'Alle', label: 'Alle Herkunft' },
|
||||||
{ id: 'Mail', label: 'Aus Mails' },
|
{ id: 'Mail', label: 'Aus Mails übernommen' },
|
||||||
{ id: 'Manuell', label: 'Selbst angelegt' },
|
{ id: 'Manuell', label: 'Selbst angelegt' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -237,7 +239,7 @@ export const Leads: React.FC<LeadsProps> = ({
|
|||||||
<select
|
<select
|
||||||
value={originFilter}
|
value={originFilter}
|
||||||
onChange={(event) => setOriginFilter(event.target.value as LeadOriginFilter)}
|
onChange={(event) => setOriginFilter(event.target.value as LeadOriginFilter)}
|
||||||
title="Kontakte, die aus dem Postfach crm@perinco.de übernommen wurden"
|
title="Kontakte, die aus dem Vorbereich „Aus Mails“ übernommen wurden"
|
||||||
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"
|
||||||
>
|
>
|
||||||
{ORIGIN_FILTERS.map((entry) => (
|
{ORIGIN_FILTERS.map((entry) => (
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Avatar } from './leadUi';
|
||||||
|
import { MailPoolMergeDialog } from './MailPoolMergeDialog';
|
||||||
|
import { ConfirmDialog } from '../ui/Modal';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { leadsApi, type MailPoolSource } from '../../lib/api';
|
||||||
|
import { formatMailDateTime } from '../../lib/mailApi';
|
||||||
|
import { leadFullName, leadInitials, type Lead } from '../../types';
|
||||||
|
import {
|
||||||
|
MAIL_POOL_FIELDS,
|
||||||
|
MAIL_POOL_ORIGIN_LABELS,
|
||||||
|
findMailPoolMatch,
|
||||||
|
isConflict,
|
||||||
|
type MailPoolFilter,
|
||||||
|
type MailPoolMatch,
|
||||||
|
} from './mailPool';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Vorbereich "Aus Mails".
|
||||||
|
*
|
||||||
|
* Der Postfachabgleich legt hier ab, wen er in Mails gefunden hat. Der
|
||||||
|
* Kontaktbestand bleibt davon unberuehrt, bis hier jemand entscheidet:
|
||||||
|
*
|
||||||
|
* - Person ist neu -> "In Kontakte übernehmen" legt den Kontakt an.
|
||||||
|
* - Person ist vorhanden -> die Karte ist gold markiert. Abweichende Felder
|
||||||
|
* stehen mit altem und neuem Wert daneben und
|
||||||
|
* lassen sich einzeln in den Kontakt uebernehmen.
|
||||||
|
*
|
||||||
|
* Die Zuordnung laeuft ueber E-Mail-Adresse, sonst ueber den vollen Namen
|
||||||
|
* (siehe mailPool.ts) - beides ohne Serveranfrage, weil beide Bestaende schon
|
||||||
|
* geladen sind.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MailPoolLeadsProps {
|
||||||
|
/** Eintraege mit mail_pool = true. */
|
||||||
|
poolLeads: Lead[];
|
||||||
|
/** Der gepflegte Kontaktbestand - Gegenstueck fuer den Abgleich. */
|
||||||
|
leads: Lead[];
|
||||||
|
isLoading: boolean;
|
||||||
|
reload: () => void;
|
||||||
|
/** Sprung in die Kontaktansicht mit geoeffnetem Kontakt. */
|
||||||
|
onOpenLead: (leadId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: { id: MailPoolFilter; label: string }[] = [
|
||||||
|
{ id: 'alle', label: 'Alle Einträge' },
|
||||||
|
{ id: 'neu', label: 'Nur neue Personen' },
|
||||||
|
{ id: 'dubletten', label: 'Nur Dubletten' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ein Feldwert der Karte; gold hinterlegt, wenn er vom Kontakt abweicht. */
|
||||||
|
const FieldCell: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
/** Wert im vorhandenen Kontakt - nur bei einer echten Abweichung gesetzt. */
|
||||||
|
previous?: string | null;
|
||||||
|
highlight?: boolean;
|
||||||
|
}> = ({ label, value, previous, highlight }) => (
|
||||||
|
<div
|
||||||
|
className={`min-w-0 rounded-xl border px-3 py-2 ${
|
||||||
|
highlight ? 'border-amber-200 bg-amber-50' : 'border-ink-100 bg-ink-50/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={`text-[10px] font-extrabold uppercase tracking-wider ${
|
||||||
|
highlight ? 'text-amber-700' : 'text-ink-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`truncate text-sm ${highlight ? 'font-bold text-amber-700' : 'text-ink-800'}`} title={value}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{previous ? (
|
||||||
|
<p className="truncate text-[11px] text-ink-400" title={`Bisher im Kontakt: ${previous}`}>
|
||||||
|
bisher: <span className="line-through">{previous}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MailPoolLeads: React.FC<MailPoolLeadsProps> = ({
|
||||||
|
poolLeads,
|
||||||
|
leads,
|
||||||
|
isLoading,
|
||||||
|
reload,
|
||||||
|
onOpenLead,
|
||||||
|
}) => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filter, setFilter] = useState<MailPoolFilter>('alle');
|
||||||
|
/**
|
||||||
|
* Standard ist das Verschieben: ein Eintrag, der als Kontakt gefuehrt wird,
|
||||||
|
* hat im Vorbereich nichts mehr zu suchen. Wer die Fundliste als Protokoll
|
||||||
|
* behalten will, setzt den Haken.
|
||||||
|
*/
|
||||||
|
const [keepPool, setKeepPool] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const [mergeFor, setMergeFor] = useState<{ poolLead: Lead; match: MailPoolMatch } | null>(null);
|
||||||
|
const [leadToDelete, setLeadToDelete] = useState<Lead | null>(null);
|
||||||
|
const [promoteAll, setPromoteAll] = useState(false);
|
||||||
|
const [sources, setSources] = useState<Record<string, MailPoolSource>>({});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Herkunftsangaben (aus welcher Mail, wie sicher) liegen nicht in /leads -
|
||||||
|
* sie werden nur hier gebraucht und deshalb nur hier geholt. Der Abruf haengt
|
||||||
|
* an der Anzahl: nach jeder Uebernahme laedt App.tsx die Kontakte neu, und
|
||||||
|
* damit passt auch diese Liste wieder.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
leadsApi
|
||||||
|
.mailPoolSources()
|
||||||
|
.then((data) => {
|
||||||
|
if (active) setSources(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ohne Herkunftsangabe ist die Liste weiterhin benutzbar - kein Toast.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [poolLeads.length]);
|
||||||
|
|
||||||
|
// Abgleich einmal je Eintrag, nicht pro Rendervorgang der Karte.
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const terms = searchTerm
|
||||||
|
.toLowerCase()
|
||||||
|
.split(',')
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return poolLeads
|
||||||
|
.map((poolLead) => ({ poolLead, match: findMailPoolMatch(poolLead, leads) }))
|
||||||
|
.filter(({ poolLead, match }) => {
|
||||||
|
if (filter === 'neu' && match) return false;
|
||||||
|
if (filter === 'dubletten' && !match) return false;
|
||||||
|
if (terms.length === 0) return true;
|
||||||
|
|
||||||
|
const haystack = [leadFullName(poolLead), poolLead.company, poolLead.email, poolLead.position]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const left = a.poolLead.created_at ? new Date(a.poolLead.created_at).getTime() : 0;
|
||||||
|
const right = b.poolLead.created_at ? new Date(b.poolLead.created_at).getTime() : 0;
|
||||||
|
return right - left;
|
||||||
|
});
|
||||||
|
}, [poolLeads, leads, searchTerm, filter]);
|
||||||
|
|
||||||
|
const duplicateCount = useMemo(
|
||||||
|
() => poolLeads.filter((poolLead) => findMailPoolMatch(poolLead, leads)).length,
|
||||||
|
[poolLeads, leads]
|
||||||
|
);
|
||||||
|
const newCount = poolLeads.length - duplicateCount;
|
||||||
|
|
||||||
|
/** Eintrag als neuer Kontakt - der Weg fuer alles, was noch nicht vorhanden ist. */
|
||||||
|
const promote = async (poolLead: Lead) => {
|
||||||
|
setBusyId(poolLead.id);
|
||||||
|
try {
|
||||||
|
await leadsApi.promote(poolLead.id, { keep_pool: keepPool });
|
||||||
|
addToast(`${leadFullName(poolLead)} steht jetzt in den Kontakten.`, 'success');
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const promoteAllNew = async () => {
|
||||||
|
const ids = poolLeads.filter((poolLead) => !findMailPoolMatch(poolLead, leads)).map((lead) => lead.id);
|
||||||
|
setPromoteAll(false);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
setBusyId(-1);
|
||||||
|
try {
|
||||||
|
const result = await leadsApi.promoteMany(ids, { keep_pool: keepPool });
|
||||||
|
addToast(
|
||||||
|
`${result.created.length} ${result.created.length === 1 ? 'Kontakt' : 'Kontakte'} übernommen.`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!leadToDelete) return;
|
||||||
|
try {
|
||||||
|
await leadsApi.remove(leadToDelete.id);
|
||||||
|
addToast(`${leadFullName(leadToDelete)} aus „Aus Mails" entfernt.`, 'success');
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setLeadToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Steuerleiste */}
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
|
<div className="relative w-full lg:max-w-md">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder="Name, Firma, Adresse suchen (Komma = UND)"
|
||||||
|
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value as MailPoolFilter)}
|
||||||
|
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||||
|
>
|
||||||
|
{FILTERS.map((entry) => (
|
||||||
|
<option key={entry.id} value={entry.id}>
|
||||||
|
{entry.id === 'neu'
|
||||||
|
? `${entry.label} (${newCount})`
|
||||||
|
: entry.id === 'dubletten'
|
||||||
|
? `${entry.label} (${duplicateCount})`
|
||||||
|
: `${entry.label} (${poolLeads.length})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 lg:ml-auto">
|
||||||
|
<label
|
||||||
|
className="flex cursor-pointer items-center gap-2 text-xs font-bold text-ink-500"
|
||||||
|
title="Ohne Haken verschwindet der Eintrag hier, sobald er als Kontakt geführt wird."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={keepPool}
|
||||||
|
onChange={(event) => setKeepPool(event.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-ink-300 text-brand-500 focus:ring-brand-500/25"
|
||||||
|
/>
|
||||||
|
Eintrag hier behalten
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPromoteAll(true)}
|
||||||
|
disabled={newCount === 0 || busyId !== null}
|
||||||
|
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Alle neuen übernehmen ({newCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="rounded-xl border border-ink-200 bg-surface px-4 py-3 text-xs leading-relaxed text-ink-500">
|
||||||
|
Diese Personen hat der Postfachabgleich in Mails gefunden. Sie stehen noch nicht in den Kontakten
|
||||||
|
und tauchen weder in Opportunities noch im Kalender auf.{' '}
|
||||||
|
<span className="font-bold text-amber-700">Gold markierte Einträge</span> gibt es dort schon – bei
|
||||||
|
ihnen lassen sich abweichende Angaben einzeln in den vorhandenen Kontakt übernehmen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Inhalt */}
|
||||||
|
{isLoading && poolLeads.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
|
||||||
|
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-20 text-center">
|
||||||
|
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-ink-50 text-ink-300">
|
||||||
|
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-ink-500">
|
||||||
|
{poolLeads.length === 0
|
||||||
|
? 'Keine offenen Funde aus dem Postfach.'
|
||||||
|
: 'Kein Eintrag passt zur Suche.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map(({ poolLead, match }) => {
|
||||||
|
const source = sources[String(poolLead.id)];
|
||||||
|
const diffByKey = new Map(match?.diffs.map((diff) => [diff.key, diff]) ?? []);
|
||||||
|
const conflicts = match?.diffs.filter(isConflict).length ?? 0;
|
||||||
|
const isBusy = busyId === poolLead.id || busyId === -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={poolLead.id}
|
||||||
|
className={`rounded-2xl border p-5 shadow-card transition ${
|
||||||
|
match ? 'border-amber-300 bg-amber-50/40' : 'border-ink-200 bg-surface'
|
||||||
|
} ${isBusy ? 'pointer-events-none opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
|
<Avatar src={poolLead.avatar} initials={leadInitials(poolLead)} size="sm" />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="truncate text-base text-ink-900">{leadFullName(poolLead)}</h3>
|
||||||
|
|
||||||
|
{match && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700"
|
||||||
|
title={
|
||||||
|
match.by === 'email'
|
||||||
|
? 'Diese E-Mail-Adresse steht schon in den Kontakten.'
|
||||||
|
: 'Ein Kontakt mit diesem Namen ist schon vorhanden.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{match.by === 'email' ? 'Dublette (Adresse)' : 'Dublette (Name)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{match && match.diffs.length > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700">
|
||||||
|
{match.diffs.length} {match.diffs.length === 1 ? 'Abweichung' : 'Abweichungen'}
|
||||||
|
{conflicts > 0 ? ` · ${conflicts} überschreibend` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{source?.origin && (
|
||||||
|
<span className="inline-flex items-center rounded-md border border-ink-200 bg-ink-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-500">
|
||||||
|
{MAIL_POOL_ORIGIN_LABELS[source.origin] ?? source.origin}
|
||||||
|
{source.confidence !== null ? ` · ${source.confidence} %` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source && (
|
||||||
|
<p className="mt-1 truncate text-xs text-ink-400" title={source.last_subject ?? undefined}>
|
||||||
|
aus {source.message_count} {source.message_count === 1 ? 'Mail' : 'Mails'}
|
||||||
|
{source.last_subject ? ` · „${source.last_subject}"` : ''}
|
||||||
|
{source.last_received_at ? ` · ${formatMailDateTime(source.last_received_at)}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{match ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenLead(match.lead.id)}
|
||||||
|
className="rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
Kontakt öffnen
|
||||||
|
</button>
|
||||||
|
{match.diffs.length > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setMergeFor({ poolLead, match })}
|
||||||
|
/*
|
||||||
|
* amber-700 wird im dunklen Design zur hellen Farbe
|
||||||
|
* (siehe index.css) - die Schrift muss deshalb aus
|
||||||
|
* derselben Skala kommen und mitdrehen. Ein festes
|
||||||
|
* text-white waere im Dark Mode kaum zu lesen.
|
||||||
|
*/
|
||||||
|
className="rounded-xl bg-amber-700 px-3 py-2 text-xs font-bold text-amber-50 transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Kontakt aktualisieren
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setLeadToDelete(poolLead)}
|
||||||
|
title="Der vorhandene Kontakt enthält alle Angaben aus dieser Mail."
|
||||||
|
className="rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
Erledigt, entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => promote(poolLead)}
|
||||||
|
title="Trotz Dublette einen zweiten Kontakt anlegen"
|
||||||
|
className="rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
Neu anlegen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => promote(poolLead)}
|
||||||
|
className="rounded-xl bg-brand-500 px-3 py-2 text-xs font-bold text-white transition hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
In Kontakte übernehmen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setLeadToDelete(poolLead)}
|
||||||
|
aria-label={`${leadFullName(poolLead)} entfernen`}
|
||||||
|
className="rounded-xl p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{MAIL_POOL_FIELDS.map((field) => {
|
||||||
|
const value = poolLead[field.key];
|
||||||
|
if (value === null || value === undefined || String(value).trim() === '') return null;
|
||||||
|
const diff = diffByKey.get(field.key);
|
||||||
|
return (
|
||||||
|
<FieldCell
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
value={String(value)}
|
||||||
|
previous={diff?.leadValue ?? null}
|
||||||
|
highlight={Boolean(diff)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mergeFor && (
|
||||||
|
<MailPoolMergeDialog
|
||||||
|
poolLead={mergeFor.poolLead}
|
||||||
|
match={mergeFor.match}
|
||||||
|
keepPool={keepPool}
|
||||||
|
onClose={() => setMergeFor(null)}
|
||||||
|
onDone={() => {
|
||||||
|
setMergeFor(null);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{promoteAll && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Alle neuen Personen übernehmen?"
|
||||||
|
message={`${newCount} ${
|
||||||
|
newCount === 1 ? 'Person' : 'Personen'
|
||||||
|
} ohne Gegenstück werden als Kontakte angelegt. Gold markierte Dubletten bleiben unberührt.`}
|
||||||
|
confirmLabel="Übernehmen"
|
||||||
|
onConfirm={promoteAllNew}
|
||||||
|
onCancel={() => setPromoteAll(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leadToDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Eintrag entfernen?"
|
||||||
|
message={`${leadFullName(
|
||||||
|
leadToDelete
|
||||||
|
)} wird aus „Aus Mails" gelöscht. Die Mails bleiben im Postfach-Bereich erhalten; taucht die Person in einer neuen Mail auf, erscheint sie hier wieder.`}
|
||||||
|
confirmLabel="Entfernen"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setLeadToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { leadsApi } from '../../lib/api';
|
||||||
|
import { leadFullName, type Lead } from '../../types';
|
||||||
|
import { isConflict, type MailPoolMatch } from './mailPool';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Dublettenfall: die Person aus der Mail steht schon in den Kontakten. Hier wird
|
||||||
|
* Feld fuer Feld entschieden, was in den vorhandenen Kontakt uebernommen wird.
|
||||||
|
*
|
||||||
|
* Bewusst nicht automatisch: der Mailfund ist eine Momentaufnahme aus Signatur
|
||||||
|
* oder Adresszeile. Eine alte Durchwahl aus einer alten Mail darf eine gepflegte
|
||||||
|
* Nummer nicht stillschweigend ersetzen. Vorausgewaehlt sind deshalb nur die
|
||||||
|
* Luecken - Felder, in denen der Kontakt bisher nichts hat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MailPoolMergeDialogProps {
|
||||||
|
poolLead: Lead;
|
||||||
|
match: MailPoolMatch;
|
||||||
|
/** Aus der Steuerleiste: Eintrag danach in "Aus Mails" behalten? */
|
||||||
|
keepPool: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MailPoolMergeDialog: React.FC<MailPoolMergeDialogProps> = ({
|
||||||
|
poolLead,
|
||||||
|
match,
|
||||||
|
keepPool,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}) => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [selected, setSelected] = useState<string[]>(() =>
|
||||||
|
match.diffs.filter((diff) => !isConflict(diff)).map((diff) => diff.key)
|
||||||
|
);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setSelected((keys) => (keys.includes(key) ? keys.filter((entry) => entry !== key) : [...keys, key]));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setIsBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await leadsApi.promote(poolLead.id, {
|
||||||
|
target_lead_id: match.lead.id,
|
||||||
|
fields: selected,
|
||||||
|
keep_pool: keepPool,
|
||||||
|
});
|
||||||
|
addToast(
|
||||||
|
result.fields > 0
|
||||||
|
? `${result.fields} ${result.fields === 1 ? 'Feld' : 'Felder'} in ${leadFullName(
|
||||||
|
match.lead
|
||||||
|
)} übernommen.`
|
||||||
|
: `${leadFullName(match.lead)} unverändert – nur die Mails wurden verknüpft.`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Kontakt aus Mailfund aktualisieren"
|
||||||
|
subtitle={`${leadFullName(poolLead)} → vorhandener Kontakt ${leadFullName(match.lead)}${
|
||||||
|
match.by === 'name' ? ' (über den Namen erkannt)' : ''
|
||||||
|
}`}
|
||||||
|
size="lg"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{keepPool
|
||||||
|
? 'Der Eintrag bleibt anschließend in „Aus Mails" stehen.'
|
||||||
|
: 'Der Eintrag verschwindet anschließend aus „Aus Mails".'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-ink-200 px-5 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{selected.length === 0
|
||||||
|
? 'Nur Mails verknüpfen'
|
||||||
|
: `${selected.length} ${selected.length === 1 ? 'Feld' : 'Felder'} übernehmen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{match.diffs.map((diff) => {
|
||||||
|
const conflict = isConflict(diff);
|
||||||
|
const isSelected = selected.includes(diff.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={diff.key}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-xl border px-4 py-3 transition ${
|
||||||
|
isSelected
|
||||||
|
? conflict
|
||||||
|
? 'border-amber-300 bg-amber-50'
|
||||||
|
: 'border-brand-200 bg-brand-50'
|
||||||
|
: 'border-ink-200 bg-surface hover:bg-ink-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggle(diff.key)}
|
||||||
|
className="mt-1 h-4 w-4 shrink-0 rounded border-ink-300 text-brand-500 focus:ring-brand-500/25"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-500">
|
||||||
|
{diff.label}
|
||||||
|
</p>
|
||||||
|
{conflict ? (
|
||||||
|
<span className="rounded-md border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700">
|
||||||
|
überschreibt
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-md border border-brand-200 bg-brand-50 px-1.5 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
|
||||||
|
ergänzt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 break-words text-sm font-bold text-ink-900">{diff.poolValue}</p>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{diff.leadValue ? (
|
||||||
|
<>
|
||||||
|
bisher: <span className="line-through">{diff.leadValue}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'im Kontakt bisher leer'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs leading-relaxed text-ink-400">
|
||||||
|
Die Mails, aus denen dieser Fund stammt, werden in jedem Fall mit dem Kontakt verknüpft – auch wenn
|
||||||
|
kein Feld ausgewählt ist.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { leadFullName, type Lead } from '../../types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Abgleich zwischen dem Vorbereich "Aus Mails" und dem Kontaktbestand.
|
||||||
|
*
|
||||||
|
* Der Vergleich laeuft im Browser und nicht in der Datenbank: beide Bestaende
|
||||||
|
* liegen ohnehin schon vollstaendig im Speicher (App.tsx laedt /leads einmal),
|
||||||
|
* und die Zuordnung soll dieselbe Regel verwenden, nach der die Oberflaeche
|
||||||
|
* spaeter markiert - eine zweite Fassung in SQL waere eine zweite Wahrheit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Verglichene und uebertragbare Felder in Anzeigereihenfolge. */
|
||||||
|
export const MAIL_POOL_FIELDS: { key: keyof Lead & string; label: string }[] = [
|
||||||
|
{ key: 'salutation', label: 'Anrede' },
|
||||||
|
{ key: 'first_name', label: 'Vorname' },
|
||||||
|
{ key: 'last_name', label: 'Nachname' },
|
||||||
|
{ key: 'email', label: 'E-Mail' },
|
||||||
|
{ key: 'phone', label: 'Telefon' },
|
||||||
|
{ key: 'mobile', label: 'Mobil' },
|
||||||
|
{ key: 'company', label: 'Firma' },
|
||||||
|
{ key: 'position', label: 'Position' },
|
||||||
|
{ key: 'street', label: 'Straße' },
|
||||||
|
{ key: 'zip', label: 'PLZ' },
|
||||||
|
{ key: 'city', label: 'Ort' },
|
||||||
|
{ key: 'country', label: 'Land' },
|
||||||
|
{ key: 'website', label: 'Webseite' },
|
||||||
|
{ key: 'linkedin', label: 'LinkedIn' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface FieldDiff {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Wert aus der Mail. Nie leer - sonst gibt es nichts zu uebertragen. */
|
||||||
|
poolValue: string;
|
||||||
|
/** Wert im vorhandenen Kontakt; null heisst: dort steht noch nichts. */
|
||||||
|
leadValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailPoolMatch {
|
||||||
|
lead: Lead;
|
||||||
|
/** Woran erkannt - die Adresse ist der belastbarere Treffer. */
|
||||||
|
by: 'email' | 'name';
|
||||||
|
/** Felder, in denen der Mailfund etwas Neues oder Abweichendes hat. */
|
||||||
|
diffs: FieldDiff[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (value: unknown): string => (value === null || value === undefined ? '' : String(value).trim());
|
||||||
|
const norm = (value: unknown): string => text(value).toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telefonnummern werden ueber ihre Ziffern verglichen: "+49 30 123-4" und
|
||||||
|
* "+49301234" sind dieselbe Nummer und sollen nicht als Abweichung erscheinen.
|
||||||
|
* Ein Unterschied wie 0049 gegen +49 bleibt einer - das ist echte Pflegearbeit.
|
||||||
|
*/
|
||||||
|
const comparable = (key: string, value: unknown): string =>
|
||||||
|
key === 'phone' || key === 'mobile' ? text(value).replace(/[^\d+]/g, '') : norm(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passenden Kontakt zu einem Eintrag aus "Aus Mails" finden. Zuerst ueber die
|
||||||
|
* E-Mail-Adresse, sonst ueber den vollen Namen - der Mailfund einer Person, die
|
||||||
|
* mit privater Adresse geschrieben hat, soll ebenfalls auffallen.
|
||||||
|
*/
|
||||||
|
export function findMailPoolMatch(poolLead: Lead, leads: Lead[]): MailPoolMatch | null {
|
||||||
|
const email = norm(poolLead.email);
|
||||||
|
const name = norm(leadFullName(poolLead));
|
||||||
|
|
||||||
|
let found: Lead | undefined;
|
||||||
|
let by: MailPoolMatch['by'] = 'email';
|
||||||
|
|
||||||
|
if (email) found = leads.find((lead) => norm(lead.email) === email);
|
||||||
|
if (!found && name) {
|
||||||
|
found = leads.find((lead) => norm(leadFullName(lead)) === name);
|
||||||
|
by = 'name';
|
||||||
|
}
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
return { lead: found, by, diffs: diffFields(poolLead, found) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felder, die der Mailfund beisteuern kann. Leere Werte aus der Mail tauchen
|
||||||
|
* nicht auf - sie wuerden im Kontakt nur Gepflegtes ueberschreiben.
|
||||||
|
*/
|
||||||
|
export function diffFields(poolLead: Lead, lead: Lead): FieldDiff[] {
|
||||||
|
const diffs: FieldDiff[] = [];
|
||||||
|
|
||||||
|
for (const field of MAIL_POOL_FIELDS) {
|
||||||
|
const poolValue = text(poolLead[field.key]);
|
||||||
|
if (!poolValue) continue;
|
||||||
|
|
||||||
|
const leadValue = text(lead[field.key]);
|
||||||
|
if (leadValue && comparable(field.key, poolValue) === comparable(field.key, leadValue)) continue;
|
||||||
|
|
||||||
|
diffs.push({
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
poolValue,
|
||||||
|
leadValue: leadValue || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Echte Abweichung (beide Seiten gefuellt, Werte verschieden) statt nur Luecke. */
|
||||||
|
export const isConflict = (diff: FieldDiff): boolean => diff.leadValue !== null;
|
||||||
|
|
||||||
|
export type MailPoolFilter = 'alle' | 'neu' | 'dubletten';
|
||||||
|
|
||||||
|
export const MAIL_POOL_ORIGIN_LABELS: Record<string, string> = {
|
||||||
|
absender: 'Absender',
|
||||||
|
empfaenger: 'Empfänger',
|
||||||
|
signatur: 'Signatur',
|
||||||
|
text: 'Mailtext',
|
||||||
|
};
|
||||||
@@ -128,6 +128,28 @@ export const usersApi = {
|
|||||||
|
|
||||||
export type LeadInput = Partial<Omit<Lead, 'id' | 'created_at' | 'updated_at'>>;
|
export type LeadInput = Partial<Omit<Lead, 'id' | 'created_at' | 'updated_at'>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Herkunft eines Eintrags im Vorbereich "Aus Mails": aus wie vielen Mails er
|
||||||
|
* stammt, welche die letzte war und wie sicher der Fund ist.
|
||||||
|
*/
|
||||||
|
export interface MailPoolSource {
|
||||||
|
message_count: number;
|
||||||
|
last_received_at: string | null;
|
||||||
|
last_subject: string | null;
|
||||||
|
/** 0-100; null, wenn der Vorschlag inzwischen entfernt wurde. */
|
||||||
|
confidence: number | null;
|
||||||
|
origin: 'absender' | 'empfaenger' | 'signatur' | 'text' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoteResult {
|
||||||
|
/** true = vorhandener Kontakt ergaenzt, false = neuer Kontakt angelegt. */
|
||||||
|
merged: boolean;
|
||||||
|
/** Anzahl der uebertragenen Felder (nur beim Ergaenzen). */
|
||||||
|
fields: number;
|
||||||
|
removed_pool: boolean;
|
||||||
|
lead: Lead;
|
||||||
|
}
|
||||||
|
|
||||||
export const leadsApi = {
|
export const leadsApi = {
|
||||||
list: () => request<Lead[]>('/leads'),
|
list: () => request<Lead[]>('/leads'),
|
||||||
create: (input: LeadInput) =>
|
create: (input: LeadInput) =>
|
||||||
@@ -135,6 +157,26 @@ export const leadsApi = {
|
|||||||
update: (id: number, input: LeadInput) =>
|
update: (id: number, input: LeadInput) =>
|
||||||
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
||||||
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
|
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
/** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */
|
||||||
|
mailPoolSources: () => request<Record<string, MailPoolSource>>('/leads/mail-pool/sources'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eintrag aus "Aus Mails" in die Kontakte holen. Ohne `target_lead_id`
|
||||||
|
* entsteht ein neuer Kontakt; mit ihr werden die genannten Felder in den
|
||||||
|
* vorhandenen Kontakt geschrieben. `keep_pool` behaelt den Eintrag im
|
||||||
|
* Vorbereich.
|
||||||
|
*/
|
||||||
|
promote: (
|
||||||
|
id: number,
|
||||||
|
input: { target_lead_id?: number | null; fields?: string[]; keep_pool?: boolean; owner_id?: number } = {}
|
||||||
|
) => request<PromoteResult>(`/leads/${id}/promote`, { method: 'POST', body: JSON.stringify(input) }),
|
||||||
|
|
||||||
|
promoteMany: (ids: number[], options: { keep_pool?: boolean } = {}) =>
|
||||||
|
request<{ ok: true; created: number[]; skipped: number[] }>('/leads/mail-pool/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids, ...options }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Opportunities ----------
|
// ---------- Opportunities ----------
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type ViewState =
|
|||||||
| 'Opportunities'
|
| 'Opportunities'
|
||||||
| 'Kalender'
|
| 'Kalender'
|
||||||
| 'Kontakte'
|
| 'Kontakte'
|
||||||
|
| 'Aus Mails'
|
||||||
| 'Mails'
|
| 'Mails'
|
||||||
| 'Scanner'
|
| 'Scanner'
|
||||||
| 'Unpassend'
|
| 'Unpassend'
|
||||||
@@ -29,6 +30,13 @@ export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
|
|||||||
*/
|
*/
|
||||||
export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten', 'Börsen-Konten'];
|
export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten', 'Börsen-Konten'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontaktbereich. "Kontakte" ist der gepflegte Bestand, "Aus Mails" der
|
||||||
|
* Vorbereich mit den Personen, die der Postfachabgleich gefunden hat. Der
|
||||||
|
* Menuepunkt klappt beide untereinander auf - genau wie beim Scanner.
|
||||||
|
*/
|
||||||
|
export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails'];
|
||||||
|
|
||||||
export type Role = 'admin' | 'user';
|
export type Role = 'admin' | 'user';
|
||||||
|
|
||||||
export interface AppUser {
|
export interface AppUser {
|
||||||
@@ -109,6 +117,12 @@ export interface Lead {
|
|||||||
country: string | null;
|
country: string | null;
|
||||||
status: LeadStatus;
|
status: LeadStatus;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
|
/**
|
||||||
|
* true = Eintrag im Vorbereich "Aus Mails". Solche Kontakte stammen aus dem
|
||||||
|
* Postfachabgleich und erscheinen nicht in der Kontaktliste, in Opportunities
|
||||||
|
* oder im Kalender - erst die Uebernahme macht daraus einen Kontakt.
|
||||||
|
*/
|
||||||
|
mail_pool: boolean;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user