feat(kontakte): Vorbereich "Aus Mails" mit Dublettenabgleich

Der Postfachabgleich hat gefundene Personen bisher direkt in den
Kontaktbestand geschrieben. Damit wächst eine gepflegte Liste ungefragt
mit jeder Adresse aus jeder Mail - genau daran ist das Altsystem
gescheitert.

Funde landen jetzt in einem Vorbereich (crm_leads.mail_pool = 1), der als
Unterpunkt "Aus Mails" unter dem Menüpunkt Kontakte aufklappt. Der
Kontaktbestand bleibt unberührt, bis dort jemand entscheidet; in
Opportunities, Kalender und Netzwerk tauchen die Funde nicht auf.

Eine Spalte statt einer zweiten Tabelle, weil crm_mail_links und
crm_mail_contact_candidates per Fremdschlüssel auf crm_leads zeigen - so
behält der Vorbereich seine Mailbezüge.

Übernehmen kennt zwei Wege:
- neue Person: Kopie mit mail_pool = 0, die Mailverknüpfungen wandern mit
- Dublette (Adresse, sonst voller Name): gold markiert, abweichende
  Felder mit altem und neuem Wert; die Feldauswahl schreibt sie in den
  vorhandenen Kontakt. Vorausgewählt sind nur Lücken - ein Wert aus einer
  alten Signatur soll nichts Gepflegtes stillschweigend ersetzen.

db.js verschiebt beim ersten Start die vorhandenen Kontakte mit
source = 'Mail' in den Vorbereich, einmalig über den Merker
leads_mail_pool_migrated in crm_settings.

