feat(akquise): gefundene Projekte als Eingang vor den Opportunities
Neuer Menuepunkt ueber den Opportunities, mit dem Unterpunkt "Archiv". Die Liste traegt genau die Spalten, die beim Durchgehen zaehlen: Datum, Von, Anbieter, Kanal, Projekttitel, Berater, ein Knopf fuer die Uebernahme und die Symbole fuer Bearbeiten und Loeschen. Eingabe steht ueber der Liste und ist bewusst kein Dialog - Projekte kommen in Serie herein. Nach dem Eintragen bleiben Datum, Anbieter, Kanal und Berater stehen, damit drei Projekte aus einer Mail nicht dreimal dieselben Angaben kosten. Von, Anbieter und Berater schlagen frueher benutzte Werte vor. Was in der Liste keine Spalte hat, aber in der Opportunity vorkommt (Endkunde, Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum, naechster Schritt, Notizen), klappt darunter auf. Eingang, Archiv und Loeschung ergeben sich aus dem Funddatum - 14 bzw. 90 Tage - und aus converted_at. Es gibt keine Zustandsspalte, die ein Job pflegen muesste und die von der Wirklichkeit abweichen kann. Der Loeschlauf raeumt beim Abruf der Liste, hoechstens einmal pro Stunde; die Abfragen filtern Faelliges ohnehin heraus. Die Uebernahme legt Opportunity und Merker in einer Transaktion an. Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld und wandern als Herkunftsvermerk in die Notizen. Der Titel wird nur dann zum Link, wenn eine http(s)-Adresse hinterlegt ist: "hays.de/projekt/1" ergaenzt das Backend zu https://, und die Anzeige laesst nichts anderes durch als http und https. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8741a9ae0c
commit
b8a78f609c
@@ -0,0 +1,259 @@
|
||||
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 type { Acquisition, 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 „Neu".`, '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>
|
||||
</div>
|
||||
) : (
|
||||
<AcquisitionForm
|
||||
editing={editing}
|
||||
suggestions={data?.suggestions ?? { providers: [], consultants: [], senders: [] }}
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user