Files
perinco-CRM/src/App.tsx
T
Sascha WolfandClaude Opus 5 42639c65fa feat(opportunities): Phasen des Vermittlungsprozesses, Kanban zuerst, Zahlen im Menue
Drei Dinge:

- Die Phasen sind jetzt die des Vermittlungsprozesses: Neu, Beworben,
  Erstgespraech Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt,
  Absage. Offen ist alles bis einschliesslich Zusage - dort ist der Einsatz
  bestaetigt, die Beauftragung steht aber noch aus. migrateOpportunityStages()
  stellt bestehende Datenbanken um und setzt alle vorhandenen Opportunities auf
  "Neu": in welcher der neuen Phasen ein Projekt steckt, sagt der alte Trichter
  nicht (so mit dem Nutzer entschieden).

  Der Datenbankwert heisst 'Erstgespraech Berater' ohne Umlaut - Projektkonvention
  fuer Datenbankwerte. Angezeigt wird er ueber STAGE_LABELS; dazu gibt es
  STAGE_HINTS mit der Erlaeuterung je Phase als Titelhinweis an Auswahl, Badge
  und Spaltenkopf.

- Kanban ist die erste Ansicht der Opportunities. Die Pipeline ist die
  Arbeitsansicht, Kachel und Liste sind zum Nachschauen.

- Hinter "Archiv", beiden "Papierkorb"-Punkten und "Unpassend" steht die Anzahl,
  wie schon bei "Aus Mails". Die Zahlen kommen aus einem eigenen Endpunkt
  (/api/badges), weil sie alle ins Menue gehoeren und zusammen kommen sollen;
  Inhalte laedt weiter jede Ansicht selbst. Der Scanner liegt in einer eigenen
  Datenbank und kann fehlen - sein Zaehler ist dann null und die Leiste zeigt
  dort einfach keine Zahl. Der Aufruf haengt bewusst hinter den Daten und nicht
  im Promise.all: faellt er aus, bleibt das Menue ohne Zahlen statt die Ansicht
  mit einem Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 14:34:41 +02:00

