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)} />
)}