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>
264 lines
10 KiB
TypeScript
264 lines
10 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 { connectionsApi, eventsApi, leadsApi, opportunitiesApi, usersApi } 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 [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);
|
|
} 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)}
|
|
mailPoolCount={mailPoolLeads.length}
|
|
/>
|
|
|
|
<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;
|