289 lines
11 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
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 { Acquisitions } from './components/acquisition/Acquisitions';
import { Opportunities } from './components/opportunities/Opportunities';
import { OpportunityTrash } from './components/opportunities/OpportunityTrash';
import { Calendar } from './components/calendar/Calendar';
import { parseSqlDateTime } from './components/calendar/calendarUtils';
import { Settings } from './components/settings/Settings';
import { Mails } from './components/mail/Mails';
import { ScannerDashboard } from './components/scanner/ScannerDashboard';
import { ScannerRejected } from './components/scanner/ScannerRejected';
import { ScannerAgents } from './components/scanner/ScannerAgents';
import { ScannerPortals } from './components/scanner/ScannerPortals';
import { useToast } from './components/Toast';
import { NavigationProvider } from './lib/navigation';
import { useAuth } from './lib/auth';
import {
badgesApi,
connectionsApi,
eventsApi,
leadsApi,
opportunitiesApi,
usersApi,
type SidebarBadges,
} from './lib/api';
import {
OPEN_STAGES,
formatEuro,
opportunityVolume,
viewTitle,
type AppUser,
type CalendarEvent,
type Lead,
type LeadConnection,
type Opportunity,
type SettingsTab,
type ViewState,
} from './types';
const Splash: React.FC = () => (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-6 bg-surface">
<img src="/logo.png" alt="perinco" className="logo-adaptive h-10 w-auto" />
<span className="h-7 w-7 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
<p className="text-xs font-bold uppercase tracking-[0.2em] text-ink-400">Lade perinco CRM...</p>
</div>
);
/** Erster Reiter im Menue - hier startet die App. */
const DEFAULT_VIEW: ViewState = 'Opportunities';
const App: React.FC = () => {
const { user, isLoading } = useAuth();
const { addToast } = useToast();
const [currentView, setCurrentView] = useState<ViewState>(DEFAULT_VIEW);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
/**
* Gewuenschter Reiter der Einstellungen. Der Diktat-Hinweis springt so direkt
* nach Whisper, das keinen eigenen Menuepunkt mehr hat.
*/
const [settingsTab, setSettingsTab] = useState<SettingsTab | undefined>(undefined);
/**
* Kontakt, der in der Kontaktansicht sofort geoeffnet werden soll. Wird beim
* Sprung aus der Mailansicht gesetzt und von dort wieder zurueckgesetzt.
*/
const [focusLeadId, setFocusLeadId] = useState<number | null>(null);
/**
* 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[]>([]);
const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
const [badges, setBadges] = useState<SidebarBadges | null>(null);
const [isDataLoading, setIsDataLoading] = useState(false);
/**
* Ansichtswechsel fuer Menue und tief liegende Komponenten. `tab` waehlt
* gleich den passenden Reiter der Einstellungen; ohne Angabe entscheidet die
* Einstellungsseite selbst (Rolle admin: Nutzerverwaltung, sonst Mein Konto).
*/
const goTo = useCallback((view: ViewState, options?: { tab?: SettingsTab; leadId?: number }) => {
setCurrentView(view);
setSettingsTab(options?.tab);
setFocusLeadId(options?.leadId ?? null);
}, []);
const loadData = useCallback(
async (silent = false) => {
if (!user) return;
if (!silent) setIsDataLoading(true);
try {
// Die Benutzerliste braucht jeder: Netzwerkdiagramm, Zustaendigkeit,
// @-Mentions, Teilnehmer und Farben der Termine. /users/directory ist
// deshalb - anders als /users - fuer alle Rollen freigegeben.
const [leadRows, connectionRows, eventRows, userRows, opportunityRows] = await Promise.all([
leadsApi.list(),
connectionsApi.list(),
eventsApi.list(),
usersApi.directory(),
opportunitiesApi.list(),
]);
setAllLeads(leadRows);
setConnections(connectionRows);
setEvents(eventRows);
setUsers(userRows);
setOpportunities(opportunityRows);
/*
* Die Zahlen der Seitenleiste haengen bewusst hinter den Daten und nicht
* im Promise.all: sie sind Beiwerk. Faellt der Aufruf aus, bleibt das
* Menue ohne Zahlen statt die ganze Ansicht mit einem Fehler.
*/
badgesApi
.get()
.then(setBadges)
.catch(() => setBadges(null));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Daten konnten nicht geladen werden.', 'error');
} finally {
setIsDataLoading(false);
}
},
[user, addToast]
);
useEffect(() => {
if (user) loadData();
}, [user, loadData]);
if (isLoading) return <Splash />;
if (!user) return <LoginScreen />;
/*
* Kontakte und Vorbereich trennen. Ueberall im CRM - Opportunities, Interviews,
* 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;
const openOpportunities = opportunities.filter((item) => OPEN_STAGES.includes(item.stage));
const openVolume = openOpportunities.reduce(
(sum, item) => sum + (opportunityVolume(item) ?? 0),
0
);
// Untertitel der Kopfzeile. Die Scanner-Reiter bringen ihre Kennzahlen selbst
// mit, deshalb steht hier nur, worum es auf der Seite geht.
const STATIC_SUBTITLES: Partial<Record<ViewState, string>> = {
Akquise: 'Gefundene Projekte - eintragen, prüfen, zur Opportunity machen',
'Opportunity-Papierkorb': 'Gelöschte Opportunities - nach 90 Tagen endgültig entfernt',
Archiv: 'Akquise-Einträge älter als 14 Tage - nach 90 Tagen werden sie gelöscht',
Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen',
Papierkorb: 'Im CRM gelöschte Mails - im Postfach crm@perinco.de bleiben sie erhalten',
Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen',
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
'Börsen-Konten': 'Projekt Scanner - Verbindungen zu den Jobbörsen',
Einstellungen: 'Konto, Diktat, Benutzer, Postfach und Scanner',
};
const subtitle =
currentView === 'Opportunities'
? `${opportunities.length} ${
opportunities.length === 1 ? 'Opportunity' : 'Opportunities'
} · ${openOpportunities.length} offen · ${formatEuro(openVolume)} Pipeline`
: currentView === 'Kontakte'
? `${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 === 'Interviews'
? `Gemeinsame Termine im Team · ${events.length} ${
events.length === 1 ? 'Termin' : 'Termine'
} · ${upcomingCount} anstehend`
: STATIC_SUBTITLES[currentView];
return (
<NavigationProvider value={{ currentView, goTo }}>
<div className="flex h-screen overflow-hidden bg-canvas text-ink-900">
<Sidebar
currentView={currentView}
onViewChange={goTo}
isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((open) => !open)}
counts={{
'Aus Mails': mailPoolLeads.length,
Archiv: badges?.acquisition_archive,
'Opportunity-Papierkorb': badges?.opportunity_trash,
Papierkorb: badges?.mail_trash,
Unpassend: badges?.scanner_rejected,
}}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Header
title={viewTitle(currentView)}
subtitle={subtitle}
onToggleSidebar={() => setIsSidebarOpen((open) => !open)}
/>
<div className="scrollbar-slim flex-1 overflow-y-auto p-4 md:p-6">
{currentView === 'Akquise' && <Acquisitions reload={() => loadData(true)} />}
{currentView === 'Archiv' && (
<Acquisitions view="archiv" reload={() => loadData(true)} />
)}
{currentView === 'Opportunity-Papierkorb' && (
<OpportunityTrash reload={() => loadData(true)} />
)}
{currentView === 'Opportunities' && (
<Opportunities
opportunities={opportunities}
leads={leads}
users={users}
isLoading={isDataLoading}
reload={() => loadData(true)}
/>
)}
{currentView === 'Interviews' && (
<Calendar
events={events}
leads={leads}
users={users}
connections={connections}
isLoading={isDataLoading}
reload={() => loadData(true)}
/>
)}
{currentView === 'Kontakte' && (
<Leads
leads={leads}
connections={connections}
users={users}
isLoading={isDataLoading}
reload={() => loadData(true)}
focusLeadId={focusLeadId}
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)} />
)}
{currentView === 'Papierkorb' && (
<Mails
leads={leads}
opportunities={opportunities}
reload={() => loadData(true)}
mode="papierkorb"
/>
)}
{currentView === 'Scanner' && <ScannerDashboard />}
{currentView === 'Unpassend' && <ScannerRejected />}
{currentView === 'Suchagenten' && <ScannerAgents />}
{currentView === 'Börsen-Konten' && <ScannerPortals />}
{currentView === 'Einstellungen' && <Settings initialTab={settingsTab} />}
</div>
</main>
</div>
</NavigationProvider>
);
};
export default App;