Files
perinco-CRM/src/components/acquisition/AcquisitionTrash.tsx
T
Sascha WolfandClaude Opus 5 62dce64739 feat(akquise): mehrere Berater je Eintrag, je Berater eine Opportunity
Ein gefundenes Projekt passt oft zu mehr als einem Berater, und fuer
jeden geht eine eigene Bewerbung heraus. Das Feld "Berater" liess bisher
nur einen Namen zu.

Gespeichert wird eine kommagetrennte Liste in crm_acquisitions.consultant
(auf VARCHAR(500) erweitert, Migration in db.js) und keine eigene
Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in
crm_acquisition_options ist eine Pflegehilfe und kein Fremdschluessel.
Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem
Feld. Die Funktionen zum Zerlegen stehen in acquisitionRules.js, das
Gegenstueck fuer das Frontend in src/types.ts, geprueft von
server/test/acquisition.test.mjs.

Zerlegt wird an vier Stellen:

- Uebernahme: je Berater eine Opportunity, alle in einer Transaktion.
  Ihre Titel tragen den Namen in Klammern, weil die Opportunity kein
  eigenes Feld fuer den Berater hat und in Pipeline und Liste sonst
  identische Zeilen stehen; bei einem einzigen Berater bleibt der Titel
  unveraendert. In den Notizen steht neben dem eigenen Berater auch, mit
  wem er sich das Projekt teilt. Der Eintrag merkt sich die erste
  Opportunity - sie entscheidet nur darueber, ob er wieder im Eingang
  auftaucht.
- Verlauf (crm_acquisition_history): ein Berater je Zeile. Ein Eintrag
  mit drei Beratern sind drei Bewerbungen, "Nico, Lisa" waere sonst ein
  eigener Schluessel neben "Nico" und "Lisa".
- Dashboard: dieselbe Zaehlung. Die Summe der Balken liegt damit ueber
  der Gesamtzahl "Bewerbungen", die weiter die Eintraege zaehlt.
- Auswahlliste pflegen: Umbenennen ersetzt den Namen innerhalb der
  Liste, Loeschen zaehlt auch, wer nur einer von mehreren ist. Ein
  Gleichheitsvergleich uebersah beides.

In der Oberflaeche haengt das Auswahlfeld jeweils einen Namen an, die
Gewaehlten stehen als Plaketten darunter und lassen sich einzeln
entfernen; ein bereits gewaehlter Berater verschwindet aus der Liste.
Bewusst kein <select multiple>: Strg-Klick ist mit der Maus unauffindbar
und mit einer Beruehrung gar nicht bedienbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:35:43 +02:00

