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>
289 lines
11 KiB
TypeScript
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;
|