Wer eine Bewerbung uebernimmt, hat den Berater bereits gesprochen. Die Opportunity in der Phase "Neu" anzulegen bedeutete, sie sofort von Hand weiterzuschieben. Sie startet jetzt direkt in "Erstgespraech Berater"; Toast, Knopf-Hinweis und Benutzeranleitung nennen dieselbe Phase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
266 lines
9.4 KiB
TypeScript
266 lines
9.4 KiB
TypeScript
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<AcquisitionsProps> = ({ view = 'eingang', reload }) => {
|
|
const { addToast } = useToast();
|
|
const { goTo } = useNavigation();
|
|
const isArchive = view === 'archiv';
|
|
|
|
const [data, setData] = useState<AcquisitionList | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [editing, setEditing] = useState<Acquisition | null>(null);
|
|
const [toDelete, setToDelete] = useState<Acquisition | null>(null);
|
|
const [busyId, setBusyId] = useState<number | null>(null);
|
|
const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({
|
|
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 (
|
|
<div className="space-y-4">
|
|
{isArchive ? (
|
|
<div className="rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4">
|
|
<p className="text-sm font-bold text-ink-700">
|
|
Älter als {archiveAfter} Tage - hierher rutscht alles von selbst.
|
|
</p>
|
|
<p className="mt-1 text-xs text-ink-500">
|
|
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 <em>Akquise</em> eingetragen.
|
|
</p>
|
|
<p className="mt-1 text-xs text-ink-500">
|
|
Hier stehen auch Bewerbungen, deren <strong className="font-bold">Opportunity gelöscht</strong>{' '}
|
|
wurde - sonst wären sie nirgends mehr zu finden. Wird die Opportunity aus ihrem
|
|
Papierkorb zurückgeholt, verschwindet der Eintrag wieder von selbst.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<AcquisitionForm
|
|
editing={editing}
|
|
options={data?.options ?? EMPTY_ACQUISITION_OPTIONS}
|
|
providerSuggestions={data?.provider_suggestions ?? []}
|
|
onSaved={() => load(true)}
|
|
onCancelEdit={() => setEditing(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Steuerleiste */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<div className="relative w-full sm:max-w-sm">
|
|
<input
|
|
type="search"
|
|
value={search}
|
|
onChange={(event) => 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"
|
|
/>
|
|
<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>
|
|
|
|
<div className="flex items-center gap-3 text-xs font-bold text-ink-400 sm:ml-auto">
|
|
<span>
|
|
{entries.length} von {data?.entries.length ?? 0}
|
|
{isArchive ? ' im Archiv' : ' im Eingang'}
|
|
</span>
|
|
{!isArchive && (data?.counts.archiv ?? 0) > 0 && (
|
|
<button
|
|
onClick={() => goTo('Archiv')}
|
|
className="rounded-lg border border-ink-200 px-3 py-1.5 transition hover:border-brand-300 hover:text-brand-700"
|
|
>
|
|
{data?.counts.archiv} im Archiv
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading && !data ? (
|
|
<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>
|
|
) : entries.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-ink-200 bg-surface px-6 py-16 text-center text-sm font-bold text-ink-400">
|
|
{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.'}
|
|
</div>
|
|
) : (
|
|
<AcquisitionTable
|
|
entries={entries}
|
|
sortConfig={sortConfig}
|
|
onSort={handleSort}
|
|
editingId={editing?.id ?? null}
|
|
busyId={busyId}
|
|
onEdit={setEditing}
|
|
onDelete={setToDelete}
|
|
onConvert={convert}
|
|
/>
|
|
)}
|
|
|
|
{toDelete && (
|
|
<ConfirmDialog
|
|
title="Eintrag löschen?"
|
|
message={`„${toDelete.title}" wird aus der Akquise entfernt. Wer das Projekt behalten will, übernimmt es stattdessen als Opportunity.`}
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|