325 lines
13 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { ConfirmDialog } from '../ui/Modal';
import { UserAvatar } from '../ui/UserAvatar';
import { useToast } from '../Toast';
import { acquisitionsApi } from '../../lib/api';
import { formatEuro, type Acquisition } from '../../types';
import { ConsultantList } from './acquisitionUi';
/*
* Papierkorb der Akquise.
*
* Loeschen war hier lange endgueltig: der Eintrag verschwand aus Eingang und
* Archiv und stand nirgends mehr - ein Fehlklick kostete den Fund. Jetzt setzt
* das Loeschen nur `deleted_at`, und diese Ansicht ist die Stelle, an der man
* ihn zurueckholt.
*
* Bewusst eine eigene, schlanke Liste und nicht die Akquise mit einem Schalter -
* dasselbe Muster wie beim Papierkorb der Opportunities: hier zaehlt, was weg
* ist, wer es weggeworfen hat und wie lange es noch da liegt. Suche, Sortierung
* und die Uebernahme in eine Opportunity waeren dafuer Beiwerk.
*/
interface AcquisitionTrashProps {
/** Daten der App neu laden - die Zahlen am Menue haengen daran. */
reload: () => void;
}
/** MariaDB-DATETIME steht in UTC; ohne das angehaengte Z liest der Browser Ortszeit. */
function parseUtc(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(`${value.replace(' ', 'T')}Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDeleted(
value: string | null | undefined,
keepDays: number
): { label: string; remaining: string } {
const date = parseUtc(value);
if (!date) return { label: '—', remaining: '' };
const days = Math.floor((Date.now() - date.getTime()) / 86_400_000);
const left = Math.max(0, keepDays - days);
return {
label: date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }),
remaining: left === 0 ? 'wird beim nächsten Aufräumen gelöscht' : `noch ${left} Tage`,
};
}
/** Funddatum kurz - es entscheidet, wo der Eintrag nach dem Zurückholen steht. */
function formatFound(value: string): string {
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
/** Kurze Zusammenfassung der Angaben ohne eigene Spalte - wie in der Akquise. */
function extras(entry: Acquisition): string[] {
const parts: string[] = [];
if (entry.company) parts.push(entry.company);
if (entry.fixed_price !== null) parts.push(`${formatEuro(entry.fixed_price)} Festpreis`);
else if (entry.daily_rate !== null) parts.push(`${formatEuro(entry.daily_rate)}/Tag`);
if (entry.duration_days !== null) parts.push(`${entry.duration_days} PT`);
return parts;
}
export const AcquisitionTrash: React.FC<AcquisitionTrashProps> = ({ reload }) => {
const { addToast } = useToast();
const [entries, setEntries] = useState<Acquisition[]>([]);
const [keepDays, setKeepDays] = useState(90);
const [archiveAfter, setArchiveAfter] = useState(14);
const [isLoading, setIsLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
const [toPurge, setToPurge] = useState<Acquisition | null>(null);
const [isEmptyOpen, setIsEmptyOpen] = useState(false);
const load = useCallback(
async (silent = false) => {
if (!silent) setIsLoading(true);
try {
const data = await acquisitionsApi.list('papierkorb');
setEntries(data.entries);
setKeepDays(data.trash_delete_after_days);
setArchiveAfter(data.archive_after_days);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Papierkorb nicht abrufbar.', 'error');
} finally {
setIsLoading(false);
}
},
[addToast]
);
useEffect(() => {
load();
}, [load]);
const restore = async (entry: Acquisition) => {
setBusyId(entry.id);
try {
await acquisitionsApi.restore(entry.id);
/*
* Wohin der Eintrag zurueckkommt, entscheidet sein Funddatum - deshalb
* steht es in der Meldung. Sonst sucht man ihn im Eingang, obwohl er alt
* genug fuer das Archiv ist.
*/
const age = Math.floor(
(Date.now() - new Date(`${entry.found_at.slice(0, 10)}T00:00:00`).getTime()) / 86_400_000
);
const target = age > archiveAfter ? 'Archiv' : 'Eingang';
addToast(`„${entry.title}" steht wieder im ${target}.`, 'success');
await load(true);
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Wiederherstellen fehlgeschlagen.', 'error');
await load(true);
} finally {
setBusyId(null);
}
};
const purge = async () => {
if (!toPurge) return;
try {
await acquisitionsApi.purge(toPurge.id);
addToast('Eintrag endgültig gelöscht.', 'success');
await load(true);
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setToPurge(null);
}
};
const emptyTrash = async () => {
setIsEmptyOpen(false);
try {
const result = await acquisitionsApi.emptyTrash();
addToast(
result.purged === 1
? '1 Eintrag endgültig gelöscht.'
: `${result.purged} Einträge endgültig gelöscht.`,
'success'
);
await load(true);
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Leeren fehlgeschlagen.', 'error');
}
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4 sm:flex-row sm:items-center">
<div className="min-w-0">
<p className="text-sm font-bold text-ink-700">
{entries.length === 0
? 'Der Papierkorb ist leer.'
: `${entries.length} ${
entries.length === 1 ? 'gelöschter Eintrag' : 'gelöschte Einträge'
}`}
</p>
<p className="mt-1 text-xs text-ink-500">
Von Hand gelöschte Akquise-Einträge liegen hier {keepDays} Tage und lassen sich
zurückholen. Danach werden sie automatisch entfernt. Wohin ein Eintrag zurückkommt,
entscheidet sein Funddatum: in den <em>Eingang</em>, wenn er jünger als{' '}
{archiveAfter} Tage ist, sonst ins <em>Archiv</em>.
</p>
<p className="mt-1 text-xs text-ink-500">
In der Auswertung zählt ein gelöschter Eintrag nicht mit - ein Fehleintrag soll die
Zahl der Bewerbungen nicht verfälschen.
</p>
</div>
<button
onClick={() => setIsEmptyOpen(true)}
disabled={entries.length === 0}
className="shrink-0 whitespace-nowrap rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700 disabled:opacity-40 sm:ml-auto"
>
Papierkorb leeren
</button>
</div>
{isLoading && entries.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>
) : 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">
Hier liegt nichts. Gelöschte Akquise-Einträge erscheinen an dieser Stelle.
</div>
) : (
<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 min-w-[900px] text-left">
<thead className="border-b border-ink-200 bg-ink-50">
<tr>
{['Projekttitel', 'Anbieter', 'Berater', 'Gefunden', 'Gelöscht', 'Von', ''].map(
(label, index) => (
<th
key={label || `spalte-${index}`}
className="px-4 py-3 text-[10px] font-extrabold uppercase tracking-wider text-ink-500"
>
{label}
</th>
)
)}
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{entries.map((entry) => {
const deleted = formatDeleted(entry.deleted_at, keepDays);
const extraParts = extras(entry);
const isBusy = busyId === entry.id;
return (
<tr key={entry.id} className="transition hover:bg-ink-50">
<td className="max-w-sm px-4 py-3">
<p className="truncate text-sm font-bold text-ink-900">{entry.title}</p>
{extraParts.length > 0 && (
<p className="mt-0.5 truncate text-[11px] text-ink-400">
{extraParts.join(' · ')}
</p>
)}
</td>
<td className="px-4 py-3 text-sm font-bold text-ink-800">
{entry.provider || '—'}
</td>
<td className="px-4 py-3 text-sm text-ink-700">
<ConsultantList value={entry.consultant} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm tabular-nums text-ink-700">
{formatFound(entry.found_at)}
</td>
<td className="whitespace-nowrap px-4 py-3">
<p className="text-sm tabular-nums text-ink-700">{deleted.label}</p>
<p className="text-[11px] text-ink-400">{deleted.remaining}</p>
</td>
<td className="whitespace-nowrap px-4 py-3">
{entry.deleted_by === null || entry.deleted_by === undefined ? (
<span className="text-sm text-ink-400">—</span>
) : (
<div className="flex items-center gap-2">
<UserAvatar
user={{
id: entry.deleted_by,
name: entry.deleted_by_name ?? '?',
color: null,
avatar: null,
}}
size={24}
/>
<span className="text-sm text-ink-700">
{entry.deleted_by_name ?? 'unbekannt'}
</span>
</div>
)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right">
<div className="inline-flex items-center gap-1">
<button
onClick={() => restore(entry)}
disabled={isBusy}
className="rounded-xl bg-brand-500 px-4 py-2 text-xs font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-50"
>
{isBusy ? 'Hole zurück...' : 'Wiederherstellen'}
</button>
<button
onClick={() => setToPurge(entry)}
title="Endgültig löschen"
aria-label={`${entry.title} endgültig löschen`}
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="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>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{toPurge && (
<ConfirmDialog
title="Endgültig löschen?"
message={`„${toPurge.title}" wird unwiderruflich entfernt und lässt sich danach nicht mehr zurückholen.`}
confirmLabel="Endgültig löschen"
onConfirm={purge}
onCancel={() => setToPurge(null)}
/>
)}
{isEmptyOpen && (
<ConfirmDialog
title="Papierkorb endgültig leeren?"
message={`${entries.length} ${
entries.length === 1 ? 'Eintrag wird' : 'Einträge werden'
} unwiderruflich gelöscht.`}
confirmLabel="Endgültig löschen"
onConfirm={emptyTrash}
onCancel={() => setIsEmptyOpen(false)}
/>
)}
</div>
);
};