feat: Projekt Scanner integrieren, Whisper in die Einstellungen
Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+200
@@ -0,0 +1,200 @@
|
||||
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 { Opportunities } from './components/opportunities/Opportunities';
|
||||
import { Calendar } from './components/calendar/Calendar';
|
||||
import { parseSqlDateTime } from './components/calendar/calendarUtils';
|
||||
import { Settings } from './components/settings/Settings';
|
||||
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,
|
||||
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);
|
||||
|
||||
const [leads, setLeads] = 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 }) => {
|
||||
setCurrentView(view);
|
||||
setSettingsTab(options?.tab);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!user) return;
|
||||
if (!silent) setIsDataLoading(true);
|
||||
try {
|
||||
// Die Benutzerliste braucht jeder: Netzwerkdiagramm, Zustaendigkeit,
|
||||
// @-Mentions, Teilnehmer und Farben im Kalender. /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(),
|
||||
]);
|
||||
setLeads(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 />;
|
||||
|
||||
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>> = {
|
||||
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 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 === 'Kalender'
|
||||
? `Gemeinsamer Teamkalender · ${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)}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Header
|
||||
title={currentView}
|
||||
subtitle={subtitle}
|
||||
onToggleSidebar={() => setIsSidebarOpen((open) => !open)}
|
||||
/>
|
||||
|
||||
<div className="scrollbar-slim flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{currentView === 'Opportunities' && (
|
||||
<Opportunities
|
||||
opportunities={opportunities}
|
||||
leads={leads}
|
||||
users={users}
|
||||
isLoading={isDataLoading}
|
||||
reload={() => loadData(true)}
|
||||
/>
|
||||
)}
|
||||
{currentView === 'Kalender' && (
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
{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;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import type { ViewState } from '../types';
|
||||
import { useTheme } from '../lib/theme';
|
||||
|
||||
interface HeaderProps {
|
||||
title: ViewState;
|
||||
subtitle?: string;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const SUN_ICON =
|
||||
'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z';
|
||||
const MOON_ICON = 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z';
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar }) => {
|
||||
const { theme, preference, toggle, useSystem } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<header className="flex shrink-0 items-center gap-4 border-b border-ink-200 bg-surface px-4 py-4 md:px-6">
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
|
||||
title="Menue ein-/ausklappen"
|
||||
aria-label="Menue ein-/ausklappen"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg text-ink-900">{title}</h1>
|
||||
{subtitle && <p className="truncate text-xs font-semibold text-ink-400">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Doppelklick loest die manuelle Wahl und folgt wieder dem System. */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
onDoubleClick={useSystem}
|
||||
title={`${isDark ? 'Helles' : 'Dunkles'} Design einschalten${
|
||||
preference === null ? ' (folgt derzeit dem System)' : ' · Doppelklick: wieder dem System folgen'
|
||||
}`}
|
||||
aria-label={`${isDark ? 'Helles' : 'Dunkles'} Design einschalten`}
|
||||
aria-pressed={isDark}
|
||||
className="flex items-center gap-2 rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d={isDark ? SUN_ICON : MOON_ICON}
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">{isDark ? 'Hell' : 'Dunkel'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
/** Dekoratives Netzwerk-Muster - greift das Knoten-Motiv des perinco Logos auf. */
|
||||
const NetworkPattern: React.FC = () => (
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full opacity-20"
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g stroke="white" strokeWidth="1.2">
|
||||
<path d="M80 300 L150 210 L240 250 L310 150 L250 80 L150 210" />
|
||||
<path d="M80 300 L160 340 L240 250" />
|
||||
<path d="M310 150 L360 240 L240 250" />
|
||||
</g>
|
||||
<g fill="white">
|
||||
<circle cx="80" cy="300" r="9" />
|
||||
<circle cx="150" cy="210" r="13" />
|
||||
<circle cx="240" cy="250" r="10" />
|
||||
<circle cx="310" cy="150" r="12" />
|
||||
<circle cx="250" cy="80" r="7" />
|
||||
<circle cx="160" cy="340" r="6" />
|
||||
<circle cx="360" cy="240" r="8" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LoginScreen: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await login(identifier, password);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-surface">
|
||||
{/* Markenpanel */}
|
||||
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-brand-500 p-12 lg:flex">
|
||||
<NetworkPattern />
|
||||
<img src="/logo.png" alt="perinco" className="relative h-12 w-auto self-start brightness-0 invert" />
|
||||
<div className="relative max-w-md">
|
||||
<h1 className="text-4xl leading-tight text-white">perinco CRM</h1>
|
||||
<p className="mt-4 text-lg font-light text-white/85">
|
||||
Kontakte pflegen, Beziehungen sichtbar machen. Wer kennt wen – auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
<p className="relative text-xs font-semibold uppercase tracking-[0.2em] text-white/60">
|
||||
Performance Intelligence Consulting
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Formular */}
|
||||
<div className="flex w-full flex-col justify-center px-6 py-12 sm:px-12 lg:w-1/2 lg:px-20">
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<img src="/logo.png" alt="perinco" className="logo-adaptive mb-10 h-10 w-auto lg:hidden" />
|
||||
|
||||
<h2 className="text-2xl text-ink-900">Anmelden</h2>
|
||||
<p className="mt-2 text-sm text-ink-500">
|
||||
Melde dich mit deinem Benutzernamen oder deiner E-Mail-Adresse an.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
|
||||
<div>
|
||||
<label htmlFor="identifier" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
|
||||
Benutzername oder E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
className="w-full rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||
<svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-brand-500 px-4 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||
Anmelden...
|
||||
</>
|
||||
) : (
|
||||
'Anmelden'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-10 text-xs text-ink-400">
|
||||
Probleme bei der Anmeldung? Wende dich an einen Administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { 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;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
name: ViewState;
|
||||
icon: string;
|
||||
/** Abweichende Beschriftung; ohne Angabe wird der Ansichtsname gezeigt. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Kategorien-Reiter in Anzeigereihenfolge. Weitere Kategorien lassen sich hier
|
||||
// ergaenzen - Einstellungen bleibt separat am unteren Rand.
|
||||
const CATEGORY_ITEMS: MenuItem[] = [
|
||||
{
|
||||
name: 'Opportunities',
|
||||
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: 'Kalender',
|
||||
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',
|
||||
},
|
||||
{
|
||||
name: 'Kontakte',
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Projekt Scanner. Der Menuepunkt klappt die Reiter des Scanners darunter auf -
|
||||
* "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners
|
||||
* stehen nicht hier, sondern als Reiter in den CRM-Einstellungen.
|
||||
*/
|
||||
const SCANNER_ITEM: MenuItem = {
|
||||
name: 'Scanner',
|
||||
icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||
};
|
||||
|
||||
const SCANNER_CHILDREN: MenuItem[] = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
* 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 }) => {
|
||||
const { user, logout } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const showLabels = isOpen || isMobile;
|
||||
|
||||
const isScannerView = SCANNER_VIEWS.includes(currentView);
|
||||
const [isScannerOpen, setIsScannerOpen] = useState(isScannerView);
|
||||
|
||||
// Wer per Klick im Scanner landet, soll die Unterpunkte auch sehen.
|
||||
useEffect(() => {
|
||||
if (isScannerView) setIsScannerOpen(true);
|
||||
}, [isScannerView]);
|
||||
|
||||
const renderItem = (item: MenuItem, options: { indented?: boolean } = {}) => {
|
||||
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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scanner-Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich auf
|
||||
* das Dashboard. Bei eingeklappter Leiste gibt es nur das Symbol, die
|
||||
* Unterpunkte erscheinen erst mit den Beschriftungen.
|
||||
*/
|
||||
const renderScannerGroup = () => {
|
||||
const isActive = currentView === 'Scanner';
|
||||
return (
|
||||
<div key="scanner-group">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsScannerOpen((open) => (isScannerView ? !open : true));
|
||||
onViewChange('Scanner');
|
||||
if (isMobile) onToggle();
|
||||
}}
|
||||
title={!showLabels ? 'Scanner' : undefined}
|
||||
aria-expanded={isScannerOpen}
|
||||
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'
|
||||
: isScannerView
|
||||
? '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={SCANNER_ITEM.icon}
|
||||
/>
|
||||
</svg>
|
||||
{showLabels && (
|
||||
<>
|
||||
<span className="truncate text-sm font-bold">Scanner</span>
|
||||
<svg
|
||||
className={`ml-auto h-4 w-4 shrink-0 transition-transform ${
|
||||
isScannerOpen ? '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 && isScannerOpen && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{SCANNER_CHILDREN.map((child) => renderItem(child, { indented: true }))}
|
||||
</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_ITEMS.map((item) => renderItem(item))}
|
||||
{renderScannerGroup()}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
|
||||
type ToastKind = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (message: string, kind?: ToastKind) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
const STYLES: Record<ToastKind, string> = {
|
||||
success: 'border-brand-200 bg-brand-50 text-brand-800',
|
||||
error: 'border-red-200 bg-red-50 text-red-700',
|
||||
info: 'border-ink-200 bg-surface text-ink-800',
|
||||
};
|
||||
|
||||
const ICONS: Record<ToastKind, string> = {
|
||||
success: 'M5 13l4 4L19 7',
|
||||
error: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, kind: ToastKind = 'info') => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, message, kind }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4500);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ addToast }), [addToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-6 right-6 z-[200] flex w-full max-w-sm flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-lift ${STYLES[toast.kind]}`}
|
||||
role={toast.kind === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
<svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={ICONS[toast.kind]} />
|
||||
</svg>
|
||||
<p className="text-sm font-semibold leading-snug">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
|
||||
className="ml-auto shrink-0 opacity-50 transition hover:opacity-100"
|
||||
aria-label="Meldung schliessen"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast muss innerhalb von <ToastProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { EventModal } from './EventModal';
|
||||
import { EventDetailModal } from './EventDetailModal';
|
||||
import { UserDetailModal } from './UserDetailModal';
|
||||
import { LeadModal } from '../leads/LeadModal';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { UserAvatar } from '../ui/UserAvatar';
|
||||
import { MentionText, mentionPlainText } from './mentions';
|
||||
import {
|
||||
DAY_MS,
|
||||
WEEKDAY_LABELS,
|
||||
addDays,
|
||||
addMonths,
|
||||
compareEvents,
|
||||
formatDayLong,
|
||||
formatEventRange,
|
||||
formatMonthYear,
|
||||
formatTime,
|
||||
isSameDay,
|
||||
monthMatrix,
|
||||
parseSqlDateTime,
|
||||
positionEvents,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
weekDays,
|
||||
} from './calendarUtils';
|
||||
import { useToast } from '../Toast';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { eventsApi } from '../../lib/api';
|
||||
import {
|
||||
eventColor,
|
||||
userColor,
|
||||
type AppUser,
|
||||
type CalendarEvent,
|
||||
type CalendarViewMode,
|
||||
type Lead,
|
||||
type LeadConnection,
|
||||
} from '../../types';
|
||||
|
||||
interface CalendarProps {
|
||||
events: CalendarEvent[];
|
||||
leads: Lead[];
|
||||
users: AppUser[];
|
||||
connections: LeadConnection[];
|
||||
isLoading: boolean;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const VIEW_BUTTONS: { mode: CalendarViewMode; label: string }[] = [
|
||||
{ mode: 'month', label: 'Monat' },
|
||||
{ mode: 'week', label: 'Woche' },
|
||||
{ mode: 'day', label: 'Tag' },
|
||||
];
|
||||
|
||||
const HOUR_HEIGHT = 48;
|
||||
/** Ab wie vielen Terminen pro Monatstag auf "+N weitere" gekuerzt wird. */
|
||||
const MONTH_CHIP_LIMIT = 3;
|
||||
|
||||
// ---------- Bausteine ----------
|
||||
// Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React
|
||||
// sie bei jedem Render als neuen Typ behandeln und das Zeitraster neu montieren
|
||||
// - die Scrollposition waere danach jedes Mal verloren.
|
||||
|
||||
const EventChip: React.FC<{
|
||||
event: CalendarEvent;
|
||||
onOpen: (id: number) => void;
|
||||
showTime?: boolean;
|
||||
}> = ({ event, onOpen, showTime = false }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
onOpen(event.id);
|
||||
}}
|
||||
title={`${event.title} · ${formatEventRange(event)}${
|
||||
event.created_by_name ? ` · ${event.created_by_name}` : ''
|
||||
}`}
|
||||
className="flex w-full items-center gap-1.5 overflow-hidden rounded-md px-1.5 py-0.5 text-left text-[11px] font-bold text-white transition hover:brightness-110"
|
||||
style={{ backgroundColor: eventColor(event) }}
|
||||
>
|
||||
{showTime && !event.all_day && (
|
||||
<span className="shrink-0 opacity-80">{formatTime(parseSqlDateTime(event.starts_at))}</span>
|
||||
)}
|
||||
<span className="truncate">{event.title}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
interface GridProps {
|
||||
today: Date;
|
||||
dayEvents: (day: Date) => CalendarEvent[];
|
||||
onCreate: (start: Date) => void;
|
||||
onOpenEvent: (id: number) => void;
|
||||
onShowDay: (day: Date) => void;
|
||||
}
|
||||
|
||||
const MonthView: React.FC<GridProps & { cursor: Date }> = ({
|
||||
cursor,
|
||||
today,
|
||||
dayEvents,
|
||||
onCreate,
|
||||
onOpenEvent,
|
||||
onShowDay,
|
||||
}) => {
|
||||
const days = monthMatrix(cursor);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface">
|
||||
<div className="grid grid-cols-7 border-b border-ink-100 bg-ink-50/60">
|
||||
{WEEKDAY_LABELS.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="px-2 py-2 text-center text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((day, index) => {
|
||||
const isCurrentMonth = day.getMonth() === cursor.getMonth();
|
||||
const isToday = isSameDay(day, today);
|
||||
const list = dayEvents(day);
|
||||
const visible = list.slice(0, MONTH_CHIP_LIMIT);
|
||||
const hidden = list.length - visible.length;
|
||||
const createAt = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toDateString()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onCreate(createAt)}
|
||||
onKeyDown={(keyEvent) => {
|
||||
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
|
||||
keyEvent.preventDefault();
|
||||
onCreate(createAt);
|
||||
}
|
||||
}}
|
||||
title="Klicken, um an diesem Tag einen Termin anzulegen"
|
||||
className={`min-h-[104px] cursor-pointer border-b border-ink-100 p-1.5 transition hover:bg-brand-50/40 ${
|
||||
index % 7 === 6 ? '' : 'border-r'
|
||||
} ${isCurrentMonth ? '' : 'bg-ink-50/50'}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between px-0.5">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-extrabold ${
|
||||
isToday
|
||||
? 'bg-brand-500 text-white'
|
||||
: isCurrentMonth
|
||||
? 'text-ink-700'
|
||||
: 'text-ink-300'
|
||||
}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
{list.length > 0 && (
|
||||
<span className="text-[10px] font-bold text-ink-300">{list.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{visible.map((event) => (
|
||||
<EventChip key={event.id} event={event} onOpen={onOpenEvent} showTime />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
onShowDay(day);
|
||||
}}
|
||||
className="w-full rounded-md px-1.5 py-0.5 text-left text-[11px] font-bold text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
|
||||
>
|
||||
+{hidden} weitere
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDivElement> }> = ({
|
||||
days,
|
||||
today,
|
||||
dayEvents,
|
||||
onCreate,
|
||||
onOpenEvent,
|
||||
onShowDay,
|
||||
scrollRef,
|
||||
}) => {
|
||||
const allDayRows = days.map((day) => dayEvents(day).filter((event) => event.all_day));
|
||||
const hasAllDay = allDayRows.some((list) => list.length > 0);
|
||||
const gridTemplate = `56px repeat(${days.length}, minmax(0, 1fr))`;
|
||||
const now = new Date();
|
||||
const nowPercent = ((now.getTime() - startOfDay(now).getTime()) / DAY_MS) * 100;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface">
|
||||
{/* Kopfzeile mit den Tagen */}
|
||||
<div
|
||||
className="grid border-b border-ink-100 bg-ink-50/60"
|
||||
style={{ gridTemplateColumns: gridTemplate }}
|
||||
>
|
||||
<div />
|
||||
{days.map((day) => (
|
||||
<button
|
||||
key={day.toDateString()}
|
||||
type="button"
|
||||
onClick={() => onShowDay(day)}
|
||||
className="border-l border-ink-100 px-2 py-2 text-center transition hover:bg-brand-50/50"
|
||||
>
|
||||
<span className="block text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
{WEEKDAY_LABELS[(day.getDay() + 6) % 7]}
|
||||
</span>
|
||||
<span
|
||||
className={`mx-auto mt-1 flex h-7 w-7 items-center justify-center rounded-full text-sm font-extrabold ${
|
||||
isSameDay(day, today) ? 'bg-brand-500 text-white' : 'text-ink-700'
|
||||
}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ganztaegige Termine */}
|
||||
{hasAllDay && (
|
||||
<div className="grid border-b border-ink-100" style={{ gridTemplateColumns: gridTemplate }}>
|
||||
<div className="px-2 py-2 text-right text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Ganztag
|
||||
</div>
|
||||
{days.map((day, index) => (
|
||||
<div key={day.toDateString()} className="space-y-1 border-l border-ink-100 p-1">
|
||||
{allDayRows[index].map((event) => (
|
||||
<EventChip key={event.id} event={event} onOpen={onOpenEvent} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zeitraster */}
|
||||
<div ref={scrollRef} className="scrollbar-slim max-h-[62vh] overflow-y-auto">
|
||||
<div className="grid" style={{ gridTemplateColumns: gridTemplate }}>
|
||||
<div>
|
||||
{Array.from({ length: 24 }, (_, hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="relative border-b border-ink-100"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
<span className="absolute -top-2 right-2 text-[10px] font-bold text-ink-400">
|
||||
{hour === 0 ? '' : `${String(hour).padStart(2, '0')}:00`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{days.map((day) => {
|
||||
const positioned = positionEvents(
|
||||
dayEvents(day).filter((event) => !event.all_day),
|
||||
day
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={day.toDateString()} className="relative border-l border-ink-100">
|
||||
{/* Stundenzellen sind gleichzeitig Klickflaeche fuer neue Termine */}
|
||||
{Array.from({ length: 24 }, (_, hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
onClick={() =>
|
||||
onCreate(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour))
|
||||
}
|
||||
title={`Termin um ${String(hour).padStart(2, '0')}:00 anlegen`}
|
||||
className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/40"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isSameDay(day, today) && (
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 right-0 z-10 border-t-2 border-red-500"
|
||||
style={{ top: `${nowPercent}%` }}
|
||||
>
|
||||
<span className="absolute -left-1 -top-[5px] h-2 w-2 rounded-full bg-red-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{positioned.map(({ event, topPercent, heightPercent, leftPercent, widthPercent }) => (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
onOpenEvent(event.id);
|
||||
}}
|
||||
title={`${event.title} · ${formatEventRange(event)}`}
|
||||
className="absolute overflow-hidden rounded-lg border-l-4 px-1.5 py-1 text-left text-[11px] leading-tight text-white shadow-card transition hover:brightness-110"
|
||||
style={{
|
||||
top: `${topPercent}%`,
|
||||
height: `calc(${heightPercent}% - 2px)`,
|
||||
left: `calc(${leftPercent}% + 2px)`,
|
||||
width: `calc(${widthPercent}% - 4px)`,
|
||||
backgroundColor: eventColor(event),
|
||||
borderLeftColor: 'rgba(255, 255, 255, 0.55)',
|
||||
}}
|
||||
>
|
||||
<span className="block truncate font-extrabold">{event.title}</span>
|
||||
<span className="block truncate opacity-85">{formatEventRange(event)}</span>
|
||||
{event.location && (
|
||||
<span className="block truncate opacity-75">{event.location}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- Hauptansicht ----------
|
||||
|
||||
export const Calendar: React.FC<CalendarProps> = ({
|
||||
events,
|
||||
leads,
|
||||
users,
|
||||
connections,
|
||||
isLoading,
|
||||
reload,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const { user, isAdmin } = useAuth();
|
||||
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
|
||||
const [cursor, setCursor] = useState<Date>(() => startOfDay(new Date()));
|
||||
|
||||
const [createStart, setCreateStart] = useState<Date | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [detailEventId, setDetailEventId] = useState<number | null>(null);
|
||||
const [eventToDelete, setEventToDelete] = useState<CalendarEvent | null>(null);
|
||||
const [detailUserId, setDetailUserId] = useState<number | null>(null);
|
||||
const [detailLeadId, setDetailLeadId] = useState<number | null>(null);
|
||||
|
||||
const timeGridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Das Zeitraster startet am Vormittag - die Nachtstunden interessieren selten.
|
||||
useEffect(() => {
|
||||
if (viewMode === 'month') return;
|
||||
const element = timeGridRef.current;
|
||||
if (element) element.scrollTop = 7 * HOUR_HEIGHT;
|
||||
}, [viewMode]);
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
// Deaktivierte Konten sollen nicht mehr auswaehlbar sein - zum Aufloesen
|
||||
// bestehender Mentions bleibt die vollstaendige Liste in Verwendung.
|
||||
const activeUsers = useMemo(() => users.filter((item) => item.is_active), [users]);
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const start = startOfDay(parseSqlDateTime(event.starts_at));
|
||||
const end = startOfDay(parseSqlDateTime(event.ends_at));
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue;
|
||||
// Mehrtaegige Termine an jedem betroffenen Tag eintragen. Der Deckel bei
|
||||
// 400 Tagen verhindert, dass ein Tippfehler im Jahr die Ansicht sprengt.
|
||||
for (let day = start, guard = 0; day <= end && guard < 400; day = addDays(day, 1), guard++) {
|
||||
const key = day.toDateString();
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(event);
|
||||
map.set(key, list);
|
||||
}
|
||||
}
|
||||
for (const list of map.values()) list.sort(compareEvents);
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const dayEvents = useMemo(
|
||||
() => (day: Date) => eventsByDay.get(day.toDateString()) ?? [],
|
||||
[eventsByDay]
|
||||
);
|
||||
|
||||
const detailEvent = detailEventId
|
||||
? events.find((event) => event.id === detailEventId) ?? null
|
||||
: null;
|
||||
const detailUser = detailUserId ? users.find((item) => item.id === detailUserId) ?? null : null;
|
||||
const detailLead = detailLeadId ? leads.find((lead) => lead.id === detailLeadId) ?? null : null;
|
||||
|
||||
const canModify = (event: CalendarEvent) => isAdmin || event.created_by === user?.id;
|
||||
|
||||
// Anstehende Termine fuer die Liste rechts: alles, was noch nicht vorbei ist,
|
||||
// der naechste Termin zuerst.
|
||||
const upcoming = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return events
|
||||
.filter((event) => parseSqlDateTime(event.ends_at).getTime() >= now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime() ||
|
||||
a.id - b.id
|
||||
);
|
||||
}, [events]);
|
||||
|
||||
const upcomingGroups = useMemo(() => {
|
||||
const groups: { day: Date; items: CalendarEvent[] }[] = [];
|
||||
for (const event of upcoming) {
|
||||
const day = startOfDay(parseSqlDateTime(event.starts_at));
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && isSameDay(last.day, day)) last.items.push(event);
|
||||
else groups.push({ day, items: [event] });
|
||||
}
|
||||
return groups;
|
||||
}, [upcoming]);
|
||||
|
||||
// Nur Benutzer mit eigenen Terminen kommen in die Legende.
|
||||
const legendUsers = useMemo(() => {
|
||||
const ids = new Set(
|
||||
events.map((event) => event.created_by).filter((id): id is number => id !== null)
|
||||
);
|
||||
return users.filter((item) => ids.has(item.id));
|
||||
}, [events, users]);
|
||||
|
||||
const step = (direction: 1 | -1) =>
|
||||
setCursor((current) => {
|
||||
if (viewMode === 'month') return addMonths(current, direction);
|
||||
if (viewMode === 'week') return addDays(current, direction * 7);
|
||||
return addDays(current, direction);
|
||||
});
|
||||
|
||||
const rangeLabel =
|
||||
viewMode === 'month'
|
||||
? formatMonthYear(cursor)
|
||||
: viewMode === 'week'
|
||||
? (() => {
|
||||
const first = startOfWeek(cursor);
|
||||
const last = addDays(first, 6);
|
||||
return `${first.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
})} – ${last.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}`;
|
||||
})()
|
||||
: formatDayLong(cursor);
|
||||
|
||||
const openCreate = (start: Date) => {
|
||||
setEditEvent(null);
|
||||
setCreateStart(start);
|
||||
};
|
||||
|
||||
const showDay = (day: Date) => {
|
||||
setCursor(startOfDay(day));
|
||||
setViewMode('day');
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!eventToDelete) return;
|
||||
try {
|
||||
await eventsApi.remove(eventToDelete.id);
|
||||
addToast('Termin geloescht.', 'success');
|
||||
setDetailEventId(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setEventToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const gridProps: GridProps = {
|
||||
today,
|
||||
dayEvents,
|
||||
onCreate: openCreate,
|
||||
onOpenEvent: setDetailEventId,
|
||||
onShowDay: showDay,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Steuerleiste */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => step(-1)}
|
||||
aria-label="Zurueck"
|
||||
className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCursor(startOfDay(new Date()))}
|
||||
className="rounded-xl border border-ink-200 bg-surface px-4 py-2 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step(1)}
|
||||
aria-label="Weiter"
|
||||
className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<p className="ml-2 truncate text-sm font-extrabold text-ink-800">{rangeLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 lg:ml-auto">
|
||||
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
|
||||
{VIEW_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.mode}
|
||||
onClick={() => setViewMode(button.mode)}
|
||||
aria-pressed={viewMode === button.mode}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-bold transition ${
|
||||
viewMode === button.mode
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-ink-500 hover:bg-brand-50 hover:text-brand-700'
|
||||
}`}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => openCreate(new Date(new Date().setMinutes(0, 0, 0) + 60 * 60 * 1000))}
|
||||
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Termin hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
{/* Kalender */}
|
||||
<div className="space-y-3">
|
||||
{isLoading && events.length === 0 ? (
|
||||
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
</div>
|
||||
) : viewMode === 'month' ? (
|
||||
<MonthView cursor={cursor} {...gridProps} />
|
||||
) : (
|
||||
<TimeGrid
|
||||
days={viewMode === 'week' ? weekDays(cursor) : [startOfDay(cursor)]}
|
||||
scrollRef={timeGridRef}
|
||||
{...gridProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{legendUsers.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-ink-200 bg-surface px-4 py-3">
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Farben
|
||||
</span>
|
||||
{legendUsers.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setDetailUserId(item.id)}
|
||||
className="flex items-center gap-2 text-xs font-bold text-ink-600 transition hover:text-brand-700"
|
||||
>
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: userColor(item) }}
|
||||
/>
|
||||
{item.name}
|
||||
{item.id === user?.id && <span className="text-ink-300">(ich)</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Anstehende Termine */}
|
||||
<aside className="flex flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface">
|
||||
<div className="shrink-0 border-b border-ink-100 px-4 py-3">
|
||||
<h2 className="text-sm text-ink-900">Naechste Termine</h2>
|
||||
<p className="text-xs text-ink-400">
|
||||
{upcoming.length === 0
|
||||
? 'Keine anstehenden Termine'
|
||||
: `${upcoming.length} ${
|
||||
upcoming.length === 1 ? 'Termin' : 'Termine'
|
||||
} · naechster zuerst`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-slim max-h-[70vh] flex-1 overflow-y-auto p-3">
|
||||
{upcomingGroups.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-10 text-center text-xs font-semibold text-ink-400">
|
||||
Nichts geplant. Lege links im Kalender einen Termin an.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{upcomingGroups.map((group) => (
|
||||
<li key={group.day.toDateString()}>
|
||||
<p className="mb-2 flex items-center gap-2 px-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
{isSameDay(group.day, today)
|
||||
? 'Heute'
|
||||
: isSameDay(group.day, addDays(today, 1))
|
||||
? 'Morgen'
|
||||
: group.day.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
})}
|
||||
<span className="h-px flex-1 bg-ink-100" />
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{group.items.map((event) => (
|
||||
<li key={event.id}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetailEventId(event.id)}
|
||||
onKeyDown={(keyEvent) => {
|
||||
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
|
||||
keyEvent.preventDefault();
|
||||
setDetailEventId(event.id);
|
||||
}
|
||||
}}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-xl border border-ink-200 bg-surface p-3 text-left transition hover:border-brand-300 hover:shadow-card"
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 w-1.5 shrink-0 self-stretch rounded-full"
|
||||
style={{ backgroundColor: eventColor(event) }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-ink-900">{event.title}</p>
|
||||
<p className="text-xs font-semibold text-ink-500">
|
||||
{formatEventRange(event)}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="truncate text-xs text-ink-400">{event.location}</p>
|
||||
)}
|
||||
|
||||
{event.description && (
|
||||
<div className="line-clamp-3 mt-1.5 text-xs text-ink-500">
|
||||
<MentionText
|
||||
text={event.description}
|
||||
leads={leads}
|
||||
users={users}
|
||||
onOpenLead={setDetailLeadId}
|
||||
onOpenUser={setDetailUserId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{event.participants.map((participant) => (
|
||||
<button
|
||||
key={participant.id}
|
||||
type="button"
|
||||
onClick={(clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
setDetailUserId(participant.id);
|
||||
}}
|
||||
className="rounded-full transition hover:brightness-110"
|
||||
>
|
||||
<UserAvatar user={participant} size={24} />
|
||||
</button>
|
||||
))}
|
||||
{event.created_by_name && (
|
||||
<span className="ml-auto truncate text-[10px] font-bold uppercase tracking-wider text-ink-300">
|
||||
{event.created_by_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Dialoge */}
|
||||
{(createStart !== null || editEvent !== null) && (
|
||||
<EventModal
|
||||
event={editEvent}
|
||||
defaultStart={createStart ?? undefined}
|
||||
leads={leads}
|
||||
users={activeUsers}
|
||||
currentUserId={user?.id ?? 0}
|
||||
onClose={() => {
|
||||
setCreateStart(null);
|
||||
setEditEvent(null);
|
||||
}}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailEvent && editEvent === null && (
|
||||
<EventDetailModal
|
||||
event={detailEvent}
|
||||
leads={leads}
|
||||
users={users}
|
||||
canModify={canModify(detailEvent)}
|
||||
onEdit={() => {
|
||||
setEditEvent(detailEvent);
|
||||
setDetailEventId(null);
|
||||
}}
|
||||
onDelete={() => setEventToDelete(detailEvent)}
|
||||
onClose={() => setDetailEventId(null)}
|
||||
onOpenLead={setDetailLeadId}
|
||||
onOpenUser={setDetailUserId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailUser && (
|
||||
<UserDetailModal
|
||||
user={detailUser}
|
||||
leads={leads}
|
||||
events={events}
|
||||
onClose={() => setDetailUserId(null)}
|
||||
onOpenLead={(leadId) => {
|
||||
setDetailUserId(null);
|
||||
setDetailLeadId(leadId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailLead && (
|
||||
<LeadModal
|
||||
lead={detailLead}
|
||||
leads={leads}
|
||||
users={users}
|
||||
connections={connections}
|
||||
onClose={() => setDetailLeadId(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{eventToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Termin loeschen?"
|
||||
message={`"${eventToDelete.title}"${
|
||||
eventToDelete.description
|
||||
? ` – ${mentionPlainText(eventToDelete.description).slice(0, 80)}`
|
||||
: ''
|
||||
} wird fuer alle Benutzer entfernt.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setEventToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { UserAvatar } from '../ui/UserAvatar';
|
||||
import { MentionText } from './mentions';
|
||||
import { formatDayLong, formatEventRange, parseSqlDateTime } from './calendarUtils';
|
||||
import { eventColor, userColor, type AppUser, type CalendarEvent, type Lead } from '../../types';
|
||||
|
||||
interface EventDetailModalProps {
|
||||
event: CalendarEvent;
|
||||
leads: Lead[];
|
||||
users: AppUser[];
|
||||
canModify: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
onOpenLead: (leadId: number) => void;
|
||||
onOpenUser: (userId: number) => void;
|
||||
}
|
||||
|
||||
const Row: React.FC<{ icon: string; children: React.ReactNode }> = ({ icon, children }) => (
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-ink-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d={icon} />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1 text-sm text-ink-700">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Leseansicht eines Termins - hier sind die @-Mentions anklickbar. */
|
||||
export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
event,
|
||||
leads,
|
||||
users,
|
||||
canModify,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClose,
|
||||
onOpenLead,
|
||||
onOpenUser,
|
||||
}) => {
|
||||
const color = eventColor(event);
|
||||
const start = parseSqlDateTime(event.starts_at);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={event.title}
|
||||
subtitle={`${formatDayLong(start)} · ${formatEventRange(event)}`}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
{canModify && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded-xl border border-red-200 px-4 py-3 text-sm font-bold text-red-600 transition hover:bg-red-50"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="flex-1 rounded-xl bg-brand-500 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3">
|
||||
<span className="h-10 w-1.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
{event.created_by !== null && (
|
||||
<UserAvatar
|
||||
user={{
|
||||
id: event.created_by,
|
||||
name: event.created_by_name ?? '?',
|
||||
color: event.created_by_color ?? null,
|
||||
avatar: event.created_by_avatar ?? null,
|
||||
}}
|
||||
size={36}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Angelegt von
|
||||
</p>
|
||||
<p className="truncate text-sm font-bold text-ink-900">
|
||||
{event.created_by_name ?? 'unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Row 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">
|
||||
<span className="font-semibold">{formatDayLong(start)}</span>
|
||||
<span className="text-ink-400"> · {formatEventRange(event)}</span>
|
||||
</Row>
|
||||
|
||||
{event.location && (
|
||||
<Row icon="M17.657 16.657L13.414 20.9a2 2 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0zM15 11a3 3 0 11-6 0 3 3 0 016 0z">
|
||||
{event.location}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row 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 0z">
|
||||
{event.participants.length === 0 ? (
|
||||
<span className="text-ink-400">Keine Teilnehmer eingetragen.</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.participants.map((participant) => {
|
||||
const tint = userColor(participant);
|
||||
return (
|
||||
<button
|
||||
key={participant.id}
|
||||
type="button"
|
||||
onClick={() => onOpenUser(participant.id)}
|
||||
className="flex items-center gap-2 rounded-lg py-1 pl-1 pr-2.5 text-xs font-bold transition hover:brightness-95"
|
||||
style={{ backgroundColor: `${tint}1f`, color: tint }}
|
||||
>
|
||||
<UserAvatar user={participant} size={20} />
|
||||
{participant.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Beschreibung
|
||||
</h3>
|
||||
{event.description ? (
|
||||
<div className="rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm leading-relaxed text-ink-700">
|
||||
<MentionText
|
||||
text={event.description}
|
||||
leads={leads}
|
||||
users={users}
|
||||
onOpenLead={onOpenLead}
|
||||
onOpenUser={onOpenUser}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
|
||||
Keine Beschreibung hinterlegt.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { MentionTextarea, buildCandidates } from './mentions';
|
||||
import {
|
||||
addDays,
|
||||
parseSqlDateTime,
|
||||
startOfDay,
|
||||
toDateInput,
|
||||
toDateTimeInput,
|
||||
toSqlDateTime,
|
||||
} from './calendarUtils';
|
||||
import { useToast } from '../Toast';
|
||||
import { eventsApi } from '../../lib/api';
|
||||
import { userColor, type AppUser, type CalendarEvent, type Lead } from '../../types';
|
||||
|
||||
interface EventModalProps {
|
||||
/** null = neuer Termin */
|
||||
event: CalendarEvent | null;
|
||||
/** Vorbelegung fuer neue Termine, z. B. der angeklickte Tag/Slot. */
|
||||
defaultStart?: Date;
|
||||
leads: Lead[];
|
||||
users: AppUser[];
|
||||
currentUserId: number;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function initialRange(event: CalendarEvent | null, defaultStart?: Date) {
|
||||
if (event) {
|
||||
return { start: parseSqlDateTime(event.starts_at), end: parseSqlDateTime(event.ends_at) };
|
||||
}
|
||||
const start = defaultStart ?? new Date(new Date().setMinutes(0, 0, 0) + HOUR);
|
||||
return { start, end: new Date(start.getTime() + HOUR) };
|
||||
}
|
||||
|
||||
export const EventModal: React.FC<EventModalProps> = ({
|
||||
event,
|
||||
defaultStart,
|
||||
leads,
|
||||
users,
|
||||
currentUserId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const range = useMemo(() => initialRange(event, defaultStart), [event, defaultStart]);
|
||||
|
||||
const [title, setTitle] = useState(event?.title ?? '');
|
||||
const [location, setLocation] = useState(event?.location ?? '');
|
||||
const [description, setDescription] = useState(event?.description ?? '');
|
||||
const [allDay, setAllDay] = useState(event?.all_day ?? false);
|
||||
const [start, setStart] = useState<Date>(range.start);
|
||||
const [end, setEnd] = useState<Date>(range.end);
|
||||
const [participantIds, setParticipantIds] = useState<number[]>(
|
||||
event ? event.participants.map((participant) => participant.id) : [currentUserId]
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const candidates = useMemo(() => buildCandidates(leads, users), [leads, users]);
|
||||
|
||||
// Start verschieben nimmt das Ende mit, solange es nicht davor liegen soll.
|
||||
const handleStartChange = (value: string) => {
|
||||
const next = parseSqlDateTime(value);
|
||||
if (Number.isNaN(next.getTime())) return;
|
||||
const shift = next.getTime() - start.getTime();
|
||||
setStart(next);
|
||||
setEnd(new Date(end.getTime() + shift));
|
||||
};
|
||||
|
||||
const handleEndChange = (value: string) => {
|
||||
const next = parseSqlDateTime(value);
|
||||
if (Number.isNaN(next.getTime())) return;
|
||||
setEnd(next);
|
||||
};
|
||||
|
||||
const toggleParticipant = (id: number) =>
|
||||
setParticipantIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
||||
|
||||
const handleSubmit = async (formEvent: React.FormEvent) => {
|
||||
formEvent.preventDefault();
|
||||
if (!title.trim()) {
|
||||
addToast('Bitte einen Titel angeben.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ganztaegig: der Termin belegt die kompletten gewaehlten Tage.
|
||||
const startsAt = allDay ? startOfDay(start) : start;
|
||||
const endsAt = allDay
|
||||
? new Date(addDays(startOfDay(end), 1).getTime() - 60_000)
|
||||
: end;
|
||||
|
||||
if (endsAt.getTime() < startsAt.getTime()) {
|
||||
addToast('Das Ende darf nicht vor dem Start liegen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
location: location.trim() || null,
|
||||
starts_at: toSqlDateTime(startsAt),
|
||||
ends_at: toSqlDateTime(endsAt),
|
||||
all_day: allDay,
|
||||
participant_ids: participantIds,
|
||||
};
|
||||
|
||||
if (event) {
|
||||
await eventsApi.update(event.id, payload);
|
||||
addToast('Termin aktualisiert.', 'success');
|
||||
} else {
|
||||
await eventsApi.create(payload);
|
||||
addToast('Termin angelegt.', 'success');
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={event ? 'Termin bearbeiten' : 'Neuer Termin'}
|
||||
subtitle={
|
||||
event
|
||||
? `Angelegt von ${event.created_by_name ?? 'unbekannt'}`
|
||||
: 'Sichtbar fuer alle Benutzer im Teamkalender'
|
||||
}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="event-form"
|
||||
disabled={isSaving}
|
||||
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="event-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="event-title">
|
||||
Titel *
|
||||
</label>
|
||||
<input
|
||||
id="event-title"
|
||||
required
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(inputEvent) => setTitle(inputEvent.target.value)}
|
||||
placeholder="z. B. Jour Fixe Vertrieb"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allDay}
|
||||
onChange={(inputEvent) => setAllDay(inputEvent.target.checked)}
|
||||
className="h-4 w-4 accent-brand-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-ink-700">Ganztaegig</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="event-start">
|
||||
Beginn *
|
||||
</label>
|
||||
<input
|
||||
id="event-start"
|
||||
required
|
||||
type={allDay ? 'date' : 'datetime-local'}
|
||||
value={allDay ? toDateInput(start) : toDateTimeInput(start)}
|
||||
onChange={(inputEvent) => handleStartChange(inputEvent.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="event-end">
|
||||
Ende *
|
||||
</label>
|
||||
<input
|
||||
id="event-end"
|
||||
required
|
||||
type={allDay ? 'date' : 'datetime-local'}
|
||||
value={allDay ? toDateInput(end) : toDateTimeInput(end)}
|
||||
onChange={(inputEvent) => handleEndChange(inputEvent.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="event-location">
|
||||
Ort
|
||||
</label>
|
||||
<input
|
||||
id="event-location"
|
||||
value={location}
|
||||
onChange={(inputEvent) => setLocation(inputEvent.target.value)}
|
||||
placeholder="Raum, Adresse oder Videolink"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="event-description">
|
||||
Beschreibung
|
||||
</label>
|
||||
<MentionTextarea
|
||||
id="event-description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
candidates={candidates}
|
||||
rows={5}
|
||||
placeholder="Agenda, Notizen... Mit @ Kontakte und Benutzer markieren."
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-ink-400">
|
||||
<span className="font-bold text-ink-500">@</span> tippen, um einen Kontakt oder Benutzer
|
||||
zu markieren – markierte Personen sind spaeter anklickbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Teilnehmer
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-ink-400">
|
||||
Mehrfachauswahl moeglich. Die Farbe des Termins im Kalender ist die des Erstellers.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{users.map((user) => {
|
||||
const isSelected = participantIds.includes(user.id);
|
||||
const color = userColor(user);
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => toggleParticipant(user.id)}
|
||||
aria-pressed={isSelected}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-bold transition ${
|
||||
isSelected
|
||||
? 'border-transparent text-white shadow-card'
|
||||
: 'border-ink-200 text-ink-600 hover:border-brand-300 hover:text-brand-700'
|
||||
}`}
|
||||
style={isSelected ? { backgroundColor: color } : undefined}
|
||||
>
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="" className="h-5 w-5 shrink-0 rounded-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: isSelected ? '#ffffff' : color }}
|
||||
/>
|
||||
)}
|
||||
{user.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { UserAvatar } from '../ui/UserAvatar';
|
||||
import { formatEventRange, parseSqlDateTime } from './calendarUtils';
|
||||
import {
|
||||
leadFullName,
|
||||
userColor,
|
||||
type AppUser,
|
||||
type CalendarEvent,
|
||||
type Lead,
|
||||
} from '../../types';
|
||||
|
||||
interface UserDetailModalProps {
|
||||
user: AppUser;
|
||||
leads: Lead[];
|
||||
events: CalendarEvent[];
|
||||
onClose: () => void;
|
||||
onOpenLead: (leadId: number) => void;
|
||||
}
|
||||
|
||||
/** Detailfenster fuer einen im Kalender markierten Benutzer. */
|
||||
export const UserDetailModal: React.FC<UserDetailModalProps> = ({
|
||||
user,
|
||||
leads,
|
||||
events,
|
||||
onClose,
|
||||
onOpenLead,
|
||||
}) => {
|
||||
const color = userColor(user);
|
||||
|
||||
const ownedLeads = useMemo(
|
||||
() =>
|
||||
leads
|
||||
.filter((lead) => lead.owner_id === user.id)
|
||||
.sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de')),
|
||||
[leads, user.id]
|
||||
);
|
||||
|
||||
// Die naechsten Termine, an denen der Benutzer beteiligt ist (Ersteller
|
||||
// oder Teilnehmer).
|
||||
const upcoming = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return events
|
||||
.filter(
|
||||
(event) =>
|
||||
(event.created_by === user.id ||
|
||||
event.participants.some((participant) => participant.id === user.id)) &&
|
||||
parseSqlDateTime(event.ends_at).getTime() >= now
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime()
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [events, user.id]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={user.name}
|
||||
subtitle={`@${user.username} · ${user.role === 'admin' ? 'Administrator' : 'Benutzer'}`}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<UserAvatar user={user} size={64} shape="rounded" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-extrabold text-ink-900">{user.name}</p>
|
||||
<p className="truncate text-sm text-ink-400">{user.email || 'Keine E-Mail hinterlegt'}</p>
|
||||
<p className="mt-1 flex items-center gap-2 text-xs font-bold text-ink-400">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
Kalenderfarbe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Naechste Termine
|
||||
</h3>
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-5 text-center text-xs font-semibold text-ink-400">
|
||||
Keine anstehenden Termine.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{upcoming.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="rounded-xl border border-ink-200 bg-surface px-3 py-2.5 text-sm"
|
||||
>
|
||||
<p className="truncate font-bold text-ink-900">{event.title}</p>
|
||||
<p className="text-xs text-ink-400">
|
||||
{parseSqlDateTime(event.starts_at).toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
})}{' '}
|
||||
· {formatEventRange(event)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Zustaendig fuer {ownedLeads.length} {ownedLeads.length === 1 ? 'Kontakt' : 'Kontakte'}
|
||||
</h3>
|
||||
{ownedLeads.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-5 text-center text-xs font-semibold text-ink-400">
|
||||
Aktuell keine Kontakte zugeordnet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="scrollbar-slim max-h-48 space-y-2 overflow-y-auto">
|
||||
{ownedLeads.map((lead) => (
|
||||
<li key={lead.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenLead(lead.id)}
|
||||
className="w-full rounded-xl border border-ink-200 bg-surface px-3 py-2 text-left transition hover:border-brand-300 hover:bg-brand-50/40"
|
||||
>
|
||||
<span className="block truncate text-sm font-bold text-ink-900">
|
||||
{leadFullName(lead)}
|
||||
</span>
|
||||
{lead.company && (
|
||||
<span className="block truncate text-xs text-ink-400">{lead.company}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { CalendarEvent } from '../../types';
|
||||
|
||||
/**
|
||||
* Das Backend liefert DATETIME als String (`dateStrings: true`), also lokale
|
||||
* Wandzeit ohne Zeitzone. `new Date('2026-08-04 10:00:00')` ist nicht in allen
|
||||
* Browsern gueltig - deshalb wird hier von Hand geparst. Das haelt Termine
|
||||
* unabhaengig von der Zeitzone des Clients an der gespeicherten Uhrzeit.
|
||||
*/
|
||||
export function parseSqlDateTime(value: string): Date {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value ?? '');
|
||||
if (!match) return new Date(NaN);
|
||||
const [, year, month, day, hour = '0', minute = '0', second = '0'] = match;
|
||||
return new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second)
|
||||
);
|
||||
}
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
|
||||
/** `YYYY-MM-DD HH:MM:SS` - das Format, das das Backend erwartet. */
|
||||
export function toSqlDateTime(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}:00`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DDTHH:MM` fuer <input type="datetime-local"> */
|
||||
export function toDateTimeInput(date: Date): string {
|
||||
return `${toDateInput(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` fuer <input type="date"> */
|
||||
export function toDateInput(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function addMonths(date: Date, months: number): Date {
|
||||
// Auf den Ersten setzen, damit der 31. nicht in den Folgemonat rutscht.
|
||||
return new Date(date.getFullYear(), date.getMonth() + months, 1);
|
||||
}
|
||||
|
||||
/** Wochenstart Montag - deutsche Konvention. */
|
||||
export function startOfWeek(date: Date): Date {
|
||||
const start = startOfDay(date);
|
||||
const shift = (start.getDay() + 6) % 7;
|
||||
return addDays(start, -shift);
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
|
||||
|
||||
/**
|
||||
* 6 Wochen ab dem Montag vor dem Monatsersten - feste Hoehe, damit das Raster
|
||||
* beim Blaettern nicht springt.
|
||||
*/
|
||||
export function monthMatrix(month: Date): Date[] {
|
||||
const first = startOfWeek(new Date(month.getFullYear(), month.getMonth(), 1));
|
||||
return Array.from({ length: 42 }, (_, index) => addDays(first, index));
|
||||
}
|
||||
|
||||
export function weekDays(date: Date): Date[] {
|
||||
const first = startOfWeek(date);
|
||||
return Array.from({ length: 7 }, (_, index) => addDays(first, index));
|
||||
}
|
||||
|
||||
export function formatTime(date: Date): string {
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatDayLong(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMonthYear(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
/** Mindesthoehe eines Termins im Zeitraster, sonst ist er nicht lesbar. */
|
||||
const MIN_SLOT_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface PositionedEvent {
|
||||
event: CalendarEvent;
|
||||
topPercent: number;
|
||||
heightPercent: number;
|
||||
leftPercent: number;
|
||||
widthPercent: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positioniert die Termine eines Tages im Zeitraster. Ueberlappende Termine
|
||||
* werden in Spalten aufgeteilt, damit keiner verdeckt wird; Prozentwerte
|
||||
* beziehen sich auf die 24-Stunden-Spalte.
|
||||
*/
|
||||
export function positionEvents(events: CalendarEvent[], day: Date): PositionedEvent[] {
|
||||
const dayStart = startOfDay(day).getTime();
|
||||
const dayEnd = dayStart + DAY_MS;
|
||||
|
||||
const items = events
|
||||
.map((event) => {
|
||||
const rawStart = parseSqlDateTime(event.starts_at).getTime();
|
||||
const rawEnd = parseSqlDateTime(event.ends_at).getTime();
|
||||
const start = Math.max(rawStart, dayStart);
|
||||
const end = Math.min(Math.max(rawEnd, start + MIN_SLOT_MS), dayEnd);
|
||||
return { event, start, end, column: 0 };
|
||||
})
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
|
||||
const positioned: PositionedEvent[] = [];
|
||||
|
||||
// Ein Cluster ist eine Kette sich beruehrender Termine; nur innerhalb eines
|
||||
// Clusters muss die Breite geteilt werden.
|
||||
const flush = (cluster: typeof items) => {
|
||||
if (cluster.length === 0) return;
|
||||
const columnEnds: number[] = [];
|
||||
for (const item of cluster) {
|
||||
let column = columnEnds.findIndex((end) => end <= item.start);
|
||||
if (column === -1) column = columnEnds.length;
|
||||
columnEnds[column] = item.end;
|
||||
item.column = column;
|
||||
}
|
||||
const columns = columnEnds.length;
|
||||
for (const item of cluster) {
|
||||
positioned.push({
|
||||
event: item.event,
|
||||
topPercent: ((item.start - dayStart) / DAY_MS) * 100,
|
||||
heightPercent: ((item.end - item.start) / DAY_MS) * 100,
|
||||
leftPercent: (item.column / columns) * 100,
|
||||
widthPercent: 100 / columns,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let cluster: typeof items = [];
|
||||
let clusterEnd = -Infinity;
|
||||
for (const item of items) {
|
||||
if (cluster.length > 0 && item.start >= clusterEnd) {
|
||||
flush(cluster);
|
||||
cluster = [];
|
||||
clusterEnd = -Infinity;
|
||||
}
|
||||
cluster.push(item);
|
||||
clusterEnd = Math.max(clusterEnd, item.end);
|
||||
}
|
||||
flush(cluster);
|
||||
|
||||
return positioned;
|
||||
}
|
||||
|
||||
/** Sortierung im Raster: ganztaegig zuerst, danach nach Startzeit. */
|
||||
export function compareEvents(a: CalendarEvent, b: CalendarEvent): number {
|
||||
if (a.all_day !== b.all_day) return a.all_day ? -1 : 1;
|
||||
const diff = parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime();
|
||||
return diff !== 0 ? diff : a.id - b.id;
|
||||
}
|
||||
|
||||
/** Zeitspanne als Text fuer Karten und Listen. */
|
||||
export function formatEventRange(event: CalendarEvent): string {
|
||||
const start = parseSqlDateTime(event.starts_at);
|
||||
const end = parseSqlDateTime(event.ends_at);
|
||||
|
||||
if (event.all_day) {
|
||||
return isSameDay(start, end)
|
||||
? 'Ganztaegig'
|
||||
: `Ganztaegig · bis ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`;
|
||||
}
|
||||
if (isSameDay(start, end)) return `${formatTime(start)} – ${formatTime(end)}`;
|
||||
return `${formatTime(start)} – ${end.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
})} ${formatTime(end)}`;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
leadFullName,
|
||||
userColor,
|
||||
type AppUser,
|
||||
type Lead,
|
||||
} from '../../types';
|
||||
|
||||
/**
|
||||
* Mentions werden als Token im Text gespeichert: `@[Anzeigename](lead:12)`
|
||||
* bzw. `@[Anzeigename](user:3)`. Der Name steht mit im Token, damit der Text
|
||||
* auch ohne geladene Listen lesbar bleibt; beim Rendern gewinnt aber der
|
||||
* aktuelle Name aus den Daten, falls die Person noch existiert.
|
||||
*/
|
||||
const MENTION_TOKEN = /@\[([^\]\n]+)\]\((lead|user):(\d+)\)/g;
|
||||
|
||||
export type MentionKind = 'lead' | 'user';
|
||||
|
||||
export interface MentionCandidate {
|
||||
kind: MentionKind;
|
||||
id: number;
|
||||
label: string;
|
||||
hint: string;
|
||||
color: string;
|
||||
/** Foto des Kontakts bzw. Benutzers, falls hinterlegt. */
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
type Segment =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'mention'; kind: MentionKind; id: number; label: string };
|
||||
|
||||
export function parseMentions(text: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
// Der Regex ist global, also vor jedem Durchlauf zuruecksetzen.
|
||||
MENTION_TOKEN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = MENTION_TOKEN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
segments.push({
|
||||
type: 'mention',
|
||||
label: match[1],
|
||||
kind: match[2] as MentionKind,
|
||||
id: Number(match[3]),
|
||||
});
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) segments.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Text ohne Token-Syntax - fuer Vorschauen, Tooltips und Suche. */
|
||||
export function mentionPlainText(text: string): string {
|
||||
return parseMentions(text)
|
||||
.map((segment) => (segment.type === 'text' ? segment.value : `@${segment.label}`))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function buildCandidates(leads: Lead[], users: AppUser[]): MentionCandidate[] {
|
||||
const fromUsers: MentionCandidate[] = users.map((user) => ({
|
||||
kind: 'user',
|
||||
id: user.id,
|
||||
label: user.name,
|
||||
hint: `Benutzer · @${user.username}`,
|
||||
color: userColor(user),
|
||||
avatar: user.avatar ?? null,
|
||||
}));
|
||||
|
||||
const fromLeads: MentionCandidate[] = leads.map((lead) => ({
|
||||
kind: 'lead',
|
||||
id: lead.id,
|
||||
label: leadFullName(lead),
|
||||
hint: [lead.company, lead.position].filter(Boolean).join(' · ') || 'Kontakt',
|
||||
color: '#6e6e6e',
|
||||
avatar: lead.avatar,
|
||||
}));
|
||||
|
||||
// Benutzer zuerst: im Teamkalender werden meist Kollegen markiert.
|
||||
return [...fromUsers, ...fromLeads];
|
||||
}
|
||||
|
||||
export function mentionToken(candidate: MentionCandidate): string {
|
||||
// Eckige Klammern im Namen wuerden das Token zerlegen.
|
||||
const label = candidate.label.replace(/[\[\]()]/g, '').trim() || 'Unbenannt';
|
||||
return `@[${label}](${candidate.kind}:${candidate.id})`;
|
||||
}
|
||||
|
||||
// ---------- Anzeige ----------
|
||||
|
||||
interface MentionTextProps {
|
||||
text: string;
|
||||
leads: Lead[];
|
||||
users: AppUser[];
|
||||
onOpenLead?: (leadId: number) => void;
|
||||
onOpenUser?: (userId: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Rendert Text mit klickbaren Mentions. Zeilenumbrueche bleiben erhalten. */
|
||||
export const MentionText: React.FC<MentionTextProps> = ({
|
||||
text,
|
||||
leads,
|
||||
users,
|
||||
onOpenLead,
|
||||
onOpenUser,
|
||||
className = '',
|
||||
}) => {
|
||||
const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]);
|
||||
const userById = useMemo(() => new Map(users.map((user) => [user.id, user])), [users]);
|
||||
|
||||
return (
|
||||
<span className={`whitespace-pre-wrap break-words ${className}`}>
|
||||
{parseMentions(text).map((segment, index) => {
|
||||
if (segment.type === 'text') return <React.Fragment key={index}>{segment.value}</React.Fragment>;
|
||||
|
||||
const lead = segment.kind === 'lead' ? leadById.get(segment.id) : undefined;
|
||||
const user = segment.kind === 'user' ? userById.get(segment.id) : undefined;
|
||||
const exists = Boolean(lead || user);
|
||||
const label = lead ? leadFullName(lead) : user ? user.name : segment.label;
|
||||
|
||||
if (!exists) {
|
||||
// Person gibt es nicht mehr (oder ist deaktiviert): nur noch Text.
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
title="Diese Person ist nicht mehr verfuegbar."
|
||||
className="rounded-md bg-ink-100 px-1.5 py-0.5 text-xs font-bold text-ink-400"
|
||||
>
|
||||
@{segment.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const tint = user ? userColor(user) : '#6ba04a';
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (segment.kind === 'lead') onOpenLead?.(segment.id);
|
||||
else onOpenUser?.(segment.id);
|
||||
}}
|
||||
title={segment.kind === 'lead' ? 'Kontakt oeffnen' : 'Benutzer anzeigen'}
|
||||
className="mx-0.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 align-baseline text-xs font-bold transition hover:brightness-95"
|
||||
style={{ backgroundColor: `${tint}1f`, color: tint }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tint }}
|
||||
/>
|
||||
@{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- Eingabe ----------
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
candidates: MentionCandidate[];
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
/** Was steht direkt vor dem Cursor gerade nach einem @? */
|
||||
function activeQuery(text: string, caret: number): { start: number; query: string } | null {
|
||||
const before = text.slice(0, caret);
|
||||
// @ am Wortanfang, danach keine Leerzeichen und kein weiteres @
|
||||
const match = /(^|[\s(,;])@([^\s@\[\]()]{0,40})$/.exec(before);
|
||||
if (!match) return null;
|
||||
return { start: caret - match[2].length - 1, query: match[2] };
|
||||
}
|
||||
|
||||
export const MentionTextarea: React.FC<MentionTextareaProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
candidates,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
className = '',
|
||||
id,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [query, setQuery] = useState<{ start: number; query: string } | null>(null);
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!query) return [];
|
||||
const needle = query.query.toLowerCase();
|
||||
const matches = needle
|
||||
? candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.label.toLowerCase().includes(needle) ||
|
||||
candidate.hint.toLowerCase().includes(needle)
|
||||
)
|
||||
: candidates;
|
||||
return matches.slice(0, MAX_SUGGESTIONS);
|
||||
}, [candidates, query]);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlighted(0);
|
||||
}, [query?.query]);
|
||||
|
||||
const isOpen = query !== null && suggestions.length > 0;
|
||||
|
||||
const syncQuery = (text: string, caret: number) => setQuery(activeQuery(text, caret));
|
||||
|
||||
const insert = (candidate: MentionCandidate) => {
|
||||
if (!query) return;
|
||||
const token = `${mentionToken(candidate)} `;
|
||||
const next = value.slice(0, query.start) + token + value.slice(query.start + 1 + query.query.length);
|
||||
onChange(next);
|
||||
setQuery(null);
|
||||
|
||||
// Cursor hinter das eingefuegte Token setzen.
|
||||
const caret = query.start + token.length;
|
||||
requestAnimationFrame(() => {
|
||||
const element = textareaRef.current;
|
||||
if (!element) return;
|
||||
element.focus();
|
||||
element.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setHighlighted((index) => (index + 1) % suggestions.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setHighlighted((index) => (index - 1 + suggestions.length) % suggestions.length);
|
||||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
insert(suggestions[highlighted]);
|
||||
} else if (event.key === 'Escape') {
|
||||
// Nicht das Modal schliessen, nur die Vorschlagsliste.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setQuery(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
rows={rows}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
syncQuery(event.target.value, event.target.selectionStart ?? 0);
|
||||
}}
|
||||
onClick={(event) =>
|
||||
syncQuery(event.currentTarget.value, event.currentTarget.selectionStart ?? 0)
|
||||
}
|
||||
onKeyUp={(event) => {
|
||||
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
||||
syncQuery(event.currentTarget.value, event.currentTarget.selectionStart ?? 0);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => window.setTimeout(() => setQuery(null), 120)}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="scrollbar-slim absolute left-0 right-0 z-20 mt-1 max-h-64 overflow-y-auto rounded-xl border border-ink-200 bg-surface py-1 shadow-lift"
|
||||
>
|
||||
{suggestions.map((candidate, index) => (
|
||||
<li key={`${candidate.kind}-${candidate.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlighted}
|
||||
// MouseDown statt Click: das Blur der Textarea darf die Liste
|
||||
// nicht vorher schliessen.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
insert(candidate);
|
||||
}}
|
||||
onMouseEnter={() => setHighlighted(index)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition ${
|
||||
index === highlighted ? 'bg-brand-50' : 'hover:bg-ink-50'
|
||||
}`}
|
||||
>
|
||||
{candidate.avatar ? (
|
||||
<img
|
||||
src={candidate.avatar}
|
||||
alt=""
|
||||
className="h-7 w-7 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[10px] font-extrabold text-white"
|
||||
style={{ backgroundColor: candidate.color }}
|
||||
>
|
||||
{candidate.kind === 'user' ? 'U' : 'K'}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold text-ink-900">
|
||||
{candidate.label}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-ink-400">{candidate.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import { Avatar, StatusBadge } from './leadUi';
|
||||
import { leadFullName, leadInitials, type Lead } from '../../types';
|
||||
|
||||
interface LeadGridProps {
|
||||
leads: Lead[];
|
||||
connectionCount: (leadId: number) => number;
|
||||
onEdit: (lead: Lead) => void;
|
||||
onDelete: (lead: Lead) => void;
|
||||
}
|
||||
|
||||
const IconLink: React.FC<{ href: string; title: string; path: string; external?: boolean }> = ({
|
||||
href,
|
||||
title,
|
||||
path,
|
||||
external,
|
||||
}) => (
|
||||
<a
|
||||
href={href}
|
||||
title={title}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="rounded-lg p-1.5 text-ink-400 transition hover:bg-brand-50 hover:text-brand-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
|
||||
const MAIL_PATH =
|
||||
'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';
|
||||
const PHONE_PATH =
|
||||
'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z';
|
||||
const GLOBE_PATH =
|
||||
'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-9';
|
||||
|
||||
export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEdit, onDelete }) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{leads.map((lead) => {
|
||||
const links = connectionCount(lead.id);
|
||||
return (
|
||||
<article
|
||||
key={lead.id}
|
||||
onClick={() => onEdit(lead)}
|
||||
className="group relative flex cursor-pointer flex-col rounded-2xl border border-ink-200 bg-surface p-5 shadow-card transition hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift"
|
||||
>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(lead);
|
||||
}}
|
||||
className="absolute right-3 top-3 rounded-lg p-2 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
|
||||
aria-label={`${leadFullName(lead)} loeschen`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar src={lead.avatar} initials={leadInitials(lead)} />
|
||||
<div className="min-w-0 flex-1 pr-6">
|
||||
<h3 className="truncate text-base text-ink-900 transition group-hover:text-brand-700">
|
||||
{leadFullName(lead)}
|
||||
</h3>
|
||||
{lead.position && <p className="truncate text-xs font-semibold text-ink-500">{lead.position}</p>}
|
||||
{lead.company && <p className="truncate text-xs text-ink-400">{lead.company}</p>}
|
||||
<div className="mt-2">
|
||||
<StatusBadge status={lead.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lead.notes && (
|
||||
<p className="line-clamp-3 mt-4 rounded-xl bg-ink-50 px-3 py-2.5 text-xs leading-relaxed text-ink-500">
|
||||
{lead.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lead.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{lead.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-md border border-brand-100 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{lead.tags.length > 4 && (
|
||||
<span className="self-center text-[10px] font-bold text-ink-400">
|
||||
+{lead.tags.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2 border-t border-ink-100 pt-4">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{lead.email && <IconLink href={`mailto:${lead.email}`} title={lead.email} path={MAIL_PATH} />}
|
||||
{(lead.phone || lead.mobile) && (
|
||||
<IconLink
|
||||
href={`tel:${lead.phone || lead.mobile}`}
|
||||
title={lead.phone || lead.mobile || ''}
|
||||
path={PHONE_PATH}
|
||||
/>
|
||||
)}
|
||||
{lead.website && <IconLink href={lead.website} title={lead.website} path={GLOBE_PATH} external />}
|
||||
{lead.linkedin && (
|
||||
<a
|
||||
href={lead.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title="LinkedIn"
|
||||
className="rounded-lg p-1.5 text-ink-400 transition hover:bg-sky-50 hover:text-sky-600"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-400"
|
||||
title={`${links} ${links === 1 ? 'Verbindung' : 'Verbindungen'}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 01-5.656-5.656l1.5-1.5m4.5-4.5l1.5-1.5a4 4 0 015.656 5.656l-3 3"
|
||||
/>
|
||||
</svg>
|
||||
{links}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{lead.created_by_name && (
|
||||
<p className="mt-2 text-[10px] font-semibold text-ink-300">
|
||||
Angelegt von {lead.created_by_name}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { Avatar, StatusBadge } from './leadUi';
|
||||
import { leadFullName, leadInitials, type Lead } from '../../types';
|
||||
|
||||
export type SortKey = 'name' | 'company' | 'status' | 'connections' | 'updated_at';
|
||||
|
||||
export interface SortConfig {
|
||||
key: SortKey;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface LeadListProps {
|
||||
leads: Lead[];
|
||||
connectionCount: (leadId: number) => number;
|
||||
sortConfig: SortConfig;
|
||||
onSort: (key: SortKey) => void;
|
||||
onEdit: (lead: Lead) => void;
|
||||
onDelete: (lead: Lead) => void;
|
||||
}
|
||||
|
||||
const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [
|
||||
{ key: 'name', label: 'Kontakt' },
|
||||
{ key: 'company', label: 'Unternehmen' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: null, label: 'Erreichbarkeit' },
|
||||
{ key: 'connections', label: 'Verbindungen' },
|
||||
{ key: null, label: 'Angelegt von' },
|
||||
{ key: 'updated_at', label: 'Geaendert' },
|
||||
];
|
||||
|
||||
export const LeadList: React.FC<LeadListProps> = ({
|
||||
leads,
|
||||
connectionCount,
|
||||
sortConfig,
|
||||
onSort,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
|
||||
<div className="scrollbar-slim overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 bg-ink-50">
|
||||
{COLUMNS.map((column) => (
|
||||
<th
|
||||
key={column.label}
|
||||
onClick={column.key ? () => onSort(column.key!) : undefined}
|
||||
className={`px-5 py-3.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-400 ${
|
||||
column.key ? 'cursor-pointer select-none transition hover:text-brand-600' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{column.label}
|
||||
{column.key && sortConfig.key === column.key && (
|
||||
<svg
|
||||
className={`h-3 w-3 transition-transform ${
|
||||
sortConfig.direction === 'desc' ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-5 py-3.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{leads.map((lead) => (
|
||||
<tr
|
||||
key={lead.id}
|
||||
onClick={() => onEdit(lead)}
|
||||
className="group cursor-pointer transition hover:bg-brand-50/40"
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={lead.avatar} initials={leadInitials(lead)} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-ink-900 transition group-hover:text-brand-700">
|
||||
{leadFullName(lead)}
|
||||
</p>
|
||||
{lead.position && <p className="truncate text-xs text-ink-400">{lead.position}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<p className="truncate text-sm text-ink-700">{lead.company || '—'}</p>
|
||||
{lead.city && <p className="truncate text-xs text-ink-400">{lead.city}</p>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<StatusBadge status={lead.status} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="space-y-0.5 text-xs">
|
||||
{lead.email ? (
|
||||
<a
|
||||
href={`mailto:${lead.email}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="block truncate text-ink-600 transition hover:text-brand-600"
|
||||
>
|
||||
{lead.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-ink-300">keine E-Mail</span>
|
||||
)}
|
||||
{(lead.phone || lead.mobile) && (
|
||||
<span className="block truncate text-ink-400">{lead.phone || lead.mobile}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex min-w-7 justify-center rounded-md bg-ink-100 px-2 py-0.5 text-xs font-extrabold text-ink-600">
|
||||
{connectionCount(lead.id)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<p className="truncate text-xs text-ink-500">{lead.created_by_name || '—'}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<p className="whitespace-nowrap text-xs text-ink-400">
|
||||
{lead.updated_at ? new Date(lead.updated_at).toLocaleDateString('de-DE') : '—'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(lead);
|
||||
}}
|
||||
className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
|
||||
aria-label={`${leadFullName(lead)} loeschen`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,508 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { Avatar, inputClass, labelClass } from './leadUi';
|
||||
import { connectionsApi, leadsApi } from '../../lib/api';
|
||||
import { useToast } from '../Toast';
|
||||
import {
|
||||
CONNECTION_TYPES,
|
||||
LEAD_STATUSES,
|
||||
leadFullName,
|
||||
leadInitials,
|
||||
type AppUser,
|
||||
type ConnectionType,
|
||||
type Lead,
|
||||
type LeadConnection,
|
||||
type LeadStatus,
|
||||
} from '../../types';
|
||||
|
||||
interface LeadModalProps {
|
||||
lead: Lead | null;
|
||||
leads: Lead[];
|
||||
users: AppUser[];
|
||||
connections: LeadConnection[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
salutation: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company: string;
|
||||
position: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mobile: string;
|
||||
linkedin: string;
|
||||
website: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
status: LeadStatus;
|
||||
source: string;
|
||||
notes: string;
|
||||
avatar: string;
|
||||
owner_id: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
function toFormState(lead: Lead | null, fallbackOwnerId: number | null): FormState {
|
||||
return {
|
||||
salutation: lead?.salutation ?? '',
|
||||
first_name: lead?.first_name ?? '',
|
||||
last_name: lead?.last_name ?? '',
|
||||
company: lead?.company ?? '',
|
||||
position: lead?.position ?? '',
|
||||
email: lead?.email ?? '',
|
||||
phone: lead?.phone ?? '',
|
||||
mobile: lead?.mobile ?? '',
|
||||
linkedin: lead?.linkedin ?? '',
|
||||
website: lead?.website ?? '',
|
||||
street: lead?.street ?? '',
|
||||
zip: lead?.zip ?? '',
|
||||
city: lead?.city ?? '',
|
||||
country: lead?.country ?? '',
|
||||
status: lead?.status ?? 'Neu',
|
||||
source: lead?.source ?? '',
|
||||
notes: lead?.notes ?? '',
|
||||
avatar: lead?.avatar ?? '',
|
||||
owner_id: String(lead?.owner_id ?? fallbackOwnerId ?? ''),
|
||||
tags: lead?.tags ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
lead,
|
||||
leads,
|
||||
users,
|
||||
connections,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const [form, setForm] = useState<FormState>(() => toFormState(lead, users[0]?.id ?? null));
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Verbindungen dieses Kontakts
|
||||
const [newConnectionId, setNewConnectionId] = useState('');
|
||||
const [newConnectionType, setNewConnectionType] = useState<ConnectionType>('Netzwerk');
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
|
||||
const leadById = useMemo(() => new Map(leads.map((l) => [l.id, l])), [leads]);
|
||||
|
||||
const ownConnections = useMemo(() => {
|
||||
if (!lead) return [];
|
||||
return connections
|
||||
.filter((c) => c.from_lead_id === lead.id || c.to_lead_id === lead.id)
|
||||
.map((c) => {
|
||||
const otherId = c.from_lead_id === lead.id ? c.to_lead_id : c.from_lead_id;
|
||||
return { connection: c, other: leadById.get(otherId) ?? null };
|
||||
})
|
||||
.filter((entry) => entry.other !== null);
|
||||
}, [lead, connections, leadById]);
|
||||
|
||||
const linkableLeads = useMemo(() => {
|
||||
if (!lead) return [];
|
||||
const linked = new Set(ownConnections.map((entry) => entry.other!.id));
|
||||
return leads
|
||||
.filter((candidate) => candidate.id !== lead.id && !linked.has(candidate.id))
|
||||
.sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de'));
|
||||
}, [lead, leads, ownConnections]);
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const addTags = () => {
|
||||
const parsed = tagInput
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t && !form.tags.includes(t));
|
||||
if (parsed.length > 0) set('tags', [...form.tags, ...parsed]);
|
||||
setTagInput('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.first_name.trim() || !form.last_name.trim()) {
|
||||
addToast('Vorname und Nachname sind erforderlich.', 'error');
|
||||
return;
|
||||
}
|
||||
if (form.linkedin && !form.linkedin.includes('linkedin.com/')) {
|
||||
addToast('Bitte eine gueltige LinkedIn-URL angeben.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
owner_id: form.owner_id === '' ? null : Number(form.owner_id),
|
||||
avatar: form.avatar || null,
|
||||
};
|
||||
if (lead) {
|
||||
await leadsApi.update(lead.id, payload);
|
||||
addToast('Kontakt aktualisiert.', 'success');
|
||||
} else {
|
||||
await leadsApi.create(payload);
|
||||
addToast('Kontakt angelegt.', 'success');
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddConnection = async () => {
|
||||
if (!lead || !newConnectionId) return;
|
||||
setIsLinking(true);
|
||||
try {
|
||||
await connectionsApi.create({
|
||||
from_lead_id: lead.id,
|
||||
to_lead_id: Number(newConnectionId),
|
||||
type: newConnectionType,
|
||||
});
|
||||
addToast('Verbindung angelegt.', 'success');
|
||||
setNewConnectionId('');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsLinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConnection = async (id: number) => {
|
||||
try {
|
||||
await connectionsApi.remove(id);
|
||||
addToast('Verbindung entfernt.', 'success');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={lead ? leadFullName(lead) : 'Neuer Kontakt'}
|
||||
subtitle={
|
||||
lead
|
||||
? `Angelegt von ${lead.created_by_name ?? 'unbekannt'}${
|
||||
lead.created_at ? ` am ${new Date(lead.created_at).toLocaleDateString('de-DE')}` : ''
|
||||
}`
|
||||
: 'Pflege einen neuen Lead-Kontakt ein'
|
||||
}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="lead-form"
|
||||
disabled={isSaving}
|
||||
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="lead-form" onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Foto + Kerndaten */}
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
<AvatarPicker
|
||||
value={form.avatar}
|
||||
onChange={(value) => set('avatar', value)}
|
||||
initials={leadInitials({ first_name: form.first_name, last_name: form.last_name })}
|
||||
/>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Anrede</label>
|
||||
<select value={form.salutation} onChange={(e) => set('salutation', e.target.value)} className={inputClass}>
|
||||
<option value="">—</option>
|
||||
<option value="Frau">Frau</option>
|
||||
<option value="Herr">Herr</option>
|
||||
<option value="Divers">Divers</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set('status', e.target.value as LeadStatus)}
|
||||
className={inputClass}
|
||||
>
|
||||
{LEAD_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Vorname *</label>
|
||||
<input required value={form.first_name} onChange={(e) => set('first_name', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Nachname *</label>
|
||||
<input required value={form.last_name} onChange={(e) => set('last_name', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Unternehmen</label>
|
||||
<input value={form.company} onChange={(e) => set('company', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Position</label>
|
||||
<input value={form.position} onChange={(e) => set('position', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kontaktdaten */}
|
||||
<section>
|
||||
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Kontaktdaten
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>E-Mail</label>
|
||||
<input type="email" value={form.email} onChange={(e) => set('email', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Telefon</label>
|
||||
<input value={form.phone} onChange={(e) => set('phone', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Mobil</label>
|
||||
<input value={form.mobile} onChange={(e) => set('mobile', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>LinkedIn</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://linkedin.com/in/..."
|
||||
value={form.linkedin}
|
||||
onChange={(e) => set('linkedin', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Website</label>
|
||||
<input type="url" value={form.website} onChange={(e) => set('website', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Adresse */}
|
||||
<section>
|
||||
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">Adresse</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-6">
|
||||
<label className={labelClass}>Strasse und Hausnummer</label>
|
||||
<input value={form.street} onChange={(e) => set('street', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<label className={labelClass}>PLZ</label>
|
||||
<input value={form.zip} onChange={(e) => set('zip', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className={labelClass}>Ort</label>
|
||||
<input value={form.city} onChange={(e) => set('city', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Land</label>
|
||||
<input value={form.country} onChange={(e) => set('country', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Einordnung */}
|
||||
<section>
|
||||
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Einordnung
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Quelle</label>
|
||||
<input
|
||||
placeholder="z. B. Messe, Empfehlung, LinkedIn"
|
||||
value={form.source}
|
||||
onChange={(e) => set('source', e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Zustaendig</label>
|
||||
<select value={form.owner_id} onChange={(e) => set('owner_id', e.target.value)} className={inputClass}>
|
||||
<option value="">Niemand</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className={labelClass}>Tags</label>
|
||||
{form.tags.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{form.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-bold text-white"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('tags', form.tags.filter((t) => t !== tag))}
|
||||
className="transition hover:text-white/70"
|
||||
aria-label={`Tag ${tag} entfernen`}
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTags();
|
||||
}
|
||||
}}
|
||||
placeholder="Mit Komma trennen, z. B. S/4HANA, M365"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTags}
|
||||
className="shrink-0 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className={labelClass}>Notizen</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Gespraechsnotizen, naechste Schritte, Hintergrund..."
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Verbindungen */}
|
||||
<section>
|
||||
<h3 className="mb-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Verbindungen
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-ink-400">
|
||||
Wer steht mit diesem Kontakt in Verbindung? Diese Kanten erscheinen in der Netzwerkansicht.
|
||||
</p>
|
||||
|
||||
{!lead ? (
|
||||
<div className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
|
||||
Verbindungen koennen gepflegt werden, sobald der Kontakt gespeichert ist.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{ownConnections.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
|
||||
Noch keine Verbindungen hinterlegt.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{ownConnections.map(({ connection, other }) => (
|
||||
<li
|
||||
key={connection.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-ink-200 bg-ink-50/50 px-3 py-2.5"
|
||||
>
|
||||
<Avatar src={other!.avatar} initials={leadInitials(other!)} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-ink-900">{leadFullName(other!)}</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{connection.type}
|
||||
{other!.company ? ` · ${other!.company}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveConnection(connection.id)}
|
||||
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
aria-label="Verbindung entfernen"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-ink-200 p-3 sm:flex-row">
|
||||
<select
|
||||
value={newConnectionId}
|
||||
onChange={(e) => setNewConnectionId(e.target.value)}
|
||||
className={`${inputClass} sm:flex-[2]`}
|
||||
>
|
||||
<option value="">Kontakt auswaehlen...</option>
|
||||
{linkableLeads.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{leadFullName(candidate)}
|
||||
{candidate.company ? ` (${candidate.company})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={newConnectionType}
|
||||
onChange={(e) => setNewConnectionType(e.target.value as ConnectionType)}
|
||||
className={`${inputClass} sm:flex-1`}
|
||||
>
|
||||
{CONNECTION_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddConnection}
|
||||
disabled={!newConnectionId || isLinking}
|
||||
className="shrink-0 rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
Verbinden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { LeadGrid } from './LeadGrid';
|
||||
import { LeadList, type SortConfig, type SortKey } from './LeadList';
|
||||
import { LeadNetwork } from './LeadNetwork';
|
||||
import { LeadModal } from './LeadModal';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
import { leadsApi } from '../../lib/api';
|
||||
import {
|
||||
LEAD_STATUSES,
|
||||
leadFullName,
|
||||
type AppUser,
|
||||
type Lead,
|
||||
type LeadConnection,
|
||||
type LeadStatus,
|
||||
type LeadViewMode,
|
||||
} from '../../types';
|
||||
|
||||
interface LeadsProps {
|
||||
leads: Lead[];
|
||||
connections: LeadConnection[];
|
||||
users: AppUser[];
|
||||
isLoading: boolean;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const VIEW_BUTTONS: { mode: LeadViewMode; label: string; icon: string }[] = [
|
||||
{
|
||||
mode: 'grid',
|
||||
label: 'Kachelansicht',
|
||||
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||
},
|
||||
{ mode: 'list', label: 'Listenansicht', icon: 'M4 6h16M4 10h16M4 14h16M4 18h16' },
|
||||
{
|
||||
mode: 'network',
|
||||
label: 'Netzwerkansicht',
|
||||
icon: 'M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 01-5.656-5.656l1.5-1.5m4.5-4.5l1.5-1.5a4 4 0 015.656 5.656l-3 3',
|
||||
},
|
||||
];
|
||||
|
||||
export const Leads: React.FC<LeadsProps> = ({ leads, connections, users, isLoading, reload }) => {
|
||||
const { addToast } = useToast();
|
||||
const [viewMode, setViewMode] = useState<LeadViewMode>('grid');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<LeadStatus | 'Alle'>('Alle');
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' });
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingLead, setEditingLead] = useState<Lead | null>(null);
|
||||
const [leadToDelete, setLeadToDelete] = useState<Lead | null>(null);
|
||||
|
||||
// Verbindungen pro Kontakt - einmal zaehlen statt pro Karte filtern.
|
||||
const connectionCounts = useMemo(() => {
|
||||
const counts = new Map<number, number>();
|
||||
for (const connection of connections) {
|
||||
counts.set(connection.from_lead_id, (counts.get(connection.from_lead_id) ?? 0) + 1);
|
||||
counts.set(connection.to_lead_id, (counts.get(connection.to_lead_id) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [connections]);
|
||||
|
||||
const connectionCount = (leadId: number) => connectionCounts.get(leadId) ?? 0;
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
// Mehrere Suchbegriffe per Komma, alle muessen passen (UND-Verknuepfung).
|
||||
const terms = searchTerm
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const matches = leads.filter((lead) => {
|
||||
if (statusFilter !== 'Alle' && lead.status !== statusFilter) return false;
|
||||
if (terms.length === 0) return true;
|
||||
|
||||
const haystack = [
|
||||
leadFullName(lead),
|
||||
lead.company,
|
||||
lead.position,
|
||||
lead.email,
|
||||
lead.city,
|
||||
lead.source,
|
||||
...lead.tags,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
|
||||
const direction = sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return [...matches].sort((a, b) => {
|
||||
switch (sortConfig.key) {
|
||||
case 'name':
|
||||
return leadFullName(a).localeCompare(leadFullName(b), 'de') * direction;
|
||||
case 'company':
|
||||
return (a.company ?? '').localeCompare(b.company ?? '', 'de') * direction;
|
||||
case 'status':
|
||||
return (
|
||||
(LEAD_STATUSES.indexOf(a.status) - LEAD_STATUSES.indexOf(b.status)) * direction
|
||||
);
|
||||
case 'connections':
|
||||
return (connectionCount(a.id) - connectionCount(b.id)) * direction;
|
||||
case 'updated_at':
|
||||
default: {
|
||||
const left = a.updated_at ? new Date(a.updated_at).getTime() : 0;
|
||||
const right = b.updated_at ? new Date(b.updated_at).getTime() : 0;
|
||||
return (left - right) * direction;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [leads, searchTerm, statusFilter, sortConfig, connectionCounts]);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
setSortConfig((prev) =>
|
||||
prev.key === key
|
||||
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { key, direction: key === 'updated_at' || key === 'connections' ? 'desc' : 'asc' }
|
||||
);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingLead(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (lead: Lead) => {
|
||||
setEditingLead(lead);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!leadToDelete) return;
|
||||
try {
|
||||
await leadsApi.remove(leadToDelete.id);
|
||||
addToast(`${leadFullName(leadToDelete)} wurde geloescht.`, 'success');
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setLeadToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Damit das Modal immer die frischen Verbindungen des bearbeiteten Kontakts
|
||||
// zeigt, wird der Lead aus der aktuellen Liste nachgeladen.
|
||||
const modalLead = editingLead ? leads.find((lead) => lead.id === editingLead.id) ?? editingLead : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Steuerleiste */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative w-full lg:max-w-md">
|
||||
<input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Name, Firma, Tag, Ort suchen (Komma = UND)"
|
||||
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as LeadStatus | 'Alle')}
|
||||
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||
>
|
||||
<option value="Alle">Alle Status</option>
|
||||
{LEAD_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex items-center gap-3 lg:ml-auto">
|
||||
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
|
||||
{VIEW_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.mode}
|
||||
onClick={() => setViewMode(button.mode)}
|
||||
title={button.label}
|
||||
aria-pressed={viewMode === button.mode}
|
||||
className={`rounded-lg p-2 transition ${
|
||||
viewMode === button.mode
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-ink-400 hover:bg-brand-50 hover:text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={button.icon} />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Kontakt hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inhalt */}
|
||||
{isLoading && leads.length === 0 ? (
|
||||
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
</div>
|
||||
) : filteredLeads.length === 0 && viewMode !== 'network' ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-20 text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-ink-50 text-ink-300">
|
||||
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-ink-500">
|
||||
{leads.length === 0 ? 'Noch keine Kontakte vorhanden.' : 'Keine Kontakte passen zur Suche.'}
|
||||
</p>
|
||||
{leads.length === 0 && (
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="mt-4 rounded-xl bg-brand-50 px-5 py-2 text-sm font-bold text-brand-700 transition hover:bg-brand-100"
|
||||
>
|
||||
Ersten Kontakt anlegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<LeadGrid
|
||||
leads={filteredLeads}
|
||||
connectionCount={connectionCount}
|
||||
onEdit={openEdit}
|
||||
onDelete={setLeadToDelete}
|
||||
/>
|
||||
) : viewMode === 'list' ? (
|
||||
<LeadList
|
||||
leads={filteredLeads}
|
||||
connectionCount={connectionCount}
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
onEdit={openEdit}
|
||||
onDelete={setLeadToDelete}
|
||||
/>
|
||||
) : (
|
||||
<LeadNetwork
|
||||
leads={filteredLeads}
|
||||
connections={connections}
|
||||
users={users}
|
||||
onEditLead={openEdit}
|
||||
reload={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<LeadModal
|
||||
lead={modalLead}
|
||||
leads={leads}
|
||||
users={users}
|
||||
connections={connections}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{leadToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Kontakt loeschen?"
|
||||
message={`${leadFullName(
|
||||
leadToDelete
|
||||
)} wird unwiderruflich entfernt - inklusive aller Verbindungen zu anderen Kontakten.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setLeadToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import type { LeadStatus } from '../../types';
|
||||
|
||||
/** Statusfarben: Gewonnen im CI-Gruen, der Rest bewusst zurueckhaltend. */
|
||||
export const STATUS_STYLES: Record<LeadStatus, string> = {
|
||||
Neu: 'bg-ink-100 text-ink-600 border-ink-200',
|
||||
Kontaktiert: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
Qualifiziert: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
Angebot: 'bg-violet-50 text-violet-700 border-violet-200',
|
||||
Gewonnen: 'bg-brand-50 text-brand-700 border-brand-200',
|
||||
Verloren: 'bg-red-50 text-red-700 border-red-200',
|
||||
};
|
||||
|
||||
/** Knotenfarbe im Netzwerkdiagramm - gleiche Logik wie das Badge. */
|
||||
export const STATUS_COLORS: Record<LeadStatus, string> = {
|
||||
Neu: '#9a9a9a',
|
||||
Kontaktiert: '#0284c7',
|
||||
Qualifiziert: '#d97706',
|
||||
Angebot: '#7c3aed',
|
||||
Gewonnen: '#6ba04a',
|
||||
Verloren: '#dc2626',
|
||||
};
|
||||
|
||||
export const StatusBadge: React.FC<{ status: LeadStatus; className?: string }> = ({
|
||||
status,
|
||||
className = '',
|
||||
}) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${STATUS_STYLES[status]} ${className}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const inputClass =
|
||||
'w-full rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25';
|
||||
|
||||
export const labelClass = 'mb-1.5 block text-[11px] font-extrabold uppercase tracking-wider text-ink-500';
|
||||
|
||||
export const Avatar: React.FC<{
|
||||
src?: string | null;
|
||||
initials: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}> = ({ src, initials, size = 'md' }) => {
|
||||
const dimensions = size === 'sm' ? 'h-10 w-10 text-xs' : size === 'lg' ? 'h-20 w-20 text-2xl' : 'h-14 w-14 text-base';
|
||||
if (src) {
|
||||
return <img src={src} alt="" className={`${dimensions} shrink-0 rounded-xl object-cover`} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`${dimensions} flex shrink-0 items-center justify-center rounded-xl bg-brand-100 font-extrabold text-brand-700`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,427 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { OpportunityGrid } from './OpportunityGrid';
|
||||
import {
|
||||
OpportunityList,
|
||||
DESC_FIRST,
|
||||
SORT_LABELS,
|
||||
type OpportunitySortConfig,
|
||||
type OpportunitySortKey,
|
||||
} from './OpportunityList';
|
||||
import { OpportunityPipeline } from './OpportunityPipeline';
|
||||
import { OpportunityModal } from './OpportunityModal';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
import { opportunitiesApi } from '../../lib/api';
|
||||
import {
|
||||
OPEN_STAGES,
|
||||
OPPORTUNITY_STAGES,
|
||||
contactFullName,
|
||||
formatEuro,
|
||||
opportunityVolume,
|
||||
weightedVolume,
|
||||
type AppUser,
|
||||
type Lead,
|
||||
type Opportunity,
|
||||
type OpportunityStage,
|
||||
type OpportunityViewMode,
|
||||
} from '../../types';
|
||||
|
||||
interface OpportunitiesProps {
|
||||
opportunities: Opportunity[];
|
||||
leads: Lead[];
|
||||
users: AppUser[];
|
||||
isLoading: boolean;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const VIEW_BUTTONS: { mode: OpportunityViewMode; label: string; icon: string }[] = [
|
||||
{
|
||||
mode: 'grid',
|
||||
label: 'Kachelansicht',
|
||||
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||
},
|
||||
{ mode: 'list', label: 'Listenansicht', icon: 'M4 6h16M4 10h16M4 14h16M4 18h16' },
|
||||
{
|
||||
mode: 'pipeline',
|
||||
label: 'Pipeline-Ansicht',
|
||||
icon: 'M4 5h4v14H4zM10 5h4v9h-4zM16 5h4v5h-4z',
|
||||
},
|
||||
];
|
||||
|
||||
/** Sortierbare Felder in der Reihenfolge des Auswahlmenues. */
|
||||
const SORT_OPTIONS: OpportunitySortKey[] = [
|
||||
'updated_at',
|
||||
'last_interaction',
|
||||
'volume',
|
||||
'weighted',
|
||||
'daily_rate',
|
||||
'duration',
|
||||
'probability',
|
||||
'stage',
|
||||
'title',
|
||||
'company',
|
||||
'start_date',
|
||||
];
|
||||
|
||||
/** Vergleichswert je Sortierschluessel; null sortiert immer ans Ende. */
|
||||
function sortValue(opportunity: Opportunity, key: OpportunitySortKey): string | number | null {
|
||||
switch (key) {
|
||||
case 'title':
|
||||
return opportunity.title.toLowerCase();
|
||||
case 'company':
|
||||
return opportunity.company?.toLowerCase() ?? null;
|
||||
case 'stage':
|
||||
return OPPORTUNITY_STAGES.indexOf(opportunity.stage);
|
||||
case 'probability':
|
||||
return opportunity.probability;
|
||||
case 'volume':
|
||||
return opportunityVolume(opportunity);
|
||||
case 'weighted':
|
||||
return weightedVolume(opportunity);
|
||||
case 'daily_rate':
|
||||
return opportunity.daily_rate;
|
||||
case 'duration':
|
||||
return opportunity.duration_days;
|
||||
case 'last_interaction':
|
||||
return opportunity.last_interaction_at ?? null;
|
||||
case 'start_date':
|
||||
return opportunity.start_date ?? null;
|
||||
case 'updated_at':
|
||||
default:
|
||||
return opportunity.updated_at ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export const Opportunities: React.FC<OpportunitiesProps> = ({
|
||||
opportunities,
|
||||
leads,
|
||||
users,
|
||||
isLoading,
|
||||
reload,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const [viewMode, setViewMode] = useState<OpportunityViewMode>('grid');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [stageFilter, setStageFilter] = useState<OpportunityStage | 'Alle' | 'Offen'>('Alle');
|
||||
const [sortConfig, setSortConfig] = useState<OpportunitySortConfig>({
|
||||
key: 'updated_at',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Opportunity | null>(null);
|
||||
const [toDelete, setToDelete] = useState<Opportunity | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const terms = searchTerm
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const matches = opportunities.filter((opportunity) => {
|
||||
if (stageFilter === 'Offen' && !OPEN_STAGES.includes(opportunity.stage)) return false;
|
||||
if (stageFilter !== 'Alle' && stageFilter !== 'Offen' && opportunity.stage !== stageFilter) {
|
||||
return false;
|
||||
}
|
||||
if (terms.length === 0) return true;
|
||||
|
||||
const haystack = [
|
||||
opportunity.title,
|
||||
opportunity.company,
|
||||
opportunity.description,
|
||||
opportunity.last_interaction_note,
|
||||
opportunity.next_step,
|
||||
opportunity.notes,
|
||||
opportunity.owner_name,
|
||||
...opportunity.tags,
|
||||
...opportunity.contacts.map((contact) => `${contactFullName(contact)} ${contact.role ?? ''}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
|
||||
const direction = sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return [...matches].sort((a, b) => {
|
||||
const left = sortValue(a, sortConfig.key);
|
||||
const right = sortValue(b, sortConfig.key);
|
||||
|
||||
// Leere Werte landen unabhaengig von der Richtung hinten.
|
||||
if (left === null && right === null) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
return (left - right) * direction;
|
||||
}
|
||||
return String(left).localeCompare(String(right), 'de') * direction;
|
||||
});
|
||||
}, [opportunities, searchTerm, stageFilter, sortConfig]);
|
||||
|
||||
const totals = useMemo(
|
||||
() => ({
|
||||
volume: filtered.reduce((sum, item) => sum + (opportunityVolume(item) ?? 0), 0),
|
||||
weighted: filtered.reduce((sum, item) => sum + (weightedVolume(item) ?? 0), 0),
|
||||
open: filtered.filter((item) => OPEN_STAGES.includes(item.stage)).length,
|
||||
}),
|
||||
[filtered]
|
||||
);
|
||||
|
||||
const handleSort = (key: OpportunitySortKey) => {
|
||||
setSortConfig((prev) =>
|
||||
prev.key === key
|
||||
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { key, direction: DESC_FIRST.includes(key) ? 'desc' : 'asc' }
|
||||
);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (opportunity: Opportunity) => {
|
||||
setEditing(opportunity);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const changeStage = async (opportunity: Opportunity, stage: OpportunityStage) => {
|
||||
try {
|
||||
await opportunitiesApi.update(opportunity.id, { stage });
|
||||
addToast(`"${opportunity.title}" ist jetzt in Phase ${stage}.`, 'success');
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Phase konnte nicht geaendert werden.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!toDelete) return;
|
||||
try {
|
||||
await opportunitiesApi.remove(toDelete.id);
|
||||
addToast(`"${toDelete.title}" wurde geloescht.`, 'success');
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Im Modal immer den frischen Stand zeigen, falls zwischenzeitlich geladen wurde.
|
||||
const modalOpportunity = editing
|
||||
? opportunities.find((item) => item.id === editing.id) ?? editing
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Steuerleiste */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative w-full lg:max-w-sm">
|
||||
<input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Titel, Firma, Ansprechpartner (Komma = UND)"
|
||||
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={stageFilter}
|
||||
onChange={(event) =>
|
||||
setStageFilter(event.target.value as OpportunityStage | 'Alle' | 'Offen')
|
||||
}
|
||||
aria-label="Nach Phase filtern"
|
||||
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||
>
|
||||
<option value="Alle">Alle Phasen</option>
|
||||
<option value="Offen">Nur offene</option>
|
||||
{OPPORTUNITY_STAGES.map((stage) => (
|
||||
<option key={stage} value={stage}>
|
||||
{stage}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Sortierung - in der Liste zusaetzlich ueber die Spaltenkoepfe */}
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={sortConfig.key}
|
||||
onChange={(event) => {
|
||||
const key = event.target.value as OpportunitySortKey;
|
||||
setSortConfig({ key, direction: DESC_FIRST.includes(key) ? 'desc' : 'asc' });
|
||||
}}
|
||||
aria-label="Sortieren nach"
|
||||
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
||||
>
|
||||
{SORT_OPTIONS.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{SORT_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() =>
|
||||
setSortConfig((prev) => ({
|
||||
...prev,
|
||||
direction: prev.direction === 'asc' ? 'desc' : 'asc',
|
||||
}))
|
||||
}
|
||||
title={sortConfig.direction === 'asc' ? 'Aufsteigend' : 'Absteigend'}
|
||||
aria-label={`Sortierrichtung wechseln (aktuell ${
|
||||
sortConfig.direction === 'asc' ? 'aufsteigend' : 'absteigend'
|
||||
})`}
|
||||
className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
sortConfig.direction === 'desc' ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 lg:ml-auto">
|
||||
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
|
||||
{VIEW_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.mode}
|
||||
onClick={() => setViewMode(button.mode)}
|
||||
title={button.label}
|
||||
aria-pressed={viewMode === button.mode}
|
||||
className={`rounded-lg p-2 transition ${
|
||||
viewMode === button.mode
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-ink-400 hover:bg-brand-50 hover:text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={button.icon} />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Opportunity anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kennzahlen der aktuellen Auswahl */}
|
||||
{filtered.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-2 rounded-2xl border border-ink-200 bg-surface px-4 py-3">
|
||||
{[
|
||||
{ label: 'Opportunities', value: String(filtered.length) },
|
||||
{ label: 'davon offen', value: String(totals.open) },
|
||||
{ label: 'Volumen', value: formatEuro(totals.volume) },
|
||||
{ label: 'Gewichtet', value: formatEuro(totals.weighted) },
|
||||
].map((metric) => (
|
||||
<div key={metric.label}>
|
||||
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p className="text-sm font-extrabold text-ink-900">{metric.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inhalt */}
|
||||
{isLoading && opportunities.length === 0 ? (
|
||||
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
</div>
|
||||
) : filtered.length === 0 && viewMode !== 'pipeline' ? (
|
||||
<div className="rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-20 text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-ink-50 text-ink-300">
|
||||
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-ink-500">
|
||||
{opportunities.length === 0
|
||||
? 'Noch keine Opportunities vorhanden.'
|
||||
: 'Keine Opportunity passt zur Suche.'}
|
||||
</p>
|
||||
{opportunities.length === 0 && (
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="mt-4 rounded-xl bg-brand-50 px-5 py-2 text-sm font-bold text-brand-700 transition hover:bg-brand-100"
|
||||
>
|
||||
Erste Opportunity anlegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<OpportunityGrid opportunities={filtered} onEdit={openEdit} onDelete={setToDelete} />
|
||||
) : viewMode === 'list' ? (
|
||||
<OpportunityList
|
||||
opportunities={filtered}
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
onEdit={openEdit}
|
||||
onDelete={setToDelete}
|
||||
/>
|
||||
) : (
|
||||
<OpportunityPipeline
|
||||
opportunities={filtered}
|
||||
onEdit={openEdit}
|
||||
onStageChange={changeStage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<OpportunityModal
|
||||
opportunity={modalOpportunity}
|
||||
leads={leads}
|
||||
users={users}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toDelete && (
|
||||
<ConfirmDialog
|
||||
title="Opportunity loeschen?"
|
||||
message={`"${toDelete.title}" wird unwiderruflich entfernt - inklusive der Zuordnung der Ansprechpartner. Die Kontakte selbst bleiben erhalten.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import { UserAvatar } from '../ui/UserAvatar';
|
||||
import { Avatar } from '../leads/leadUi';
|
||||
import {
|
||||
CollapsibleText,
|
||||
ProbabilityBar,
|
||||
STAGE_COLORS,
|
||||
StageBadge,
|
||||
formatDate,
|
||||
formatDays,
|
||||
relativeDays,
|
||||
} from './opportunityUi';
|
||||
import {
|
||||
contactFullName,
|
||||
formatEuro,
|
||||
opportunityVolume,
|
||||
type Opportunity,
|
||||
} from '../../types';
|
||||
|
||||
interface OpportunityGridProps {
|
||||
opportunities: Opportunity[];
|
||||
onEdit: (opportunity: Opportunity) => void;
|
||||
onDelete: (opportunity: Opportunity) => void;
|
||||
}
|
||||
|
||||
const Metric: React.FC<{ label: string; value: string; className?: string }> = ({
|
||||
label,
|
||||
value,
|
||||
className = '',
|
||||
}) => (
|
||||
<div className={className}>
|
||||
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">{label}</p>
|
||||
<p className="truncate text-sm font-bold text-ink-800">{value}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
|
||||
opportunities,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{opportunities.map((opportunity) => {
|
||||
const color = STAGE_COLORS[opportunity.stage];
|
||||
const interaction = relativeDays(opportunity.last_interaction_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opportunity.id}
|
||||
onClick={() => onEdit(opportunity)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onEdit(opportunity);
|
||||
}
|
||||
}}
|
||||
className="group flex cursor-pointer flex-col gap-3 overflow-hidden rounded-2xl border border-ink-200 bg-surface p-4 shadow-card transition hover:border-brand-300 hover:shadow-lift"
|
||||
>
|
||||
{/* Kopf */}
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 h-8 w-1.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-extrabold text-ink-900 transition group-hover:text-brand-700">
|
||||
{opportunity.title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-ink-400">{opportunity.company || 'Ohne Firma'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(opportunity);
|
||||
}}
|
||||
className="shrink-0 rounded-lg p-1.5 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
|
||||
aria-label={`${opportunity.title} loeschen`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StageBadge stage={opportunity.stage} />
|
||||
{opportunity.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-md bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{opportunity.tags.length > 2 && (
|
||||
<span className="text-[10px] font-bold text-ink-400">
|
||||
+{opportunity.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProbabilityBar value={opportunity.probability} color={color} />
|
||||
|
||||
{/* Projektdaten */}
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-ink-100 pt-3">
|
||||
<Metric label="Volumen" value={formatEuro(opportunityVolume(opportunity))} />
|
||||
<Metric label="Tagessatz" value={formatEuro(opportunity.daily_rate)} />
|
||||
<Metric label="Laufzeit" value={formatDays(opportunity.duration_days)} />
|
||||
<Metric
|
||||
label="Zeitraum"
|
||||
value={
|
||||
opportunity.start_date || opportunity.end_date
|
||||
? `${formatDate(opportunity.start_date)} – ${formatDate(opportunity.end_date)}`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CollapsibleText text={opportunity.description} />
|
||||
|
||||
{/* Letzte Interaktion */}
|
||||
<div className="rounded-xl border border-ink-100 px-3 py-2">
|
||||
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Letzte Interaktion
|
||||
</p>
|
||||
<p className="text-xs font-bold text-ink-700">
|
||||
{formatDate(opportunity.last_interaction_at)}
|
||||
{interaction && <span className="ml-1 font-semibold text-ink-400">({interaction})</span>}
|
||||
</p>
|
||||
{opportunity.last_interaction_note && (
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-ink-500">
|
||||
{opportunity.last_interaction_note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{opportunity.next_step && (
|
||||
<div className="flex items-start gap-2 rounded-xl bg-brand-50 px-3 py-2">
|
||||
<svg
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand-700"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<p className="line-clamp-2 text-xs font-semibold text-brand-800">
|
||||
{opportunity.next_step}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ansprechpartner und Zustaendigkeit */}
|
||||
<div className="mt-auto flex items-center gap-2 border-t border-ink-100 pt-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{opportunity.contacts.length === 0 ? (
|
||||
<span className="text-[11px] font-semibold text-ink-300">
|
||||
Kein Ansprechpartner
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{opportunity.contacts.slice(0, 3).map((contact) => (
|
||||
<Avatar
|
||||
key={contact.lead_id}
|
||||
src={contact.avatar}
|
||||
initials={`${contact.first_name.charAt(0)}${contact.last_name.charAt(0)}`.toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
{opportunity.contacts.length > 3 ? (
|
||||
<span className="text-[11px] font-bold text-ink-400">
|
||||
+{opportunity.contacts.length - 3}
|
||||
</span>
|
||||
) : (
|
||||
opportunity.contacts.length === 1 && (
|
||||
<span className="truncate text-[11px] font-semibold text-ink-500">
|
||||
{contactFullName(opportunity.contacts[0])}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{opportunity.owner_id !== null && (
|
||||
<UserAvatar
|
||||
user={{
|
||||
id: opportunity.owner_id,
|
||||
name: opportunity.owner_name ?? '?',
|
||||
color: opportunity.owner_color ?? null,
|
||||
avatar: opportunity.owner_avatar ?? null,
|
||||
}}
|
||||
size={24}
|
||||
title={`Zustaendig: ${opportunity.owner_name ?? 'unbekannt'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,260 @@
|
||||
import React from 'react';
|
||||
import { UserAvatar } from '../ui/UserAvatar';
|
||||
import {
|
||||
CollapsibleText,
|
||||
STAGE_COLORS,
|
||||
StageBadge,
|
||||
formatDate,
|
||||
formatDays,
|
||||
relativeDays,
|
||||
} from './opportunityUi';
|
||||
import {
|
||||
contactFullName,
|
||||
formatEuro,
|
||||
opportunityVolume,
|
||||
weightedVolume,
|
||||
type Opportunity,
|
||||
} from '../../types';
|
||||
|
||||
/** Sortierschluessel - geteilt mit der Steuerleiste in Opportunities.tsx. */
|
||||
export type OpportunitySortKey =
|
||||
| 'title'
|
||||
| 'company'
|
||||
| 'stage'
|
||||
| 'probability'
|
||||
| 'volume'
|
||||
| 'weighted'
|
||||
| 'daily_rate'
|
||||
| 'duration'
|
||||
| 'last_interaction'
|
||||
| 'start_date'
|
||||
| 'updated_at';
|
||||
|
||||
export interface OpportunitySortConfig {
|
||||
key: OpportunitySortKey;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/** Beschriftungen fuer Spaltenkoepfe und das Sortiermenue. */
|
||||
export const SORT_LABELS: Record<OpportunitySortKey, string> = {
|
||||
title: 'Titel',
|
||||
company: 'Firma',
|
||||
stage: 'Phase',
|
||||
probability: 'Chance',
|
||||
volume: 'Volumen',
|
||||
weighted: 'Gewichtet',
|
||||
daily_rate: 'Tagessatz',
|
||||
duration: 'Laufzeit',
|
||||
last_interaction: 'Letzte Interaktion',
|
||||
start_date: 'Projektstart',
|
||||
updated_at: 'Geaendert',
|
||||
};
|
||||
|
||||
/** Diese Schluessel starten absteigend - bei Geld und Datum ist das erwartbar. */
|
||||
export const DESC_FIRST: OpportunitySortKey[] = [
|
||||
'probability',
|
||||
'volume',
|
||||
'weighted',
|
||||
'daily_rate',
|
||||
'duration',
|
||||
'last_interaction',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
const COLUMNS: { key: OpportunitySortKey | null; label: string; align?: 'right' }[] = [
|
||||
{ key: 'title', label: 'Opportunity' },
|
||||
{ key: 'stage', label: 'Phase' },
|
||||
{ key: 'probability', label: 'Chance' },
|
||||
{ key: 'volume', label: 'Volumen', align: 'right' },
|
||||
{ key: 'daily_rate', label: 'Tagessatz', align: 'right' },
|
||||
{ key: 'duration', label: 'Laufzeit', align: 'right' },
|
||||
{ key: 'last_interaction', label: 'Letzte Interaktion' },
|
||||
{ key: null, label: 'Ansprechpartner' },
|
||||
{ key: null, label: 'Zustaendig' },
|
||||
];
|
||||
|
||||
interface OpportunityListProps {
|
||||
opportunities: Opportunity[];
|
||||
sortConfig: OpportunitySortConfig;
|
||||
onSort: (key: OpportunitySortKey) => void;
|
||||
onEdit: (opportunity: Opportunity) => void;
|
||||
onDelete: (opportunity: Opportunity) => void;
|
||||
}
|
||||
|
||||
export const OpportunityList: React.FC<OpportunityListProps> = ({
|
||||
opportunities,
|
||||
sortConfig,
|
||||
onSort,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
|
||||
<div className="scrollbar-slim overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 bg-ink-50">
|
||||
{COLUMNS.map((column) => (
|
||||
<th
|
||||
key={column.label}
|
||||
onClick={column.key ? () => onSort(column.key!) : undefined}
|
||||
aria-sort={
|
||||
column.key && sortConfig.key === column.key
|
||||
? sortConfig.direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
className={`px-4 py-3.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-400 ${
|
||||
column.key ? 'cursor-pointer select-none transition hover:text-brand-600' : ''
|
||||
} ${column.align === 'right' ? 'text-right' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 ${
|
||||
column.align === 'right' ? 'justify-end' : ''
|
||||
}`}
|
||||
>
|
||||
{column.label}
|
||||
{column.key && sortConfig.key === column.key && (
|
||||
<svg
|
||||
className={`h-3 w-3 transition-transform ${
|
||||
sortConfig.direction === 'desc' ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{opportunities.map((opportunity) => {
|
||||
const color = STAGE_COLORS[opportunity.stage];
|
||||
const interaction = relativeDays(opportunity.last_interaction_at);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={opportunity.id}
|
||||
onClick={() => onEdit(opportunity)}
|
||||
className="group cursor-pointer align-top transition hover:bg-brand-50/40"
|
||||
>
|
||||
<td className="max-w-xs px-4 py-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
className="mt-1 h-7 w-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-ink-900 transition group-hover:text-brand-700">
|
||||
{opportunity.title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{opportunity.company || 'Ohne Firma'}
|
||||
</p>
|
||||
{/* Beschreibung bleibt eingeklappt, damit die Zeile flach bleibt */}
|
||||
<div className="mt-1.5">
|
||||
<CollapsibleText text={opportunity.description} collapsedLines={1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StageBadge stage={opportunity.stage} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-bold tabular-nums text-ink-700">
|
||||
{opportunity.probability}%
|
||||
</span>
|
||||
<p className="whitespace-nowrap text-[11px] text-ink-400">
|
||||
{formatEuro(weightedVolume(opportunity))}
|
||||
</p>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-bold text-ink-800">
|
||||
{formatEuro(opportunityVolume(opportunity))}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-ink-700">
|
||||
{formatEuro(opportunity.daily_rate)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-ink-700">
|
||||
{formatDays(opportunity.duration_days)}
|
||||
{(opportunity.start_date || opportunity.end_date) && (
|
||||
<p className="text-[11px] text-ink-400">
|
||||
{formatDate(opportunity.start_date)} – {formatDate(opportunity.end_date)}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<p className="text-sm text-ink-700">{formatDate(opportunity.last_interaction_at)}</p>
|
||||
{interaction && <p className="text-[11px] text-ink-400">{interaction}</p>}
|
||||
{opportunity.last_interaction_note && (
|
||||
<p className="max-w-[14rem] truncate text-[11px] text-ink-500">
|
||||
{opportunity.last_interaction_note}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{opportunity.contacts.length === 0 ? (
|
||||
<span className="text-xs text-ink-300">—</span>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{opportunity.contacts.slice(0, 2).map((contact) => (
|
||||
<li key={contact.lead_id} className="whitespace-nowrap text-xs text-ink-600">
|
||||
{contactFullName(contact)}
|
||||
{contact.role && <span className="text-ink-400"> · {contact.role}</span>}
|
||||
</li>
|
||||
))}
|
||||
{opportunity.contacts.length > 2 && (
|
||||
<li className="text-[11px] font-bold text-ink-400">
|
||||
+{opportunity.contacts.length - 2} weitere
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{opportunity.owner_id === null ? (
|
||||
<span className="text-xs text-ink-300">—</span>
|
||||
) : (
|
||||
<UserAvatar
|
||||
user={{
|
||||
id: opportunity.owner_id,
|
||||
name: opportunity.owner_name ?? '?',
|
||||
color: opportunity.owner_color ?? null,
|
||||
avatar: opportunity.owner_avatar ?? null,
|
||||
}}
|
||||
size={28}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(opportunity);
|
||||
}}
|
||||
className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
|
||||
aria-label={`${opportunity.title} loeschen`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,632 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField';
|
||||
import { Avatar, inputClass, labelClass } from '../leads/leadUi';
|
||||
import { STAGE_COLORS } from './opportunityUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { opportunitiesApi } from '../../lib/api';
|
||||
import {
|
||||
OPPORTUNITY_STAGES,
|
||||
contactFullName,
|
||||
formatEuro,
|
||||
leadFullName,
|
||||
leadInitials,
|
||||
type AppUser,
|
||||
type Lead,
|
||||
type Opportunity,
|
||||
type OpportunityStage,
|
||||
} from '../../types';
|
||||
|
||||
interface OpportunityModalProps {
|
||||
/** null = neue Opportunity */
|
||||
opportunity: Opportunity | null;
|
||||
leads: Lead[];
|
||||
users: AppUser[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface ContactEntry {
|
||||
lead_id: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
title: string;
|
||||
company: string;
|
||||
description: string;
|
||||
stage: OpportunityStage;
|
||||
probability: string;
|
||||
daily_rate: string;
|
||||
duration_days: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
last_interaction_at: string;
|
||||
last_interaction_note: string;
|
||||
next_step: string;
|
||||
notes: string;
|
||||
owner_id: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** Zahlen kommen als String ins Formular, damit leere Felder moeglich bleiben. */
|
||||
function numberToInput(value: number | null): string {
|
||||
return value === null ? '' : String(value).replace('.', ',');
|
||||
}
|
||||
|
||||
function toFormState(opportunity: Opportunity | null, fallbackOwnerId: number | null): FormState {
|
||||
return {
|
||||
title: opportunity?.title ?? '',
|
||||
company: opportunity?.company ?? '',
|
||||
description: opportunity?.description ?? '',
|
||||
stage: opportunity?.stage ?? 'Neu',
|
||||
probability: String(opportunity?.probability ?? 10),
|
||||
daily_rate: numberToInput(opportunity?.daily_rate ?? null),
|
||||
duration_days: numberToInput(opportunity?.duration_days ?? null),
|
||||
start_date: opportunity?.start_date?.slice(0, 10) ?? '',
|
||||
end_date: opportunity?.end_date?.slice(0, 10) ?? '',
|
||||
last_interaction_at: opportunity?.last_interaction_at?.slice(0, 10) ?? '',
|
||||
last_interaction_note: opportunity?.last_interaction_note ?? '',
|
||||
next_step: opportunity?.next_step ?? '',
|
||||
notes: opportunity?.notes ?? '',
|
||||
owner_id: String(opportunity?.owner_id ?? fallbackOwnerId ?? ''),
|
||||
tags: opportunity?.tags ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
||||
opportunity,
|
||||
leads,
|
||||
users,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const [form, setForm] = useState<FormState>(() => toFormState(opportunity, users[0]?.id ?? null));
|
||||
const [contacts, setContacts] = useState<ContactEntry[]>(
|
||||
() => opportunity?.contacts.map((c) => ({ lead_id: c.lead_id, role: c.role ?? '' })) ?? []
|
||||
);
|
||||
const [contactToAdd, setContactToAdd] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]);
|
||||
|
||||
const addableLeads = useMemo(() => {
|
||||
const taken = new Set(contacts.map((contact) => contact.lead_id));
|
||||
return leads
|
||||
.filter((lead) => !taken.has(lead.id))
|
||||
.sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de'));
|
||||
}, [leads, contacts]);
|
||||
|
||||
// Volumen live mitrechnen, damit die Wirkung von Tagessatz und Laufzeit
|
||||
// sofort sichtbar ist.
|
||||
const preview = useMemo(() => {
|
||||
const rate = Number(form.daily_rate.replace(',', '.'));
|
||||
const days = Number(form.duration_days.replace(',', '.'));
|
||||
if (!Number.isFinite(rate) || !Number.isFinite(days) || !form.daily_rate || !form.duration_days) {
|
||||
return null;
|
||||
}
|
||||
const volume = rate * days;
|
||||
const probability = Number(form.probability) || 0;
|
||||
return { volume, weighted: (volume * probability) / 100 };
|
||||
}, [form.daily_rate, form.duration_days, form.probability]);
|
||||
|
||||
const addTags = () => {
|
||||
const parsed = tagInput
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag && !form.tags.includes(tag));
|
||||
if (parsed.length > 0) set('tags', [...form.tags, ...parsed]);
|
||||
setTagInput('');
|
||||
};
|
||||
|
||||
const addContact = () => {
|
||||
const leadId = Number(contactToAdd);
|
||||
if (!Number.isInteger(leadId) || leadId <= 0) return;
|
||||
setContacts((prev) => [...prev, { lead_id: leadId, role: '' }]);
|
||||
setContactToAdd('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.title.trim()) {
|
||||
addToast('Ein Titel ist erforderlich.', 'error');
|
||||
return;
|
||||
}
|
||||
if (form.start_date && form.end_date && form.end_date < form.start_date) {
|
||||
addToast('Das Projektende darf nicht vor dem Start liegen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
company: form.company.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
stage: form.stage,
|
||||
probability: Number(form.probability) || 0,
|
||||
daily_rate: form.daily_rate.trim() === '' ? null : form.daily_rate.replace(',', '.'),
|
||||
duration_days: form.duration_days.trim() === '' ? null : form.duration_days.replace(',', '.'),
|
||||
start_date: form.start_date || null,
|
||||
end_date: form.end_date || null,
|
||||
last_interaction_at: form.last_interaction_at || null,
|
||||
last_interaction_note: form.last_interaction_note.trim() || null,
|
||||
next_step: form.next_step.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
owner_id: form.owner_id === '' ? null : Number(form.owner_id),
|
||||
tags: form.tags,
|
||||
contacts: contacts.map((contact) => ({
|
||||
lead_id: contact.lead_id,
|
||||
role: contact.role.trim() || null,
|
||||
})),
|
||||
} as Parameters<typeof opportunitiesApi.create>[0];
|
||||
|
||||
if (opportunity) {
|
||||
await opportunitiesApi.update(opportunity.id, payload);
|
||||
addToast('Opportunity aktualisiert.', 'success');
|
||||
} else {
|
||||
await opportunitiesApi.create(payload);
|
||||
addToast('Opportunity angelegt.', 'success');
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={opportunity ? opportunity.title : 'Neue Opportunity'}
|
||||
subtitle={
|
||||
opportunity
|
||||
? `Angelegt von ${opportunity.created_by_name ?? 'unbekannt'}${
|
||||
opportunity.created_at
|
||||
? ` am ${new Date(opportunity.created_at).toLocaleDateString('de-DE')}`
|
||||
: ''
|
||||
}`
|
||||
: 'Vertriebschance mit Projektdaten und Ansprechpartnern'
|
||||
}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="opportunity-form"
|
||||
disabled={isSaving}
|
||||
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="opportunity-form" onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Weist auf das fehlende Sprachmodell hin und fuehrt dorthin. Der
|
||||
Dialog schliesst sich dabei, sonst liegt er ueber der Ansicht. */}
|
||||
<DictationHint onBeforeNavigate={onClose} />
|
||||
|
||||
{/* Kerndaten */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass} htmlFor="opp-title">
|
||||
Titel *
|
||||
</label>
|
||||
<input
|
||||
id="opp-title"
|
||||
required
|
||||
autoFocus
|
||||
value={form.title}
|
||||
onChange={(event) => set('title', event.target.value)}
|
||||
placeholder="z. B. S/4HANA Migration Rollout"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-company">
|
||||
Firma
|
||||
</label>
|
||||
<input
|
||||
id="opp-company"
|
||||
value={form.company}
|
||||
onChange={(event) => set('company', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-stage">
|
||||
Phase
|
||||
</label>
|
||||
<select
|
||||
id="opp-stage"
|
||||
value={form.stage}
|
||||
onChange={(event) => set('stage', event.target.value as OpportunityStage)}
|
||||
className={inputClass}
|
||||
style={{ borderLeft: `4px solid ${STAGE_COLORS[form.stage]}` }}
|
||||
>
|
||||
{OPPORTUNITY_STAGES.map((stage) => (
|
||||
<option key={stage} value={stage}>
|
||||
{stage}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DictateTextarea
|
||||
id="opp-description"
|
||||
label="Projektbeschreibung"
|
||||
value={form.description}
|
||||
onChange={(value) => set('description', value)}
|
||||
onBeforeNavigate={onClose}
|
||||
rows={6}
|
||||
placeholder="Ausgangslage, Ziel, Umfang, Rahmenbedingungen..."
|
||||
/>
|
||||
|
||||
{/* Kommerzielles */}
|
||||
<section>
|
||||
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Projektdaten
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-rate">
|
||||
Tagessatz (EUR)
|
||||
</label>
|
||||
<input
|
||||
id="opp-rate"
|
||||
inputMode="decimal"
|
||||
value={form.daily_rate}
|
||||
onChange={(event) => set('daily_rate', event.target.value)}
|
||||
placeholder="1150"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-duration">
|
||||
Laufzeit (Personentage)
|
||||
</label>
|
||||
<input
|
||||
id="opp-duration"
|
||||
inputMode="decimal"
|
||||
value={form.duration_days}
|
||||
onChange={(event) => set('duration_days', event.target.value)}
|
||||
placeholder="40"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-start">
|
||||
Projektstart
|
||||
</label>
|
||||
<input
|
||||
id="opp-start"
|
||||
type="date"
|
||||
value={form.start_date}
|
||||
onChange={(event) => set('start_date', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-end">
|
||||
Projektende
|
||||
</label>
|
||||
<input
|
||||
id="opp-end"
|
||||
type="date"
|
||||
value={form.end_date}
|
||||
onChange={(event) => set('end_date', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-probability">
|
||||
Abschlusswahrscheinlichkeit: {form.probability} %
|
||||
</label>
|
||||
<input
|
||||
id="opp-probability"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={form.probability}
|
||||
onChange={(event) => set('probability', event.target.value)}
|
||||
className="w-full accent-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-owner">
|
||||
Zustaendig
|
||||
</label>
|
||||
<select
|
||||
id="opp-owner"
|
||||
value={form.owner_id}
|
||||
onChange={(event) => set('owner_id', event.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Niemand</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="mt-4 flex flex-wrap gap-6 rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Volumen
|
||||
</p>
|
||||
<p className="text-lg font-extrabold text-ink-900">{formatEuro(preview.volume)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Gewichtet ({form.probability} %)
|
||||
</p>
|
||||
<p className="text-lg font-extrabold text-brand-700">
|
||||
{formatEuro(preview.weighted)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Ansprechpartner */}
|
||||
<section>
|
||||
<h3 className="mb-1 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Ansprechpartner
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-ink-400">
|
||||
Kontakte aus dem CRM; die Rolle ist Freitext, z. B. Entscheider oder Fachbereich.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{contacts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
|
||||
Noch keine Ansprechpartner zugeordnet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{contacts.map((contact, index) => {
|
||||
const lead = leadById.get(contact.lead_id);
|
||||
return (
|
||||
<li
|
||||
key={contact.lead_id}
|
||||
className="flex flex-col gap-2 rounded-xl border border-ink-200 bg-ink-50/50 px-3 py-2.5 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Avatar
|
||||
src={lead?.avatar ?? null}
|
||||
initials={lead ? leadInitials(lead) : '?'}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-ink-900">
|
||||
{lead ? leadFullName(lead) : `Kontakt #${contact.lead_id}`}
|
||||
</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{[lead?.company, lead?.position].filter(Boolean).join(' · ') || 'Kontakt'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={contact.role}
|
||||
onChange={(event) =>
|
||||
setContacts((prev) =>
|
||||
prev.map((entry, position) =>
|
||||
position === index ? { ...entry, role: event.target.value } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
placeholder="Rolle"
|
||||
aria-label={`Rolle von ${lead ? leadFullName(lead) : 'Kontakt'}`}
|
||||
className={`${inputClass} sm:w-44`}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setContacts((prev) => prev.filter((_, position) => position !== index))
|
||||
}
|
||||
className="shrink-0 self-end rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600 sm:self-auto"
|
||||
aria-label="Ansprechpartner entfernen"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-ink-200 p-3 sm:flex-row">
|
||||
<select
|
||||
value={contactToAdd}
|
||||
onChange={(event) => setContactToAdd(event.target.value)}
|
||||
aria-label="Kontakt als Ansprechpartner auswaehlen"
|
||||
className={`${inputClass} sm:flex-1`}
|
||||
>
|
||||
<option value="">Kontakt auswaehlen...</option>
|
||||
{addableLeads.map((lead) => (
|
||||
<option key={lead.id} value={lead.id}>
|
||||
{leadFullName(lead)}
|
||||
{lead.company ? ` (${lead.company})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addContact}
|
||||
disabled={!contactToAdd}
|
||||
className="shrink-0 rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Verlauf */}
|
||||
<section>
|
||||
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Verlauf
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-interaction">
|
||||
Letzte Interaktion
|
||||
</label>
|
||||
<input
|
||||
id="opp-interaction"
|
||||
type="date"
|
||||
value={form.last_interaction_at}
|
||||
onChange={(event) => set('last_interaction_at', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||
<label className="block text-[11px] font-extrabold uppercase tracking-wider text-ink-500" htmlFor="opp-interaction-note">
|
||||
Was wurde besprochen?
|
||||
</label>
|
||||
<DictateButton
|
||||
label="Interaktion"
|
||||
onBeforeNavigate={onClose}
|
||||
compact
|
||||
onText={(text) =>
|
||||
set(
|
||||
'last_interaction_note',
|
||||
form.last_interaction_note
|
||||
? `${form.last_interaction_note.trimEnd()} ${text}`
|
||||
: text
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="opp-interaction-note"
|
||||
value={form.last_interaction_note}
|
||||
onChange={(event) => set('last_interaction_note', event.target.value)}
|
||||
placeholder="Kurznotiz zum letzten Kontakt"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||
<label className="block text-[11px] font-extrabold uppercase tracking-wider text-ink-500" htmlFor="opp-next">
|
||||
Naechster Schritt
|
||||
</label>
|
||||
<DictateButton
|
||||
label="Naechster Schritt"
|
||||
onBeforeNavigate={onClose}
|
||||
compact
|
||||
onText={(text) =>
|
||||
set('next_step', form.next_step ? `${form.next_step.trimEnd()} ${text}` : text)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="opp-next"
|
||||
value={form.next_step}
|
||||
onChange={(event) => set('next_step', event.target.value)}
|
||||
placeholder="z. B. Angebot bis Freitag nachfassen"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className={labelClass}>Tags</label>
|
||||
{form.tags.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{form.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-bold text-white"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('tags', form.tags.filter((item) => item !== tag))}
|
||||
className="transition hover:text-white/70"
|
||||
aria-label={`Tag ${tag} entfernen`}
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={tagInput}
|
||||
onChange={(event) => setTagInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addTags();
|
||||
}
|
||||
}}
|
||||
placeholder="Mit Komma trennen, z. B. SAP, Festpreis"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTags}
|
||||
className="shrink-0 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<DictateTextarea
|
||||
id="opp-notes"
|
||||
label="Interne Notizen"
|
||||
value={form.notes}
|
||||
onChange={(value) => set('notes', value)}
|
||||
onBeforeNavigate={onClose}
|
||||
rows={4}
|
||||
placeholder="Politik beim Kunden, Wettbewerb, Preisspielraum..."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{opportunity && opportunity.contacts.length > 0 && (
|
||||
<p className="text-xs text-ink-400">
|
||||
Gespeicherte Ansprechpartner:{' '}
|
||||
{opportunity.contacts.map((contact) => contactFullName(contact)).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserAvatar } from '../ui/UserAvatar';
|
||||
import { STAGE_COLORS, StageBadge, formatDate, formatDays, relativeDays } from './opportunityUi';
|
||||
import {
|
||||
OPPORTUNITY_STAGES,
|
||||
contactFullName,
|
||||
formatEuro,
|
||||
opportunityVolume,
|
||||
type Opportunity,
|
||||
type OpportunityStage,
|
||||
} from '../../types';
|
||||
|
||||
interface OpportunityPipelineProps {
|
||||
opportunities: Opportunity[];
|
||||
onEdit: (opportunity: Opportunity) => void;
|
||||
/** Verschiebt eine Karte in eine andere Phase. */
|
||||
onStageChange: (opportunity: Opportunity, stage: OpportunityStage) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline nach Phasen. Karten lassen sich per Drag & Drop in eine andere Spalte
|
||||
* ziehen; wer nicht ziehen mag, nutzt das Auswahlfeld auf der Karte.
|
||||
*/
|
||||
export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
|
||||
opportunities,
|
||||
onEdit,
|
||||
onStageChange,
|
||||
}) => {
|
||||
const [draggedId, setDraggedId] = useState<number | null>(null);
|
||||
const [hoverStage, setHoverStage] = useState<OpportunityStage | null>(null);
|
||||
|
||||
const byStage = (stage: OpportunityStage) =>
|
||||
opportunities.filter((opportunity) => opportunity.stage === stage);
|
||||
|
||||
const columnSum = (stage: OpportunityStage) =>
|
||||
byStage(stage).reduce((sum, opportunity) => sum + (opportunityVolume(opportunity) ?? 0), 0);
|
||||
|
||||
const handleDrop = (stage: OpportunityStage) => {
|
||||
setHoverStage(null);
|
||||
const dragged = opportunities.find((opportunity) => opportunity.id === draggedId);
|
||||
setDraggedId(null);
|
||||
if (dragged && dragged.stage !== stage) onStageChange(dragged, stage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scrollbar-slim overflow-x-auto pb-2">
|
||||
<div className="flex gap-3" style={{ minWidth: `${OPPORTUNITY_STAGES.length * 250}px` }}>
|
||||
{OPPORTUNITY_STAGES.map((stage) => {
|
||||
const items = byStage(stage);
|
||||
const color = STAGE_COLORS[stage];
|
||||
const isTarget = hoverStage === stage;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage}
|
||||
data-stage={stage}
|
||||
onDragOver={(event) => {
|
||||
// Ohne preventDefault laesst der Browser kein Ablegen zu.
|
||||
event.preventDefault();
|
||||
setHoverStage(stage);
|
||||
}}
|
||||
onDragLeave={() => setHoverStage((current) => (current === stage ? null : current))}
|
||||
onDrop={() => handleDrop(stage)}
|
||||
className={`flex w-[250px] shrink-0 flex-col rounded-2xl border transition ${
|
||||
isTarget ? 'border-brand-400 bg-brand-50/60' : 'border-ink-200 bg-ink-50/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-ink-100 px-3 py-2.5">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-xs font-extrabold uppercase tracking-wider text-ink-600">
|
||||
{stage}
|
||||
</span>
|
||||
<span className="ml-auto rounded-md bg-ink-100 px-1.5 py-0.5 text-[10px] font-extrabold text-ink-500">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-ink-100 px-3 py-1.5 text-[11px] font-bold text-ink-400">
|
||||
{formatEuro(columnSum(stage))}
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-slim flex max-h-[62vh] flex-1 flex-col gap-2 overflow-y-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-200 px-3 py-6 text-center text-[11px] font-semibold text-ink-300">
|
||||
Karte hierher ziehen
|
||||
</p>
|
||||
) : (
|
||||
items.map((opportunity) => {
|
||||
const interaction = relativeDays(opportunity.last_interaction_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opportunity.id}
|
||||
draggable
|
||||
onDragStart={() => setDraggedId(opportunity.id)}
|
||||
onDragEnd={() => {
|
||||
setDraggedId(null);
|
||||
setHoverStage(null);
|
||||
}}
|
||||
onClick={() => onEdit(opportunity)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onEdit(opportunity);
|
||||
}
|
||||
}}
|
||||
className={`cursor-grab rounded-xl border border-ink-200 bg-surface p-2.5 shadow-card transition hover:border-brand-300 active:cursor-grabbing ${
|
||||
draggedId === opportunity.id ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-xs font-extrabold text-ink-900">
|
||||
{opportunity.title}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-ink-400">
|
||||
{opportunity.company || 'Ohne Firma'}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold text-ink-800">
|
||||
{formatEuro(opportunityVolume(opportunity))}
|
||||
</span>
|
||||
<span className="text-[11px] font-bold tabular-nums text-ink-400">
|
||||
{opportunity.probability}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-[11px] text-ink-400">
|
||||
{formatDays(opportunity.duration_days)}
|
||||
{opportunity.start_date && ` ab ${formatDate(opportunity.start_date)}`}
|
||||
</p>
|
||||
|
||||
{interaction && (
|
||||
<p className="mt-1 text-[11px] text-ink-400">Kontakt {interaction}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 border-t border-ink-100 pt-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-ink-500">
|
||||
{opportunity.contacts.length === 0
|
||||
? 'kein Ansprechpartner'
|
||||
: opportunity.contacts.length === 1
|
||||
? contactFullName(opportunity.contacts[0])
|
||||
: `${opportunity.contacts.length} Ansprechpartner`}
|
||||
</span>
|
||||
{opportunity.owner_id !== null && (
|
||||
<UserAvatar
|
||||
user={{
|
||||
id: opportunity.owner_id,
|
||||
name: opportunity.owner_name ?? '?',
|
||||
color: opportunity.owner_color ?? null,
|
||||
avatar: opportunity.owner_avatar ?? null,
|
||||
}}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tastatur- und Touch-Weg ohne Drag & Drop */}
|
||||
<select
|
||||
value={opportunity.stage}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onStageChange(opportunity, event.target.value as OpportunityStage);
|
||||
}}
|
||||
aria-label={`Phase von ${opportunity.title} aendern`}
|
||||
className="mt-2 w-full rounded-lg border border-ink-200 bg-surface px-2 py-1 text-[11px] font-bold text-ink-600 outline-none transition focus:border-brand-500"
|
||||
>
|
||||
{OPPORTUNITY_STAGES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legende der Phasen, damit die Farben auch ausserhalb der Pipeline sitzen */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{OPPORTUNITY_STAGES.map((stage) => (
|
||||
<StageBadge key={stage} stage={stage} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { OpportunityStage } from '../../types';
|
||||
|
||||
/** Phasenfarben - dieselbe Logik wie die Statusfarben der Kontakte. */
|
||||
export const STAGE_STYLES: Record<OpportunityStage, string> = {
|
||||
Neu: 'bg-ink-100 text-ink-600 border-ink-200',
|
||||
Qualifizierung: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
Angebot: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
Verhandlung: 'bg-violet-50 text-violet-700 border-violet-200',
|
||||
Gewonnen: 'bg-brand-50 text-brand-700 border-brand-200',
|
||||
Verloren: 'bg-red-50 text-red-700 border-red-200',
|
||||
};
|
||||
|
||||
/** Akzentfarbe fuer Kartenrand und Pipeline-Spalten. */
|
||||
export const STAGE_COLORS: Record<OpportunityStage, string> = {
|
||||
Neu: '#9a9a9a',
|
||||
Qualifizierung: '#0284c7',
|
||||
Angebot: '#d97706',
|
||||
Verhandlung: '#7c3aed',
|
||||
Gewonnen: '#6ba04a',
|
||||
Verloren: '#dc2626',
|
||||
};
|
||||
|
||||
export const StageBadge: React.FC<{ stage: OpportunityStage; className?: string }> = ({
|
||||
stage,
|
||||
className = '',
|
||||
}) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${STAGE_STYLES[stage]} ${className}`}
|
||||
>
|
||||
{stage}
|
||||
</span>
|
||||
);
|
||||
|
||||
/** `2026-08-04` → `04.08.2026`; null → Gedankenstrich. */
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
return match ? `${match[3]}.${match[2]}.${match[1]}` : value;
|
||||
}
|
||||
|
||||
/** "vor 3 Tagen" - macht in Listen schneller klar, wie kalt eine Chance ist. */
|
||||
export function relativeDays(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
||||
if (!match) return null;
|
||||
|
||||
const then = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
const today = new Date();
|
||||
const days = Math.round(
|
||||
(new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() - then.getTime()) /
|
||||
86_400_000
|
||||
);
|
||||
|
||||
if (days === 0) return 'heute';
|
||||
if (days === 1) return 'gestern';
|
||||
if (days > 1) return `vor ${days} Tagen`;
|
||||
if (days === -1) return 'morgen';
|
||||
return `in ${Math.abs(days)} Tagen`;
|
||||
}
|
||||
|
||||
/** Laufzeit in Personentagen, halbe Tage bleiben erhalten. */
|
||||
export function formatDays(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
const formatted = Number.isInteger(value) ? String(value) : value.toFixed(1).replace('.', ',');
|
||||
return `${formatted} ${value === 1 ? 'Tag' : 'Tage'}`;
|
||||
}
|
||||
|
||||
interface CollapsibleTextProps {
|
||||
text: string | null;
|
||||
/** Ueberschrift des Aufklappers. */
|
||||
label?: string;
|
||||
/** Zeilen, die eingeklappt zu sehen sind. */
|
||||
collapsedLines?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projektbeschreibungen sind lang. Eingeklappt zeigt die Karte nur den Anfang,
|
||||
* der Rest kommt auf Klick - ohne dass die Karte dabei die Liste sprengt.
|
||||
*/
|
||||
export const CollapsibleText: React.FC<CollapsibleTextProps> = ({
|
||||
text,
|
||||
label = 'Projektbeschreibung',
|
||||
collapsedLines = 2,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
if (!text) return null;
|
||||
|
||||
const clamp = collapsedLines === 1 ? 'line-clamp-1' : collapsedLines === 3 ? 'line-clamp-3' : 'line-clamp-2';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-100 bg-ink-50/60 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsOpen((open) => !open);
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-400 transition hover:text-brand-700"
|
||||
>
|
||||
<svg
|
||||
className={`h-3 w-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
<p
|
||||
className={`mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600 ${
|
||||
isOpen ? '' : clamp
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Balken fuer die Abschlusswahrscheinlichkeit. */
|
||||
export const ProbabilityBar: React.FC<{ value: number; color: string }> = ({ value, color }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-ink-100">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%`, backgroundColor: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-9 shrink-0 text-right text-[11px] font-extrabold tabular-nums text-ink-500">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { MatchBadge, MatchStatusBadge, formatDateTime } from './scannerUi';
|
||||
import type { MatchStatus, ScannerMatch } from '../../lib/scannerApi';
|
||||
|
||||
interface MatchDetailModalProps {
|
||||
match: ScannerMatch;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: MatchStatus) => void;
|
||||
onDelete: () => void;
|
||||
/**
|
||||
* "rejected" ist die Ansicht aus Unpassend: dort gibt es nur
|
||||
* Zurueckholen und Loeschen, keine Statuskette.
|
||||
*/
|
||||
variant?: 'default' | 'rejected';
|
||||
}
|
||||
|
||||
const statusButtonClass = (active: boolean) =>
|
||||
`w-full rounded-xl border px-3 py-2 text-xs font-bold transition ${
|
||||
active
|
||||
? 'border-brand-500 bg-brand-500 text-white'
|
||||
: 'border-ink-200 text-ink-600 hover:border-brand-300 hover:text-brand-700'
|
||||
}`;
|
||||
|
||||
export const MatchDetailModal: React.FC<MatchDetailModalProps> = ({
|
||||
match,
|
||||
onClose,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
variant = 'default',
|
||||
}) => {
|
||||
const project = match.Project;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${match.matchPercentage}% Passgenauigkeit`}
|
||||
subtitle={`${match.Agent?.name ?? 'Unbekannter Agent'} · gefunden am ${formatDateTime(
|
||||
match.createdAt
|
||||
)}`}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-ink-200 px-5 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="min-w-0">
|
||||
<h3 className="border-b border-ink-100 pb-3 text-lg text-ink-900">{project?.title}</h3>
|
||||
|
||||
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-ink-400">
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-extrabold uppercase tracking-wider">Portal</dt>
|
||||
<dd className="font-semibold text-ink-700">{project?.portal}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-extrabold uppercase tracking-wider">Ort</dt>
|
||||
<dd className="font-semibold text-ink-700">{project?.location || 'Remote'}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-extrabold uppercase tracking-wider">Budget</dt>
|
||||
<dd className="font-semibold text-ink-700">{project?.rate || 'Keine Angabe'}</dd>
|
||||
</div>
|
||||
{project?.company && (
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-extrabold uppercase tracking-wider">Firma</dt>
|
||||
<dd className="font-semibold text-ink-700">{project.company}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<h4 className="mt-5 text-sm font-extrabold text-ink-900">Projektbeschreibung</h4>
|
||||
<div className="scrollbar-slim mt-2 max-h-64 overflow-y-auto whitespace-pre-line rounded-xl border border-ink-200 bg-ink-50/60 p-4 text-sm text-ink-700">
|
||||
{project?.description || 'Keine Beschreibung hinterlegt.'}
|
||||
</div>
|
||||
|
||||
<h4 className="mt-6 text-sm font-extrabold text-ink-900">Eignungsanalyse (KI)</h4>
|
||||
<div className="mt-2 grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-brand-200 bg-brand-50 p-4">
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wider text-brand-700">
|
||||
Pro
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-sm text-ink-700">
|
||||
{match.pros?.length ? (
|
||||
match.pros.map((entry, index) => (
|
||||
<li key={index} className="flex gap-2">
|
||||
<span className="text-brand-600">+</span>
|
||||
<span>{entry}</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-ink-400">Keine Angaben.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wider text-red-700">
|
||||
Contra / Lücken
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-sm text-ink-700">
|
||||
{match.cons?.length ? (
|
||||
match.cons.map((entry, index) => (
|
||||
<li key={index} className="flex gap-2">
|
||||
<span className="text-red-600">−</span>
|
||||
<span>{entry}</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-ink-400">Keine Angaben.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-ink-200 p-4 text-center">
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Passgenauigkeit
|
||||
</p>
|
||||
<div className="my-4 flex justify-center">
|
||||
<MatchBadge value={match.matchPercentage} size="lg" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<MatchStatusBadge status={match.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-2">
|
||||
{variant === 'default' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onStatusChange('reviewed')}
|
||||
className={statusButtonClass(match.status === 'reviewed')}
|
||||
>
|
||||
Als „Gesehen" markieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onStatusChange('applied')}
|
||||
className={statusButtonClass(match.status === 'applied')}
|
||||
>
|
||||
Als „Beworben" markieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onStatusChange('new')}
|
||||
className={statusButtonClass(match.status === 'new')}
|
||||
>
|
||||
Zurück auf „Neu"
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onStatusChange('new')}
|
||||
className={statusButtonClass(false)}
|
||||
>
|
||||
Zurück ins Dashboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-2xl border border-ink-200 p-4">
|
||||
<a
|
||||
href={project?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full rounded-xl bg-brand-500 py-2.5 text-center text-sm font-bold text-white transition hover:bg-brand-600"
|
||||
>
|
||||
Börse öffnen ↗
|
||||
</a>
|
||||
{variant === 'default' && (
|
||||
<button
|
||||
onClick={() => onStatusChange('rejected')}
|
||||
className="w-full rounded-xl border border-ink-200 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-600"
|
||||
>
|
||||
Passt nicht ✕
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="w-full rounded-xl bg-red-600 py-2.5 text-sm font-bold text-white transition hover:bg-red-700"
|
||||
>
|
||||
Treffer löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ConfirmDialog, Modal } from '../ui/Modal';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import {
|
||||
EmptyState,
|
||||
KeywordTag,
|
||||
Spinner,
|
||||
dangerButtonClass,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from './scannerUi';
|
||||
import { openAgentCv, scannerApi, type AgentInput, type ScannerAgent } from '../../lib/scannerApi';
|
||||
|
||||
/*
|
||||
* Suchagenten = Beraterprofile. Jeder Agent bringt ein CV-PDF (daraus wird der
|
||||
* Text extrahiert und gegen die Projektbeschreibung bewertet) und die Keywords,
|
||||
* mit denen auf den Portalen gesucht wird.
|
||||
*/
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
email: string;
|
||||
keywords: string;
|
||||
excludeKeywords: string;
|
||||
isActive: boolean;
|
||||
cv: File | null;
|
||||
/** Name des bereits gespeicherten PDFs, nur zur Anzeige. */
|
||||
existingCv: string;
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
keywords: '',
|
||||
excludeKeywords: '',
|
||||
isActive: true,
|
||||
cv: null,
|
||||
existingCv: '',
|
||||
};
|
||||
|
||||
const splitTerms = (value: string | null) =>
|
||||
(value ?? '')
|
||||
.split(',')
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const ScannerAgents: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [agents, setAgents] = useState<ScannerAgent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<ScannerAgent | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [agentToDelete, setAgentToDelete] = useState<ScannerAgent | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setAgents(await scannerApi.agents());
|
||||
} catch (err) {
|
||||
addToast(
|
||||
err instanceof Error ? err.message : 'Suchagenten konnten nicht geladen werden.',
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingAgent(null);
|
||||
setForm(emptyForm);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (agent: ScannerAgent) => {
|
||||
setEditingAgent(agent);
|
||||
setForm({
|
||||
name: agent.name,
|
||||
email: agent.email ?? '',
|
||||
keywords: agent.keywords,
|
||||
excludeKeywords: agent.excludeKeywords ?? '',
|
||||
isActive: agent.isActive,
|
||||
cv: null,
|
||||
existingCv: agent.cvFilePath ? agent.cvFilePath.split(/[\\/]/).pop() ?? '' : '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.name.trim() || !form.keywords.trim()) {
|
||||
addToast('Name und Suchbegriffe sind erforderlich.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!editingAgent && !form.cv) {
|
||||
addToast('Ohne CV-PDF kann der Agent keine Projekte bewerten.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AgentInput = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
keywords: form.keywords.trim(),
|
||||
excludeKeywords: form.excludeKeywords.trim(),
|
||||
isActive: form.isActive,
|
||||
cv: form.cv,
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (editingAgent) {
|
||||
await scannerApi.updateAgent(editingAgent.id, payload);
|
||||
addToast(`Agent „${payload.name}" aktualisiert.`, 'success');
|
||||
} else {
|
||||
await scannerApi.createAgent(payload);
|
||||
addToast(`Agent „${payload.name}" angelegt.`, 'success');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!agentToDelete) return;
|
||||
try {
|
||||
await scannerApi.removeAgent(agentToDelete.id);
|
||||
addToast(`Agent „${agentToDelete.name}" gelöscht.`, 'success');
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setAgentToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const showCv = async (agent: ScannerAgent) => {
|
||||
try {
|
||||
await openAgentCv(agent.id);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'CV konnte nicht geöffnet werden.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-ink-500">
|
||||
Beraterprofile mit CV und Suchbegriffen. Nur aktive Agenten suchen im Hintergrund.
|
||||
</p>
|
||||
<button onClick={openCreate} className={primaryButtonClass}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Agent anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner label="Lade Suchagenten..." />
|
||||
) : agents.length === 0 ? (
|
||||
<EmptyState>Noch keine Suchagenten angelegt.</EmptyState>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<li key={agent.id} className="rounded-2xl border border-ink-200 bg-surface p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-extrabold text-ink-900">{agent.name}</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${
|
||||
agent.isActive
|
||||
? 'border-brand-200 bg-brand-50 text-brand-700'
|
||||
: 'border-ink-200 bg-ink-100 text-ink-500'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
agent.isActive ? 'bg-brand-500' : 'bg-ink-400'
|
||||
}`}
|
||||
/>
|
||||
{agent.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-ink-400">{agent.email || 'Keine E-Mail'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{agent.cvFilePath ? (
|
||||
<button onClick={() => showCv(agent)} className={secondaryButtonClass}>
|
||||
CV anzeigen ↗
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-red-600">Kein CV</span>
|
||||
)}
|
||||
<button onClick={() => openEdit(agent)} className={secondaryButtonClass}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => setAgentToDelete(agent)} className={dangerButtonClass}>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Suchbegriffe
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{splitTerms(agent.keywords).map((term) => (
|
||||
<KeywordTag key={term}>{term}</KeywordTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Ausschluss
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{splitTerms(agent.excludeKeywords).length ? (
|
||||
splitTerms(agent.excludeKeywords).map((term) => (
|
||||
<KeywordTag key={term} tone="danger">
|
||||
{term}
|
||||
</KeywordTag>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-ink-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<Modal
|
||||
title={editingAgent ? `${editingAgent.name} bearbeiten` : 'Neuer Suchagent'}
|
||||
subtitle="CV wird als Text ausgelesen und per OpenAI gegen die Projektbeschreibung bewertet"
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="scanner-agent-form"
|
||||
disabled={isSaving}
|
||||
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="scanner-agent-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Berater-Name *</label>
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => set('name', event.target.value)}
|
||||
placeholder="z. B. Max Mustermann"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>E-Mail (für Benachrichtigungen)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) => set('email', event.target.value)}
|
||||
placeholder="max.mustermann@perinco.de"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Suchbegriffe * (kommagetrennt)</label>
|
||||
<input
|
||||
required
|
||||
value={form.keywords}
|
||||
onChange={(event) => set('keywords', event.target.value)}
|
||||
placeholder="SAP MM, SAP PP, Cloud Architect"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Mit diesen Begriffen wird auf den Portalen gesucht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Ausschluss-Begriffe (kommagetrennt)</label>
|
||||
<input
|
||||
value={form.excludeKeywords}
|
||||
onChange={(event) => set('excludeKeywords', event.target.value)}
|
||||
placeholder="ANÜ, Arbeitnehmerüberlassung, Festanstellung"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Projekte mit einem dieser Begriffe in Titel oder Beschreibung werden übersprungen —
|
||||
ohne OpenAI-Kosten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Lebenslauf (PDF){editingAgent ? '' : ' *'}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="w-full rounded-xl border-2 border-dashed border-ink-200 px-4 py-6 text-center transition hover:border-brand-400 hover:bg-brand-50/50"
|
||||
>
|
||||
<p className="text-sm font-bold text-ink-800">
|
||||
{form.cv ? form.cv.name : form.existingCv || 'PDF auswählen'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
{form.cv
|
||||
? 'Wird beim Speichern hochgeladen'
|
||||
: form.existingCv
|
||||
? 'Gespeichertes CV — neues PDF wählen zum Ersetzen'
|
||||
: 'Klicken, um eine PDF-Datei auszuwählen'}
|
||||
</p>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
className="hidden"
|
||||
onChange={(event) => set('cv', event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
onChange={(event) => set('isActive', event.target.checked)}
|
||||
className="h-4 w-4 accent-brand-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-ink-700">
|
||||
Agent ist aktiv und sucht im Hintergrund
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{agentToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Suchagent löschen?"
|
||||
message={`„${agentToDelete.name}" wird mit CV und allen gefundenen Matches entfernt.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setAgentToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,679 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
import { MatchDetailModal } from './MatchDetailModal';
|
||||
import {
|
||||
EmptyState,
|
||||
MatchBadge,
|
||||
MatchStatusBadge,
|
||||
Spinner,
|
||||
cardClass,
|
||||
formatDateTime,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
projectTimestamp,
|
||||
} from './scannerUi';
|
||||
import {
|
||||
SCANNER_SETTINGS_DEFAULTS,
|
||||
scannerApi,
|
||||
type MatchStatus,
|
||||
type ScannerAgent,
|
||||
type ScannerMatch,
|
||||
type ScannerSettings,
|
||||
type ScannerStatus,
|
||||
} from '../../lib/scannerApi';
|
||||
|
||||
/*
|
||||
* Dashboard des Projekt Scanners. Verhalten wie in der eigenstaendigen App:
|
||||
* alle 30 Sekunden nachladen, bei neuen Matches oberhalb des
|
||||
* Benachrichtigungs-Schwellenwerts Signalton und Hinweisbanner.
|
||||
*/
|
||||
|
||||
/** Kurzer Zweiklang ueber die Web-Audio-API - kein Audio-Asset notwendig. */
|
||||
function playMatchBeep() {
|
||||
try {
|
||||
const AudioCtor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtor) return;
|
||||
const ctx = new AudioCtor();
|
||||
const playTone = (freq: number, start: number, duration: number) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0, ctx.currentTime + start);
|
||||
gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + start + 0.02);
|
||||
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + start + duration);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start(ctx.currentTime + start);
|
||||
osc.stop(ctx.currentTime + start + duration);
|
||||
};
|
||||
playTone(880, 0, 0.18);
|
||||
playTone(1175, 0.18, 0.22);
|
||||
setTimeout(() => ctx.close().catch(() => {}), 600);
|
||||
} catch {
|
||||
// Kein Ton ist kein Grund, das Dashboard zu stoeren.
|
||||
}
|
||||
}
|
||||
|
||||
type TimeFilter = '24h' | '2d' | '3d' | '7d' | 'all';
|
||||
|
||||
const TIME_WINDOWS: Record<TimeFilter, number | null> = {
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'2d': 2 * 24 * 60 * 60 * 1000,
|
||||
'3d': 3 * 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
all: null,
|
||||
};
|
||||
|
||||
const TIME_LABELS: Record<TimeFilter, string> = {
|
||||
'24h': 'letzte 24 Stunden',
|
||||
'2d': 'letzte 2 Tage',
|
||||
'3d': 'letzte 3 Tage',
|
||||
'7d': 'letzte Woche',
|
||||
all: 'alle',
|
||||
};
|
||||
|
||||
type SortKey = 'matchPercentage' | 'agent' | 'title' | 'portal' | 'status' | 'createdAt';
|
||||
|
||||
const COLUMNS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'matchPercentage', label: 'Match' },
|
||||
{ key: 'agent', label: 'Berater (Agent)' },
|
||||
{ key: 'title', label: 'Projekt-Titel' },
|
||||
{ key: 'portal', label: 'Plattform' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'createdAt', label: 'Gefunden am' },
|
||||
];
|
||||
|
||||
const selectClass =
|
||||
'rounded-lg border border-ink-200 bg-surface px-2.5 py-1.5 text-xs font-semibold text-ink-700 outline-none transition focus:border-brand-500';
|
||||
|
||||
const numberInputClass =
|
||||
'w-16 rounded-lg border border-ink-200 bg-surface px-2 py-1.5 text-xs font-semibold text-ink-700 outline-none transition focus:border-brand-500';
|
||||
|
||||
interface Stats {
|
||||
activeAgents: number;
|
||||
portalsOnline: number;
|
||||
portalsTotal: number;
|
||||
projects: number;
|
||||
matches: number;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<{ icon: React.ReactNode; value: string; label: string }> = ({
|
||||
icon,
|
||||
value,
|
||||
label,
|
||||
}) => (
|
||||
<div className={`${cardClass} flex items-center gap-4`}>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xl font-extrabold text-ink-900">{value}</p>
|
||||
<p className="truncate text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const icon = (path: string) => (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d={path} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ScannerDashboard: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [matches, setMatches] = useState<ScannerMatch[]>([]);
|
||||
const [agents, setAgents] = useState<ScannerAgent[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
activeAgents: 0,
|
||||
portalsOnline: 0,
|
||||
portalsTotal: 0,
|
||||
projects: 0,
|
||||
matches: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [status, setStatus] = useState<ScannerStatus | null>(null);
|
||||
|
||||
const [selectedMatch, setSelectedMatch] = useState<ScannerMatch | null>(null);
|
||||
const [matchToDelete, setMatchToDelete] = useState<ScannerMatch | null>(null);
|
||||
|
||||
const [timeFilter, setTimeFilter] = useState<TimeFilter>('24h');
|
||||
const [agentFilter, setAgentFilter] = useState<'all' | number>('all');
|
||||
const [displayThreshold, setDisplayThreshold] = useState(0);
|
||||
const [matchThreshold, setMatchThreshold] = useState(70);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'pending' | 'saved' | 'error'>('idle');
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
|
||||
const [sortKey, setSortKey] = useState<SortKey>('matchPercentage');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Benachrichtigungen laufen ueber Refs, damit das Polling keine Render-Schleife
|
||||
// ausloest. null = erster Durchlauf, der nur die Grundmenge merkt.
|
||||
const seenMatchIds = useRef<Set<number> | null>(null);
|
||||
const notifyThreshold = useRef(70);
|
||||
const soundEnabled = useRef(true);
|
||||
const [banner, setBanner] = useState<{ count: number; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!silent) setIsLoading(true);
|
||||
try {
|
||||
const [agentRows, portalRows, matchRows, settings, statusRow] = await Promise.all([
|
||||
scannerApi.agents(),
|
||||
scannerApi.portals(),
|
||||
scannerApi.matches({ limit: 2000 }),
|
||||
// Fehlende Einstellungen sind kein Grund, das Dashboard leer zu lassen.
|
||||
scannerApi.settings().catch((): Partial<ScannerSettings> => ({})),
|
||||
scannerApi.status().catch((): ScannerStatus | null => null),
|
||||
]);
|
||||
|
||||
setAgents(agentRows);
|
||||
setMatches(matchRows);
|
||||
setStatus(statusRow);
|
||||
// Ein Lauf aus dem Scheduler oder aus einem anderen Browserfenster soll
|
||||
// den Knopf hier genauso sperren wie ein selbst gestarteter.
|
||||
if (statusRow) setIsScanning(statusRow.scanRunning);
|
||||
|
||||
const parsedDisplay = parseInt(
|
||||
settings.display_threshold ?? SCANNER_SETTINGS_DEFAULTS.display_threshold,
|
||||
10
|
||||
);
|
||||
if (!Number.isNaN(parsedDisplay)) setDisplayThreshold(parsedDisplay);
|
||||
|
||||
const parsedNotify = parseInt(
|
||||
settings.match_threshold ?? SCANNER_SETTINGS_DEFAULTS.match_threshold,
|
||||
10
|
||||
);
|
||||
if (!Number.isNaN(parsedNotify)) {
|
||||
notifyThreshold.current = parsedNotify;
|
||||
setMatchThreshold(parsedNotify);
|
||||
}
|
||||
soundEnabled.current = settings.sound_notifications_active !== 'false';
|
||||
|
||||
// Abgelehnte Treffer sind ausgeblendet und zaehlen auch nicht mit.
|
||||
const countable = matchRows.filter((m) => m.status !== 'rejected');
|
||||
setStats({
|
||||
activeAgents: agentRows.filter((a) => a.isActive).length,
|
||||
portalsOnline: portalRows.filter((p) => p.connectionStatus === 'connected').length,
|
||||
portalsTotal: portalRows.length,
|
||||
projects: new Set(countable.map((m) => m.projectId)).size,
|
||||
matches: countable.length,
|
||||
});
|
||||
|
||||
// Neue Treffer oberhalb des Schwellenwerts melden.
|
||||
const currentIds = new Set(matchRows.map((m) => m.id));
|
||||
if (seenMatchIds.current === null) {
|
||||
seenMatchIds.current = currentIds;
|
||||
} else {
|
||||
const fresh = matchRows.filter(
|
||||
(m) =>
|
||||
!seenMatchIds.current?.has(m.id) &&
|
||||
(m.matchPercentage ?? 0) >= notifyThreshold.current
|
||||
);
|
||||
if (fresh.length > 0) {
|
||||
if (soundEnabled.current) playMatchBeep();
|
||||
const titles = fresh
|
||||
.slice(0, 3)
|
||||
.map((m) => `${m.matchPercentage}% – ${m.Project?.title ?? ''}`);
|
||||
const more = fresh.length > 3 ? ` (+${fresh.length - 3} weitere)` : '';
|
||||
setBanner({ count: fresh.length, text: titles.join(' · ') + more });
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification(`${fresh.length} neuer Match`, {
|
||||
body: titles.join('\n'),
|
||||
tag: 'perinco-scanner-match',
|
||||
});
|
||||
} catch {
|
||||
// Browser kann die Benachrichtigung ablehnen - nicht kritisch.
|
||||
}
|
||||
}
|
||||
}
|
||||
seenMatchIds.current = currentIds;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
addToast(
|
||||
err instanceof Error ? err.message : 'Scanner-Daten konnten nicht geladen werden.',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(() => load(true), 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
// Browser fragen erst nach einer Interaktion nach der Erlaubnis.
|
||||
useEffect(() => {
|
||||
const ask = () => {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', ask, { once: true });
|
||||
window.addEventListener('keydown', ask, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener('click', ask);
|
||||
window.removeEventListener('keydown', ask);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!banner) return;
|
||||
const timer = window.setTimeout(() => setBanner(null), 10_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [banner]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
}, []);
|
||||
|
||||
/** Beide Schwellenwerte liegen in den Einstellungen - gebuendelt speichern. */
|
||||
const persistThresholds = (display: number, notify: number) => {
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
setSaveState('pending');
|
||||
saveTimer.current = window.setTimeout(async () => {
|
||||
try {
|
||||
await scannerApi.saveSettings({
|
||||
display_threshold: String(display),
|
||||
match_threshold: String(notify),
|
||||
});
|
||||
setSaveState('saved');
|
||||
window.setTimeout(() => setSaveState('idle'), 1500);
|
||||
} catch {
|
||||
setSaveState('error');
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const clamp = (value: string) => {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? 0 : Math.max(0, Math.min(100, parsed));
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
if (isScanning) return;
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const { message } = await scannerApi.scan();
|
||||
addToast(message, 'success');
|
||||
// Der Scan laeuft im Hintergrund; erste Treffer koennen nach wenigen
|
||||
// Sekunden da sein. Das Ende meldet der Statusabruf unten.
|
||||
window.setTimeout(() => load(true), 8000);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Scan konnte nicht gestartet werden.', 'error');
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Waehrend ein Lauf aktiv ist, haeufiger nachsehen als im 30-Sekunden-Takt -
|
||||
// sonst bleibt der Knopf nach dem Ende unnoetig lange gesperrt.
|
||||
useEffect(() => {
|
||||
if (!isScanning) return;
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const current = await scannerApi.status();
|
||||
setStatus(current);
|
||||
if (!current.scanRunning) {
|
||||
setIsScanning(false);
|
||||
load(true);
|
||||
}
|
||||
} catch {
|
||||
// Netzwerkaussetzer aendern den Zustand nicht.
|
||||
}
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isScanning, load]);
|
||||
|
||||
const changeStatus = async (match: ScannerMatch, status: MatchStatus) => {
|
||||
try {
|
||||
await scannerApi.setMatchStatus(match.id, status);
|
||||
setMatches((prev) => prev.map((m) => (m.id === match.id ? { ...m, status } : m)));
|
||||
setSelectedMatch((prev) => (prev && prev.id === match.id ? { ...prev, status } : prev));
|
||||
if (status === 'rejected') {
|
||||
setSelectedMatch(null);
|
||||
addToast('Treffer nach „Unpassend" verschoben.', 'success');
|
||||
load(true);
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Status konnte nicht geändert werden.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!matchToDelete) return;
|
||||
try {
|
||||
await scannerApi.removeMatch(matchToDelete.id);
|
||||
setMatches((prev) => prev.filter((m) => m.id !== matchToDelete.id));
|
||||
setSelectedMatch(null);
|
||||
addToast('Treffer gelöscht.', 'success');
|
||||
load(true);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setMatchToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection((direction) => (direction === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const visibleMatches = useMemo(() => {
|
||||
const window_ = TIME_WINDOWS[timeFilter];
|
||||
const cutoff = window_ ? Date.now() - window_ : null;
|
||||
|
||||
const sortValue = (match: ScannerMatch): string | number => {
|
||||
switch (sortKey) {
|
||||
case 'matchPercentage':
|
||||
return match.matchPercentage ?? 0;
|
||||
case 'agent':
|
||||
return (match.Agent?.name ?? '').toLowerCase();
|
||||
case 'title':
|
||||
return (match.Project?.title ?? '').toLowerCase();
|
||||
case 'portal':
|
||||
return (match.Project?.portal ?? '').toLowerCase();
|
||||
case 'status':
|
||||
return match.status ?? '';
|
||||
case 'createdAt':
|
||||
return new Date(match.createdAt).getTime();
|
||||
}
|
||||
};
|
||||
|
||||
return matches
|
||||
.filter((match) => match.status !== 'rejected')
|
||||
.filter((match) => (match.matchPercentage ?? 0) >= displayThreshold)
|
||||
.filter((match) => agentFilter === 'all' || match.agentId === agentFilter)
|
||||
// Gefiltert wird nach dem Alter des Projekts, nicht des Matches.
|
||||
.filter((match) => (cutoff === null ? true : projectTimestamp(match) >= cutoff))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const left = sortValue(a);
|
||||
const right = sortValue(b);
|
||||
if (left < right) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (left > right) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [matches, timeFilter, agentFilter, displayThreshold, sortKey, sortDirection]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-ink-500">
|
||||
Übersicht über Suchagenten, Portale und Projektübereinstimmungen.
|
||||
</p>
|
||||
{status && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] font-extrabold uppercase tracking-wider">
|
||||
{/* Beim Parallelbetrieb ist wichtig, ob dieser Scanner selbst
|
||||
sucht - die alte App hat ihren eigenen Zeitplan. */}
|
||||
{!status.scanningActive ? (
|
||||
<span className="rounded-md border border-ink-200 bg-ink-100 px-2 py-0.5 text-ink-500">
|
||||
○ Scanner pausiert
|
||||
</span>
|
||||
) : status.schedulerEnabled ? (
|
||||
<span className="rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-brand-700">
|
||||
● Automatische Suche aktiv
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-700">
|
||||
○ Nur manuelle Suche
|
||||
</span>
|
||||
)}
|
||||
{status.scanRunning && (
|
||||
<span className="rounded-md border border-sky-200 bg-sky-50 px-2 py-0.5 text-sky-700">
|
||||
Suchlauf läuft
|
||||
</span>
|
||||
)}
|
||||
<span className="normal-case tracking-normal text-ink-400">{status.database}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleScan} disabled={isScanning} className={primaryButtonClass}>
|
||||
{isScanning ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||
Scanne...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{icon('M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15')}
|
||||
Jetzt scannen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{banner && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-brand-200 bg-brand-50 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-extrabold text-brand-700">
|
||||
🔔 {banner.count} neuer Match
|
||||
</p>
|
||||
<p className="truncate text-xs text-ink-600">{banner.text}</p>
|
||||
</div>
|
||||
<button onClick={() => setBanner(null)} className={secondaryButtonClass}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={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 0z'
|
||||
)}
|
||||
value={String(stats.activeAgents)}
|
||||
label="Aktive Agenten"
|
||||
/>
|
||||
<StatCard
|
||||
icon={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'
|
||||
)}
|
||||
value={`${stats.portalsOnline} / ${stats.portalsTotal}`}
|
||||
label="Portale online"
|
||||
/>
|
||||
<StatCard
|
||||
icon={icon('M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z')}
|
||||
value={String(stats.projects)}
|
||||
label="Scraped Projekte"
|
||||
/>
|
||||
<StatCard
|
||||
icon={icon(
|
||||
'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
)}
|
||||
value={String(stats.matches)}
|
||||
label="Gesamt Matches"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cardClass}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base text-ink-900">Projekt-Übereinstimmungen</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
<label className="flex items-center gap-1.5">
|
||||
Zeitraum
|
||||
<select
|
||||
value={timeFilter}
|
||||
onChange={(event) => setTimeFilter(event.target.value as TimeFilter)}
|
||||
className={selectClass}
|
||||
>
|
||||
{Object.entries(TIME_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5">
|
||||
Agent
|
||||
<select
|
||||
value={String(agentFilter)}
|
||||
onChange={(event) =>
|
||||
setAgentFilter(event.target.value === 'all' ? 'all' : Number(event.target.value))
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="all">alle</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5" title="Treffer unter diesem Wert ausblenden">
|
||||
Anzeige ab %
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={displayThreshold}
|
||||
onChange={(event) => {
|
||||
const value = clamp(event.target.value);
|
||||
setDisplayThreshold(value);
|
||||
persistThresholds(value, matchThreshold);
|
||||
}}
|
||||
className={numberInputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5" title="Ab diesem Wert Ton und Hinweis">
|
||||
Hinweis ab %
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={matchThreshold}
|
||||
onChange={(event) => {
|
||||
const value = clamp(event.target.value);
|
||||
setMatchThreshold(value);
|
||||
notifyThreshold.current = value;
|
||||
persistThresholds(displayThreshold, value);
|
||||
}}
|
||||
className={numberInputClass}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span className="normal-case tracking-normal">
|
||||
{visibleMatches.length} / {matches.length}
|
||||
{saveState === 'pending' && ' · speichert…'}
|
||||
{saveState === 'saved' && ' · gespeichert'}
|
||||
{saveState === 'error' && ' · Fehler'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner label="Lade Matches..." />
|
||||
) : matches.length === 0 ? (
|
||||
<EmptyState>
|
||||
Noch keine Matches. Lege Suchagenten mit CV und Keywords an und starte einen Scan.
|
||||
</EmptyState>
|
||||
) : visibleMatches.length === 0 ? (
|
||||
<EmptyState>
|
||||
Keine Treffer für die aktuellen Filter (Zeitraum, Agent, Anzeige ab {displayThreshold}%).
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="scrollbar-slim -mx-5 overflow-x-auto px-5">
|
||||
<table className="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200">
|
||||
{COLUMNS.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
onClick={() => toggleSort(column.key)}
|
||||
className="cursor-pointer select-none px-3 py-2.5 text-left text-[10px] font-extrabold uppercase tracking-wider text-ink-400 transition hover:text-brand-700"
|
||||
>
|
||||
{column.label}
|
||||
{sortKey === column.key && (sortDirection === 'asc' ? ' ▲' : ' ▼')}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleMatches.map((match) => (
|
||||
<tr
|
||||
key={match.id}
|
||||
onClick={() => setSelectedMatch(match)}
|
||||
className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/50"
|
||||
>
|
||||
<td className="px-3 py-2.5">
|
||||
<MatchBadge value={match.matchPercentage} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-bold text-ink-800">
|
||||
{match.Agent?.name ?? '—'}
|
||||
</td>
|
||||
<td className="max-w-md px-3 py-2.5">
|
||||
<p className="truncate font-bold text-ink-900">{match.Project?.title}</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{match.Project?.location || 'Remote'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-ink-600">{match.Project?.portal}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<MatchStatusBadge status={match.status} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-xs text-ink-400">
|
||||
{formatDateTime(match.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5" onClick={(event) => event.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setSelectedMatch(match)}
|
||||
className={secondaryButtonClass}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedMatch && (
|
||||
<MatchDetailModal
|
||||
match={selectedMatch}
|
||||
onClose={() => setSelectedMatch(null)}
|
||||
onStatusChange={(status) => changeStatus(selectedMatch, status)}
|
||||
onDelete={() => setMatchToDelete(selectedMatch)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{matchToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Treffer löschen?"
|
||||
message={`„${matchToDelete.Project?.title ?? 'Treffer'}" wird endgültig entfernt. Bei einem späteren Scan kann er erneut gefunden werden.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setMatchToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import {
|
||||
PortalStatusPill,
|
||||
Spinner,
|
||||
cardClass,
|
||||
formatDateTime,
|
||||
primaryButtonClass,
|
||||
secondaryButtonClass,
|
||||
} from './scannerUi';
|
||||
import { scannerApi, type ScannerPortal } from '../../lib/scannerApi';
|
||||
|
||||
/*
|
||||
* Börsen-Konten. Geschuetzte Portale brauchen eine importierte Session
|
||||
* (Cookie-Editor), oeffentliche Portale werden ohne Login gescrapt.
|
||||
*/
|
||||
const AUTH_PORTALS = ['freelance.de', 'freelancermap.de'];
|
||||
|
||||
export const ScannerPortals: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [portals, setPortals] = useState<ScannerPortal[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
|
||||
const [credentialPortal, setCredentialPortal] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSavingCredentials, setIsSavingCredentials] = useState(false);
|
||||
|
||||
const [sessionPortal, setSessionPortal] = useState('');
|
||||
const [rawCookies, setRawCookies] = useState('');
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setPortals(await scannerApi.portals());
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Portale konnten nicht geladen werden.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const isAuthPortal = (name: string) => AUTH_PORTALS.includes(name);
|
||||
|
||||
const saveCredentials = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!credentialPortal || !username || !password) {
|
||||
addToast('Portal, Benutzername und Passwort sind erforderlich.', 'error');
|
||||
return;
|
||||
}
|
||||
setIsSavingCredentials(true);
|
||||
try {
|
||||
const { message } = await scannerApi.savePortalCredentials({
|
||||
name: credentialPortal,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
addToast(message, 'success');
|
||||
setCredentialPortal('');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSavingCredentials(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importSession = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!sessionPortal || !rawCookies.trim()) {
|
||||
addToast('Portal wählen und Cookies einfügen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const input = rawCookies.trim();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(input);
|
||||
} catch {
|
||||
// Kein JSON: der rohe Cookie-Header "name=wert; name2=wert2" wird
|
||||
// in die von Playwright erwartete Liste umgebaut.
|
||||
if (!input.includes('=')) {
|
||||
throw new Error('Eingabe ist weder JSON noch ein Cookie-Header.');
|
||||
}
|
||||
payload = input.split(';').map((entry) => {
|
||||
const parts = entry.trim().split('=');
|
||||
return {
|
||||
name: parts[0],
|
||||
value: parts.slice(1).join('='),
|
||||
domain: `.${sessionPortal}`,
|
||||
path: '/',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { message } = await scannerApi.syncPortalSession(sessionPortal, payload);
|
||||
addToast(message, 'success');
|
||||
setRawCookies('');
|
||||
setSessionPortal('');
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Import fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testConnection = async (portal: ScannerPortal) => {
|
||||
setTestingId(portal.id);
|
||||
try {
|
||||
const result = await scannerApi.testPortal(portal.id);
|
||||
addToast(result.message, result.status === 'connected' ? 'success' : 'error');
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Verbindungstest fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-ink-500">
|
||||
Geschützte Portale (Freelance.de, Freelancermap.de) brauchen importierte Session-Cookies.
|
||||
Öffentliche Portale wie Hays.de oder Etengo.de werden ohne Login gescrapt.
|
||||
</p>
|
||||
|
||||
<div className={cardClass}>
|
||||
<h3 className="text-base text-ink-900">Verbindungs-Status</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner label="Lade Portale..." />
|
||||
) : (
|
||||
<div className="scrollbar-slim -mx-5 mt-4 overflow-x-auto px-5">
|
||||
<table className="w-full min-w-[760px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200">
|
||||
{['Portal', 'Typ', 'Benutzername', 'Status', 'Zuletzt geprüft', ''].map((label, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className="px-3 py-2.5 text-left text-[10px] font-extrabold uppercase tracking-wider text-ink-400"
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portals.map((portal) => {
|
||||
const authNeeded = isAuthPortal(portal.name);
|
||||
return (
|
||||
<tr key={portal.id} className="border-b border-ink-100">
|
||||
<td className="px-3 py-2.5 font-bold text-ink-900">{portal.name}</td>
|
||||
<td className="px-3 py-2.5 text-xs font-semibold">
|
||||
{authNeeded ? (
|
||||
<span className="text-amber-700">Login nötig</span>
|
||||
) : (
|
||||
<span className="text-brand-700">Öffentlich</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-ink-600">
|
||||
{portal.username || (
|
||||
<span className="text-xs text-ink-400">
|
||||
{authNeeded ? 'Keine Zugangsdaten' : '—'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<PortalStatusPill
|
||||
status={portal.connectionStatus}
|
||||
fallbackLabel={authNeeded ? undefined : 'Bereit'}
|
||||
/>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-xs text-ink-400">
|
||||
{formatDateTime(portal.lastLoginAttempt)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<button
|
||||
onClick={() => testConnection(portal)}
|
||||
disabled={testingId === portal.id || (authNeeded && !portal.hasSession)}
|
||||
title={
|
||||
authNeeded && !portal.hasSession ? 'Erst Cookies importieren' : undefined
|
||||
}
|
||||
className={secondaryButtonClass}
|
||||
>
|
||||
{testingId === portal.id ? 'Prüft...' : 'Jetzt prüfen'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<form onSubmit={saveCredentials} className={cardClass}>
|
||||
<h3 className="text-base text-ink-900">Portal-Zugangsdaten (optional)</h3>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Damit der Scraper bei abgelaufener Session einen automatischen Login versuchen kann.
|
||||
Passwörter liegen verschlüsselt in der Datenbank.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Portal</label>
|
||||
<select
|
||||
required
|
||||
value={credentialPortal}
|
||||
onChange={(event) => setCredentialPortal(event.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{AUTH_PORTALS.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Benutzername / E-Mail</label>
|
||||
<input
|
||||
required
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className={inputClass}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Passwort</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className={inputClass}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={isSavingCredentials} className={primaryButtonClass}>
|
||||
{isSavingCredentials ? 'Speichere...' : 'Zugangsdaten speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onSubmit={importSession} className={cardClass}>
|
||||
<h3 className="text-base text-ink-900">Cookie-Import</h3>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Cookies aus der Browser-Erweiterung Cookie-Editor als JSON exportieren und hier
|
||||
einfügen. Ein roher Cookie-Header wird ebenfalls erkannt.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Portal</label>
|
||||
<select
|
||||
required
|
||||
value={sessionPortal}
|
||||
onChange={(event) => setSessionPortal(event.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{AUTH_PORTALS.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Cookies (JSON oder Header)</label>
|
||||
<textarea
|
||||
required
|
||||
rows={6}
|
||||
value={rawCookies}
|
||||
onChange={(event) => setRawCookies(event.target.value)}
|
||||
placeholder='[{"name":"session_id","value":"..."}]'
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={isImporting} className={primaryButtonClass}>
|
||||
{isImporting ? 'Importiere...' : 'Cookies importieren'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
import { MatchDetailModal } from './MatchDetailModal';
|
||||
import {
|
||||
EmptyState,
|
||||
MatchBadge,
|
||||
Spinner,
|
||||
cardClass,
|
||||
dangerButtonClass,
|
||||
formatDateTime,
|
||||
secondaryButtonClass,
|
||||
} from './scannerUi';
|
||||
import { scannerApi, type ScannerMatch } from '../../lib/scannerApi';
|
||||
|
||||
/*
|
||||
* Alle Treffer, die als „passt nicht" markiert wurden (status = rejected).
|
||||
* Sie sind im Dashboard ausgeblendet und lassen sich hier zurueckholen oder
|
||||
* endgueltig loeschen.
|
||||
*/
|
||||
export const ScannerRejected: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [matches, setMatches] = useState<ScannerMatch[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedMatch, setSelectedMatch] = useState<ScannerMatch | null>(null);
|
||||
const [matchToDelete, setMatchToDelete] = useState<ScannerMatch | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setMatches(await scannerApi.matches({ status: 'rejected', limit: 2000 }));
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Daten konnten nicht geladen werden.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const restore = async (match: ScannerMatch) => {
|
||||
try {
|
||||
await scannerApi.setMatchStatus(match.id, 'new');
|
||||
setMatches((prev) => prev.filter((m) => m.id !== match.id));
|
||||
setSelectedMatch(null);
|
||||
addToast('Treffer ist wieder im Dashboard.', 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Zurückholen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!matchToDelete) return;
|
||||
try {
|
||||
await scannerApi.removeMatch(matchToDelete.id);
|
||||
setMatches((prev) => prev.filter((m) => m.id !== matchToDelete.id));
|
||||
setSelectedMatch(null);
|
||||
addToast('Treffer gelöscht.', 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setMatchToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-ink-500">
|
||||
Projekte, die du als „passt nicht" markiert hast. Sie erscheinen nicht im Dashboard.
|
||||
</p>
|
||||
|
||||
<div className={cardClass}>
|
||||
{isLoading ? (
|
||||
<Spinner label="Lade Treffer..." />
|
||||
) : matches.length === 0 ? (
|
||||
<EmptyState>Keine als „passt nicht" markierten Projekte.</EmptyState>
|
||||
) : (
|
||||
<div className="scrollbar-slim -mx-5 overflow-x-auto px-5">
|
||||
<table className="w-full min-w-[800px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200">
|
||||
{['Match', 'Berater (Agent)', 'Projekt-Titel', 'Plattform', 'Gefunden am', 'Aktionen'].map(
|
||||
(label) => (
|
||||
<th
|
||||
key={label}
|
||||
className="px-3 py-2.5 text-left text-[10px] font-extrabold uppercase tracking-wider text-ink-400"
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.map((match) => (
|
||||
<tr
|
||||
key={match.id}
|
||||
onClick={() => setSelectedMatch(match)}
|
||||
className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/50"
|
||||
>
|
||||
<td className="px-3 py-2.5">
|
||||
<MatchBadge value={match.matchPercentage} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-bold text-ink-800">
|
||||
{match.Agent?.name ?? '—'}
|
||||
</td>
|
||||
<td className="max-w-md px-3 py-2.5">
|
||||
<p className="truncate font-bold text-ink-900">{match.Project?.title}</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{match.Project?.location || 'Remote'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-ink-600">{match.Project?.portal}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-xs text-ink-400">
|
||||
{formatDateTime(match.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => restore(match)} className={secondaryButtonClass}>
|
||||
Zurück ins Dashboard
|
||||
</button>
|
||||
<button onClick={() => setMatchToDelete(match)} className={dangerButtonClass}>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedMatch && (
|
||||
<MatchDetailModal
|
||||
match={selectedMatch}
|
||||
variant="rejected"
|
||||
onClose={() => setSelectedMatch(null)}
|
||||
onStatusChange={() => restore(selectedMatch)}
|
||||
onDelete={() => setMatchToDelete(selectedMatch)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{matchToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Treffer endgültig löschen?"
|
||||
message={`„${matchToDelete.Project?.title ?? 'Treffer'}" wird aus der Datenbank entfernt.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setMatchToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import type { MatchStatus, PortalStatus, ScannerMatch } from '../../lib/scannerApi';
|
||||
|
||||
/*
|
||||
* Gemeinsame Bausteine der Scanner-Ansichten. Die Farblogik ist aus der
|
||||
* urspruenglichen Scanner-App uebernommen (>=75 % gut, >=50 % mittel, darunter
|
||||
* schwach), nur auf die CRM-Palette umgestellt.
|
||||
*/
|
||||
|
||||
export const cardClass = 'rounded-2xl border border-ink-200 bg-surface p-5';
|
||||
|
||||
export const primaryButtonClass =
|
||||
'flex shrink-0 items-center gap-2 rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60';
|
||||
|
||||
export const secondaryButtonClass =
|
||||
'rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 disabled:opacity-50';
|
||||
|
||||
export const dangerButtonClass =
|
||||
'rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-bold text-red-700 transition hover:bg-red-100 disabled:opacity-50';
|
||||
|
||||
export const Spinner: React.FC<{ label?: string }> = ({ label }) => (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-14">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
{label && <p className="text-xs font-semibold text-ink-400">{label}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const EmptyState: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<p className="py-14 text-center text-sm text-ink-400">{children}</p>
|
||||
);
|
||||
|
||||
/** Rundes Prozent-Badge wie im alten Dashboard. */
|
||||
export const MatchBadge: React.FC<{ value: number; size?: 'sm' | 'lg' }> = ({
|
||||
value,
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const tone =
|
||||
value >= 75
|
||||
? 'border-brand-300 bg-brand-50 text-brand-700'
|
||||
: value >= 50
|
||||
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: 'border-ink-200 bg-ink-100 text-ink-500';
|
||||
const dimensions = size === 'lg' ? 'h-16 w-16 text-base' : 'h-10 w-10 text-[11px]';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full border-2 font-extrabold ${tone} ${dimensions}`}
|
||||
>
|
||||
{value}%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const MATCH_STATUS_LABELS: Record<MatchStatus, { label: string; className: string }> = {
|
||||
new: { label: 'Neu', className: 'border-sky-200 bg-sky-50 text-sky-700' },
|
||||
reviewed: { label: 'Gesehen', className: 'border-amber-200 bg-amber-50 text-amber-700' },
|
||||
applied: { label: 'Beworben', className: 'border-brand-200 bg-brand-50 text-brand-700' },
|
||||
rejected: { label: 'Abgelehnt', className: 'border-red-200 bg-red-50 text-red-700' },
|
||||
};
|
||||
|
||||
export const MatchStatusBadge: React.FC<{ status: MatchStatus }> = ({ status }) => {
|
||||
const info = MATCH_STATUS_LABELS[status] ?? {
|
||||
label: status,
|
||||
className: 'border-ink-200 bg-ink-100 text-ink-500',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${info.className}`}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PORTAL_STATUS_LABELS: Record<PortalStatus, { label: string; className: string; dot: string }> =
|
||||
{
|
||||
connected: {
|
||||
label: 'Verbunden',
|
||||
className: 'border-brand-200 bg-brand-50 text-brand-700',
|
||||
dot: 'bg-brand-500',
|
||||
},
|
||||
expired: {
|
||||
label: 'Abgelaufen / CAPTCHA',
|
||||
className: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
error: {
|
||||
label: 'Fehler',
|
||||
className: 'border-red-200 bg-red-50 text-red-700',
|
||||
dot: 'bg-red-500',
|
||||
},
|
||||
disconnected: {
|
||||
label: 'Getrennt',
|
||||
className: 'border-ink-200 bg-ink-100 text-ink-500',
|
||||
dot: 'bg-ink-400',
|
||||
},
|
||||
};
|
||||
|
||||
export const PortalStatusPill: React.FC<{ status: PortalStatus; fallbackLabel?: string }> = ({
|
||||
status,
|
||||
fallbackLabel,
|
||||
}) => {
|
||||
const info = PORTAL_STATUS_LABELS[status] ?? PORTAL_STATUS_LABELS.disconnected;
|
||||
const label = status === 'disconnected' && fallbackLabel ? fallbackLabel : info.label;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${info.className}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${info.dot}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeywordTag: React.FC<{ children: React.ReactNode; tone?: 'neutral' | 'danger' }> = ({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
}) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-semibold ${
|
||||
tone === 'danger'
|
||||
? 'border-red-200 bg-red-50 text-red-700'
|
||||
: 'border-ink-200 bg-ink-100 text-ink-600'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Datum + Uhrzeit im deutschen Format; leere Werte werden zu einem Strich. */
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitpunkt, an dem das Projekt gescraped wurde. Das Project-Modell benennt
|
||||
* created_at um, deshalb kommen beide Schreibweisen vor.
|
||||
*/
|
||||
export function projectTimestamp(match: ScannerMatch): number {
|
||||
const raw = match.Project?.created_at || match.Project?.createdAt || match.Project?.publishedAt;
|
||||
return raw ? new Date(raw).getTime() : 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { USER_COLORS } from '../../types';
|
||||
|
||||
interface ColorPickerProps {
|
||||
/** null = automatisch aus der Palette ableiten */
|
||||
value: string | null;
|
||||
onChange: (color: string | null) => void;
|
||||
/** Farbe, die bei value === null tatsaechlich verwendet wird. */
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/** Auswahl der persoenlichen Kalenderfarbe. */
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange, fallback }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{USER_COLORS.map((color) => {
|
||||
const isSelected = value?.toLowerCase() === color;
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onChange(color)}
|
||||
aria-label={`Farbe ${color}`}
|
||||
aria-pressed={isSelected}
|
||||
className={`h-8 w-8 rounded-lg transition ${
|
||||
isSelected ? 'ring-2 ring-ink-900 ring-offset-2' : 'hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<label
|
||||
title="Eigene Farbe waehlen"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dashed border-ink-300 text-ink-400 transition hover:border-brand-400 hover:text-brand-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="color"
|
||||
value={value ?? fallback}
|
||||
onChange={(event) => onChange(event.target.value.toLowerCase())}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex h-9 flex-1 items-center rounded-xl px-3 text-xs font-extrabold uppercase tracking-wider text-white"
|
||||
style={{ backgroundColor: value ?? fallback }}
|
||||
>
|
||||
{value ?? `${fallback} (automatisch)`}
|
||||
</span>
|
||||
{value !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className="shrink-0 rounded-xl border border-ink-200 px-3 py-2 text-xs font-bold text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
Automatisch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import {
|
||||
SCANNER_SETTINGS_DEFAULTS,
|
||||
SECRET_MASK,
|
||||
scannerApi,
|
||||
type ScannerSettings as ScannerSettingsValues,
|
||||
} from '../../lib/scannerApi';
|
||||
|
||||
/*
|
||||
* Alle Einstellungen des Projekt Scanners an einer Stelle. Anzeige- und
|
||||
* Benachrichtigungs-Schwellenwert lassen sich zusaetzlich direkt im
|
||||
* Scanner-Dashboard verstellen - es ist derselbe Wert.
|
||||
*/
|
||||
|
||||
const Section: React.FC<{
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
accent?: string;
|
||||
}> = ({ title, description, children, accent }) => (
|
||||
<section
|
||||
className={`rounded-2xl border border-ink-200 bg-surface p-5 ${
|
||||
accent ? `border-l-4 ${accent}` : ''
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-base text-ink-900">{title}</h3>
|
||||
{description && <p className="mt-1 text-xs text-ink-400">{description}</p>}
|
||||
<div className="mt-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const Checkbox: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
}> = ({ checked, onChange, label }) => (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="h-4 w-4 accent-brand-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-ink-700">{label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
export const ScannerSettings: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const [values, setValues] = useState<ScannerSettingsValues>(SCANNER_SETTINGS_DEFAULTS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await scannerApi.settings();
|
||||
setValues({ ...SCANNER_SETTINGS_DEFAULTS, ...data });
|
||||
} catch (err) {
|
||||
addToast(
|
||||
err instanceof Error ? err.message : 'Scanner-Einstellungen nicht erreichbar.',
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Einmal beim Oeffnen laden - addToast ist stabil, load braucht keine Deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const set = <K extends keyof ScannerSettingsValues>(key: K, value: string) =>
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const setFlag = (key: keyof ScannerSettingsValues, checked: boolean) =>
|
||||
set(key, checked ? 'true' : 'false');
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await scannerApi.saveSettings(values);
|
||||
addToast('Scanner-Einstellungen gespeichert.', 'success');
|
||||
// Neu laden, damit Geheimnisse wieder maskiert erscheinen.
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-14">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isScanningActive = values.scanning_active === 'true';
|
||||
const emailActive = values.email_notifications_active === 'true';
|
||||
const keyStored = values.openai_api_key === SECRET_MASK;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<Section
|
||||
title="Scanner-Status"
|
||||
description="Hauptschalter für den Hintergrund-Scanner. Pausiert durchsucht die App keine Jobbörsen mehr, auch der manuelle Scan ist dann blockiert."
|
||||
accent={isScanningActive ? 'border-l-brand-500' : 'border-l-ink-300'}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${
|
||||
isScanningActive
|
||||
? 'border-brand-200 bg-brand-50 text-brand-700'
|
||||
: 'border-ink-200 bg-ink-100 text-ink-500'
|
||||
}`}
|
||||
>
|
||||
{isScanningActive ? '● Aktiv' : '○ Pausiert'}
|
||||
</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={isScanningActive}
|
||||
onChange={(checked) => setFlag('scanning_active', checked)}
|
||||
label="Scanner aktiviert"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Suche">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className={labelClass}>Suchintervall (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
max={1440}
|
||||
required
|
||||
value={values.search_interval}
|
||||
onChange={(event) => set('search_interval', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Wie oft der Hintergrund-Scanner die Jobbörsen durchsucht.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Anzeige ab %</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={values.display_threshold}
|
||||
onChange={(event) => set('display_threshold', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Treffer darunter bleiben im Dashboard ausgeblendet.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Benachrichtigung ab %</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={values.match_threshold}
|
||||
onChange={(event) => set('match_threshold', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Ab diesem Wert gibt es Signalton und E-Mail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="OpenAI"
|
||||
description="Wird für die semantische Bewertung des Lebenslaufs gegen die Projektbeschreibung gebraucht (gpt-4o-mini)."
|
||||
>
|
||||
<label className={labelClass}>
|
||||
API-Key
|
||||
{keyStored && (
|
||||
<span className="ml-2 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
|
||||
gespeichert
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={keyStored ? '' : values.openai_api_key}
|
||||
onChange={(event) => set('openai_api_key', event.target.value)}
|
||||
placeholder={
|
||||
keyStored
|
||||
? 'Leer lassen, um den gespeicherten Key zu behalten'
|
||||
: 'sk-proj-...'
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Browser-Benachrichtigungen"
|
||||
description={`Bei einem neuen Treffer über ${values.match_threshold}% spielt das Scanner-Dashboard einen Signalton. Voraussetzung: Der Reiter ist im Browser geöffnet. Browser lassen Töne erst nach dem ersten Klick auf der Seite zu.`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={values.sound_notifications_active === 'true'}
|
||||
onChange={(checked) => setFlag('sound_notifications_active', checked)}
|
||||
label="Signalton bei neuen Matches"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="E-Mail-Benachrichtigungen">
|
||||
<Checkbox
|
||||
checked={emailActive}
|
||||
onChange={(checked) => setFlag('email_notifications_active', checked)}
|
||||
label="E-Mail bei neuen Matches versenden"
|
||||
/>
|
||||
|
||||
{emailActive && (
|
||||
<div className="mt-4 space-y-4 border-l-2 border-brand-200 pl-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>Empfänger (Standard)</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={values.notification_email_recipient}
|
||||
onChange={(event) => set('notification_email_recipient', event.target.value)}
|
||||
placeholder="info@perinco.de"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Wird verwendet, wenn beim Agenten keine eigene E-Mail hinterlegt ist.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Absender (From)</label>
|
||||
<input
|
||||
value={values.smtp_from}
|
||||
onChange={(event) => set('smtp_from', event.target.value)}
|
||||
placeholder="scanner@perinco.de"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[2fr_1fr]">
|
||||
<div>
|
||||
<label className={labelClass}>SMTP-Host</label>
|
||||
<input
|
||||
required
|
||||
value={values.smtp_host}
|
||||
onChange={(event) => set('smtp_host', event.target.value)}
|
||||
placeholder="smtp.deine-domain.de"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>SMTP-Port</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={values.smtp_port}
|
||||
onChange={(event) => set('smtp_port', event.target.value)}
|
||||
placeholder="587"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>SMTP-Benutzer</label>
|
||||
<input
|
||||
required
|
||||
value={values.smtp_user}
|
||||
onChange={(event) => set('smtp_user', event.target.value)}
|
||||
className={inputClass}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
SMTP-Passwort
|
||||
{values.smtp_pass === SECRET_MASK && (
|
||||
<span className="ml-2 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
|
||||
gespeichert
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={values.smtp_pass === SECRET_MASK ? '' : values.smtp_pass}
|
||||
onChange={(event) => set('smtp_pass', event.target.value)}
|
||||
placeholder={
|
||||
values.smtp_pass === SECRET_MASK
|
||||
? 'Leer lassen, um das gespeicherte Passwort zu behalten'
|
||||
: 'Passwort'
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="rounded-xl bg-brand-500 px-6 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { UserManagement } from './UserManagement';
|
||||
import { ScannerSettings } from './ScannerSettings';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { WhisperSettings } from '../whisper/WhisperSettings';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { authApi } from '../../lib/api';
|
||||
import { ADMIN_SETTINGS_TABS, userColor, type SettingsTab } from '../../types';
|
||||
|
||||
const TABS: { id: SettingsTab; label: string }[] = [
|
||||
{ id: 'users', label: 'Nutzerverwaltung' },
|
||||
{ id: 'account', label: 'Mein Konto' },
|
||||
{ id: 'scanner', label: 'Scanner' },
|
||||
{ id: 'whisper', label: 'Whisper' },
|
||||
];
|
||||
|
||||
const AccountTab: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const { user, updateUser } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
|
||||
// Farbe und Foto werden sofort gespeichert - ein eigener Speichern-Button
|
||||
// waere fuer zwei Felder unnoetiger Klick.
|
||||
const saveProfile = async (
|
||||
input: { color?: string | null; avatar?: string | null },
|
||||
successMessage: string
|
||||
) => {
|
||||
setIsSavingProfile(true);
|
||||
try {
|
||||
const { user: updated } = await authApi.updateMe(input);
|
||||
updateUser(updated);
|
||||
addToast(successMessage, 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
addToast('Die neuen Passwoerter stimmen nicht ueberein.', 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
addToast('Das neue Passwort muss mindestens 8 Zeichen haben.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await authApi.changePassword(currentPassword, newPassword);
|
||||
addToast('Passwort geaendert.', 'success');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Aenderung fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="text-base text-ink-900">Angemeldet als</h3>
|
||||
|
||||
<div className={`mt-4 ${isSavingProfile ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
<AvatarPicker
|
||||
value={user?.avatar ?? ''}
|
||||
onChange={(value) =>
|
||||
saveProfile(
|
||||
{ avatar: value || null },
|
||||
value ? 'Foto gespeichert.' : 'Foto entfernt.'
|
||||
)
|
||||
}
|
||||
initials={user?.name?.charAt(0).toUpperCase() ?? '?'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 space-y-2 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-ink-400">Name</dt>
|
||||
<dd className="font-semibold text-ink-800">{user?.name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-ink-400">Benutzername</dt>
|
||||
<dd className="font-semibold text-ink-800">@{user?.username}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-ink-400">E-Mail</dt>
|
||||
<dd className="truncate font-semibold text-ink-800">{user?.email || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-ink-400">Rolle</dt>
|
||||
<dd className="font-semibold text-ink-800">
|
||||
{user?.role === 'admin' ? 'Administrator' : 'Benutzer'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 border-t border-ink-100 pt-5">
|
||||
<h4 className="text-sm font-extrabold text-ink-900">Meine Kalenderfarbe</h4>
|
||||
<p className="mb-3 text-xs text-ink-400">
|
||||
Mit dieser Farbe erscheinen die von dir angelegten Termine im Teamkalender.
|
||||
</p>
|
||||
<div className={isSavingProfile ? 'pointer-events-none opacity-60' : undefined}>
|
||||
<ColorPicker
|
||||
value={user?.color ?? null}
|
||||
fallback={userColor(user ? { id: user.id, color: null } : null)}
|
||||
onChange={(color) => saveProfile({ color }, 'Kalenderfarbe gespeichert.')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="text-base text-ink-900">Passwort aendern</h3>
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Aktuelles Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Neues Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Neues Passwort bestaetigen</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="w-full rounded-xl bg-brand-500 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Passwort aendern'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab }) => {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
// Nutzerverwaltung und Scanner nur fuer Admins; Konto und Whisper fuer alle.
|
||||
const tabs = TABS.filter((item) => isAdmin || !ADMIN_SETTINGS_TABS.includes(item.id));
|
||||
const defaultTab: SettingsTab = isAdmin ? 'users' : 'account';
|
||||
|
||||
const [tab, setTab] = useState<SettingsTab>(() =>
|
||||
initialTab && tabs.some((item) => item.id === initialTab) ? initialTab : defaultTab
|
||||
);
|
||||
|
||||
// Sprung aus dem Diktat-Hinweis: der Reiter kommt von aussen.
|
||||
useEffect(() => {
|
||||
if (initialTab && tabs.some((item) => item.id === initialTab)) setTab(initialTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialTab]);
|
||||
|
||||
// Wird die Rolle waehrend der Sitzung herabgesetzt, darf kein Adminreiter
|
||||
// offen bleiben.
|
||||
useEffect(() => {
|
||||
if (!tabs.some((item) => item.id === tab)) setTab(defaultTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdmin]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap gap-1 rounded-xl border border-ink-200 bg-surface p-1">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setTab(item.id)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-bold transition ${
|
||||
tab === item.id ? 'bg-brand-500 text-white' : 'text-ink-500 hover:bg-brand-50 hover:text-brand-700'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'users' && isAdmin && <UserManagement />}
|
||||
{tab === 'account' && <AccountTab />}
|
||||
{tab === 'scanner' && isAdmin && <ScannerSettings />}
|
||||
{tab === 'whisper' && <WhisperSettings />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Modal, ConfirmDialog } from '../ui/Modal';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { UserAvatar } from '../ui/UserAvatar';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { inputClass, labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { usersApi, type UserInput } from '../../lib/api';
|
||||
import { userColor, type AppUser, type Role } from '../../types';
|
||||
|
||||
interface UserFormState {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
is_active: boolean;
|
||||
password: string;
|
||||
/** null = Farbe automatisch aus der Palette */
|
||||
color: string | null;
|
||||
/** '' = kein Foto, dann wird die Initiale angezeigt */
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
const emptyForm: UserFormState = {
|
||||
username: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
password: '',
|
||||
color: null,
|
||||
avatar: '',
|
||||
};
|
||||
|
||||
const RoleBadge: React.FC<{ role: Role }> = ({ role }) =>
|
||||
role === 'admin' ? (
|
||||
<span className="rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
|
||||
Admin
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-md border border-ink-200 bg-ink-100 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-500">
|
||||
User
|
||||
</span>
|
||||
);
|
||||
|
||||
export const UserManagement: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const { user: currentUser, updateUser } = useAuth();
|
||||
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [editingUser, setEditingUser] = useState<AppUser | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [form, setForm] = useState<UserFormState>(emptyForm);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<AppUser | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setUsers(await usersApi.list());
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Benutzer konnten nicht geladen werden.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm(emptyForm);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (user: AppUser) => {
|
||||
setEditingUser(user);
|
||||
setForm({
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
email: user.email ?? '',
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
password: '',
|
||||
color: user.color ?? null,
|
||||
avatar: user.avatar ?? '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const set = <K extends keyof UserFormState>(key: K, value: UserFormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.username.trim() || !form.name.trim()) {
|
||||
addToast('Benutzername und Name sind erforderlich.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!editingUser && form.password.length < 8) {
|
||||
addToast('Das Passwort muss mindestens 8 Zeichen haben.', 'error');
|
||||
return;
|
||||
}
|
||||
if (editingUser && form.password && form.password.length < 8) {
|
||||
addToast('Das neue Passwort muss mindestens 8 Zeichen haben.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload: UserInput = {
|
||||
username: form.username.trim(),
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
role: form.role,
|
||||
is_active: form.is_active,
|
||||
color: form.color,
|
||||
avatar: form.avatar || null,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
|
||||
if (editingUser) {
|
||||
const updated = await usersApi.update(editingUser.id, payload);
|
||||
addToast(`Benutzer "${updated.name}" wurde aktualisiert.`, 'success');
|
||||
// Eigene Daten sofort in der laufenden Sitzung uebernehmen.
|
||||
if (currentUser && updated.id === currentUser.id) updateUser(updated);
|
||||
} else {
|
||||
const created = await usersApi.create(payload);
|
||||
addToast(`Benutzer "${created.name}" wurde angelegt.`, 'success');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!userToDelete) return;
|
||||
try {
|
||||
await usersApi.remove(userToDelete.id);
|
||||
addToast(`Benutzer "${userToDelete.name}" wurde geloescht.`, 'success');
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base text-ink-900">Benutzer ({users.length})</h3>
|
||||
<p className="text-xs text-ink-400">
|
||||
Rolle Admin sieht die Einstellungen, Rolle User nicht.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex shrink-0 items-center gap-2 rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Benutzer anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-14">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-ink-200 bg-surface px-4 py-3"
|
||||
>
|
||||
<UserAvatar
|
||||
user={user}
|
||||
size={40}
|
||||
color={user.is_active ? undefined : '#c2c2c2'}
|
||||
className={user.is_active ? undefined : 'grayscale'}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-bold text-ink-900">{user.name}</span>
|
||||
<span className="text-xs text-ink-400">@{user.username}</span>
|
||||
<RoleBadge role={user.role} />
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="rounded-md border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-sky-700">
|
||||
Ich
|
||||
</span>
|
||||
)}
|
||||
{!user.is_active && (
|
||||
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-amber-700">
|
||||
Deaktiviert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{user.email && <p className="truncate text-xs text-ink-400">{user.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{user.id !== currentUser?.id && (
|
||||
<button
|
||||
onClick={() => setUserToDelete(user)}
|
||||
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
|
||||
aria-label={`${user.name} loeschen`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<Modal
|
||||
title={editingUser ? `${editingUser.name} bearbeiten` : 'Neuer Benutzer'}
|
||||
subtitle={editingUser ? `@${editingUser.username}` : 'Zugang fuer das perinco CRM anlegen'}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="user-form"
|
||||
disabled={isSaving}
|
||||
className="flex-[2] rounded-xl bg-brand-500 py-3 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="user-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Foto</label>
|
||||
<AvatarPicker
|
||||
value={form.avatar}
|
||||
onChange={(value) => set('avatar', value)}
|
||||
initials={form.name.charAt(0).toUpperCase() || '?'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Benutzername *</label>
|
||||
<input
|
||||
required
|
||||
value={form.username}
|
||||
onChange={(event) => set('username', event.target.value)}
|
||||
className={inputClass}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Anzeigename *</label>
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => set('name', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(event) => set('email', event.target.value)}
|
||||
className={inputClass}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Rolle</label>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(event) => set('role', event.target.value as Role)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="user">User – ohne Einstellungen</option>
|
||||
<option value="admin">Admin – mit Einstellungen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
{editingUser ? 'Neues Passwort (optional)' : 'Passwort *'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!editingUser}
|
||||
value={form.password}
|
||||
onChange={(event) => set('password', event.target.value)}
|
||||
placeholder={editingUser ? 'Leer lassen, um es nicht zu aendern' : 'Mindestens 8 Zeichen'}
|
||||
className={inputClass}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Kalenderfarbe</label>
|
||||
<ColorPicker
|
||||
value={form.color}
|
||||
fallback={userColor(editingUser ? { id: editingUser.id, color: null } : { id: 0 })}
|
||||
onChange={(color) => set('color', color)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_active}
|
||||
onChange={(event) => set('is_active', event.target.checked)}
|
||||
className="h-4 w-4 accent-brand-500"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-ink-700">Konto aktiv</span>
|
||||
</label>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{userToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Benutzer loeschen?"
|
||||
message={`"${userToDelete.name}" verliert den Zugang. Angelegte Kontakte bleiben erhalten, verlieren aber die Zuordnung.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setUserToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { compressImage, formatBytes } from '../../lib/imageCompress';
|
||||
|
||||
interface AvatarPickerProps {
|
||||
/** Data-URL oder externe URL; '' = kein Bild. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Fallback, solange kein Bild gesetzt ist. */
|
||||
initials: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
removeLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Foto-Upload mit Vorschau - genutzt fuer Kontakte und Benutzer. Das Bild wird
|
||||
* im Browser auf ein Quadrat verkleinert, bevor es hochgeladen wird; damit
|
||||
* bleiben die Data-URLs in der Datenbank klein.
|
||||
*/
|
||||
export const AvatarPicker: React.FC<AvatarPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
initials,
|
||||
size = 'lg',
|
||||
removeLabel = 'Foto entfernen',
|
||||
}) => {
|
||||
const { addToast } = useToast();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
// Zuruecksetzen, damit dieselbe Datei nach einem Entfernen erneut gewaehlt
|
||||
// werden kann.
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const result = await compressImage(file);
|
||||
onChange(result.dataUrl);
|
||||
addToast(
|
||||
`Foto uebernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`,
|
||||
'success'
|
||||
);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Das Bild konnte nicht verarbeitet werden.', 'error');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="group relative">
|
||||
<Avatar src={value || null} initials={initials} size={size} />
|
||||
<label
|
||||
className={`absolute inset-0 flex cursor-pointer items-center justify-center rounded-xl bg-ink-950/45 text-[10px] font-extrabold uppercase tracking-wider text-white transition ${
|
||||
isBusy ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{isBusy ? (
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||
) : (
|
||||
'Aendern'
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={isBusy}
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{value && !isBusy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="text-[10px] font-bold uppercase tracking-wider text-ink-400 transition hover:text-red-600"
|
||||
>
|
||||
{removeLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from 'react';
|
||||
import { useRecorder } from '../../lib/dictation';
|
||||
import { useWhisper } from '../../lib/whisper';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { useToast } from '../Toast';
|
||||
import { inputClass } from '../leads/leadUi';
|
||||
|
||||
const MIC_ICON =
|
||||
'M19 11a7 7 0 01-14 0m7 7v3m0-3a7 7 0 007-7m-7 7a7 7 0 01-7-7m7-9a3 3 0 013 3v6a3 3 0 01-6 0V5a3 3 0 013-3z';
|
||||
const STOP_ICON = 'M9 9h6v6H9z';
|
||||
|
||||
function formatSeconds(total: number): string {
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface DictateButtonProps {
|
||||
/** Erhaelt den erkannten Text; der Aufrufer entscheidet, ob er anhaengt. */
|
||||
onText: (text: string) => void;
|
||||
/** Kurzer Hinweis, was diktiert wird - landet im Titel des Buttons. */
|
||||
label: string;
|
||||
compact?: boolean;
|
||||
/**
|
||||
* Wird vor dem Wechsel in die Whisper-Einstellungen aufgerufen - ein offener
|
||||
* Dialog muss sich sonst nicht schliessen und liegt ueber der Ansicht.
|
||||
*/
|
||||
onBeforeNavigate?: () => void;
|
||||
}
|
||||
|
||||
export const DictateButton: React.FC<DictateButtonProps> = ({
|
||||
onText,
|
||||
label,
|
||||
compact,
|
||||
onBeforeNavigate,
|
||||
}) => {
|
||||
const { status, isSupported } = useWhisper();
|
||||
const { goTo } = useNavigation();
|
||||
const { addToast } = useToast();
|
||||
const { state, seconds, start, stop, cancel } = useRecorder(onText, (message) =>
|
||||
addToast(message, 'error')
|
||||
);
|
||||
|
||||
// Ohne Mikrofon oder ohne HTTPS hat ein Diktat-Button keinen Sinn.
|
||||
if (!isSupported) return null;
|
||||
|
||||
// Modell noch nicht geladen: statt eines toten Buttons der Weg dorthin.
|
||||
if (status !== 'ready') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onBeforeNavigate?.();
|
||||
goTo('Einstellungen', { tab: 'whisper' });
|
||||
}}
|
||||
title="Das Spracherkennungsmodell ist noch nicht geladen - hier einrichten"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-ink-300 px-2 py-1 text-[11px] font-bold text-ink-400 transition hover:border-brand-400 hover:text-brand-700"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={MIC_ICON} />
|
||||
</svg>
|
||||
Diktat einrichten
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const isRecording = state === 'recording';
|
||||
const isBusy = state === 'transcribing';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? stop : start}
|
||||
disabled={isBusy}
|
||||
title={
|
||||
isRecording
|
||||
? 'Aufnahme beenden und erkennen'
|
||||
: isBusy
|
||||
? 'Erkennung laeuft...'
|
||||
: `${label} diktieren`
|
||||
}
|
||||
aria-label={isRecording ? 'Aufnahme beenden' : `${label} diktieren`}
|
||||
className={`flex items-center gap-1.5 rounded-lg border px-2 py-1 text-[11px] font-bold transition disabled:opacity-60 ${
|
||||
isRecording
|
||||
? 'border-red-300 bg-red-50 text-red-700'
|
||||
: 'border-ink-200 text-ink-500 hover:border-brand-300 hover:text-brand-700'
|
||||
}`}
|
||||
>
|
||||
{isBusy ? (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={isRecording ? STOP_ICON : MIC_ICON}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isRecording ? (
|
||||
<span className="tabular-nums">{formatSeconds(seconds)}</span>
|
||||
) : isBusy ? (
|
||||
'Erkenne...'
|
||||
) : compact ? (
|
||||
'Diktat'
|
||||
) : (
|
||||
'Diktieren'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRecording && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
title="Aufnahme verwerfen"
|
||||
aria-label="Aufnahme verwerfen"
|
||||
className="rounded-lg p-1 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DictationHintProps {
|
||||
/** Wird vor dem Wechsel aufgerufen, z. B. um den Dialog zu schliessen. */
|
||||
onBeforeNavigate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweisbox fuer Formulare: erklaert, warum kein Mikrofon zu sehen ist, und
|
||||
* fuehrt direkt zu den Whisper-Einstellungen. Zeigt sich nur, solange das
|
||||
* Modell nicht bereit ist.
|
||||
*/
|
||||
export const DictationHint: React.FC<DictationHintProps> = ({ onBeforeNavigate }) => {
|
||||
const { status, isSupported, isCached, progress, progressLabel, phase } = useWhisper();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
if (!isSupported || status === 'ready') return null;
|
||||
|
||||
const isLoading = status === 'loading';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3 sm:flex-row sm:items-center">
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-ink-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d={MIC_ICON} />
|
||||
</svg>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-ink-800">
|
||||
{isLoading
|
||||
? 'Spracherkennung wird geladen...'
|
||||
: status === 'error'
|
||||
? 'Die Spracherkennung konnte nicht geladen werden.'
|
||||
: 'Diktieren ist moeglich, das Modell fehlt aber noch.'}
|
||||
</p>
|
||||
<p className="text-xs text-ink-500">
|
||||
{isLoading
|
||||
? phase === 'init'
|
||||
? 'Download fertig, Modell wird initialisiert...'
|
||||
: progressLabel || 'Bitte kurz warten.'
|
||||
: isCached
|
||||
? 'Das Modell liegt bereits im Browser - es muss nur noch aktiviert werden.'
|
||||
: 'Das Modell wird einmalig in diesen Browser geladen und laeuft danach lokal auf diesem Geraet.'}
|
||||
</p>
|
||||
{isLoading && (
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onBeforeNavigate?.();
|
||||
goTo('Einstellungen', { tab: 'whisper' });
|
||||
}}
|
||||
className="shrink-0 rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white transition hover:bg-brand-600"
|
||||
>
|
||||
Zu Whisper
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DictateTextareaProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
onBeforeNavigate?: () => void;
|
||||
}
|
||||
|
||||
/** Textfeld mit Diktat-Button darueber; erkannter Text wird angehaengt. */
|
||||
export const DictateTextarea: React.FC<DictateTextareaProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
onBeforeNavigate,
|
||||
}) => (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[11px] font-extrabold uppercase tracking-wider text-ink-500"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<DictateButton
|
||||
label={label}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
onText={(text) => onChange(value ? `${value.trimEnd()} ${text}` : text)}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
id={id}
|
||||
rows={rows}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={`${inputClass} resize-y`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const SIZES: Record<NonNullable<ModalProps['size']>, string> = {
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
// Escape schliesst den Dialog - erwartetes Verhalten und schneller als
|
||||
// zum Schliessen-Button zu greifen.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-lift ${SIZES[size]}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-ink-100 px-6 py-5">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-xl text-ink-900">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 truncate text-xs font-semibold text-ink-400">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-ink-100 hover:text-ink-800"
|
||||
aria-label="Dialog schliessen"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-slim flex-1 overflow-y-auto px-6 py-6">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<div className="shrink-0 border-t border-ink-100 bg-ink-50/60 px-6 py-4">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Loeschen',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => (
|
||||
<div className="fixed inset-0 z-[130] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-ink-200 bg-surface p-7 text-center shadow-lift">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<svg className="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg text-ink-900">{title}</h3>
|
||||
<p className="mt-2 text-sm text-ink-500">{message}</p>
|
||||
<div className="mt-7 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-xl bg-red-600 py-3 text-sm font-bold text-white transition hover:bg-red-700"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { userColor } from '../../types';
|
||||
|
||||
/** Alles, was ein Benutzerbild braucht - passt auf AppUser und EventParticipant. */
|
||||
export interface AvatarUser {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
interface UserAvatarProps {
|
||||
user: AvatarUser;
|
||||
/** Kantenlaenge in Pixel. */
|
||||
size?: number;
|
||||
shape?: 'circle' | 'rounded';
|
||||
/** Ueberschreibt die Hintergrundfarbe, z. B. grau fuer deaktivierte Konten. */
|
||||
color?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Foto des Benutzers, sonst die Initiale auf seiner Kalenderfarbe. Damit sehen
|
||||
* Benutzer ueberall gleich aus - Sidebar, Kalender, Nutzerverwaltung.
|
||||
*/
|
||||
export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
user,
|
||||
size = 32,
|
||||
shape = 'circle',
|
||||
color,
|
||||
title,
|
||||
className = '',
|
||||
}) => {
|
||||
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-xl';
|
||||
const dimensions = { width: size, height: size };
|
||||
|
||||
if (user.avatar) {
|
||||
return (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
title={title ?? user.name}
|
||||
style={dimensions}
|
||||
className={`shrink-0 object-cover ${radius} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
title={title ?? user.name}
|
||||
style={{ ...dimensions, backgroundColor: color ?? userColor(user), fontSize: size * 0.4 }}
|
||||
className={`flex shrink-0 items-center justify-center font-extrabold leading-none text-white ${radius} ${className}`}
|
||||
>
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { labelClass } from '../leads/leadUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { useRecorder } from '../../lib/dictation';
|
||||
import { WHISPER_MODEL, useWhisper } from '../../lib/whisper';
|
||||
import { fetchModelSize, formatSize } from '../../lib/whisperModels';
|
||||
|
||||
/**
|
||||
* Reiter "Whisper" in den Einstellungen - fuer jede Rolle sichtbar, denn jeder
|
||||
* richtet sein Diktat auf seinem eigenen Geraet ein.
|
||||
*
|
||||
* Es gibt hier bewusst nichts zu konfigurieren: Modell, Genauigkeit, Rechenweg
|
||||
* und Sprache sind festgelegt (siehe lib/whisper.tsx). Uebrig bleibt, was ein
|
||||
* Benutzer wirklich braucht - einmal herunterladen, danach laeuft es. Und die
|
||||
* Moeglichkeit, es wieder loszuwerden.
|
||||
*/
|
||||
export const WhisperSettings: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const {
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
load,
|
||||
clearCache,
|
||||
isSupported,
|
||||
} = useWhisper();
|
||||
|
||||
const [testText, setTestText] = useState('');
|
||||
const [askClear, setAskClear] = useState(false);
|
||||
/** Downloadgroesse, direkt bei Hugging Face erfragt. */
|
||||
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchModelSize(WHISPER_MODEL)
|
||||
.then((size) => {
|
||||
if (!cancelled && size) setSizeLabel(formatSize(size.totalBytes));
|
||||
})
|
||||
.catch(() => {
|
||||
// Ohne Angabe laeuft es genauso - nur ohne Zahl im Text.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const recorder = useRecorder(
|
||||
(text) => {
|
||||
setTestText(text);
|
||||
addToast('Erkennung abgeschlossen.', 'success');
|
||||
},
|
||||
(message) => addToast(message, 'error')
|
||||
);
|
||||
|
||||
const handleLoad = async () => {
|
||||
try {
|
||||
await load();
|
||||
addToast('Diktat ist bereit.', 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = status === 'loading';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="text-base text-ink-900">Diktat mit Whisper</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||
Die Spracherkennung laeuft vollstaendig in diesem Browser. Aufnahmen verlassen das Geraet
|
||||
nicht und werden nirgends gespeichert - weder auf dem perinco-Server noch bei einem
|
||||
Dienstleister.
|
||||
</p>
|
||||
<p className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||
Einmal herunterladen genuegt: das Modell bleibt in diesem Browser und steht danach bei
|
||||
jeder Anmeldung bereit. Auf einem anderen Geraet oder in einem anderen Browser faellt der
|
||||
Download erneut an.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Zustand und die beiden Aktionen */}
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base text-ink-900">
|
||||
{status === 'ready'
|
||||
? 'Diktat ist bereit'
|
||||
: isLoading
|
||||
? phase === 'init'
|
||||
? 'Wird eingerichtet...'
|
||||
: 'Wird geladen...'
|
||||
: status === 'error'
|
||||
? 'Laden fehlgeschlagen'
|
||||
: 'Diktat noch nicht eingerichtet'}
|
||||
</h3>
|
||||
<p className="text-sm text-ink-500">
|
||||
{status === 'ready'
|
||||
? 'Die Mikrofon-Buttons in den Formularen sind aktiv.'
|
||||
: isLoading
|
||||
? phase === 'init'
|
||||
? 'Gleich fertig.'
|
||||
: progressLabel || 'Download startet...'
|
||||
: isCached
|
||||
? 'Das Modell liegt in diesem Browser und wird gleich bereitgestellt.'
|
||||
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen - danach bleibt es in diesem Browser.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status !== 'ready' && (
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={isLoading || !isSupported}
|
||||
className="rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||
>
|
||||
{isLoading
|
||||
? 'Laedt...'
|
||||
: isCached
|
||||
? 'Diktat aktivieren'
|
||||
: `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`}
|
||||
</button>
|
||||
)}
|
||||
{(isCached || status === 'ready') && (
|
||||
<button
|
||||
onClick={() => setAskClear(true)}
|
||||
className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-600"
|
||||
>
|
||||
Sprachmodell loeschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-ink-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Nur melden, was den Benutzer betrifft - sonst Stille. */}
|
||||
{!isSupported && (
|
||||
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||
Auf diesem Geraet ist kein Diktat moeglich - es fehlt ein Mikrofon oder eine sichere
|
||||
Verbindung. Die Mikrofon-Buttons bleiben ausgeblendet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isSupported && !capabilities.webgpu && (
|
||||
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700">
|
||||
Dieses Geraet hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die
|
||||
Erkennung dauert aber deutlich laenger.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'ready' && !isCached && (
|
||||
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700">
|
||||
Dieser Browser hat das Modell nicht behalten - beim naechsten Start muss es erneut
|
||||
geladen werden. Ursache ist meist ein privates Fenster oder eine Speicherbeschraenkung.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Probe */}
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="text-base text-ink-900">Probe sprechen</h3>
|
||||
<p className="mt-1 text-sm text-ink-500">
|
||||
Kurz etwas diktieren, um zu pruefen, ob alles sitzt.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={recorder.state === 'recording' ? recorder.stop : recorder.start}
|
||||
disabled={status !== 'ready' || recorder.state === 'transcribing'}
|
||||
className={`flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-bold transition disabled:opacity-50 ${
|
||||
recorder.state === 'recording'
|
||||
? 'border border-red-300 bg-red-50 text-red-700'
|
||||
: 'bg-brand-500 text-white hover:bg-brand-600'
|
||||
}`}
|
||||
>
|
||||
{recorder.state === 'recording'
|
||||
? `Aufnahme beenden (${recorder.seconds} s)`
|
||||
: recorder.state === 'transcribing'
|
||||
? 'Erkenne...'
|
||||
: 'Aufnahme starten'}
|
||||
</button>
|
||||
{recorder.state === 'recording' && (
|
||||
<button
|
||||
onClick={recorder.cancel}
|
||||
className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
)}
|
||||
{status !== 'ready' && (
|
||||
<span className="text-xs font-semibold text-ink-400">
|
||||
Erst das Sprachmodell laden.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className={labelClass}>Erkannter Text</label>
|
||||
<p className="min-h-16 whitespace-pre-wrap rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3 text-sm text-ink-700">
|
||||
{testText || <span className="text-ink-400">Noch keine Probe.</span>}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{askClear && (
|
||||
<ConfirmDialog
|
||||
title="Sprachmodell loeschen?"
|
||||
message="Das Modell wird aus diesem Browser entfernt. Fuer ein weiteres Diktat muss es erneut geladen werden."
|
||||
confirmLabel="Loeschen"
|
||||
onConfirm={async () => {
|
||||
await clearCache();
|
||||
setAskClear(false);
|
||||
addToast('Sprachmodell entfernt.', 'success');
|
||||
}}
|
||||
onCancel={() => setAskClear(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* perinco CRM - Corporate Identity
|
||||
* Farben direkt aus dem perinco Logo gesampelt:
|
||||
* Gruen #6BA04A (Akzent #68A33F)
|
||||
* Grau #6E6E6E
|
||||
* Schrift: perinco.de nutzt Avenir LT (Light 35 / Heavy 85). Ist Avenir lokal
|
||||
* installiert, wird es verwendet, sonst Nunito Sans als freies Aequivalent.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Avenir Next', 'Avenir', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/*
|
||||
* Flaechen sind bewusst semantisch benannt statt "weiss": im Dark Mode werden
|
||||
* unten nur diese Variablen umgesetzt, die Utility-Klassen in den Komponenten
|
||||
* bleiben unveraendert.
|
||||
* canvas = Seitenhintergrund
|
||||
* surface = Karten, Leisten, Dialoge
|
||||
*/
|
||||
--color-canvas: #f7f7f7;
|
||||
--color-surface: #ffffff;
|
||||
|
||||
--color-brand-50: #f2f8ee;
|
||||
--color-brand-100: #e2efd9;
|
||||
--color-brand-200: #c6e0b6;
|
||||
--color-brand-300: #a3cd8a;
|
||||
--color-brand-400: #85ba66;
|
||||
--color-brand-500: #6ba04a;
|
||||
--color-brand-600: #58873b;
|
||||
--color-brand-700: #456a2f;
|
||||
--color-brand-800: #375427;
|
||||
--color-brand-900: #2d4421;
|
||||
--color-brand-accent: #68a33f;
|
||||
|
||||
--color-ink-50: #f7f7f7;
|
||||
--color-ink-100: #ededed;
|
||||
--color-ink-200: #dcdcdc;
|
||||
--color-ink-300: #c2c2c2;
|
||||
--color-ink-400: #9a9a9a;
|
||||
--color-ink-500: #6e6e6e;
|
||||
--color-ink-600: #5a5a5a;
|
||||
--color-ink-700: #464646;
|
||||
--color-ink-800: #333333;
|
||||
--color-ink-900: #1f1f1f;
|
||||
--color-ink-950: #141414;
|
||||
|
||||
--shadow-card: 0 1px 2px rgb(31 31 31 / 0.04), 0 4px 16px rgb(31 31 31 / 0.06);
|
||||
--shadow-lift: 0 8px 28px rgb(31 31 31 / 0.1);
|
||||
}
|
||||
|
||||
/*
|
||||
* Dark Mode. Die Komponenten benutzen durchgaengig die Skalen ink-* und brand-*
|
||||
* sowie canvas/surface - hier werden nur deren Werte getauscht, deshalb braucht
|
||||
* es keine dark:-Varianten im Markup.
|
||||
*
|
||||
* Zwei Ausnahmen bleiben absichtlich in beiden Modi gleich:
|
||||
* ink-950 - liegt nur unter Dialog-Overlays, muss also dunkel bleiben
|
||||
* text-white - steht immer auf farbigen Flaechen (Buttons, Termin-Chips)
|
||||
*/
|
||||
.dark {
|
||||
--color-canvas: #141414;
|
||||
--color-surface: #1c1c1c;
|
||||
|
||||
/* Neutrale Skala gedreht: 50 = dunkelste Flaeche, 900 = hellster Text */
|
||||
--color-ink-50: #1f1f1f;
|
||||
--color-ink-100: #292929;
|
||||
--color-ink-200: #383838;
|
||||
/* Nicht die exakte Umkehrung von #c2c2c2: dunkler waeren Hinweistexte
|
||||
(text-ink-300) auf der Flaeche kaum noch zu lesen. */
|
||||
--color-ink-300: #6e6e6e;
|
||||
--color-ink-400: #909090;
|
||||
--color-ink-500: #a8a8a8;
|
||||
--color-ink-600: #bdbdbd;
|
||||
--color-ink-700: #d2d2d2;
|
||||
--color-ink-800: #e6e6e6;
|
||||
--color-ink-900: #f5f5f5;
|
||||
--color-ink-950: #000000;
|
||||
|
||||
/*
|
||||
* brand-500 bleibt das CI-Gruen (Buttons). Die hellen Stufen werden zu
|
||||
* dunklen Tints (Hintergruende), die dunklen zu hellen Toenen (Text) - 600
|
||||
* dient beidem: als hellerer Button-Hover und als lesbare Textfarbe.
|
||||
*/
|
||||
--color-brand-50: #1d2b15;
|
||||
--color-brand-100: #26381c;
|
||||
--color-brand-200: #3a5a29;
|
||||
--color-brand-300: #4e7739;
|
||||
--color-brand-400: #6b9f4f;
|
||||
--color-brand-600: #7fb75c;
|
||||
--color-brand-700: #a3cd8a;
|
||||
--color-brand-800: #c6e0b6;
|
||||
--color-brand-900: #e2efd9;
|
||||
|
||||
/*
|
||||
* Statusfarben der Kontakte und Toasts sind getoente Pillen: helle Flaeche mit
|
||||
* dunklem Text. Im Dark Mode wird das gedreht. Die Stufen 500/600 bleiben
|
||||
* unberuehrt - dort steht weisse Schrift drauf (Loeschen-Button).
|
||||
*/
|
||||
--color-sky-50: #0d2537;
|
||||
--color-sky-200: #1d4a6b;
|
||||
--color-sky-700: #7cc4ef;
|
||||
|
||||
--color-amber-50: #2e2008;
|
||||
--color-amber-200: #5c4210;
|
||||
--color-amber-700: #f0b356;
|
||||
|
||||
--color-violet-50: #241a3d;
|
||||
--color-violet-200: #3f2f66;
|
||||
--color-violet-700: #b49bf2;
|
||||
|
||||
--color-red-50: #2e1414;
|
||||
--color-red-200: #5e2626;
|
||||
--color-red-700: #f28b8b;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink-900);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Farbwechsel beim Umschalten laeuft weich, ohne Layout zu bewegen */
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
/* Fokus-Ring durchgaengig in CI-Gruen */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-brand-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/*
|
||||
* Das perinco Logo ist fuer helle Hintergruende gemacht - der Zusatz unter
|
||||
* dem Wortzeichen ist dunkelgrau und waere im Dark Mode unsichtbar. invert
|
||||
* dreht die Helligkeit, hue-rotate holt das CI-Gruen zurueck.
|
||||
*/
|
||||
.dark .logo-adaptive {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
.scrollbar-slim::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.scrollbar-slim::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-slim::-webkit-scrollbar-thumb {
|
||||
background: var(--color-ink-200);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.scrollbar-slim::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-ink-300);
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
AppUser,
|
||||
CalendarEvent,
|
||||
Lead,
|
||||
LeadConnection,
|
||||
Opportunity,
|
||||
Role,
|
||||
} from '../types';
|
||||
|
||||
const TOKEN_KEY = 'perinco_crm_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wird bei 401 gefeuert, damit die App zurueck auf den Login springt. */
|
||||
type UnauthorizedHandler = () => void;
|
||||
let onUnauthorized: UnauthorizedHandler | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: UnauthorizedHandler | null) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
// JSON ist der Normalfall, aber nicht der einzige: der CV-Upload des Scanners
|
||||
// schickt FormData, dort muss der Browser die multipart-Grenze selbst setzen.
|
||||
if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api${path}`, { ...options, headers });
|
||||
} catch {
|
||||
throw new ApiError('Server nicht erreichbar. Bitte Verbindung pruefen.', 0);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json().catch(() => null) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) ||
|
||||
`Anfrage fehlgeschlagen (HTTP ${response.status}).`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
/** Kurzform fuer die Aufrufe in dieser Datei. */
|
||||
const request = apiRequest;
|
||||
|
||||
// ---------- Auth ----------
|
||||
|
||||
export const authApi = {
|
||||
login: (identifier: string, password: string) =>
|
||||
request<{ token: string; user: AppUser }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
}),
|
||||
me: () => request<{ user: AppUser }>('/auth/me'),
|
||||
/**
|
||||
* Eigene Kalenderfarbe und eigenes Foto setzen - ohne Adminrechte.
|
||||
* null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale.
|
||||
*/
|
||||
updateMe: (input: { color?: string | null; avatar?: string | null }) =>
|
||||
request<{ user: AppUser }>('/auth/me', { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
request<{ ok: true }>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password, new_password }),
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------- Benutzerverwaltung (nur admin) ----------
|
||||
|
||||
export interface UserInput {
|
||||
username: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
password?: string;
|
||||
role?: Role;
|
||||
color?: string | null;
|
||||
avatar?: string | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
list: () => request<AppUser[]>('/users'),
|
||||
/**
|
||||
* Aktive Benutzer - fuer jeden Angemeldeten abrufbar. Der Kalender braucht
|
||||
* die Liste fuer @-Mentions, Teilnehmer und Farben, auch fuer Rolle "user".
|
||||
*/
|
||||
directory: () => request<AppUser[]>('/users/directory'),
|
||||
create: (input: UserInput) =>
|
||||
request<AppUser>('/users', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: number, input: Partial<UserInput>) =>
|
||||
request<AppUser>(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
remove: (id: number) => request<{ ok: true }>(`/users/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ---------- Leads ----------
|
||||
|
||||
export type LeadInput = Partial<Omit<Lead, 'id' | 'created_at' | 'updated_at'>>;
|
||||
|
||||
export const leadsApi = {
|
||||
list: () => request<Lead[]>('/leads'),
|
||||
create: (input: LeadInput) =>
|
||||
request<Lead>('/leads', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: number, input: LeadInput) =>
|
||||
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ---------- Opportunities ----------
|
||||
|
||||
export type OpportunityInput = Partial<
|
||||
Omit<Opportunity, 'id' | 'created_at' | 'updated_at' | 'contacts'>
|
||||
> & {
|
||||
/** Ansprechpartner werden immer komplett ersetzt. */
|
||||
contacts?: { lead_id: number; role?: string | null }[];
|
||||
};
|
||||
|
||||
export const opportunitiesApi = {
|
||||
list: () => request<Opportunity[]>('/opportunities'),
|
||||
create: (input: OpportunityInput) =>
|
||||
request<Opportunity>('/opportunities', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: number, input: OpportunityInput) =>
|
||||
request<Opportunity>(`/opportunities/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ---------- Kalender ----------
|
||||
|
||||
export interface EventInput {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
/** `YYYY-MM-DDTHH:MM` oder `YYYY-MM-DD HH:MM:SS` */
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
all_day?: boolean;
|
||||
participant_ids?: number[];
|
||||
}
|
||||
|
||||
export const eventsApi = {
|
||||
list: () => request<CalendarEvent[]>('/events'),
|
||||
create: (input: EventInput) =>
|
||||
request<CalendarEvent>('/events', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: number, input: Partial<EventInput>) =>
|
||||
request<CalendarEvent>(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
remove: (id: number) => request<{ ok: true }>(`/events/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// ---------- Verbindungen ----------
|
||||
|
||||
export interface ConnectionInput {
|
||||
from_lead_id: number;
|
||||
to_lead_id: number;
|
||||
type?: LeadConnection['type'];
|
||||
label?: string | null;
|
||||
strength?: number;
|
||||
}
|
||||
|
||||
export const connectionsApi = {
|
||||
list: () => request<LeadConnection[]>('/connections'),
|
||||
create: (input: ConnectionInput) =>
|
||||
request<LeadConnection>('/connections', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: number, input: Partial<Omit<ConnectionInput, 'from_lead_id' | 'to_lead_id'>>) =>
|
||||
request<LeadConnection>(`/connections/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
remove: (id: number) => request<{ ok: true }>(`/connections/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Whisper erwartet Mono-PCM mit 16 kHz als Float32. Der MediaRecorder liefert
|
||||
* aber WebM/Opus in der Aufnahmerate der Hardware - hier wird dekodiert und
|
||||
* heruntergerechnet.
|
||||
*/
|
||||
export const WHISPER_SAMPLE_RATE = 16000;
|
||||
|
||||
export interface DecodedAudio {
|
||||
audio: Float32Array;
|
||||
/** Laenge der Aufnahme in Sekunden. */
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export async function toWhisperAudio(blob: Blob): Promise<DecodedAudio> {
|
||||
const bytes = await blob.arrayBuffer();
|
||||
|
||||
const AudioContextClass =
|
||||
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) throw new Error('Dieser Browser kann kein Audio dekodieren.');
|
||||
|
||||
const context = new AudioContextClass();
|
||||
let decoded: AudioBuffer;
|
||||
try {
|
||||
decoded = await context.decodeAudioData(bytes);
|
||||
} finally {
|
||||
// Nicht geschlossene Kontexte zaehlen gegen das Browserlimit.
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
|
||||
if (decoded.duration === 0) throw new Error('Die Aufnahme ist leer.');
|
||||
|
||||
// Resampling ueber einen OfflineAudioContext - das erledigt gleich das
|
||||
// Zusammenfassen mehrerer Kanaele auf Mono.
|
||||
const frames = Math.max(1, Math.ceil(decoded.duration * WHISPER_SAMPLE_RATE));
|
||||
const offline = new OfflineAudioContext(1, frames, WHISPER_SAMPLE_RATE);
|
||||
const source = offline.createBufferSource();
|
||||
source.buffer = decoded;
|
||||
source.connect(offline.destination);
|
||||
source.start();
|
||||
const resampled = await offline.startRendering();
|
||||
|
||||
return { audio: resampled.getChannelData(0), seconds: decoded.duration };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { AppUser } from '../types';
|
||||
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AppUser | null;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
/** Aktualisiert den lokalen Benutzer, z.B. nach Bearbeiten des eigenen Kontos. */
|
||||
updateUser: (user: AppUser) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AppUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
// Ein 401 aus irgendeinem API-Call wirft den Benutzer sauber zurueck
|
||||
// auf den Login, statt eine leere Ansicht zu zeigen.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
// Beim Start pruefen, ob das gespeicherte Token noch gilt.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const restore = async () => {
|
||||
if (!getToken()) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { user: current } = await authApi.me();
|
||||
if (!cancelled) setUser(current);
|
||||
} catch {
|
||||
clearToken();
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
restore();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (identifier: string, password: string) => {
|
||||
const { token, user: loggedIn } = await authApi.login(identifier, password);
|
||||
setToken(token);
|
||||
setUser(loggedIn);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAdmin: user?.role === 'admin',
|
||||
login,
|
||||
logout,
|
||||
updateUser: setUser,
|
||||
}),
|
||||
[user, isLoading, login, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useWhisper } from './whisper';
|
||||
|
||||
/**
|
||||
* Aufnahme fuer das Diktat. Die Erkennung selbst macht der Whisper-Worker im
|
||||
* Browser (siehe whisper.tsx) - hier geht es nur um Mikrofon, Dauer und das
|
||||
* Weiterreichen der Aufnahme.
|
||||
*/
|
||||
|
||||
/** Erstes Format, das der Browser aufnehmen kann. */
|
||||
function pickMimeType(): string {
|
||||
const candidates = ['audio/webm', 'audio/ogg', 'audio/mp4'];
|
||||
for (const type of candidates) {
|
||||
if (MediaRecorder.isTypeSupported?.(type)) return type;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'transcribing';
|
||||
|
||||
interface UseRecorderResult {
|
||||
state: RecorderState;
|
||||
/** Laufende Aufnahmedauer in Sekunden. */
|
||||
seconds: number;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function useRecorder(
|
||||
onText: (text: string) => void,
|
||||
onError: (message: string) => void
|
||||
): UseRecorderResult {
|
||||
const { transcribe, status } = useWhisper();
|
||||
const [state, setState] = useState<RecorderState>('idle');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const discardRef = useRef(false);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// Mikrofon freigeben, sonst bleibt die Aufnahmeanzeige des Browsers stehen.
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setSeconds(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => cleanup, [cleanup]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (state !== 'idle') return;
|
||||
if (status !== 'ready') {
|
||||
onError('Das Spracherkennungsmodell ist noch nicht geladen.');
|
||||
return;
|
||||
}
|
||||
discardRef.current = false;
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err) {
|
||||
const name = (err as DOMException)?.name;
|
||||
onError(
|
||||
name === 'NotAllowedError'
|
||||
? 'Kein Zugriff aufs Mikrofon. Bitte im Browser erlauben.'
|
||||
: name === 'NotFoundError'
|
||||
? 'Es wurde kein Mikrofon gefunden.'
|
||||
: 'Die Aufnahme konnte nicht gestartet werden.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = pickMimeType();
|
||||
let recorder: MediaRecorder;
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
} catch {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
onError('Dieser Browser kann kein passendes Audioformat aufnehmen.');
|
||||
return;
|
||||
}
|
||||
|
||||
streamRef.current = stream;
|
||||
recorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
|
||||
cleanup();
|
||||
|
||||
if (discardRef.current) {
|
||||
setState('idle');
|
||||
return;
|
||||
}
|
||||
if (blob.size < 1024) {
|
||||
setState('idle');
|
||||
onError('Die Aufnahme war zu kurz.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState('transcribing');
|
||||
try {
|
||||
const { text } = await transcribe(blob);
|
||||
if (text) onText(text);
|
||||
else onError('Es war nichts Verstaendliches zu hoeren.');
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.');
|
||||
} finally {
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setState('recording');
|
||||
setSeconds(0);
|
||||
timerRef.current = window.setInterval(() => setSeconds((value) => value + 1), 1000);
|
||||
}, [state, status, cleanup, onError, onText, transcribe]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
discardRef.current = true;
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
return { state, seconds, start, stop, cancel };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Bilder werden als Data-URL in der Datenbank gespeichert. Angezeigt werden sie
|
||||
* hoechstens mit 80 px Kantenlaenge, deshalb wird vor dem Upload im Browser auf
|
||||
* ein Quadrat verkleinert - das druckt ein 4-MB-Handyfoto auf wenige Kilobyte.
|
||||
*/
|
||||
|
||||
/** Zielkantenlaenge; 256 px reicht fuer die groesste Anzeige auch auf Retina. */
|
||||
const TARGET_SIZE = 256;
|
||||
|
||||
/** WebP ist deutlich kleiner als JPEG; ohne Unterstuetzung wird JPEG genutzt. */
|
||||
const CANDIDATES: { type: string; quality: number }[] = [
|
||||
{ type: 'image/webp', quality: 0.82 },
|
||||
{ type: 'image/jpeg', quality: 0.82 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Quelldateien werden vor dem Dekodieren begrenzt - ein 100-MB-TIFF soll den
|
||||
* Browser nicht in die Knie zwingen.
|
||||
*/
|
||||
export const MAX_SOURCE_BYTES = 25_000_000;
|
||||
|
||||
/** Ergebnis der Komprimierung; die Groessen dienen der Rueckmeldung an den Benutzer. */
|
||||
export interface CompressedImage {
|
||||
dataUrl: string;
|
||||
sourceBytes: number;
|
||||
resultBytes: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Das Bild konnte nicht gelesen werden.'));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Laenge einer Data-URL in Bytes (Base64 traegt 4 Zeichen pro 3 Bytes). */
|
||||
function dataUrlBytes(dataUrl: string): number {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
||||
return Math.round((base64.length * 3) / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkleinert das Bild auf ein Quadrat von TARGET_SIZE und schneidet dabei
|
||||
* mittig zu (wie `object-cover` in der Anzeige), damit das Ergebnis exakt dem
|
||||
* entspricht, was spaeter zu sehen ist.
|
||||
*/
|
||||
export async function compressImage(file: File): Promise<CompressedImage> {
|
||||
if (file.size > MAX_SOURCE_BYTES) {
|
||||
throw new Error('Die Datei ist zu gross (max. 25 MB).');
|
||||
}
|
||||
|
||||
const image = await loadImage(file);
|
||||
const { naturalWidth: width, naturalHeight: height } = image;
|
||||
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
|
||||
|
||||
// Kleine Bilder werden nicht vergroessert.
|
||||
const side = Math.min(TARGET_SIZE, width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = side;
|
||||
canvas.height = side;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Der Browser stellt keinen Canvas bereit.');
|
||||
|
||||
// Mittiger Ausschnitt des kuerzeren Randes
|
||||
const crop = Math.min(width, height);
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side);
|
||||
|
||||
for (const candidate of CANDIDATES) {
|
||||
const dataUrl = canvas.toDataURL(candidate.type, candidate.quality);
|
||||
// Liefert der Browser das Format nicht, faellt er still auf PNG zurueck.
|
||||
if (dataUrl.startsWith(`data:${candidate.type}`)) {
|
||||
return {
|
||||
dataUrl,
|
||||
sourceBytes: file.size,
|
||||
resultBytes: dataUrlBytes(dataUrl),
|
||||
type: candidate.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const png = canvas.toDataURL('image/png');
|
||||
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
|
||||
}
|
||||
|
||||
/** "2,4 MB" bzw. "18 KB" */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace('.', ',')} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1000))} KB`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { SettingsTab, ViewState } from '../types';
|
||||
|
||||
/**
|
||||
* Kleiner Kontext, damit tief liegende Komponenten die Ansicht wechseln koennen -
|
||||
* etwa der Hinweis im Opportunity-Formular, der zu den Whisper-Einstellungen
|
||||
* fuehrt. Sonst muesste die Funktion durch vier Ebenen Props gereicht werden.
|
||||
*
|
||||
* `tab` waehlt beim Sprung in die Einstellungen gleich den richtigen Reiter aus;
|
||||
* Whisper ist genau so ein Ziel und hat deshalb keinen eigenen Menuepunkt mehr.
|
||||
*/
|
||||
interface NavigationContextValue {
|
||||
currentView: ViewState;
|
||||
goTo: (view: ViewState, options?: { tab?: SettingsTab }) => void;
|
||||
}
|
||||
|
||||
const NavigationContext = createContext<NavigationContextValue | null>(null);
|
||||
|
||||
export const NavigationProvider: React.FC<{
|
||||
value: NavigationContextValue;
|
||||
children: React.ReactNode;
|
||||
}> = ({ value, children }) => (
|
||||
<NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>
|
||||
);
|
||||
|
||||
export function useNavigation(): NavigationContextValue {
|
||||
const ctx = useContext(NavigationContext);
|
||||
if (!ctx) throw new Error('useNavigation muss innerhalb von <NavigationProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { apiRequest, getToken } from './api';
|
||||
|
||||
/*
|
||||
* Zugriff auf den portierten Projekt Scanner. Alle Endpunkte liegen unter
|
||||
* /api/scanner und haengen an derselben CRM-Anmeldung wie der Rest der App.
|
||||
* Die Feldnamen sind bewusst identisch zur Scanner-Datenbank (camelCase aus
|
||||
* Sequelize) - so bleibt der Umstieg vom alten Frontend nachvollziehbar.
|
||||
*/
|
||||
|
||||
export interface ScannerAgent {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
/** Kommagetrennte Suchbegriffe */
|
||||
keywords: string;
|
||||
/** Kommagetrennte Ausschlussbegriffe */
|
||||
excludeKeywords: string | null;
|
||||
cvFilePath: string | null;
|
||||
isActive: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type PortalStatus = 'connected' | 'disconnected' | 'expired' | 'error';
|
||||
|
||||
export interface ScannerPortal {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string | null;
|
||||
hasPassword: boolean;
|
||||
hasSession: boolean;
|
||||
connectionStatus: PortalStatus;
|
||||
lastLoginAttempt: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ScannerProject {
|
||||
id: number;
|
||||
portal: string;
|
||||
portalProjectId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
company: string | null;
|
||||
location: string | null;
|
||||
rate: string | null;
|
||||
url: string;
|
||||
publishedAt: string | null;
|
||||
/**
|
||||
* Das Project-Modell benennt die Spalte selbst um (`createdAt: 'created_at'`),
|
||||
* deshalb kommt sie als snake_case aus der API.
|
||||
*/
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export type MatchStatus = 'new' | 'reviewed' | 'applied' | 'rejected';
|
||||
|
||||
export interface ScannerMatch {
|
||||
id: number;
|
||||
agentId: number;
|
||||
projectId: number;
|
||||
matchPercentage: number;
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
status: MatchStatus;
|
||||
notificationSent: boolean;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
/** Aus dem Join - Sequelize haengt die Modellnamen gross an. */
|
||||
Agent?: Pick<ScannerAgent, 'id' | 'name' | 'email' | 'isActive'> | null;
|
||||
Project?: ScannerProject | null;
|
||||
}
|
||||
|
||||
/** Alle Werte liegen in der DB als Text - auch Zahlen und Schalter. */
|
||||
export interface ScannerSettings {
|
||||
scanning_active: string;
|
||||
search_interval: string;
|
||||
openai_api_key: string;
|
||||
match_threshold: string;
|
||||
display_threshold: string;
|
||||
sound_notifications_active: string;
|
||||
email_notifications_active: string;
|
||||
smtp_host: string;
|
||||
smtp_port: string;
|
||||
smtp_user: string;
|
||||
smtp_pass: string;
|
||||
smtp_from: string;
|
||||
notification_email_recipient: string;
|
||||
}
|
||||
|
||||
export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
|
||||
scanning_active: 'true',
|
||||
search_interval: '60',
|
||||
openai_api_key: '',
|
||||
match_threshold: '70',
|
||||
display_threshold: '50',
|
||||
sound_notifications_active: 'true',
|
||||
email_notifications_active: 'false',
|
||||
smtp_host: '',
|
||||
smtp_port: '587',
|
||||
smtp_user: '',
|
||||
smtp_pass: '',
|
||||
smtp_from: 'scanner@perinco.de',
|
||||
notification_email_recipient: '',
|
||||
};
|
||||
|
||||
/** Sentinel des Backends fuer gesetzte Geheimnisse. */
|
||||
export const SECRET_MASK = '********';
|
||||
|
||||
/** Betriebszustand dieser Scanner-Instanz. */
|
||||
export interface ScannerStatus {
|
||||
/** Ein Suchlauf laeuft gerade (Scraping + Bewertung). */
|
||||
scanRunning: boolean;
|
||||
/** Hintergrund-Scheduler dieser Instanz aktiv (SCANNER_SCHEDULER). */
|
||||
schedulerEnabled: boolean;
|
||||
/** Hauptschalter aus den Einstellungen. */
|
||||
scanningActive: boolean;
|
||||
/** Name der Scanner-Datenbank - beim Parallelbetrieb hilfreich. */
|
||||
database: string;
|
||||
}
|
||||
|
||||
export interface AgentInput {
|
||||
name: string;
|
||||
email: string;
|
||||
keywords: string;
|
||||
excludeKeywords: string;
|
||||
isActive: boolean;
|
||||
/** Neues CV-PDF; fehlt es, bleibt das gespeicherte erhalten. */
|
||||
cv?: File | null;
|
||||
}
|
||||
|
||||
function agentFormData(input: AgentInput): FormData {
|
||||
const form = new FormData();
|
||||
form.append('name', input.name);
|
||||
form.append('email', input.email);
|
||||
form.append('keywords', input.keywords);
|
||||
form.append('excludeKeywords', input.excludeKeywords);
|
||||
form.append('isActive', String(input.isActive));
|
||||
if (input.cv) form.append('cv', input.cv);
|
||||
return form;
|
||||
}
|
||||
|
||||
export const scannerApi = {
|
||||
// ---------- Agenten ----------
|
||||
agents: () => apiRequest<ScannerAgent[]>('/scanner/agents'),
|
||||
createAgent: (input: AgentInput) =>
|
||||
apiRequest<ScannerAgent>('/scanner/agents', { method: 'POST', body: agentFormData(input) }),
|
||||
updateAgent: (id: number, input: AgentInput) =>
|
||||
apiRequest<ScannerAgent>(`/scanner/agents/${id}`, { method: 'PUT', body: agentFormData(input) }),
|
||||
removeAgent: (id: number) =>
|
||||
apiRequest<{ message: string }>(`/scanner/agents/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// ---------- Portale ----------
|
||||
portals: () => apiRequest<ScannerPortal[]>('/scanner/portals'),
|
||||
savePortalCredentials: (input: { name: string; username: string; password: string }) =>
|
||||
apiRequest<{ message: string }>('/scanner/portals/credentials', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
syncPortalSession: (name: string, sessionState: unknown) =>
|
||||
apiRequest<{ message: string; status: PortalStatus }>('/scanner/portals/sync-session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, sessionState }),
|
||||
}),
|
||||
testPortal: (id: number) =>
|
||||
apiRequest<{ status: PortalStatus; message: string }>(
|
||||
`/scanner/portals/test-connection/${id}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// ---------- Matches ----------
|
||||
matches: (params: { status?: MatchStatus; agentId?: number; limit?: number } = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.agentId) query.set('agentId', String(params.agentId));
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
const suffix = query.toString() ? `?${query}` : '';
|
||||
return apiRequest<ScannerMatch[]>(`/scanner/matches${suffix}`);
|
||||
},
|
||||
match: (id: number) => apiRequest<ScannerMatch>(`/scanner/matches/${id}`),
|
||||
setMatchStatus: (id: number, status: MatchStatus) =>
|
||||
apiRequest<ScannerMatch>(`/scanner/matches/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
removeMatch: (id: number) =>
|
||||
apiRequest<{ message: string }>(`/scanner/matches/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// ---------- Einstellungen ----------
|
||||
settings: () => apiRequest<Partial<ScannerSettings>>('/scanner/settings'),
|
||||
saveSettings: (input: Partial<ScannerSettings>) =>
|
||||
apiRequest<{ message: string }>('/scanner/settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
|
||||
// ---------- Scan ----------
|
||||
scan: () => apiRequest<{ message: string }>('/scanner/scan', { method: 'POST' }),
|
||||
status: () => apiRequest<ScannerStatus>('/scanner/status'),
|
||||
};
|
||||
|
||||
/**
|
||||
* CV-PDF anzeigen. Ein einfacher Link kaeme ohne Bearer-Token beim Backend an,
|
||||
* deshalb wird die Datei geholt und als Blob in einem neuen Tab geoeffnet.
|
||||
*/
|
||||
export async function openAgentCv(agentId: number): Promise<void> {
|
||||
const token = getToken();
|
||||
const response = await fetch(`/api/scanner/agents/${agentId}/cv`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!response.ok) throw new Error('CV konnte nicht geladen werden.');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Der Tab haelt den Blob; nach einer Minute darf der Speicher zurueck.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
/** null = der Systemeinstellung folgen */
|
||||
type Preference = Theme | null;
|
||||
|
||||
const STORAGE_KEY = 'perinco_crm_theme';
|
||||
|
||||
interface ThemeContextValue {
|
||||
/** Tatsaechlich aktive Darstellung. */
|
||||
theme: Theme;
|
||||
/** Gespeicherte Wahl; null bedeutet "folgt dem System". */
|
||||
preference: Preference;
|
||||
/** Schaltet explizit auf das jeweils andere Thema. */
|
||||
toggle: () => void;
|
||||
/** Zurueck zur Systemeinstellung. */
|
||||
useSystem: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function readPreference(): Preference {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === 'light' || stored === 'dark' ? stored : null;
|
||||
} catch {
|
||||
// Privater Modus ohne localStorage - dann eben ohne gespeicherte Wahl.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function systemTheme(): Theme {
|
||||
return typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Klasse, an der die dunklen Farbwerte in index.css haengen. Ein
|
||||
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame, damit
|
||||
* es beim Laden nicht weiss aufblitzt.
|
||||
*/
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', theme === 'dark');
|
||||
root.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [preference, setPreference] = useState<Preference>(() => readPreference());
|
||||
const [resolvedSystem, setResolvedSystem] = useState<Theme>(() => systemTheme());
|
||||
|
||||
const theme = preference ?? resolvedSystem;
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!query) return;
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
setResolvedSystem(event.matches ? 'dark' : 'light');
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
const store = useCallback((next: Preference) => {
|
||||
setPreference(next);
|
||||
try {
|
||||
if (next === null) localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Nicht speicherbar - die Wahl gilt dann nur fuer diese Sitzung.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
preference,
|
||||
toggle: () => store(theme === 'dark' ? 'light' : 'dark'),
|
||||
useSystem: () => store(null),
|
||||
}),
|
||||
[theme, preference, store]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { toWhisperAudio } from './audio';
|
||||
import type { WorkerRequest, WorkerResponse } from './whisper.worker';
|
||||
|
||||
/**
|
||||
* Diktat laeuft vollstaendig im Browser: Whisper als ONNX-Modell ueber
|
||||
* transformers.js, gerechnet auf der GPU (WebGPU) oder ersatzweise auf der CPU
|
||||
* (WASM). Der Server ist daran nicht beteiligt - Aufnahmen verlassen das Geraet
|
||||
* nicht.
|
||||
*
|
||||
* Es gibt bewusst nichts einzustellen:
|
||||
*
|
||||
* large-v3-turbo - beste Erkennung im Deutschen, auch bei Fachbegriffen
|
||||
* ("S4 HANA"). small verliert genau dort, tiny ist unbrauchbar.
|
||||
* q4 - andere Genauigkeiten scheitern: q8 liefert auf der
|
||||
* Grafikkarte Unsinn, fp16 blockiert den Browser-Tab, fp32
|
||||
* passt nicht in einen Speicherblock.
|
||||
* automatisch - Grafikkarte wenn vorhanden, sonst CPU
|
||||
*
|
||||
* Die Belege dazu stehen im README. Wer daran drehen will, aendert diese
|
||||
* Konstanten - und prueft die Kombination vorher mit einer echten Aufnahme.
|
||||
*/
|
||||
export const WHISPER_MODEL = 'onnx-community/whisper-large-v3-turbo';
|
||||
export const WHISPER_DTYPE = 'q4' as const;
|
||||
export const WHISPER_LANGUAGE = 'de';
|
||||
|
||||
/**
|
||||
* WebGPU steht noch nicht in den TypeScript-Standardtypen. Statt eines eigenen
|
||||
* Typpakets hier nur das, was wirklich gebraucht wird.
|
||||
*/
|
||||
interface GpuAdapterLike {
|
||||
info?: Record<string, string>;
|
||||
limits?: Record<string, number>;
|
||||
}
|
||||
interface GpuLike {
|
||||
requestAdapter(): Promise<GpuAdapterLike | null>;
|
||||
}
|
||||
|
||||
function getGpu(): GpuLike | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
return (navigator as unknown as { gpu?: GpuLike }).gpu ?? null;
|
||||
}
|
||||
|
||||
/** Cache, in dem transformers.js die Modelldateien ablegt. */
|
||||
const MODEL_CACHE = 'transformers-cache';
|
||||
|
||||
/** Woran man im Cache erkennt, dass die Modelldateien schon vorliegen. */
|
||||
const MODEL_FILE_PATTERN = /encoder_model[^/]*q4[^/]*\.onnx$/i;
|
||||
|
||||
/**
|
||||
* Bittet den Browser, den Speicher dauerhaft zu behandeln.
|
||||
*
|
||||
* Die Modelldateien liegen im Cache-Speicher der Seite (nicht in Cookies - dort
|
||||
* passen nur wenige Kilobyte). Der ueberlebt Sitzungen ohnehin, gilt ohne diese
|
||||
* Bitte aber als "best effort": bei Platznot auf dem Geraet darf der Browser ihn
|
||||
* verwerfen. Mit erteilter Erlaubnis raeumt er ihn nur noch auf ausdruecklichen
|
||||
* Wunsch des Benutzers weg.
|
||||
*
|
||||
* Chrome entscheidet das nach eigenen Kriterien (u. a. wie oft die Seite genutzt
|
||||
* wird) und kann ablehnen. Dann funktioniert alles genauso - nur ohne diese
|
||||
* zusaetzliche Zusicherung.
|
||||
*/
|
||||
async function requestPersistentStorage(): Promise<boolean> {
|
||||
try {
|
||||
if (!navigator.storage?.persist) return false;
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liegt das Modell schon im Browser? Wird direkt am Cache geprueft und nicht
|
||||
* selbst mitgeschrieben - so wird auch erkannt, was in einer anderen Sitzung
|
||||
* geladen wurde.
|
||||
*/
|
||||
async function detectCached(): Promise<boolean> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return false;
|
||||
const cache = await caches.open(MODEL_CACHE);
|
||||
const keys = await cache.keys();
|
||||
return keys.some(
|
||||
(request) => request.url.includes(`${WHISPER_MODEL}/`) && MODEL_FILE_PATTERN.test(request.url)
|
||||
);
|
||||
} catch {
|
||||
// Cache API gesperrt (privater Modus) - dann eben ohne Erkennung.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne Rueckmeldung so lange gewartet? Dann ist etwas haengen geblieben. Das
|
||||
* Anlegen der ONNX-Sitzung meldet keinen Fortschritt, darf also dauern - aber
|
||||
* nicht endlos.
|
||||
*/
|
||||
const STALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Grenze fuer eine einzelne Erkennung, abhaengig von der Aufnahmelaenge: auf
|
||||
* der CPU ist ein Vielfaches der Aufnahmedauer normal, endlos aber nie.
|
||||
*/
|
||||
function transcribeTimeoutMs(seconds: number): number {
|
||||
return Math.min(300_000, Math.max(45_000, Math.round(seconds * 15_000)));
|
||||
}
|
||||
|
||||
export type WhisperStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
/** Woran gerade gearbeitet wird, solange geladen wird. */
|
||||
export type WhisperPhase = 'download' | 'init' | null;
|
||||
|
||||
export interface WhisperCapabilities {
|
||||
webgpu: boolean;
|
||||
adapter: string | null;
|
||||
cores: number | null;
|
||||
mediaRecorder: boolean;
|
||||
secureContext: boolean;
|
||||
}
|
||||
|
||||
interface WhisperContextValue {
|
||||
status: WhisperStatus;
|
||||
/** Download oder Initialisierung - nur waehrend des Ladens gesetzt. */
|
||||
phase: WhisperPhase;
|
||||
/** 0-100, waehrend des Downloads. */
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error: string | null;
|
||||
/** Modell liegt bereits im Browser-Cache. */
|
||||
isCached: boolean;
|
||||
capabilities: WhisperCapabilities;
|
||||
/** Tatsaechlich genutzter Rechenweg - Grafikkarte wenn vorhanden. */
|
||||
resolvedDevice: 'webgpu' | 'wasm';
|
||||
load: () => Promise<void>;
|
||||
unload: () => void;
|
||||
clearCache: () => Promise<void>;
|
||||
/** Nimmt eine Aufnahme und liefert den erkannten Text. */
|
||||
transcribe: (blob: Blob) => Promise<{ text: string; ms: number; seconds: number }>;
|
||||
/** Browser kann grundsaetzlich aufnehmen und rechnen. */
|
||||
isSupported: boolean;
|
||||
}
|
||||
|
||||
const WhisperContext = createContext<WhisperContextValue | null>(null);
|
||||
|
||||
export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = useState<WhisperStatus>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressLabel, setProgressLabel] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadMs, setLoadMs] = useState<number | null>(null);
|
||||
const [downloadedBytes, setDownloadedBytes] = useState<number | null>(null);
|
||||
const [isCached, setIsCached] = useState(false);
|
||||
const [phase, setPhase] = useState<WhisperPhase>(null);
|
||||
const [capabilities, setCapabilities] = useState<WhisperCapabilities>({
|
||||
webgpu: getGpu() !== null,
|
||||
adapter: null,
|
||||
cores: typeof navigator !== 'undefined' ? navigator.hardwareConcurrency ?? null : null,
|
||||
mediaRecorder: typeof MediaRecorder !== 'undefined',
|
||||
secureContext: typeof window !== 'undefined' ? window.isSecureContext : false,
|
||||
});
|
||||
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const loadWaiterRef = useRef<{ resolve: () => void; reject: (err: Error) => void } | null>(null);
|
||||
/** Zeitpunkt der letzten Worker-Nachricht - Grundlage der Haenger-Erkennung. */
|
||||
const lastActivityRef = useRef(0);
|
||||
const stallTimerRef = useRef<number | null>(null);
|
||||
const jobsRef = useRef(
|
||||
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
|
||||
);
|
||||
const nextJobId = useRef(1);
|
||||
/** Verhindert, dass nach Loeschen oder Entladen sofort neu geladen wird. */
|
||||
const autoLoadTriedRef = useRef(false);
|
||||
|
||||
// Details zur GPU nachladen - das geht nur asynchron.
|
||||
useEffect(() => {
|
||||
const gpu = getGpu();
|
||||
if (!gpu) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const adapter = await gpu.requestAdapter();
|
||||
if (cancelled) return;
|
||||
if (!adapter) {
|
||||
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfuegbar' }));
|
||||
return;
|
||||
}
|
||||
const info = adapter.info ?? {};
|
||||
const described = [info.vendor, info.architecture, info.device, info.description]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
setCapabilities((prev) => ({ ...prev, adapter: described || 'Adapter vorhanden' }));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setCapabilities((prev) => ({
|
||||
...prev,
|
||||
webgpu: false,
|
||||
adapter: err instanceof Error ? err.message : 'Abfrage fehlgeschlagen',
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Grafikkarte wenn verfuegbar, sonst CPU - daran gibt es nichts zu waehlen.
|
||||
const resolvedDevice: 'webgpu' | 'wasm' = capabilities.webgpu ? 'webgpu' : 'wasm';
|
||||
|
||||
const isSupported = capabilities.mediaRecorder && capabilities.secureContext;
|
||||
|
||||
// Cache-Zustand am Browser erfragen, nicht selbst mitschreiben.
|
||||
const refreshCacheState = useCallback(async () => {
|
||||
setIsCached(await detectCached());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCacheState();
|
||||
}, [refreshCacheState, status]);
|
||||
|
||||
const stopStallWatch = useCallback(() => {
|
||||
if (stallTimerRef.current !== null) {
|
||||
window.clearInterval(stallTimerRef.current);
|
||||
stallTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureWorker = useCallback((): Worker => {
|
||||
if (workerRef.current) return workerRef.current;
|
||||
|
||||
const worker = new Worker(new URL('./whisper.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
/** Bricht das Laden mit einer verwertbaren Meldung ab. */
|
||||
const failLoad = (message: string) => {
|
||||
stopStallWatch();
|
||||
setStatus('error');
|
||||
setPhase(null);
|
||||
setError(message);
|
||||
// Nach einem Fehlschlag ist der Worker nicht mehr vertrauenswuerdig.
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
loadWaiterRef.current?.reject(new Error(message));
|
||||
loadWaiterRef.current = null;
|
||||
// Laufende Erkennungen warten sonst fuer immer auf einen toten Worker.
|
||||
for (const job of jobsRef.current.values()) job.reject(new Error(message));
|
||||
jobsRef.current.clear();
|
||||
};
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const message = event.data;
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
if (message.type === 'progress') {
|
||||
if (message.total > 0) {
|
||||
const done = message.loaded >= message.total;
|
||||
setProgress(Math.min(100, (message.loaded / message.total) * 100));
|
||||
setProgressLabel(
|
||||
`${Math.round(message.loaded / 1e6)} von ${Math.round(message.total / 1e6)} MB`
|
||||
);
|
||||
// Nach dem letzten Byte folgt das Anlegen der Rechensitzung - das
|
||||
// meldet keinen Fortschritt mehr und soll trotzdem sichtbar sein.
|
||||
setPhase(done ? 'init' : 'download');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'ready') {
|
||||
stopStallWatch();
|
||||
setStatus('ready');
|
||||
setPhase(null);
|
||||
setProgress(100);
|
||||
setProgressLabel('');
|
||||
loadWaiterRef.current?.resolve();
|
||||
loadWaiterRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
jobsRef.current.get(message.id)?.resolve({ text: message.text, ms: message.ms });
|
||||
jobsRef.current.delete(message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fehler
|
||||
if (message.id !== undefined) {
|
||||
jobsRef.current.get(message.id)?.reject(new Error(message.message));
|
||||
jobsRef.current.delete(message.id);
|
||||
} else {
|
||||
failLoad(message.message);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (event) => {
|
||||
failLoad(event.message || 'Der Whisper-Worker ist abgestuerzt.');
|
||||
};
|
||||
|
||||
workerRef.current = worker;
|
||||
return worker;
|
||||
}, [stopStallWatch]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (status === 'loading') return;
|
||||
if (status === 'ready') return;
|
||||
|
||||
setStatus('loading');
|
||||
setPhase('download');
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressLabel(isCached ? 'aus dem Cache' : 'Download startet...');
|
||||
|
||||
// Vor dem Download um dauerhaften Speicher bitten, damit der Browser die
|
||||
// Dateien danach nicht bei Platznot wieder wegraeumt.
|
||||
void requestPersistentStorage();
|
||||
|
||||
const worker = ensureWorker();
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
// Haenger-Erkennung: bleibt der Worker stumm - etwa weil das Anlegen der
|
||||
// Sitzung am Speicher scheitert - wird das Laden mit Meldung abgebrochen,
|
||||
// statt endlos "Laedt..." zu zeigen.
|
||||
stopStallWatch();
|
||||
stallTimerRef.current = window.setInterval(() => {
|
||||
if (Date.now() - lastActivityRef.current < STALL_TIMEOUT_MS) return;
|
||||
stopStallWatch();
|
||||
const message =
|
||||
'Das Modell reagiert seit zwei Minuten nicht. Bitte die Seite neu laden und es erneut versuchen.';
|
||||
setStatus('error');
|
||||
setPhase(null);
|
||||
setError(message);
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
loadWaiterRef.current?.reject(new Error(message));
|
||||
loadWaiterRef.current = null;
|
||||
}, 5000);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
loadWaiterRef.current = { resolve, reject };
|
||||
const request: WorkerRequest = {
|
||||
type: 'load',
|
||||
model: WHISPER_MODEL,
|
||||
device: resolvedDevice,
|
||||
dtype: WHISPER_DTYPE,
|
||||
};
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}, [status, isCached, ensureWorker, resolvedDevice, stopStallWatch]);
|
||||
|
||||
const unload = useCallback(() => {
|
||||
stopStallWatch();
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
jobsRef.current.clear();
|
||||
setStatus('idle');
|
||||
setPhase(null);
|
||||
setProgress(0);
|
||||
setProgressLabel('');
|
||||
}, [stopStallWatch]);
|
||||
|
||||
const clearCache = useCallback(async () => {
|
||||
unload();
|
||||
try {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((name) => caches.delete(name)));
|
||||
} catch {
|
||||
// Cache API gesperrt - dann bleibt der Download eben liegen.
|
||||
}
|
||||
await refreshCacheState();
|
||||
}, [unload, refreshCacheState]);
|
||||
|
||||
const transcribe = useCallback(
|
||||
async (blob: Blob) => {
|
||||
if (status !== 'ready') throw new Error('Das Spracherkennungsmodell ist nicht geladen.');
|
||||
|
||||
const { audio, seconds } = await toWhisperAudio(blob);
|
||||
const worker = ensureWorker();
|
||||
const id = nextJobId.current++;
|
||||
|
||||
const outcome = await new Promise<{ text: string; ms: number }>((resolve, reject) => {
|
||||
// Ohne Frist bliebe die Oberflaeche bei "Erkenne..." stehen, wenn die
|
||||
// Rechenumgebung nicht antwortet.
|
||||
const timer = window.setTimeout(() => {
|
||||
jobsRef.current.delete(id);
|
||||
const message =
|
||||
'Die Erkennung hat nicht geantwortet. Bitte die Seite neu laden und es erneut versuchen.';
|
||||
// Der Worker steckt fest; ein neuer Versuch braucht einen frischen.
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
setStatus('idle');
|
||||
setError(message);
|
||||
reject(new Error(message));
|
||||
}, transcribeTimeoutMs(seconds));
|
||||
|
||||
jobsRef.current.set(id, {
|
||||
resolve: (value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (err) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
|
||||
const request: WorkerRequest = {
|
||||
type: 'transcribe',
|
||||
id,
|
||||
audio,
|
||||
language: WHISPER_LANGUAGE,
|
||||
};
|
||||
// Der Puffer wird uebergeben statt kopiert - bei langen Aufnahmen spart
|
||||
// das mehrere Megabyte.
|
||||
worker.postMessage(request, [audio.buffer]);
|
||||
});
|
||||
|
||||
return { ...outcome, seconds };
|
||||
},
|
||||
[status, ensureWorker]
|
||||
);
|
||||
|
||||
/**
|
||||
* Liegt das Modell im Browser, wird es beim Anmelden von selbst bereitgestellt
|
||||
* - einmal herunterladen genuegt also. Ohne Cache passiert nichts: ein
|
||||
* Download von 299 MB soll nie unaufgefordert starten.
|
||||
*
|
||||
* Bewusst nur ein einziges Mal pro Seitenaufruf: wer das Modell absichtlich
|
||||
* loescht oder aus dem Speicher nimmt, will es nicht sofort wieder geladen
|
||||
* bekommen.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (autoLoadTriedRef.current) return;
|
||||
if (!isCached || status !== 'idle') return;
|
||||
autoLoadTriedRef.current = true;
|
||||
load().catch(() => {
|
||||
/* Fehler steht im Status */
|
||||
});
|
||||
}, [isCached, status, load]);
|
||||
|
||||
useEffect(() => () => workerRef.current?.terminate(), []);
|
||||
|
||||
const value = useMemo<WhisperContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
resolvedDevice,
|
||||
load,
|
||||
unload,
|
||||
clearCache,
|
||||
transcribe,
|
||||
isSupported,
|
||||
}),
|
||||
[
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
resolvedDevice,
|
||||
load,
|
||||
unload,
|
||||
clearCache,
|
||||
transcribe,
|
||||
isSupported,
|
||||
]
|
||||
);
|
||||
|
||||
return <WhisperContext.Provider value={value}>{children}</WhisperContext.Provider>;
|
||||
};
|
||||
|
||||
export function useWhisper(): WhisperContextValue {
|
||||
const ctx = useContext(WhisperContext);
|
||||
if (!ctx) throw new Error('useWhisper muss innerhalb von <WhisperProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Whisper laeuft im Worker, nicht im Hauptthread: die Erkennung rechnet je nach
|
||||
* Modell mehrere Sekunden, und solange darf die Oberflaeche nicht einfrieren.
|
||||
*
|
||||
* Das Modell wird beim ersten Laden vom Hugging-Face-CDN geholt und danach im
|
||||
* Browser-Cache gehalten. Die Aufnahme selbst verlaesst das Geraet nie.
|
||||
*/
|
||||
import { pipeline, env, type AutomaticSpeechRecognitionPipeline } from '@huggingface/transformers';
|
||||
|
||||
// Ohne das versucht transformers.js zuerst einen lokalen Pfad und laeuft in
|
||||
// unnoetige 404er.
|
||||
env.allowLocalModels = false;
|
||||
env.allowRemoteModels = true;
|
||||
|
||||
export type WorkerRequest =
|
||||
| { type: 'load'; model: string; device: 'webgpu' | 'wasm'; dtype: string }
|
||||
| { type: 'transcribe'; id: number; audio: Float32Array; language: string };
|
||||
|
||||
export type WorkerResponse =
|
||||
| { type: 'progress'; loaded: number; total: number; file: string }
|
||||
| { type: 'ready'; ms: number; downloadedBytes: number }
|
||||
| { type: 'result'; id: number; text: string; ms: number }
|
||||
| { type: 'error'; id?: number; message: string };
|
||||
|
||||
/**
|
||||
* `pipeline()` ist ueber alle Aufgabenarten hinweg typisiert; TypeScript kann
|
||||
* die entstehende Union nicht mehr darstellen ("union type too complex").
|
||||
* Deshalb hier eine schmale Signatur nur fuer die Spracherkennung.
|
||||
*/
|
||||
type CreatePipeline = (
|
||||
task: 'automatic-speech-recognition',
|
||||
model: string,
|
||||
options: Record<string, unknown>
|
||||
) => Promise<AutomaticSpeechRecognitionPipeline>;
|
||||
|
||||
const createPipeline = pipeline as unknown as CreatePipeline;
|
||||
|
||||
let transcriber: AutomaticSpeechRecognitionPipeline | null = null;
|
||||
/** Groesse je Datei, damit der Fortschritt ueber alle Dateien stimmt. */
|
||||
const files = new Map<string, { loaded: number; total: number }>();
|
||||
|
||||
const post = (message: WorkerResponse) => self.postMessage(message);
|
||||
|
||||
async function load(model: string, device: 'webgpu' | 'wasm', dtype: string) {
|
||||
files.clear();
|
||||
const started = performance.now();
|
||||
|
||||
transcriber = await createPipeline('automatic-speech-recognition', model, {
|
||||
device,
|
||||
dtype,
|
||||
progress_callback: (event: {
|
||||
status: string;
|
||||
file?: string;
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
}) => {
|
||||
if (event.status !== 'progress' || !event.file) return;
|
||||
files.set(event.file, { loaded: event.loaded ?? 0, total: event.total ?? 0 });
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
for (const entry of files.values()) {
|
||||
loaded += entry.loaded;
|
||||
total += entry.total;
|
||||
}
|
||||
post({ type: 'progress', loaded, total, file: event.file });
|
||||
},
|
||||
});
|
||||
|
||||
let downloadedBytes = 0;
|
||||
for (const entry of files.values()) downloadedBytes += entry.total;
|
||||
|
||||
post({ type: 'ready', ms: Math.round(performance.now() - started), downloadedBytes });
|
||||
}
|
||||
|
||||
async function transcribe(id: number, audio: Float32Array, language: string) {
|
||||
if (!transcriber) throw new Error('Das Modell ist nicht geladen.');
|
||||
|
||||
const started = performance.now();
|
||||
const output = await transcriber(audio, {
|
||||
language,
|
||||
task: 'transcribe',
|
||||
// Laengere Aufnahmen werden in Fenster zerlegt; der Ueberlappungsbereich
|
||||
// verhindert abgeschnittene Woerter an den Grenzen.
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
});
|
||||
|
||||
const text = Array.isArray(output)
|
||||
? output.map((part) => part.text ?? '').join(' ')
|
||||
: (output.text ?? '');
|
||||
|
||||
post({ type: 'result', id, text: text.trim(), ms: Math.round(performance.now() - started) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim Anlegen der ONNX-Sitzung koennen Fehler ausserhalb der await-Kette
|
||||
* auftreten - etwa "Array buffer allocation failed", wenn das Modell zu gross
|
||||
* fuer einen zusammenhaengenden Speicherblock ist. Ohne diese Handler bleibt
|
||||
* das Lade-Promise fuer immer offen und die Oberflaeche haengt bei "Laedt...".
|
||||
*/
|
||||
function reportFatal(reason: unknown) {
|
||||
const raw = reason instanceof Error ? reason.message : String(reason);
|
||||
const isMemory = /allocation failed|out of memory|Array buffer/i.test(raw);
|
||||
post({
|
||||
type: 'error',
|
||||
message: isMemory
|
||||
? `Der Speicher reicht fuer dieses Modell in dieser Genauigkeit nicht (${raw}). Bitte eine kleinere Genauigkeit wie q4 waehlen.`
|
||||
: raw,
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('error', (event) => {
|
||||
event.preventDefault();
|
||||
reportFatal(event.error ?? event.message);
|
||||
});
|
||||
|
||||
self.addEventListener('unhandledrejection', (event) => {
|
||||
event.preventDefault();
|
||||
reportFatal(event.reason);
|
||||
});
|
||||
|
||||
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
const request = event.data;
|
||||
try {
|
||||
if (request.type === 'load') {
|
||||
await load(request.model, request.device, request.dtype);
|
||||
} else {
|
||||
await transcribe(request.id, request.audio, request.language);
|
||||
}
|
||||
} catch (err) {
|
||||
post({
|
||||
type: 'error',
|
||||
id: request.type === 'transcribe' ? request.id : undefined,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Downloadgroesse des Sprachmodells bei Hugging Face erfragen, damit im Reiter
|
||||
* eine echte Zahl steht statt einer Schaetzung.
|
||||
*
|
||||
* Geladen werden zwei Dateien: der Encoder und der zusammengefuehrte Decoder,
|
||||
* jeweils in der Variante q4.
|
||||
*/
|
||||
const DTYPE_SUFFIX = '_q4';
|
||||
|
||||
export interface ModelSize {
|
||||
totalBytes: number;
|
||||
/** Groesste Einzeldatei - sie entscheidet, ob der Browser sie halten kann. */
|
||||
largestBytes: number;
|
||||
}
|
||||
|
||||
interface Sibling {
|
||||
rfilename: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, ModelSize>();
|
||||
|
||||
export async function fetchModelSize(model: string): Promise<ModelSize | null> {
|
||||
const cached = cache.get(model);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(`https://huggingface.co/api/models/${model}?blobs=true`);
|
||||
if (!response.ok) throw new Error(`Modellinformationen nicht abrufbar (HTTP ${response.status}).`);
|
||||
|
||||
const data = (await response.json()) as { siblings?: Sibling[] };
|
||||
const files = new Map<string, number>();
|
||||
for (const sibling of data.siblings ?? []) {
|
||||
if (sibling.rfilename.endsWith('.onnx')) {
|
||||
files.set(sibling.rfilename.replace(/^onnx\//, '').replace(/\.onnx$/, ''), sibling.size ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// transformers.js nimmt den zusammengefuehrten Decoder, wenn er vorliegt.
|
||||
const decoderName = files.has(`decoder_model_merged${DTYPE_SUFFIX}`)
|
||||
? `decoder_model_merged${DTYPE_SUFFIX}`
|
||||
: `decoder_model${DTYPE_SUFFIX}`;
|
||||
const encoder = files.get(`encoder_model${DTYPE_SUFFIX}`);
|
||||
const decoder = files.get(decoderName);
|
||||
if (encoder === undefined || decoder === undefined) return null;
|
||||
|
||||
const size: ModelSize = {
|
||||
totalBytes: encoder + decoder,
|
||||
largestBytes: Math.max(encoder, decoder),
|
||||
};
|
||||
cache.set(model, size);
|
||||
return size;
|
||||
}
|
||||
|
||||
/** "299 MB" bzw. "1,6 GB" */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes >= 1_000_000_000) return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`;
|
||||
return `${Math.round(bytes / 1e6)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { AuthProvider } from './lib/auth';
|
||||
import { ThemeProvider } from './lib/theme';
|
||||
import { WhisperProvider } from './lib/whisper';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Root-Element #root nicht gefunden.');
|
||||
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
{/* Haelt Modelleinstellungen und den Whisper-Worker */}
|
||||
<WhisperProvider>
|
||||
<App />
|
||||
</WhisperProvider>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
export type ViewState =
|
||||
| 'Opportunities'
|
||||
| 'Kalender'
|
||||
| 'Kontakte'
|
||||
| 'Scanner'
|
||||
| 'Unpassend'
|
||||
| 'Suchagenten'
|
||||
| 'Börsen-Konten'
|
||||
| 'Einstellungen';
|
||||
|
||||
/**
|
||||
* Reiter der Einstellungen. Whisper sitzt hier und nicht mehr im Menue - es sind
|
||||
* Geraeteeinstellungen, die man einmal einrichtet.
|
||||
*
|
||||
* Rollen: `users` und `scanner` sind Administratoren vorbehalten, `account` und
|
||||
* `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto und richtet sein
|
||||
* eigenes Diktat ein.
|
||||
*/
|
||||
export type SettingsTab = 'users' | 'account' | 'scanner' | 'whisper';
|
||||
|
||||
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'scanner'];
|
||||
|
||||
/**
|
||||
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die
|
||||
* Ansicht, die beim Aufklappen des Menuepunkts erscheint. Die Einstellungen des
|
||||
* Scanners liegen absichtlich nicht hier, sondern als Reiter in den
|
||||
* CRM-Einstellungen.
|
||||
*/
|
||||
export const SCANNER_VIEWS: ViewState[] = ['Scanner', 'Unpassend', 'Suchagenten', 'Börsen-Konten'];
|
||||
|
||||
export type Role = 'admin' | 'user';
|
||||
|
||||
export interface AppUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
name: string;
|
||||
role: Role;
|
||||
/** Persoenliche Kalenderfarbe als Hex; null = automatisch aus der Palette. */
|
||||
color?: string | null;
|
||||
/** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */
|
||||
avatar?: string | null;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Farbpalette fuer Benutzer im Kalender. Bewusst kontraststark untereinander
|
||||
* und dunkel genug fuer weisse Schrift; das CI-Gruen steht an erster Stelle.
|
||||
*/
|
||||
export const USER_COLORS = [
|
||||
'#6ba04a',
|
||||
'#0284c7',
|
||||
'#d97706',
|
||||
'#7c3aed',
|
||||
'#db2777',
|
||||
'#0d9488',
|
||||
'#dc2626',
|
||||
'#4f46e5',
|
||||
'#65a30d',
|
||||
'#c2410c',
|
||||
] as const;
|
||||
|
||||
/** Gesetzte Farbe, sonst stabil aus der ID abgeleitet. */
|
||||
export function userColor(user?: { id: number; color?: string | null } | null): string {
|
||||
if (!user) return '#9a9a9a';
|
||||
if (user.color && /^#[0-9a-fA-F]{6}$/.test(user.color)) return user.color;
|
||||
return USER_COLORS[Math.abs(user.id) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
export const LEAD_STATUSES = [
|
||||
'Neu',
|
||||
'Kontaktiert',
|
||||
'Qualifiziert',
|
||||
'Angebot',
|
||||
'Gewonnen',
|
||||
'Verloren',
|
||||
] as const;
|
||||
|
||||
export type LeadStatus = (typeof LEAD_STATUSES)[number];
|
||||
|
||||
export interface Lead {
|
||||
id: number;
|
||||
salutation: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company: string | null;
|
||||
position: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
linkedin: string | null;
|
||||
website: string | null;
|
||||
street: string | null;
|
||||
zip: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
status: LeadStatus;
|
||||
source: string | null;
|
||||
tags: string[];
|
||||
notes: string | null;
|
||||
avatar: string | null;
|
||||
owner_id: number | null;
|
||||
created_by: number | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
/** Angereichert vom Backend */
|
||||
created_by_name?: string | null;
|
||||
owner_name?: string | null;
|
||||
}
|
||||
|
||||
export const CONNECTION_TYPES = [
|
||||
'Kollege',
|
||||
'Vorgesetzter',
|
||||
'Kunde',
|
||||
'Partner',
|
||||
'Empfehlung',
|
||||
'Netzwerk',
|
||||
'Sonstige',
|
||||
] as const;
|
||||
|
||||
export type ConnectionType = (typeof CONNECTION_TYPES)[number];
|
||||
|
||||
export interface LeadConnection {
|
||||
id: number;
|
||||
from_lead_id: number;
|
||||
to_lead_id: number;
|
||||
type: ConnectionType;
|
||||
label: string | null;
|
||||
strength: number;
|
||||
created_by: number | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export type LeadViewMode = 'grid' | 'list' | 'network';
|
||||
|
||||
// ---------- Opportunities ----------
|
||||
|
||||
/** Phasen der Vertriebspipeline, in dieser Reihenfolge. */
|
||||
export const OPPORTUNITY_STAGES = [
|
||||
'Neu',
|
||||
'Qualifizierung',
|
||||
'Angebot',
|
||||
'Verhandlung',
|
||||
'Gewonnen',
|
||||
'Verloren',
|
||||
] as const;
|
||||
|
||||
export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number];
|
||||
|
||||
/** Phasen, in denen die Chance noch offen ist. */
|
||||
export const OPEN_STAGES: OpportunityStage[] = ['Neu', 'Qualifizierung', 'Angebot', 'Verhandlung'];
|
||||
|
||||
export interface OpportunityContact {
|
||||
lead_id: number;
|
||||
/** Freitext, z. B. "Entscheider" */
|
||||
role: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
company: string | null;
|
||||
position: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface Opportunity {
|
||||
id: number;
|
||||
title: string;
|
||||
company: string | null;
|
||||
description: string | null;
|
||||
stage: OpportunityStage;
|
||||
/** 0-100 */
|
||||
probability: number;
|
||||
/** Tagessatz in Euro */
|
||||
daily_rate: number | null;
|
||||
/** Laufzeit in Personentagen */
|
||||
duration_days: number | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
last_interaction_at: string | null;
|
||||
last_interaction_note: string | null;
|
||||
next_step: string | null;
|
||||
tags: string[];
|
||||
notes: string | null;
|
||||
owner_id: number | null;
|
||||
created_by: number | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
/** Angereichert vom Backend */
|
||||
created_by_name?: string | null;
|
||||
owner_name?: string | null;
|
||||
owner_color?: string | null;
|
||||
owner_avatar?: string | null;
|
||||
contacts: OpportunityContact[];
|
||||
}
|
||||
|
||||
export type OpportunityViewMode = 'grid' | 'list' | 'pipeline';
|
||||
|
||||
/** Projektvolumen = Tagessatz x Personentage; null, wenn eines fehlt. */
|
||||
export function opportunityVolume(opportunity: Opportunity): number | null {
|
||||
if (opportunity.daily_rate === null || opportunity.duration_days === null) return null;
|
||||
return opportunity.daily_rate * opportunity.duration_days;
|
||||
}
|
||||
|
||||
/** Mit der Abschlusswahrscheinlichkeit gewichtetes Volumen. */
|
||||
export function weightedVolume(opportunity: Opportunity): number | null {
|
||||
const volume = opportunityVolume(opportunity);
|
||||
return volume === null ? null : (volume * opportunity.probability) / 100;
|
||||
}
|
||||
|
||||
export function formatEuro(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
return value.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function contactFullName(contact: Pick<OpportunityContact, 'first_name' | 'last_name'>): string {
|
||||
return `${contact.first_name} ${contact.last_name}`.trim();
|
||||
}
|
||||
|
||||
// ---------- Kalender ----------
|
||||
|
||||
export interface EventParticipant {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
/** Kann Mention-Tokens der Form `@[Name](lead:12)` bzw. `@[Name](user:3)` enthalten. */
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
/** MariaDB-DATETIME als String: `YYYY-MM-DD HH:MM:SS` (lokale Wandzeit). */
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
all_day: boolean;
|
||||
created_by: number | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
/** Angereichert vom Backend */
|
||||
created_by_name?: string | null;
|
||||
created_by_color?: string | null;
|
||||
created_by_avatar?: string | null;
|
||||
participants: EventParticipant[];
|
||||
}
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day';
|
||||
|
||||
/** Farbe eines Termins = Farbe seines Erstellers. */
|
||||
export function eventColor(event: CalendarEvent): string {
|
||||
if (event.created_by === null) return '#9a9a9a';
|
||||
return userColor({ id: event.created_by, color: event.created_by_color ?? null });
|
||||
}
|
||||
|
||||
export function leadFullName(lead: Pick<Lead, 'first_name' | 'last_name'>): string {
|
||||
return `${lead.first_name} ${lead.last_name}`.trim();
|
||||
}
|
||||
|
||||
export function leadInitials(lead: Pick<Lead, 'first_name' | 'last_name'>): string {
|
||||
const a = lead.first_name?.charAt(0) ?? '';
|
||||
const b = lead.last_name?.charAt(0) ?? '';
|
||||
return (a + b).toUpperCase() || '?';
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user