Die Sidebar-Gruppe ist dabei generisch geworden (MenuGroup) - Kontakte
und Scanner benutzen jetzt denselben Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 08:59:14 +02:00
co-authored by Claude Opus 5
parent 8090d0aae1
commit ea55acdb5c
22 changed files with 1524 additions and 115 deletions
+30 -2
View File
@@ -3,6 +3,7 @@ import { Sidebar } from './components/Sidebar';
import { Header } from './components/Header';
import { LoginScreen } from './components/LoginScreen';
import { Leads } from './components/leads/Leads';
import { MailPoolLeads } from './components/leads/MailPoolLeads';
import { Opportunities } from './components/opportunities/Opportunities';
import { Calendar } from './components/calendar/Calendar';
import { parseSqlDateTime } from './components/calendar/calendarUtils';
@@ -57,7 +58,12 @@ const App: React.FC = () => {
*/
const [focusLeadId, setFocusLeadId] = useState<number | null>(null);
const [leads, setLeads] = useState<Lead[]>([]);
/**
* Alle Kontakte inklusive Vorbereich - getrennt wird direkt darunter. Der
* Server liefert absichtlich beide Bestaende in einem Aufruf: die Ansicht
* "Aus Mails" braucht ohnehin beide, um Dubletten zu erkennen.
*/
const [allLeads, setAllLeads] = useState<Lead[]>([]);
const [connections, setConnections] = useState<LeadConnection[]>([]);
const [users, setUsers] = useState<AppUser[]>([]);
const [events, setEvents] = useState<CalendarEvent[]>([]);
@@ -90,7 +96,7 @@ const App: React.FC = () => {
usersApi.directory(),
opportunitiesApi.list(),
]);
setLeads(leadRows);
setAllLeads(leadRows);
setConnections(connectionRows);
setEvents(eventRows);
setUsers(userRows);
@@ -111,6 +117,14 @@ const App: React.FC = () => {
if (isLoading) return <Splash />;
if (!user) return <LoginScreen />;
/*
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Kalender,
* Netzwerk, Mailzuordnung - gilt nur der gepflegte Bestand; die Funde aus dem
* Postfach sind Rohmaterial und sollen in keiner Auswahlliste auftauchen.
*/
const leads = allLeads.filter((lead) => !lead.mail_pool);
const mailPoolLeads = allLeads.filter((lead) => lead.mail_pool);
const upcomingCount = events.filter(
(event) => parseSqlDateTime(event.ends_at).getTime() >= Date.now()
).length;
@@ -141,6 +155,10 @@ const App: React.FC = () => {
? `${leads.length} ${leads.length === 1 ? 'Kontakt' : 'Kontakte'} · ${connections.length} ${
connections.length === 1 ? 'Verbindung' : 'Verbindungen'
}`
: currentView === 'Aus Mails'
? `${mailPoolLeads.length} ${
mailPoolLeads.length === 1 ? 'Fund' : 'Funde'
} aus dem Postfach · noch nicht in den Kontakten`
: currentView === 'Kalender'
? `Gemeinsamer Teamkalender · ${events.length} ${
events.length === 1 ? 'Termin' : 'Termine'
@@ -155,6 +173,7 @@ const App: React.FC = () => {
onViewChange={goTo}
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((open) => !open)}
mailPoolCount={mailPoolLeads.length}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -195,6 +214,15 @@ const App: React.FC = () => {
onFocusHandled={() => setFocusLeadId(null)}
/>
)}
{currentView === 'Aus Mails' && (
<MailPoolLeads
poolLeads={mailPoolLeads}
leads={leads}
isLoading={isDataLoading}
reload={() => loadData(true)}
onOpenLead={(leadId) => goTo('Kontakte', { leadId })}
/>
)}
{currentView === 'Mails' && (
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
)}
+131 -67
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { SCANNER_VIEWS, type ViewState } from '../types';
import { CONTACT_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import { UserAvatar } from './ui/UserAvatar';
import { useAuth } from '../lib/auth';
@@ -8,6 +8,8 @@ interface SidebarProps {
onViewChange: (view: ViewState) => void;
isOpen: boolean;
onToggle: () => void;
/** Anzahl der Eintraege in "Aus Mails" - als Zaehler am Unterpunkt. */
mailPoolCount?: number;
}
interface MenuItem {
@@ -17,9 +19,70 @@ interface MenuItem {
label?: string;
}
// Kategorien-Reiter in Anzeigereihenfolge. Weitere Kategorien lassen sich hier
// ergaenzen - Einstellungen bleibt separat am unteren Rand.
const CATEGORY_ITEMS: MenuItem[] = [
/**
* Aufklappbarer Menuepunkt: der Kopf wechselt auf `item.name` und zeigt die
* Unterpunkte. `views` sind alle Ansichten der Gruppe - liegt die aktuelle
* darunter, bleibt die Gruppe offen und hervorgehoben.
*/
interface MenuGroup {
item: MenuItem;
children: MenuItem[];
views: ViewState[];
}
const isGroup = (entry: MenuItem | MenuGroup): entry is MenuGroup => 'children' in entry;
const CONTACTS_ICON =
'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z';
const MAIL_ICON =
'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z';
/**
* Kontakte. Der Menuepunkt klappt den gepflegten Bestand und den Vorbereich
* "Aus Mails" darunter auf - dort sammelt der Postfachabgleich Personen, die
* noch niemand gesichtet hat.
*/
const CONTACTS_GROUP: MenuGroup = {
item: { name: 'Kontakte', icon: CONTACTS_ICON },
views: CONTACT_VIEWS,
children: [
{ name: 'Kontakte', label: 'Alle Kontakte', icon: CONTACTS_ICON },
{ name: 'Aus Mails', icon: MAIL_ICON },
],
};
/**
* Projekt Scanner. Wie bei den Kontakten klappen die Reiter darunter auf -
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
* stehen nicht hier, sondern als Reiter in den CRM-Einstellungen.
*/
const SCANNER_GROUP: MenuGroup = {
item: { name: 'Scanner', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
views: SCANNER_VIEWS,
children: [
{
name: 'Scanner',
label: 'Dashboard',
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
},
{
name: 'Unpassend',
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
},
{
name: 'Suchagenten',
icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
},
{
name: 'Börsen-Konten',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
],
};
// Kategorien in Anzeigereihenfolge, einzelne Punkte und Gruppen gemischt.
// Einstellungen bleibt separat am unteren Rand.
const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [
{
name: 'Opportunities',
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
@@ -28,44 +91,9 @@ const CATEGORY_ITEMS: MenuItem[] = [
name: 'Kalender',
icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
},
{
name: 'Kontakte',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
name: 'Mails',
icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
},
];
/**
* Projekt Scanner. Der Menuepunkt klappt die Reiter des Scanners darunter auf -
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
* stehen nicht hier, sondern als Reiter in den CRM-Einstellungen.
*/
const SCANNER_ITEM: MenuItem = {
name: 'Scanner',
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
};
const SCANNER_CHILDREN: MenuItem[] = [
{
name: 'Scanner',
label: 'Dashboard',
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
},
{
name: 'Unpassend',
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
},
{
name: 'Suchagenten',
icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
},
{
name: 'Börsen-Konten',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
CONTACTS_GROUP,
{ name: 'Mails', icon: MAIL_ICON },
SCANNER_GROUP,
];
/*
@@ -93,20 +121,35 @@ function useIsMobile() {
return isMobile;
}
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isOpen, onToggle }) => {
export const Sidebar: React.FC<SidebarProps> = ({
currentView,
onViewChange,
isOpen,
onToggle,
mailPoolCount = 0,
}) => {
const { user, logout } = useAuth();
const isMobile = useIsMobile();
const showLabels = isOpen || isMobile;
const isScannerView = SCANNER_VIEWS.includes(currentView);
const [isScannerOpen, setIsScannerOpen] = useState(isScannerView);
/*
* Offene Gruppen, nach dem Namen des Kopfeintrags. Wer per Klick in einer
* Gruppenansicht landet, soll die Unterpunkte auch sehen - deshalb oeffnet der
* Effekt die Gruppe, zu der die aktuelle Ansicht gehoert.
*/
const [openGroups, setOpenGroups] = useState<ViewState[]>(() =>
CATEGORY_ENTRIES.filter(isGroup)
.filter((group) => group.views.includes(currentView))
.map((group) => group.item.name)
);
// Wer per Klick im Scanner landet, soll die Unterpunkte auch sehen.
useEffect(() => {
if (isScannerView) setIsScannerOpen(true);
}, [isScannerView]);
const active = CATEGORY_ENTRIES.filter(isGroup).find((group) => group.views.includes(currentView));
if (!active) return;
setOpenGroups((open) => (open.includes(active.item.name) ? open : [...open, active.item.name]));
}, [currentView]);
const renderItem = (item: MenuItem, options: { indented?: boolean } = {}) => {
const renderItem = (item: MenuItem, options: { indented?: boolean; badge?: number } = {}) => {
const isActive = currentView === item.name;
return (
<button
@@ -145,34 +188,51 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
{item.label ?? item.name}
</span>
)}
{showLabels && options.badge ? (
<span
className={`ml-auto shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${
isActive ? 'bg-white/25 text-white' : 'border border-amber-200 bg-amber-50 text-amber-700'
}`}
>
{options.badge}
</span>
) : null}
</button>
);
};
/**
* Scanner-Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich auf
* das Dashboard. Bei eingeklappter Leiste gibt es nur das Symbol, die
* Unterpunkte erscheinen erst mit den Beschriftungen.
* Aufklappbare Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich
* auf die Hauptansicht der Gruppe. Bei eingeklappter Leiste gibt es nur das
* Symbol, die Unterpunkte erscheinen erst mit den Beschriftungen.
*/
const renderScannerGroup = () => {
const isActive = currentView === 'Scanner';
const renderGroup = (group: MenuGroup) => {
const { item, children, views } = group;
const isActive = currentView === item.name;
const isInGroup = views.includes(currentView);
const isGroupOpen = openGroups.includes(item.name);
return (
<div key="scanner-group">
<div key={`group-${item.name}`}>
<button
onClick={() => {
setIsScannerOpen((open) => (isScannerView ? !open : true));
onViewChange('Scanner');
setOpenGroups((open) =>
isInGroup && open.includes(item.name)
? open.filter((name) => name !== item.name)
: [...open.filter((name) => name !== item.name), item.name]
);
onViewChange(item.name);
if (isMobile) onToggle();
}}
title={!showLabels ? 'Scanner' : undefined}
aria-expanded={isScannerOpen}
title={!showLabels ? item.label ?? item.name : undefined}
aria-expanded={isGroupOpen}
aria-current={isActive ? 'page' : undefined}
className={`group flex w-full items-center rounded-xl transition ${
showLabels ? 'gap-3 px-3 py-2.5' : 'justify-center px-0 py-2.5'
} ${
isActive
? 'bg-brand-500 text-white shadow-card'
: isScannerView
: isInGroup
? 'bg-brand-50 text-brand-700'
: 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
}`}
@@ -182,15 +242,15 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={isActive ? 2.2 : 1.7}
d={SCANNER_ITEM.icon}
d={item.icon}
/>
</svg>
{showLabels && (
<>
<span className="truncate text-sm font-bold">Scanner</span>
<span className="truncate text-sm font-bold">{item.label ?? item.name}</span>
<svg
className={`ml-auto h-4 w-4 shrink-0 transition-transform ${
isScannerOpen ? 'rotate-180' : ''
isGroupOpen ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
@@ -202,9 +262,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
)}
</button>
{showLabels && isScannerOpen && (
{showLabels && isGroupOpen && (
<div className="mt-1 space-y-1">
{SCANNER_CHILDREN.map((child) => renderItem(child, { indented: true }))}
{children.map((child) =>
renderItem(child, {
indented: true,
badge: child.name === 'Aus Mails' ? mailPoolCount : undefined,
})
)}
</div>
)}
</div>
@@ -244,8 +309,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange, isO
Kategorien
</p>
)}
{CATEGORY_ITEMS.map((item) => renderItem(item))}
{renderScannerGroup()}
{CATEGORY_ENTRIES.map((entry) => (isGroup(entry) ? renderGroup(entry) : renderItem(entry)))}
</nav>
{/* Einstellungen unten - Inhalt richtet sich nach der Rolle */}
+6 -4
View File
@@ -30,12 +30,14 @@ interface LeadsProps {
}
/**
* Herkunft der Kontakte. "Aus Mails" sind die aus dem Postfach uebernommenen -
* sie tragen `source = 'Mail'` und brauchen deshalb kein eigenes Feld.
* Herkunft der Kontakte. `source = 'Mail'` tragen die Kontakte, die aus dem
* Vorbereich "Aus Mails" uebernommen wurden - die Herkunft wandert bei der
* Uebernahme mit. Die noch nicht uebernommenen Funde stehen nicht hier, sondern
* im eigenen Menuepunkt "Aus Mails".
*/
const ORIGIN_FILTERS: { id: LeadOriginFilter; label: string }[] = [
{ id: 'Alle', label: 'Alle Herkunft' },
{ id: 'Mail', label: 'Aus Mails' },
{ id: 'Mail', label: 'Aus Mails übernommen' },
{ id: 'Manuell', label: 'Selbst angelegt' },
];
@@ -237,7 +239,7 @@ export const Leads: React.FC<LeadsProps> = ({
<select
value={originFilter}
onChange={(event) => setOriginFilter(event.target.value as LeadOriginFilter)}
title="Kontakte, die aus dem Postfach crm@perinco.de übernommen wurden"
title="Kontakte, die aus dem Vorbereich „Aus Mails“ übernommen wurden"
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{ORIGIN_FILTERS.map((entry) => (
+495
View File
@@ -0,0 +1,495 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Avatar } from './leadUi';
import { MailPoolMergeDialog } from './MailPoolMergeDialog';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
import { leadsApi, type MailPoolSource } from '../../lib/api';
import { formatMailDateTime } from '../../lib/mailApi';
import { leadFullName, leadInitials, type Lead } from '../../types';
import {
MAIL_POOL_FIELDS,
MAIL_POOL_ORIGIN_LABELS,
findMailPoolMatch,
isConflict,
type MailPoolFilter,
type MailPoolMatch,
} from './mailPool';
/*
* Vorbereich "Aus Mails".
*
* Der Postfachabgleich legt hier ab, wen er in Mails gefunden hat. Der
* Kontaktbestand bleibt davon unberuehrt, bis hier jemand entscheidet:
*
* - Person ist neu -> "In Kontakte übernehmen" legt den Kontakt an.
* - Person ist vorhanden -> die Karte ist gold markiert. Abweichende Felder
* stehen mit altem und neuem Wert daneben und
* lassen sich einzeln in den Kontakt uebernehmen.
*
* Die Zuordnung laeuft ueber E-Mail-Adresse, sonst ueber den vollen Namen
* (siehe mailPool.ts) - beides ohne Serveranfrage, weil beide Bestaende schon
* geladen sind.
*/
interface MailPoolLeadsProps {
/** Eintraege mit mail_pool = true. */
poolLeads: Lead[];
/** Der gepflegte Kontaktbestand - Gegenstueck fuer den Abgleich. */
leads: Lead[];
isLoading: boolean;
reload: () => void;
/** Sprung in die Kontaktansicht mit geoeffnetem Kontakt. */
onOpenLead: (leadId: number) => void;
}
const FILTERS: { id: MailPoolFilter; label: string }[] = [
{ id: 'alle', label: 'Alle Einträge' },
{ id: 'neu', label: 'Nur neue Personen' },
{ id: 'dubletten', label: 'Nur Dubletten' },
];
/** Ein Feldwert der Karte; gold hinterlegt, wenn er vom Kontakt abweicht. */
const FieldCell: React.FC<{
label: string;
value: string;
/** Wert im vorhandenen Kontakt - nur bei einer echten Abweichung gesetzt. */
previous?: string | null;
highlight?: boolean;
}> = ({ label, value, previous, highlight }) => (
<div
className={`min-w-0 rounded-xl border px-3 py-2 ${
highlight ? 'border-amber-200 bg-amber-50' : 'border-ink-100 bg-ink-50/60'
}`}
>
<p
className={`text-[10px] font-extrabold uppercase tracking-wider ${
highlight ? 'text-amber-700' : 'text-ink-400'
}`}
>
{label}
</p>
<p className={`truncate text-sm ${highlight ? 'font-bold text-amber-700' : 'text-ink-800'}`} title={value}>
{value}
</p>
{previous ? (
<p className="truncate text-[11px] text-ink-400" title={`Bisher im Kontakt: ${previous}`}>
bisher: <span className="line-through">{previous}</span>
</p>
) : null}
</div>
);
export const MailPoolLeads: React.FC<MailPoolLeadsProps> = ({
poolLeads,
leads,
isLoading,
reload,
onOpenLead,
}) => {
const { addToast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const [filter, setFilter] = useState<MailPoolFilter>('alle');
/**
* Standard ist das Verschieben: ein Eintrag, der als Kontakt gefuehrt wird,
* hat im Vorbereich nichts mehr zu suchen. Wer die Fundliste als Protokoll
* behalten will, setzt den Haken.
*/
const [keepPool, setKeepPool] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [mergeFor, setMergeFor] = useState<{ poolLead: Lead; match: MailPoolMatch } | null>(null);
const [leadToDelete, setLeadToDelete] = useState<Lead | null>(null);
const [promoteAll, setPromoteAll] = useState(false);
const [sources, setSources] = useState<Record<string, MailPoolSource>>({});
/*
* Herkunftsangaben (aus welcher Mail, wie sicher) liegen nicht in /leads -
* sie werden nur hier gebraucht und deshalb nur hier geholt. Der Abruf haengt
* an der Anzahl: nach jeder Uebernahme laedt App.tsx die Kontakte neu, und
* damit passt auch diese Liste wieder.
*/
useEffect(() => {
let active = true;
leadsApi
.mailPoolSources()
.then((data) => {
if (active) setSources(data);
})
.catch(() => {
// Ohne Herkunftsangabe ist die Liste weiterhin benutzbar - kein Toast.
});
return () => {
active = false;
};
}, [poolLeads.length]);
// Abgleich einmal je Eintrag, nicht pro Rendervorgang der Karte.
const entries = useMemo(() => {
const terms = searchTerm
.toLowerCase()
.split(',')
.map((term) => term.trim())
.filter(Boolean);
return poolLeads
.map((poolLead) => ({ poolLead, match: findMailPoolMatch(poolLead, leads) }))
.filter(({ poolLead, match }) => {
if (filter === 'neu' && match) return false;
if (filter === 'dubletten' && !match) return false;
if (terms.length === 0) return true;
const haystack = [leadFullName(poolLead), poolLead.company, poolLead.email, poolLead.position]
.filter(Boolean)
.join(' ')
.toLowerCase();
return terms.every((term) => haystack.includes(term));
})
.sort((a, b) => {
const left = a.poolLead.created_at ? new Date(a.poolLead.created_at).getTime() : 0;
const right = b.poolLead.created_at ? new Date(b.poolLead.created_at).getTime() : 0;
return right - left;
});
}, [poolLeads, leads, searchTerm, filter]);
const duplicateCount = useMemo(
() => poolLeads.filter((poolLead) => findMailPoolMatch(poolLead, leads)).length,
[poolLeads, leads]
);
const newCount = poolLeads.length - duplicateCount;
/** Eintrag als neuer Kontakt - der Weg fuer alles, was noch nicht vorhanden ist. */
const promote = async (poolLead: Lead) => {
setBusyId(poolLead.id);
try {
await leadsApi.promote(poolLead.id, { keep_pool: keepPool });
addToast(`${leadFullName(poolLead)} steht jetzt in den Kontakten.`, 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
} finally {
setBusyId(null);
}
};
const promoteAllNew = async () => {
const ids = poolLeads.filter((poolLead) => !findMailPoolMatch(poolLead, leads)).map((lead) => lead.id);
setPromoteAll(false);
if (ids.length === 0) return;
setBusyId(-1);
try {
const result = await leadsApi.promoteMany(ids, { keep_pool: keepPool });
addToast(
`${result.created.length} ${result.created.length === 1 ? 'Kontakt' : 'Kontakte'} übernommen.`,
'success'
);
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
} finally {
setBusyId(null);
}
};
const confirmDelete = async () => {
if (!leadToDelete) return;
try {
await leadsApi.remove(leadToDelete.id);
addToast(`${leadFullName(leadToDelete)} aus „Aus Mails" entfernt.`, 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
} finally {
setLeadToDelete(null);
}
};
return (
<div className="space-y-4">
{/* Steuerleiste */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative w-full lg:max-w-md">
<input
type="search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Name, Firma, Adresse suchen (Komma = UND)"
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
/>
<svg
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<select
value={filter}
onChange={(event) => setFilter(event.target.value as MailPoolFilter)}
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{FILTERS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.id === 'neu'
? `${entry.label} (${newCount})`
: entry.id === 'dubletten'
? `${entry.label} (${duplicateCount})`
: `${entry.label} (${poolLeads.length})`}
</option>
))}
</select>
<div className="flex flex-wrap items-center gap-3 lg:ml-auto">
<label
className="flex cursor-pointer items-center gap-2 text-xs font-bold text-ink-500"
title="Ohne Haken verschwindet der Eintrag hier, sobald er als Kontakt geführt wird."
>
<input
type="checkbox"
checked={keepPool}
onChange={(event) => setKeepPool(event.target.checked)}
className="h-4 w-4 rounded border-ink-300 text-brand-500 focus:ring-brand-500/25"
/>
Eintrag hier behalten
</label>
<button
onClick={() => setPromoteAll(true)}
disabled={newCount === 0 || busyId !== null}
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
Alle neuen übernehmen ({newCount})
</button>
</div>
</div>
<p className="rounded-xl border border-ink-200 bg-surface px-4 py-3 text-xs leading-relaxed text-ink-500">
Diese Personen hat der Postfachabgleich in Mails gefunden. Sie stehen noch nicht in den Kontakten
und tauchen weder in Opportunities noch im Kalender auf.{' '}
<span className="font-bold text-amber-700">Gold markierte Einträge</span> gibt es dort schon – bei
ihnen lassen sich abweichende Angaben einzeln in den vorhandenen Kontakt übernehmen.
</p>
{/* Inhalt */}
{isLoading && poolLeads.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : entries.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-20 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-ink-50 text-ink-300">
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<p className="text-sm font-bold text-ink-500">
{poolLeads.length === 0
? 'Keine offenen Funde aus dem Postfach.'
: 'Kein Eintrag passt zur Suche.'}
</p>
</div>
) : (
<div className="space-y-3">
{entries.map(({ poolLead, match }) => {
const source = sources[String(poolLead.id)];
const diffByKey = new Map(match?.diffs.map((diff) => [diff.key, diff]) ?? []);
const conflicts = match?.diffs.filter(isConflict).length ?? 0;
const isBusy = busyId === poolLead.id || busyId === -1;
return (
<article
key={poolLead.id}
className={`rounded-2xl border p-5 shadow-card transition ${
match ? 'border-amber-300 bg-amber-50/40' : 'border-ink-200 bg-surface'
} ${isBusy ? 'pointer-events-none opacity-60' : ''}`}
>
<div className="flex flex-wrap items-start gap-4">
<Avatar src={poolLead.avatar} initials={leadInitials(poolLead)} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-base text-ink-900">{leadFullName(poolLead)}</h3>
{match && (
<span
className="inline-flex items-center rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700"
title={
match.by === 'email'
? 'Diese E-Mail-Adresse steht schon in den Kontakten.'
: 'Ein Kontakt mit diesem Namen ist schon vorhanden.'
}
>
{match.by === 'email' ? 'Dublette (Adresse)' : 'Dublette (Name)'}
</span>
)}
{match && match.diffs.length > 0 && (
<span className="inline-flex items-center rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700">
{match.diffs.length} {match.diffs.length === 1 ? 'Abweichung' : 'Abweichungen'}
{conflicts > 0 ? ` · ${conflicts} überschreibend` : ''}
</span>
)}
{source?.origin && (
<span className="inline-flex items-center rounded-md border border-ink-200 bg-ink-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-500">
{MAIL_POOL_ORIGIN_LABELS[source.origin] ?? source.origin}
{source.confidence !== null ? ` · ${source.confidence} %` : ''}
</span>
)}
</div>
{source && (
<p className="mt-1 truncate text-xs text-ink-400" title={source.last_subject ?? undefined}>
aus {source.message_count} {source.message_count === 1 ? 'Mail' : 'Mails'}
{source.last_subject ? ` · „${source.last_subject}"` : ''}
{source.last_received_at ? ` · ${formatMailDateTime(source.last_received_at)}` : ''}
</p>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{match ? (
<>
<button
onClick={() => onOpenLead(match.lead.id)}
className="rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
Kontakt öffnen
</button>
{match.diffs.length > 0 ? (
<button
onClick={() => setMergeFor({ poolLead, match })}
/*
* amber-700 wird im dunklen Design zur hellen Farbe
* (siehe index.css) - die Schrift muss deshalb aus
* derselben Skala kommen und mitdrehen. Ein festes
* text-white waere im Dark Mode kaum zu lesen.
*/
className="rounded-xl bg-amber-700 px-3 py-2 text-xs font-bold text-amber-50 transition hover:opacity-90"
>
Kontakt aktualisieren
</button>
) : (
<button
onClick={() => setLeadToDelete(poolLead)}
title="Der vorhandene Kontakt enthält alle Angaben aus dieser Mail."
className="rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
Erledigt, entfernen
</button>
)}
<button
onClick={() => promote(poolLead)}
title="Trotz Dublette einen zweiten Kontakt anlegen"
className="rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
Neu anlegen
</button>
</>
) : (
<button
onClick={() => promote(poolLead)}
className="rounded-xl bg-brand-500 px-3 py-2 text-xs font-bold text-white transition hover:bg-brand-600"
>
In Kontakte übernehmen
</button>
)}
<button
onClick={() => setLeadToDelete(poolLead)}
aria-label={`${leadFullName(poolLead)} entfernen`}
className="rounded-xl p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
{MAIL_POOL_FIELDS.map((field) => {
const value = poolLead[field.key];
if (value === null || value === undefined || String(value).trim() === '') return null;
const diff = diffByKey.get(field.key);
return (
<FieldCell
key={field.key}
label={field.label}
value={String(value)}
previous={diff?.leadValue ?? null}
highlight={Boolean(diff)}
/>
);
})}
</div>
</article>
);
})}
</div>
)}
{mergeFor && (
<MailPoolMergeDialog
poolLead={mergeFor.poolLead}
match={mergeFor.match}
keepPool={keepPool}
onClose={() => setMergeFor(null)}
onDone={() => {
setMergeFor(null);
reload();
}}
/>
)}
{promoteAll && (
<ConfirmDialog
title="Alle neuen Personen übernehmen?"
message={`${newCount} ${
newCount === 1 ? 'Person' : 'Personen'
} ohne Gegenstück werden als Kontakte angelegt. Gold markierte Dubletten bleiben unberührt.`}
confirmLabel="Übernehmen"
onConfirm={promoteAllNew}
onCancel={() => setPromoteAll(false)}
/>
)}
{leadToDelete && (
<ConfirmDialog
title="Eintrag entfernen?"
message={`${leadFullName(
leadToDelete
)} wird aus „Aus Mails" gelöscht. Die Mails bleiben im Postfach-Bereich erhalten; taucht die Person in einer neuen Mail auf, erscheint sie hier wieder.`}
confirmLabel="Entfernen"
onConfirm={confirmDelete}
onCancel={() => setLeadToDelete(null)}
/>
)}
</div>
);
};
@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useToast } from '../Toast';
import { leadsApi } from '../../lib/api';
import { leadFullName, type Lead } from '../../types';
import { isConflict, type MailPoolMatch } from './mailPool';
/*
* Dublettenfall: die Person aus der Mail steht schon in den Kontakten. Hier wird
* Feld fuer Feld entschieden, was in den vorhandenen Kontakt uebernommen wird.
*
* Bewusst nicht automatisch: der Mailfund ist eine Momentaufnahme aus Signatur
* oder Adresszeile. Eine alte Durchwahl aus einer alten Mail darf eine gepflegte
* Nummer nicht stillschweigend ersetzen. Vorausgewaehlt sind deshalb nur die
* Luecken - Felder, in denen der Kontakt bisher nichts hat.
*/
interface MailPoolMergeDialogProps {
poolLead: Lead;
match: MailPoolMatch;
/** Aus der Steuerleiste: Eintrag danach in "Aus Mails" behalten? */
keepPool: boolean;
onClose: () => void;
onDone: () => void;
}
export const MailPoolMergeDialog: React.FC<MailPoolMergeDialogProps> = ({
poolLead,
match,
keepPool,
onClose,
onDone,
}) => {
const { addToast } = useToast();
const [selected, setSelected] = useState<string[]>(() =>
match.diffs.filter((diff) => !isConflict(diff)).map((diff) => diff.key)
);
const [isBusy, setIsBusy] = useState(false);
const toggle = (key: string) =>
setSelected((keys) => (keys.includes(key) ? keys.filter((entry) => entry !== key) : [...keys, key]));
const submit = async () => {
setIsBusy(true);
try {
const result = await leadsApi.promote(poolLead.id, {
target_lead_id: match.lead.id,
fields: selected,
keep_pool: keepPool,
});
addToast(
result.fields > 0
? `${result.fields} ${result.fields === 1 ? 'Feld' : 'Felder'} in ${leadFullName(
match.lead
)} übernommen.`
: `${leadFullName(match.lead)} unverändert – nur die Mails wurden verknüpft.`,
'success'
);
onDone();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
} finally {
setIsBusy(false);
}
};
return (
<Modal
title="Kontakt aus Mailfund aktualisieren"
subtitle={`${leadFullName(poolLead)} → vorhandener Kontakt ${leadFullName(match.lead)}${
match.by === 'name' ? ' (über den Namen erkannt)' : ''
}`}
size="lg"
onClose={onClose}
footer={
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-ink-400">
{keepPool
? 'Der Eintrag bleibt anschließend in „Aus Mails" stehen.'
: 'Der Eintrag verschwindet anschließend aus „Aus Mails".'}
</p>
<div className="flex gap-3">
<button
onClick={onClose}
className="rounded-xl border border-ink-200 px-5 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
<button
onClick={submit}
disabled={isBusy}
className="rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
{selected.length === 0
? 'Nur Mails verknüpfen'
: `${selected.length} ${selected.length === 1 ? 'Feld' : 'Felder'} übernehmen`}
</button>
</div>
</div>
}
>
<div className="space-y-2">
{match.diffs.map((diff) => {
const conflict = isConflict(diff);
const isSelected = selected.includes(diff.key);
return (
<label
key={diff.key}
className={`flex cursor-pointer items-start gap-3 rounded-xl border px-4 py-3 transition ${
isSelected
? conflict
? 'border-amber-300 bg-amber-50'
: 'border-brand-200 bg-brand-50'
: 'border-ink-200 bg-surface hover:bg-ink-50'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggle(diff.key)}
className="mt-1 h-4 w-4 shrink-0 rounded border-ink-300 text-brand-500 focus:ring-brand-500/25"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-500">
{diff.label}
</p>
{conflict ? (
<span className="rounded-md border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700">
überschreibt
</span>
) : (
<span className="rounded-md border border-brand-200 bg-brand-50 px-1.5 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
ergänzt
</span>
)}
</div>
<p className="mt-1 break-words text-sm font-bold text-ink-900">{diff.poolValue}</p>
<p className="text-xs text-ink-400">
{diff.leadValue ? (
<>
bisher: <span className="line-through">{diff.leadValue}</span>
</>
) : (
'im Kontakt bisher leer'
)}
</p>
</div>
</label>
);
})}
</div>
<p className="mt-4 text-xs leading-relaxed text-ink-400">
Die Mails, aus denen dieser Fund stammt, werden in jedem Fall mit dem Kontakt verknüpft – auch wenn
kein Feld ausgewählt ist.
</p>
</Modal>
);
};
+115
View File
@@ -0,0 +1,115 @@
import { leadFullName, type Lead } from '../../types';
/*
* Abgleich zwischen dem Vorbereich "Aus Mails" und dem Kontaktbestand.
*
* Der Vergleich laeuft im Browser und nicht in der Datenbank: beide Bestaende
* liegen ohnehin schon vollstaendig im Speicher (App.tsx laedt /leads einmal),
* und die Zuordnung soll dieselbe Regel verwenden, nach der die Oberflaeche
* spaeter markiert - eine zweite Fassung in SQL waere eine zweite Wahrheit.
*/
/** Verglichene und uebertragbare Felder in Anzeigereihenfolge. */
export const MAIL_POOL_FIELDS: { key: keyof Lead & string; label: string }[] = [
{ key: 'salutation', label: 'Anrede' },
{ key: 'first_name', label: 'Vorname' },
{ key: 'last_name', label: 'Nachname' },
{ key: 'email', label: 'E-Mail' },
{ key: 'phone', label: 'Telefon' },
{ key: 'mobile', label: 'Mobil' },
{ key: 'company', label: 'Firma' },
{ key: 'position', label: 'Position' },
{ key: 'street', label: 'Straße' },
{ key: 'zip', label: 'PLZ' },
{ key: 'city', label: 'Ort' },
{ key: 'country', label: 'Land' },
{ key: 'website', label: 'Webseite' },
{ key: 'linkedin', label: 'LinkedIn' },
];
export interface FieldDiff {
key: string;
label: string;
/** Wert aus der Mail. Nie leer - sonst gibt es nichts zu uebertragen. */
poolValue: string;
/** Wert im vorhandenen Kontakt; null heisst: dort steht noch nichts. */
leadValue: string | null;
}
export interface MailPoolMatch {
lead: Lead;
/** Woran erkannt - die Adresse ist der belastbarere Treffer. */
by: 'email' | 'name';
/** Felder, in denen der Mailfund etwas Neues oder Abweichendes hat. */
diffs: FieldDiff[];
}
const text = (value: unknown): string => (value === null || value === undefined ? '' : String(value).trim());
const norm = (value: unknown): string => text(value).toLowerCase();
/**
* Telefonnummern werden ueber ihre Ziffern verglichen: "+49 30 123-4" und
* "+49301234" sind dieselbe Nummer und sollen nicht als Abweichung erscheinen.
* Ein Unterschied wie 0049 gegen +49 bleibt einer - das ist echte Pflegearbeit.
*/
const comparable = (key: string, value: unknown): string =>
key === 'phone' || key === 'mobile' ? text(value).replace(/[^\d+]/g, '') : norm(value);
/**
* Passenden Kontakt zu einem Eintrag aus "Aus Mails" finden. Zuerst ueber die
* E-Mail-Adresse, sonst ueber den vollen Namen - der Mailfund einer Person, die
* mit privater Adresse geschrieben hat, soll ebenfalls auffallen.
*/
export function findMailPoolMatch(poolLead: Lead, leads: Lead[]): MailPoolMatch | null {
const email = norm(poolLead.email);
const name = norm(leadFullName(poolLead));
let found: Lead | undefined;
let by: MailPoolMatch['by'] = 'email';
if (email) found = leads.find((lead) => norm(lead.email) === email);
if (!found && name) {
found = leads.find((lead) => norm(leadFullName(lead)) === name);
by = 'name';
}
if (!found) return null;
return { lead: found, by, diffs: diffFields(poolLead, found) };
}
/**
* Felder, die der Mailfund beisteuern kann. Leere Werte aus der Mail tauchen
* nicht auf - sie wuerden im Kontakt nur Gepflegtes ueberschreiben.
*/
export function diffFields(poolLead: Lead, lead: Lead): FieldDiff[] {
const diffs: FieldDiff[] = [];
for (const field of MAIL_POOL_FIELDS) {
const poolValue = text(poolLead[field.key]);
if (!poolValue) continue;
const leadValue = text(lead[field.key]);
if (leadValue && comparable(field.key, poolValue) === comparable(field.key, leadValue)) continue;
diffs.push({
key: field.key,
label: field.label,
poolValue,
leadValue: leadValue || null,
});
}
return diffs;
}
/** Echte Abweichung (beide Seiten gefuellt, Werte verschieden) statt nur Luecke. */
export const isConflict = (diff: FieldDiff): boolean => diff.leadValue !== null;
export type MailPoolFilter = 'alle' | 'neu' | 'dubletten';
export const MAIL_POOL_ORIGIN_LABELS: Record<string, string> = {
absender: 'Absender',
empfaenger: 'Empfänger',
signatur: 'Signatur',
text: 'Mailtext',
};
+42
View File
@@ -128,6 +128,28 @@ export const usersApi = {
export type LeadInput = Partial<Omit<Lead, 'id' | 'created_at' | 'updated_at'>>;
/**
* Herkunft eines Eintrags im Vorbereich "Aus Mails": aus wie vielen Mails er
* stammt, welche die letzte war und wie sicher der Fund ist.
*/
export interface MailPoolSource {
message_count: number;
last_received_at: string | null;
last_subject: string | null;
/** 0-100; null, wenn der Vorschlag inzwischen entfernt wurde. */
confidence: number | null;
origin: 'absender' | 'empfaenger' | 'signatur' | 'text' | null;
}
export interface PromoteResult {
/** true = vorhandener Kontakt ergaenzt, false = neuer Kontakt angelegt. */
merged: boolean;
/** Anzahl der uebertragenen Felder (nur beim Ergaenzen). */
fields: number;
removed_pool: boolean;
lead: Lead;
}
export const leadsApi = {
list: () => request<Lead[]>('/leads'),
create: (input: LeadInput) =>
@@ -135,6 +157,26 @@ export const leadsApi = {
update: (id: number, input: LeadInput) =>
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
/** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */
mailPoolSources: () => request<Record<string, MailPoolSource>>('/leads/mail-pool/sources'),
/**
* Eintrag aus "Aus Mails" in die Kontakte holen. Ohne `target_lead_id`
* entsteht ein neuer Kontakt; mit ihr werden die genannten Felder in den
* vorhandenen Kontakt geschrieben. `keep_pool` behaelt den Eintrag im
* Vorbereich.
*/
promote: (
id: number,
input: { target_lead_id?: number | null; fields?: string[]; keep_pool?: boolean; owner_id?: number } = {}
) => request<PromoteResult>(`/leads/${id}/promote`, { method: 'POST', body: JSON.stringify(input) }),
promoteMany: (ids: number[], options: { keep_pool?: boolean } = {}) =>
request<{ ok: true; created: number[]; skipped: number[] }>('/leads/mail-pool/promote', {
method: 'POST',
body: JSON.stringify({ ids, ...options }),
}),
};
// ---------- Opportunities ----------
+14
View File
@@ -2,6 +2,7 @@ export type ViewState =
| 'Opportunities'
| 'Kalender'
| 'Kontakte'
| 'Aus Mails'
| 'Mails'
| 'Scanner'
| 'Unpassend'
@@ -29,6 +30,13 @@ export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
*/
export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten', 'Börsen-Konten'];
/**
* Kontaktbereich. "Kontakte" ist der gepflegte Bestand, "Aus Mails" der
* Vorbereich mit den Personen, die der Postfachabgleich gefunden hat. Der
* Menuepunkt klappt beide untereinander auf - genau wie beim Scanner.
*/
export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails'];
export type Role = 'admin' | 'user';
export interface AppUser {
@@ -109,6 +117,12 @@ export interface Lead {
country: string | null;
status: LeadStatus;
source: string | null;
/**
* true = Eintrag im Vorbereich "Aus Mails". Solche Kontakte stammen aus dem
* Postfachabgleich und erscheinen nicht in der Kontaktliste, in Opportunities
* oder im Kalender - erst die Uebernahme macht daraus einen Kontakt.
*/
mail_pool: boolean;
tags: string[];
notes: string | null;
avatar: string | null;