Files
perinco-CRM/src/App.tsx
T
Sascha WolfandClaude Opus 5 6f57483f33 feat(dashboard): Startansicht mit Bewerbungen, Wandlung und Gewinnen
Erster Menuepunkt und Startansicht; das Logo oben links fuehrt dorthin zurueck.

Vier Kennzahlen (Bewerbungen, daraus Opportunities samt Quote, gewonnene
Projekte, aktuell beim Kunden), ein Saeulendiagramm mit denselben drei Reihen
ueber der Zeit, die Bewerbungen je Berater, die naechsten Termine und alle
Opportunities in Kundenvorstellung bzw. Interview Kunde. Zeitraum ueber vier
Vorgaben oder frei, Einteilung nach Tag, Monat, Quartal oder Jahr.

Bewerbung heisst dabei: ein Eintrag in der Akquise - jeder Eintrag bedeutet, dass
ein Profil raus ist (so vom Nutzer festgelegt).

Zwei Dinge dafuer am Datenmodell:

- crm_opportunities.won_at haelt den Zeitpunkt des Wechsels nach "Beauftragt".
  Die Phase allein sagt nur, wie es jetzt steht, nicht wann es passiert ist.
  Beim Wechsel heraus wird das Datum geleert, sonst zaehlt das Dashboard einen
  Gewinn, den es nicht gibt.
- Die Akquise loescht nach 90 Tagen - damit waere jede Jahresauswertung falsch.
  Der Aufraeumlauf sichert deshalb vorher Tag, Berater und Anzahl in
  crm_acquisition_history, in derselben Transaktion wie das Loeschen. Gesichert
  wird nur, was verschwindet; die Auswertung addiert beide Quellen und kann sich
  damit nicht ueberschneiden. Projektdaten sind nach dem Loeschen weiterhin weg.

Termine und die Phasenlisten kommen nicht aus einem eigenen Endpunkt: Termine und
Opportunities laedt die App ohnehin komplett, das Dashboard filtert sie selbst.

Diagramme von Hand (SVG-freie Balken plus Tailwind-Uebergaenge) statt einer
Bibliothek - drei Darstellungen rechtfertigen keine weitere Abhaengigkeit, und so
kommen die Farben aus denselben Tokens wie der Rest. Die Zahlen laufen hoch, die
Saeulen wachsen; wer Bewegung im System abgeschaltet hat, bekommt sofort den
Endwert.

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

294 lines
12 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 { Dashboard } from './components/dashboard/Dashboard';
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 = 'Dashboard';
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>> = {
Dashboard: 'Wie der Vertrieb läuft - Bewerbungen, Opportunities, gewonnene Projekte',
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 === 'Dashboard' && (
<Dashboard opportunities={opportunities} events={events} isLoading={isDataLoading} />
)}
{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;