Das Loeschsymbol gab es in der Kachelansicht schon, es war aber bis zum
Ueberfahren durchsichtig - ein Knopf, den man nur findet, wenn man ihn kennt.
Jetzt ist er sichtbar, in der Liste ebenso, und die Pipeline-Karte hat ihn
ueberhaupt zum ersten Mal.
Geloescht wird nicht mehr endgueltig: DELETE setzt deleted_at, die Zeile bleibt.
An einer Opportunity haengen Ansprechpartner, Verlauf und Mailzuordnungen, und
ein Fehlklick soll das nicht kosten. Der Papierkorb ist ein Unterpunkt unter
Opportunities und zeigt, wann und von wem geloescht wurde und wie lange der
Eintrag noch liegt. Zurueckholen darf, wer auch loeschen darf; das Leeren jeder
Angemeldete - was dort liegt, hat schon jemand mit den noetigen Rechten
weggeworfen. Beides fragt vorher nach.
Nach 90 Tagen raeumt das Backend selbst auf, beim Abruf der Liste und hoechstens
einmal pro Stunde je Prozess - dasselbe Muster wie in der Akquise. Ein eigener
Zeitplan waere eine weitere Stelle, die ausfallen kann, und die Abfragen filtern
Faelliges ohnehin heraus.
Im Papierkorb liegende Opportunities sind aus allen Listen, Summen und
Zuordnungsdialogen heraus und lassen sich nicht bearbeiten (409) - erst
zurueckholen, dann aendern.
Nebenbei: der Header bekommt seinen Titel jetzt ueber viewTitle(). Der
Ansichtsname musste eindeutig sein ("Opportunity-Papierkorb", weil die Mails
schon einen Papierkorb haben), angezeigt wird beides als "Papierkorb".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
ACQUISITION_VIEWS,
|
|
CONTACT_VIEWS,
|
|
MAIL_VIEWS,
|
|
OPPORTUNITY_VIEWS,
|
|
SCANNER_VIEWS,
|
|
type ViewState,
|
|
} from '../types';
|
|
import { UserAvatar } from './ui/UserAvatar';
|
|
import { useAuth } from '../lib/auth';
|
|
|
|
interface SidebarProps {
|
|
currentView: ViewState;
|
|
onViewChange: (view: ViewState) => void;
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
/** Anzahl der Eintraege in "Aus Mails" - als Zaehler am Unterpunkt. */
|
|
mailPoolCount?: number;
|
|
}
|
|
|
|
interface MenuItem {
|
|
name: ViewState;
|
|
icon: string;
|
|
/** Abweichende Beschriftung; ohne Angabe wird der Ansichtsname gezeigt. */
|
|
label?: string;
|
|
}
|
|
|
|
/**
|
|
* 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 },
|
|
],
|
|
};
|
|
|
|
const ACQUISITION_ICON =
|
|
'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01';
|
|
const ARCHIVE_ICON =
|
|
'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4';
|
|
|
|
/**
|
|
* Akquise. Der Eingang zeigt die Funde der letzten 14 Tage, darunter klappt das
|
|
* Archiv mit allem Aelteren auf. Beides ist dieselbe Liste - geteilt wird nach
|
|
* dem Funddatum, nicht durch Verschieben.
|
|
*/
|
|
const ACQUISITION_GROUP: MenuGroup = {
|
|
item: { name: 'Akquise', icon: ACQUISITION_ICON },
|
|
views: ACQUISITION_VIEWS,
|
|
children: [
|
|
{ name: 'Akquise', label: 'Eingang', icon: ACQUISITION_ICON },
|
|
{ name: 'Archiv', icon: ARCHIVE_ICON },
|
|
],
|
|
};
|
|
|
|
const TRASH_ICON =
|
|
'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';
|
|
|
|
const OPPORTUNITY_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';
|
|
|
|
/**
|
|
* Opportunities mit ihrem Papierkorb. Geloeschte Opportunities liegen dort 90
|
|
* Tage, damit ein Fehlklick nicht Verlauf, Ansprechpartner und Mailbezuege
|
|
* kostet.
|
|
*/
|
|
const OPPORTUNITIES_GROUP: MenuGroup = {
|
|
item: { name: 'Opportunities', icon: OPPORTUNITY_ICON },
|
|
views: OPPORTUNITY_VIEWS,
|
|
children: [
|
|
{ name: 'Opportunities', label: 'Alle', icon: OPPORTUNITY_ICON },
|
|
{ name: 'Opportunity-Papierkorb', label: 'Papierkorb', icon: TRASH_ICON },
|
|
],
|
|
};
|
|
|
|
/**
|
|
* Mails. Darunter klappt der Papierkorb auf: Mails, die im CRM geloescht wurden.
|
|
* Im Postfach bleiben sie stehen - der Abgleich holt sie aber nicht wieder.
|
|
*/
|
|
const MAILS_GROUP: MenuGroup = {
|
|
item: { name: 'Mails', icon: MAIL_ICON },
|
|
views: MAIL_VIEWS,
|
|
children: [
|
|
{ name: 'Mails', label: 'Postfach', icon: MAIL_ICON },
|
|
{ name: 'Papierkorb', icon: TRASH_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)[] = [
|
|
ACQUISITION_GROUP,
|
|
OPPORTUNITIES_GROUP,
|
|
{
|
|
// Der Terminkalender heisst im CRM "Interviews": genutzt wird er fuer
|
|
// Gespraechstermine, nicht als allgemeiner Kalender.
|
|
name: 'Interviews',
|
|
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',
|
|
},
|
|
CONTACTS_GROUP,
|
|
MAILS_GROUP,
|
|
SCANNER_GROUP,
|
|
];
|
|
|
|
/*
|
|
* Whisper hat keinen eigenen Menuepunkt mehr - es ist ein Reiter in den
|
|
* Einstellungen. Deshalb sind die Einstellungen jetzt fuer jede Rolle sichtbar:
|
|
* jeder pflegt sein Konto und richtet sein eigenes Diktat ein. Welche Reiter
|
|
* erscheinen, entscheidet die Rolle innerhalb der Einstellungen.
|
|
*/
|
|
const SETTINGS_ITEM: MenuItem = {
|
|
name: 'Einstellungen',
|
|
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
|
|
};
|
|
|
|
function useIsMobile() {
|
|
const [isMobile, setIsMobile] = useState(() =>
|
|
typeof window === 'undefined' ? false : window.innerWidth < 1024
|
|
);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => setIsMobile(window.innerWidth < 1024);
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, []);
|
|
|
|
return isMobile;
|
|
}
|
|
|
|
export const Sidebar: React.FC<SidebarProps> = ({
|
|
currentView,
|
|
onViewChange,
|
|
isOpen,
|
|
onToggle,
|
|
mailPoolCount = 0,
|
|
}) => {
|
|
const { user, logout } = useAuth();
|
|
const isMobile = useIsMobile();
|
|
const showLabels = isOpen || isMobile;
|
|
|
|
/*
|
|
* 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)
|
|
);
|
|
|
|
useEffect(() => {
|
|
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; badge?: number } = {}) => {
|
|
const isActive = currentView === item.name;
|
|
return (
|
|
<button
|
|
key={item.name}
|
|
onClick={() => {
|
|
onViewChange(item.name);
|
|
if (isMobile) onToggle();
|
|
}}
|
|
title={!showLabels ? item.label ?? item.name : undefined}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
className={`group flex w-full items-center rounded-xl transition ${
|
|
showLabels
|
|
? `gap-3 py-2.5 ${options.indented ? 'pl-9 pr-3' : 'px-3'}`
|
|
: 'justify-center px-0 py-2.5'
|
|
} ${
|
|
isActive
|
|
? 'bg-brand-500 text-white shadow-card'
|
|
: 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
|
|
}`}
|
|
>
|
|
<svg
|
|
className={`shrink-0 ${options.indented && showLabels ? 'h-4 w-4' : 'h-5 w-5'}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={isActive ? 2.2 : 1.7}
|
|
d={item.icon}
|
|
/>
|
|
</svg>
|
|
{showLabels && (
|
|
<span className={`truncate font-bold ${options.indented ? 'text-[13px]' : 'text-sm'}`}>
|
|
{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>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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 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={`group-${item.name}`}>
|
|
<button
|
|
onClick={() => {
|
|
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 ? 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'
|
|
: isInGroup
|
|
? 'bg-brand-50 text-brand-700'
|
|
: 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
|
|
}`}
|
|
>
|
|
<svg className="h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={isActive ? 2.2 : 1.7}
|
|
d={item.icon}
|
|
/>
|
|
</svg>
|
|
{showLabels && (
|
|
<>
|
|
<span className="truncate text-sm font-bold">{item.label ?? item.name}</span>
|
|
<svg
|
|
className={`ml-auto h-4 w-4 shrink-0 transition-transform ${
|
|
isGroupOpen ? 'rotate-180' : ''
|
|
}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{showLabels && isGroupOpen && (
|
|
<div className="mt-1 space-y-1">
|
|
{children.map((child) =>
|
|
renderItem(child, {
|
|
indented: true,
|
|
badge: child.name === 'Aus Mails' ? mailPoolCount : undefined,
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{isOpen && isMobile && (
|
|
<div className="fixed inset-0 z-20 bg-ink-950/40 backdrop-blur-sm lg:hidden" onClick={onToggle} />
|
|
)}
|
|
|
|
<aside
|
|
className={`fixed inset-y-0 left-0 z-30 flex h-full flex-col overflow-hidden border-r border-ink-200 bg-surface transition-all duration-300 lg:static ${
|
|
isOpen ? 'w-64 translate-x-0' : 'w-0 -translate-x-full lg:w-[76px] lg:translate-x-0'
|
|
}`}
|
|
>
|
|
{/* Logo */}
|
|
<div
|
|
className={`flex shrink-0 items-center border-b border-ink-100 ${
|
|
showLabels ? 'px-5 py-5' : 'justify-center px-0 py-5'
|
|
}`}
|
|
>
|
|
{showLabels ? (
|
|
<img src="/logo.png" alt="perinco CRM" className="logo-adaptive h-9 w-auto" />
|
|
) : (
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-500 text-base font-extrabold text-white">
|
|
p
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Kategorien */}
|
|
<nav className="scrollbar-slim flex-1 space-y-1 overflow-y-auto overflow-x-hidden px-3 py-4">
|
|
{showLabels && (
|
|
<p className="mb-2 px-3 text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
|
Kategorien
|
|
</p>
|
|
)}
|
|
{CATEGORY_ENTRIES.map((entry) => (isGroup(entry) ? renderGroup(entry) : renderItem(entry)))}
|
|
</nav>
|
|
|
|
{/* Einstellungen unten - Inhalt richtet sich nach der Rolle */}
|
|
<div className="shrink-0 space-y-1 border-t border-ink-100 px-3 py-3">
|
|
{renderItem(SETTINGS_ITEM)}
|
|
</div>
|
|
|
|
{/* Benutzer */}
|
|
<div
|
|
className={`shrink-0 border-t border-ink-100 ${
|
|
showLabels ? 'p-4' : 'flex flex-col items-center gap-3 p-3'
|
|
}`}
|
|
>
|
|
<div className="flex w-full min-w-0 items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
{user ? (
|
|
<UserAvatar user={user} size={36} />
|
|
) : (
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-100 text-sm font-extrabold text-brand-700">
|
|
?
|
|
</div>
|
|
)}
|
|
{showLabels && (
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-bold text-ink-900">{user?.name}</p>
|
|
<p className="truncate text-xs text-ink-400">
|
|
{user?.role === 'admin' ? 'Administrator' : 'Benutzer'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showLabels && (
|
|
<button
|
|
onClick={logout}
|
|
title="Abmelden"
|
|
className="shrink-0 rounded-lg p-2 text-ink-400 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{!showLabels && (
|
|
<button
|
|
onClick={logout}
|
|
title="Abmelden"
|
|
className="rounded-lg p-2 text-ink-400 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</>
|
|
);
|
|
};
|