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
+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 */}
|
||||
|
||||
Reference in New Issue
Block a user