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:
Sascha Wolf
2026-08-11 08:59:14 +02:00
co-authored by Claude Opus 5
parent 8090d0aae1
commit ea55acdb5c
22 changed files with 1524 additions and 115 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ den Apache-Proxy erreichbar.
| Tabelle | Inhalt |
| --- | --- |
| `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_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 |
+10 -1
View File
@@ -41,6 +41,8 @@ erDiagram
varchar last_name
varchar company
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 avatar
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
MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert.
- **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in
+21 -6
View File
@@ -12,6 +12,8 @@ flowchart TD
K --> O["Opportunities<br/>(Startansicht)"]
K --> C["Kalender"]
K --> L["Kontakte"]
L --> L1["Alle Kontakte"]
L --> L2["Aus Mails<br/>Vorbereich, Zaehler im Menue"]
K --> M["Mails"]
M --> M1["Postfach"]
M --> M2["Kontakt-Vorschlaege"]
@@ -31,9 +33,14 @@ flowchart TD
*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht
*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her.
Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das
Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole,
die Unterpunkte kommen mit den Beschriftungen zurueck.
Die Menuepunkte *Kontakte* und *Scanner* klappen ihre Unterpunkte auf; der
Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard).
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
@@ -55,7 +62,8 @@ flowchart TB
APP --> DATA
DATA --> V1["Opportunities"]
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"]
NAV --> DF["DictationField<br/>springt nach Whisper"]
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
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
Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den
Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads`
@@ -97,11 +111,12 @@ src/
audio.ts Aufnahme zu 16-kHz-Mono-PCM
imageCompress.ts Bilder vor dem Upload verkleinern
components/
Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe
Sidebar.tsx Menue inkl. aufklappbarer Gruppen (Kontakte, Scanner)
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
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
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
+42 -5
View File
@@ -128,8 +128,42 @@ erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht a
Suche und Statusfilter wirken auch hier.
Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails* zeigt nur die automatisch
entstandenen, *Selbst angelegt* nur die von Hand erfassten.
Statusfilter ein **Herkunftsfilter**: *Aus Mails uebernommen* zeigt nur die aus
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
@@ -172,7 +206,8 @@ CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen.
Aus Absender, Empfaengerliste und Signatur jeder Mail liest die Anwendung
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.
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:
* **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
vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt
ist, bleibt unangetastet.
@@ -342,4 +379,4 @@ nicht zum Benutzerkonto.
| Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen &rarr; Postfach* die Verbindung zu testen |
| „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 |
| 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 |
+7 -3
View File
@@ -66,7 +66,7 @@ Alle unter `router.use(requireAuth)`.
| --- | --- | --- |
| `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 |
| `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 |
| `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 |
@@ -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()` |
| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()` |
| `contacts.js` | `adoptCandidate()`: anlegen oder vorhandenen Kontakt ergaenzen, `markCandidate()` |
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()`, `findLeadByEmail()` je Bestand |
| `contacts.js` | `adoptCandidate()`: Eintrag in „Aus Mails" (`target: 'pool'`) oder richtiger Kontakt (`'contacts'`), sonst ergaenzen; `markCandidate()` |
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
| `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 |
| `ApiError` | Fehler mit `status` |
| `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
`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 |
| --- | --- |
| `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/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
+6 -4
View File
@@ -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. |
| **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. |
| **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. |
| **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`. |
@@ -31,7 +33,7 @@ Quelltext.
| **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. |
| **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
@@ -47,8 +49,8 @@ Quelltext.
| Wert | in der Oberflaeche | Bedeutung |
| --- | --- | --- |
| `offen` | Offen | wartet auf eine Entscheidung |
| `uebernommen` | Uebernommen | es wurde ein Kontakt angelegt |
| `dublette` | Ergaenzt | Adresse war vorhanden, der Kontakt wurde erweitert |
| `uebernommen` | Uebernommen | es entstand ein Eintrag - im Automatiklauf in *Aus Mails*, von Hand ein Kontakt |
| `dublette` | Ergaenzt | die Adresse war im Zielbestand vorhanden, der Eintrag wurde erweitert |
| `verworfen` | Verworfen | von Hand abgelehnt |
## Statuswerte im Scanner
+61 -6
View File
@@ -192,8 +192,8 @@ flowchart TD
F -->|"Person"| C["Vorschlag mit Zuversicht"]
C --> Z{"E-Mail vorhanden<br/>und Zuversicht &ge; 70?"}
Z -->|ja, Automatik an| N["Kontakt anlegen<br/>source = 'Mail'"]
Z -->|Adresse existiert| G["vorhandenen Kontakt ergaenzen"]
Z -->|ja, Automatik an| N["Eintrag in 'Aus Mails'<br/>mail_pool = 1"]
Z -->|"Adresse dort schon"| G["Eintrag ergaenzen"]
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
dem Anzeigenamen - dort stehen die Umlaute richtig.
Uebernommene Kontakte tragen `crm_leads.source = 'Mail'`. Das ist die
„Unterkategorie": die Kontaktliste hat dafuer einen Herkunftsfilter, ein eigenes
Feld war nicht noetig.
## Vorbereich „Aus Mails"
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
@@ -251,8 +293,21 @@ Alle unter `/api/mail`, alle hinter `requireAuth`.
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity haengen |
| DELETE | `/links/:id` | alle | Zuordnung loesen |
| 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 |
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 |
| POST | `/settings` | admin | Einstellungen speichern |
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff pruefen |
+30
View File
@@ -48,9 +48,39 @@ export async function initDatabase() {
conn.release();
}
await migrateMailPool();
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() {
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
if (rows[0].count > 0) return;
+30 -6
View File
@@ -4,7 +4,7 @@ import { findLeadByEmail, linkMessage } from './store.js';
/*
* 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,
* 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
* 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. */
@@ -73,13 +80,22 @@ async function enrichLead(leadId, candidate) {
*
* `mode` steuert nur die Protokollierung: 'automatisch' kommt aus dem
* 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
* 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' } = {}) {
const existing = candidate.email ? await findLeadByEmail(candidate.email) : null;
export async function adoptCandidate(
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) {
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 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 [result] = await pool.query(
+13 -4
View File
@@ -200,12 +200,21 @@ export async function insertCandidates(messageId, candidates) {
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;
const [rows] = await pool.query(
'SELECT id, first_name, last_name FROM crm_leads WHERE LOWER(email) = LOWER(?) ORDER BY id LIMIT 1',
[email]
`SELECT id, first_name, last_name, mail_pool FROM crm_leads
WHERE LOWER(email) = LOWER(?)${mailPool === null ? '' : ' AND mail_pool = ?'}
ORDER BY id LIMIT 1`,
mailPool === null ? [email] : [email, mailPool]
);
return rows[0] ?? null;
}
+7 -2
View File
@@ -240,13 +240,18 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
* Automatisch uebernommen wird nur, wer eine echte Adresse hat und aus
* Absender oder Empfaengerliste stammt. Signatur- und Textfunde liegen
* 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) {
for (const candidate of created) {
if (!candidate.email) continue;
if (candidate.confidence < AUTO_ADOPT_MIN_CONFIDENCE) continue;
try {
const result = await adoptCandidate(candidate, { mode: 'automatisch' });
const result = await adoptCandidate(candidate, { mode: 'automatisch', target: 'pool' });
if (result.status === 'uebernommen') adoptedContacts += 1;
} catch (err) {
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) {
console.log(
`[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) {
+267 -1
View File
@@ -59,7 +59,14 @@ function serializeTags(value) {
}
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 = `
@@ -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
router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id);
+20 -3
View File
@@ -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_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_leads WHERE mail_pool = 1) AS pool_leads,
(SELECT COUNT(*) FROM crm_mail_links) AS links`
);
@@ -99,6 +100,7 @@ router.get('/status', async (req, res, next) => {
messages: Number(counts.messages),
open_candidates: Number(counts.open_candidates),
adopted_candidates: Number(counts.adopted_candidates),
pool_leads: Number(counts.pool_leads),
links: Number(counts.links),
},
last_result: lastMailSyncResult(),
@@ -283,8 +285,12 @@ router.get('/messages/:id', async (req, res, next) => {
const [candidates] = await pool.query(
`SELECT c.*,
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
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
FROM crm_mail_contact_candidates c
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,
u.name AS decided_by_name,
/* 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
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
FROM crm_mail_contact_candidates c
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);
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, {
userId: req.user.id,
ownerId,
mode: 'manuell',
target,
});
res.json(result);
+14
View File
@@ -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
) 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
-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend).
CREATE TABLE IF NOT EXISTS crm_lead_connections (
+30 -2
View File
@@ -3,6 +3,7 @@ import { Sidebar } from './components/Sidebar';
import { Header } from './components/Header';
import { LoginScreen } from './components/LoginScreen';
import { Leads } from './components/leads/Leads';
import { MailPoolLeads } from './components/leads/MailPoolLeads';
import { Opportunities } from './components/opportunities/Opportunities';
import { Calendar } from './components/calendar/Calendar';
import { parseSqlDateTime } from './components/calendar/calendarUtils';
@@ -57,7 +58,12 @@ const App: React.FC = () => {
*/
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 [users, setUsers] = useState<AppUser[]>([]);
const [events, setEvents] = useState<CalendarEvent[]>([]);
@@ -90,7 +96,7 @@ const App: React.FC = () => {
usersApi.directory(),
opportunitiesApi.list(),
]);
setLeads(leadRows);
setAllLeads(leadRows);
setConnections(connectionRows);
setEvents(eventRows);
setUsers(userRows);
@@ -111,6 +117,14 @@ const App: React.FC = () => {
if (isLoading) return <Splash />;
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(
(event) => parseSqlDateTime(event.ends_at).getTime() >= Date.now()
).length;
@@ -141,6 +155,10 @@ const App: React.FC = () => {
? `${leads.length} ${leads.length === 1 ? 'Kontakt' : 'Kontakte'} · ${connections.length} ${
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'
? `Gemeinsamer Teamkalender · ${events.length} ${
events.length === 1 ? 'Termin' : 'Termine'
@@ -155,6 +173,7 @@ const App: React.FC = () => {
onViewChange={goTo}
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((open) => !open)}
mailPoolCount={mailPoolLeads.length}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -195,6 +214,15 @@ const App: React.FC = () => {
onFocusHandled={() => setFocusLeadId(null)}
/>
)}
{currentView === 'Aus Mails' && (
<MailPoolLeads
poolLeads={mailPoolLeads}
leads={leads}
isLoading={isDataLoading}
reload={() => loadData(true)}
onOpenLead={(leadId) => goTo('Kontakte', { leadId })}
/>
)}
{currentView === 'Mails' && (
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
)}
+131 -67
View File
@@ -1,5 +1,5 @@
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 { useAuth } from '../lib/auth';
@@ -8,6 +8,8 @@ interface SidebarProps {
onViewChange: (view: ViewState) => void;
isOpen: boolean;
onToggle: () => void;
/** Anzahl der Eintraege in "Aus Mails" - als Zaehler am Unterpunkt. */
mailPoolCount?: number;
}
interface MenuItem {
@@ -17,9 +19,70 @@ interface MenuItem {
label?: string;
}
// Kategorien-Reiter in Anzeigereihenfolge. Weitere Kategorien lassen sich hier
// ergaenzen - Einstellungen bleibt separat am unteren Rand.
const CATEGORY_ITEMS: MenuItem[] = [
/**
* Aufklappbarer Menuepunkt: der Kopf wechselt auf `item.name` und zeigt die
* 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',
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',
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',
},
{
name: 'Kontakte',
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',
},
{
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',
},
CONTACTS_GROUP,
{ name: 'Mails', icon: MAIL_ICON },
SCANNER_GROUP,
];
/*
@@ -93,20 +121,35 @@ function useIsMobile() {
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 isMobile = useIsMobile();
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(() => {
if (isScannerView) setIsScannerOpen(true);
}, [isScannerView]);
const active = CATEGORY_ENTRIES.filter(isGroup).find((group) => group.views.includes(currentView));
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;
return (
<button
@@ -145,34 +188,51 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
{item.label ?? item.name}
</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>
);
};
/**
* Scanner-Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich auf
* das Dashboard. Bei eingeklappter Leiste gibt es nur das Symbol, die
* Unterpunkte erscheinen erst mit den Beschriftungen.
* Aufklappbare Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich
* auf die Hauptansicht der Gruppe. Bei eingeklappter Leiste gibt es nur das
* Symbol, die Unterpunkte erscheinen erst mit den Beschriftungen.
*/
const renderScannerGroup = () => {
const isActive = currentView === 'Scanner';
const renderGroup = (group: MenuGroup) => {
const { item, children, views } = group;
const isActive = currentView === item.name;
const isInGroup = views.includes(currentView);
const isGroupOpen = openGroups.includes(item.name);
return (
<div key="scanner-group">
<div key={`group-${item.name}`}>
<button
onClick={() => {
setIsScannerOpen((open) => (isScannerView ? !open : true));
onViewChange('Scanner');
setOpenGroups((open) =>
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();
}}
title={!showLabels ? 'Scanner' : undefined}
aria-expanded={isScannerOpen}
title={!showLabels ? item.label ?? item.name : undefined}
aria-expanded={isGroupOpen}
aria-current={isActive ? 'page' : undefined}
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'
} ${
isActive
? 'bg-brand-500 text-white shadow-card'
: isScannerView
: isInGroup
? 'bg-brand-50 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"
strokeLinejoin="round"
strokeWidth={isActive ? 2.2 : 1.7}
d={SCANNER_ITEM.icon}
d={item.icon}
/>
</svg>
{showLabels && (
<>
<span className="truncate text-sm font-bold">Scanner</span>
<span className="truncate text-sm font-bold">{item.label ?? item.name}</span>
<svg
className={`ml-auto h-4 w-4 shrink-0 transition-transform ${
isScannerOpen ? 'rotate-180' : ''
isGroupOpen ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
@@ -202,9 +262,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
)}
</button>
{showLabels && isScannerOpen && (
{showLabels && isGroupOpen && (
<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>
@@ -244,8 +309,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
Kategorien
</p>
)}
{CATEGORY_ITEMS.map((item) => renderItem(item))}
{renderScannerGroup()}
{CATEGORY_ENTRIES.map((entry) => (isGroup(entry) ? renderGroup(entry) : renderItem(entry)))}
</nav>
{/* Einstellungen unten - Inhalt richtet sich nach der Rolle */}
+6 -4
View File
@@ -30,12 +30,14 @@ interface LeadsProps {
}
/**
* Herkunft der Kontakte. "Aus Mails" sind die aus dem Postfach uebernommenen -
* sie tragen `source = 'Mail'` und brauchen deshalb kein eigenes Feld.
* Herkunft der Kontakte. `source = 'Mail'` tragen die Kontakte, die aus dem
* 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 }[] = [
{ id: 'Alle', label: 'Alle Herkunft' },
{ id: 'Mail', label: 'Aus Mails' },
{ id: 'Mail', label: 'Aus Mails übernommen' },
{ id: 'Manuell', label: 'Selbst angelegt' },
];
@@ -237,7 +239,7 @@ export const Leads: React.FC<LeadsProps> = ({
<select
value={originFilter}
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"
>
{ORIGIN_FILTERS.map((entry) => (
+495
View File
@@ -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>
);
};
+115
View File
@@ -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',
};
+42
View File
@@ -128,6 +128,28 @@ export const usersApi = {
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 = {
list: () => request<Lead[]>('/leads'),
create: (input: LeadInput) =>
@@ -135,6 +157,26 @@ export const leadsApi = {
update: (id: number, input: LeadInput) =>
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
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 ----------
+14
View File
@@ -2,6 +2,7 @@ export type ViewState =
| 'Opportunities'
| 'Kalender'
| 'Kontakte'
| 'Aus Mails'
| 'Mails'
| 'Scanner'
| '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'];
/**
* 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 interface AppUser {
@@ -109,6 +117,12 @@ export interface Lead {
country: string | null;
status: LeadStatus;
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[];
notes: string | null;
avatar: string | null;