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 |
|
||||
| --- | --- |
|
||||
| `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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 → 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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -192,8 +192,8 @@ flowchart TD
|
||||
F -->|"Person"| C["Vorschlag mit Zuversicht"]
|
||||
|
||||
C --> Z{"E-Mail vorhanden<br/>und Zuversicht ≥ 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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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'>>;
|
||||
|
||||
/**
|
||||
* 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 ----------
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user