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
+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 */}