feat(akquise): Papierkorb fuer geloeschte Eintraege

Loeschen in der Akquise war ein echtes DELETE: der Eintrag stand danach
in keiner Liste, auch nicht im Archiv, und war nicht zurueckholbar. Jetzt
setzt es `deleted_at`/`deleted_by`, die Zeile bleibt 90 Tage stehen.

Der Papierkorb ist die eine Ausnahme von der Regel, dass diese Tabelle
keinen Zustand fuehrt: Loeschen von Hand ist eine Entscheidung, kein
Ablauf. Was dort liegt, blenden alle anderen Abfragen ueber NOT_DELETED
aus - Eingang, Archiv, Menuezahlen und Dashboard. Der Aufraeumlauf ueber
`found_at` laesst geloeschte Zeilen ausdruecklich liegen, sonst waere ein
alter Fund im Papierkorb sofort weg; der Papierkorb raeumt ab
`deleted_at`.

Zwei Rueckwege, deshalb zwei Routen. `/reopen` (bisher `/restore`) holt
aus dem Archiv in den Eingang und setzt das Funddatum auf heute.
`/restore` holt aus dem Papierkorb zurueck und laesst das Funddatum in
Ruhe - der Eintrag soll dort auftauchen, wo er hingehoert, also im
Archiv, wenn er alt ist. Die Meldung nennt das Ziel.

Dazu `DELETE /:id/permanent` und `POST /trash/empty`. Im Papierkorb wird
nicht gepflegt: Bearbeiten und Uebernehmen antworten 409.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-03 10:17:21 +02:00
co-authored by Claude Opus 5
parent f319b8207b
commit 6e66bd39d7
17 changed files with 732 additions and 87 deletions
@@ -0,0 +1,321 @@
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';
/*
* 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">{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>
);
};