import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { AcquisitionForm } from './AcquisitionForm'; import { AcquisitionTable, type AcquisitionSortConfig, type AcquisitionSortKey, } from './AcquisitionTable'; import { ConfirmDialog } from '../ui/Modal'; import { useToast } from '../Toast'; import { useNavigation } from '../../lib/navigation'; import { acquisitionsApi } from '../../lib/api'; import { EMPTY_ACQUISITION_OPTIONS, type Acquisition, type AcquisitionList } from '../../types'; /* * Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber. * * Derselbe Aufbau traegt das Archiv (`view = 'archiv'`): dieselbe Liste, nur * ohne Eingabe - im Archiv wird nichts Neues eingetragen, es wird abgearbeitet * oder es verfaellt. * * Eingang und Archiv trennt allein das Funddatum; das entscheidet das Backend. * Nichts hier verschiebt Eintraege, es gibt keinen Zustand, der driften kann. */ interface AcquisitionsProps { /** 'archiv' zeigt die Eintraege, die aelter als die Frist sind. */ view?: 'eingang' | 'archiv'; /** Opportunities der App neu laden - nach einer Uebernahme. */ reload: () => void; } /** Vergleichswert je Sortierschluessel; leere Felder sortieren ans Ende. */ function sortValue(entry: Acquisition, key: AcquisitionSortKey): string | null { switch (key) { case 'found_at': return entry.found_at; case 'title': return entry.title.toLowerCase(); default: return entry[key]?.toLowerCase() ?? null; } } export const Acquisitions: React.FC = ({ view = 'eingang', reload }) => { const { addToast } = useToast(); const { goTo } = useNavigation(); const isArchive = view === 'archiv'; const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [search, setSearch] = useState(''); const [editing, setEditing] = useState(null); const [toDelete, setToDelete] = useState(null); const [busyId, setBusyId] = useState(null); const [sortConfig, setSortConfig] = useState({ key: 'found_at', direction: 'desc', }); const load = useCallback( async (silent = false) => { if (!silent) setIsLoading(true); try { setData(await acquisitionsApi.list(view)); } catch (err) { addToast(err instanceof Error ? err.message : 'Akquise nicht abrufbar.', 'error'); } finally { setIsLoading(false); } }, [view, addToast] ); useEffect(() => { load(); }, [load]); // Beim Wechsel zwischen Eingang und Archiv nichts Angefangenes mitnehmen. useEffect(() => { setEditing(null); setSearch(''); }, [view]); const entries = useMemo(() => { const all = data?.entries ?? []; const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); const matches = all.filter((entry) => { if (terms.length === 0) return true; const haystack = [ entry.title, entry.sender, entry.provider, entry.channel, entry.consultant, entry.company, entry.description, entry.notes, entry.tags.join(' '), ] .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); if (left === right) return (a.id - b.id) * direction; if (left === null) return 1; if (right === null) return -1; return left.localeCompare(right, 'de') * direction; }); }, [data, search, sortConfig]); const handleSort = (key: AcquisitionSortKey) => { setSortConfig((prev) => prev.key === key ? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' } : // Datum beginnt absteigend - das Neueste zuerst ist der Normalfall. { key, direction: key === 'found_at' ? 'desc' : 'asc' } ); }; const convert = async (entry: Acquisition) => { setBusyId(entry.id); try { await acquisitionsApi.toOpportunity(entry.id); addToast(`Opportunity „${entry.title}" angelegt - Phase „Erstgespräch Berater".`, 'success'); if (editing?.id === entry.id) setEditing(null); await load(true); // Die Opportunity-Liste der App kennt den neuen Eintrag noch nicht. reload(); } catch (err) { addToast(err instanceof Error ? err.message : 'Übernahme fehlgeschlagen.', 'error'); await load(true); } finally { setBusyId(null); } }; const confirmDelete = async () => { if (!toDelete) return; try { await acquisitionsApi.remove(toDelete.id); addToast('Eintrag gelöscht.', 'success'); if (editing?.id === toDelete.id) setEditing(null); await load(true); } catch (err) { addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error'); } finally { setToDelete(null); } }; const archiveAfter = data?.archive_after_days ?? 14; const deleteAfter = data?.delete_after_days ?? 90; return (
{isArchive ? (

Älter als {archiveAfter} Tage - hierher rutscht alles von selbst.

Nach {deleteAfter} Tagen wird ein Eintrag automatisch gelöscht. Bis dahin lässt er sich weiter bearbeiten und jederzeit in eine Opportunity übernehmen. Neue Projekte werden unter Akquise eingetragen.

Hier stehen auch Bewerbungen, deren Opportunity gelöscht{' '} wurde - sonst wären sie nirgends mehr zu finden. Wird die Opportunity aus ihrem Papierkorb zurückgeholt, verschwindet der Eintrag wieder von selbst.

) : ( load(true)} onCancelEdit={() => setEditing(null)} /> )} {/* Steuerleiste */}
setSearch(event.target.value)} placeholder="Titel, Anbieter, Berater, Notizen suchen" 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" />
{entries.length} von {data?.entries.length ?? 0} {isArchive ? ' im Archiv' : ' im Eingang'} {!isArchive && (data?.counts.archiv ?? 0) > 0 && ( )}
{isLoading && !data ? (
) : entries.length === 0 ? (
{search ? 'Kein Eintrag passt zur Suche.' : isArchive ? `Das Archiv ist leer - es gibt keine Projekte, die älter als ${archiveAfter} Tage sind.` : 'Noch keine Projekte eingetragen. Oben ausfüllen und auf „Eintragen" klicken.'}
) : ( )} {toDelete && ( setToDelete(null)} /> )}
); };