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:
co-authored by
Claude Opus 5
parent
f319b8207b
commit
6e66bd39d7
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user