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
+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