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
@@ -6,6 +6,7 @@ import { Leads } from './components/leads/Leads';
|
||||
import { MailPoolLeads } from './components/leads/MailPoolLeads';
|
||||
import { Dashboard } from './components/dashboard/Dashboard';
|
||||
import { Acquisitions } from './components/acquisition/Acquisitions';
|
||||
import { AcquisitionTrash } from './components/acquisition/AcquisitionTrash';
|
||||
import { Opportunities } from './components/opportunities/Opportunities';
|
||||
import { OpportunityTrash } from './components/opportunities/OpportunityTrash';
|
||||
import { Calendar } from './components/calendar/Calendar';
|
||||
@@ -204,6 +205,7 @@ const App: React.FC = () => {
|
||||
counts={{
|
||||
'Aus Mails': mailPoolLeads.length,
|
||||
Archiv: badges?.acquisition_archive,
|
||||
'Akquise-Papierkorb': badges?.acquisition_trash,
|
||||
'Opportunity-Papierkorb': badges?.opportunity_trash,
|
||||
Papierkorb: badges?.mail_trash,
|
||||
Unpassend: badges?.scanner_rejected,
|
||||
@@ -225,6 +227,9 @@ const App: React.FC = () => {
|
||||
{currentView === 'Archiv' && (
|
||||
<Acquisitions view="archiv" reload={() => loadData(true)} />
|
||||
)}
|
||||
{currentView === 'Akquise-Papierkorb' && (
|
||||
<AcquisitionTrash reload={() => loadData(true)} />
|
||||
)}
|
||||
{currentView === 'Opportunity-Papierkorb' && (
|
||||
<OpportunityTrash reload={() => loadData(true)} />
|
||||
)}
|
||||
|
||||
@@ -70,10 +70,14 @@ const ACQUISITION_ICON =
|
||||
const ARCHIVE_ICON =
|
||||
'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4';
|
||||
|
||||
const TRASH_ICON =
|
||||
'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';
|
||||
|
||||
/**
|
||||
* Akquise. Der Eingang zeigt die Funde der letzten 14 Tage, darunter klappt das
|
||||
* Archiv mit allem Aelteren auf. Beides ist dieselbe Liste - geteilt wird nach
|
||||
* dem Funddatum, nicht durch Verschieben.
|
||||
* dem Funddatum, nicht durch Verschieben. Der Papierkorb darunter ist die
|
||||
* Ausnahme: dort liegt, was jemand von Hand geloescht hat.
|
||||
*/
|
||||
const ACQUISITION_GROUP: MenuGroup = {
|
||||
item: { name: 'Akquise', icon: ACQUISITION_ICON },
|
||||
@@ -81,12 +85,10 @@ const ACQUISITION_GROUP: MenuGroup = {
|
||||
children: [
|
||||
{ name: 'Akquise', label: 'Eingang', icon: ACQUISITION_ICON },
|
||||
{ name: 'Archiv', icon: ARCHIVE_ICON },
|
||||
{ name: 'Akquise-Papierkorb', label: 'Papierkorb', icon: TRASH_ICON },
|
||||
],
|
||||
};
|
||||
|
||||
const TRASH_ICON =
|
||||
'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';
|
||||
|
||||
const OPPORTUNITY_ICON =
|
||||
'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z';
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ interface AcquisitionTableProps {
|
||||
onEdit: (entry: Acquisition) => void;
|
||||
onDelete: (entry: Acquisition) => void;
|
||||
onConvert: (entry: Acquisition) => void;
|
||||
/** Nur im Archiv gesetzt - im Eingang gibt es nichts wiederherzustellen. */
|
||||
onRestore?: (entry: Acquisition) => void;
|
||||
/** Nur im Archiv gesetzt - im Eingang gibt es nichts zurueckzuholen. */
|
||||
onReopen?: (entry: Acquisition) => void;
|
||||
}
|
||||
|
||||
const COLUMNS: { key: AcquisitionSortKey | null; label: string; className?: string }[] = [
|
||||
@@ -118,7 +118,7 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConvert,
|
||||
onRestore,
|
||||
onReopen,
|
||||
}) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
|
||||
<div className="scrollbar-slim overflow-x-auto">
|
||||
@@ -230,12 +230,12 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
|
||||
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right">
|
||||
<div className="inline-flex gap-1">
|
||||
{onRestore && (
|
||||
{onReopen && (
|
||||
<button
|
||||
onClick={() => onRestore(entry)}
|
||||
onClick={() => onReopen(entry)}
|
||||
disabled={isBusy}
|
||||
title="Zurück in die Akquise: der Eintrag bekommt das heutige Datum und steht wieder im Eingang."
|
||||
aria-label={`${entry.title} wiederherstellen`}
|
||||
aria-label={`${entry.title} zurück in die Akquise`}
|
||||
className="rounded-lg p-2 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700 disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -53,7 +53,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
const [search, setSearch] = useState('');
|
||||
const [editing, setEditing] = useState<Acquisition | null>(null);
|
||||
const [toDelete, setToDelete] = useState<Acquisition | null>(null);
|
||||
const [toRestore, setToRestore] = useState<Acquisition | null>(null);
|
||||
const [toReopen, setToReopen] = useState<Acquisition | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({
|
||||
key: 'found_at',
|
||||
@@ -82,7 +82,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
useEffect(() => {
|
||||
setEditing(null);
|
||||
setSearch('');
|
||||
setToRestore(null);
|
||||
setToReopen(null);
|
||||
}, [view]);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
@@ -145,10 +145,10 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (entry: Acquisition) => {
|
||||
const reopen = async (entry: Acquisition) => {
|
||||
setBusyId(entry.id);
|
||||
try {
|
||||
await acquisitionsApi.restore(entry.id);
|
||||
await acquisitionsApi.reopen(entry.id);
|
||||
addToast(`„${entry.title}“ steht wieder in der Akquise.`, 'success');
|
||||
if (editing?.id === entry.id) setEditing(null);
|
||||
await load(true);
|
||||
@@ -159,7 +159,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
await load(true);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
setToRestore(null);
|
||||
setToReopen(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,18 +168,20 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
* bekommt nur ein neues Funddatum. Bei einem Rueckfaeller geht dabei der
|
||||
* Verweis auf die (geloeschte) Opportunity verloren, das wird gefragt.
|
||||
*/
|
||||
const handleRestore = (entry: Acquisition) => {
|
||||
if (entry.converted_at === null) void restore(entry);
|
||||
else setToRestore(entry);
|
||||
const handleReopen = (entry: Acquisition) => {
|
||||
if (entry.converted_at === null) void reopen(entry);
|
||||
else setToReopen(entry);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!toDelete) return;
|
||||
try {
|
||||
await acquisitionsApi.remove(toDelete.id);
|
||||
addToast('Eintrag gelöscht.', 'success');
|
||||
addToast('Eintrag in den Papierkorb gelegt.', 'success');
|
||||
if (editing?.id === toDelete.id) setEditing(null);
|
||||
await load(true);
|
||||
// Die Zahl am Menuepunkt "Papierkorb" haengt an den Daten der App.
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
@@ -189,6 +191,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
|
||||
const archiveAfter = data?.archive_after_days ?? 14;
|
||||
const deleteAfter = data?.delete_after_days ?? 90;
|
||||
const trashKeepDays = data?.trash_delete_after_days ?? 90;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -218,6 +221,10 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
zurück in die Akquise holen - er bekommt dann das heutige Datum. Neue Projekte
|
||||
werden unter <em>Akquise</em> eingetragen.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
Gelöschte Einträge sind nicht verloren: sie liegen {trashKeepDays} Tage im{' '}
|
||||
<em>Papierkorb</em> darunter und lassen sich dort zurückholen.
|
||||
</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
|
||||
@@ -297,30 +304,31 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
||||
onEdit={setEditing}
|
||||
onDelete={setToDelete}
|
||||
onConvert={convert}
|
||||
onRestore={isArchive ? handleRestore : undefined}
|
||||
onReopen={isArchive ? handleReopen : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toRestore && (
|
||||
{toReopen && (
|
||||
<ConfirmDialog
|
||||
title="Zurück in die Akquise?"
|
||||
message={
|
||||
`„${toRestore.title}“ bekommt das heutige Datum und steht wieder im Eingang. ` +
|
||||
(toRestore.opportunity_deleted_at
|
||||
`„${toReopen.title}“ bekommt das heutige Datum und steht wieder im Eingang. ` +
|
||||
(toReopen.opportunity_deleted_at
|
||||
? 'Der Verweis auf die Opportunity im Papierkorb geht dabei verloren - wird sie zurückgeholt, steht sie neben diesem Eintrag.'
|
||||
: 'Der Verweis auf die gelöschte Opportunity geht dabei verloren.')
|
||||
}
|
||||
confirmLabel="Wiederherstellen"
|
||||
tone="neutral"
|
||||
onConfirm={() => restore(toRestore)}
|
||||
onCancel={() => setToRestore(null)}
|
||||
onConfirm={() => reopen(toReopen)}
|
||||
onCancel={() => setToReopen(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toDelete && (
|
||||
<ConfirmDialog
|
||||
title="Eintrag löschen?"
|
||||
message={`„${toDelete.title}" wird aus der Akquise entfernt. Wer das Projekt behalten will, übernimmt es stattdessen als Opportunity.`}
|
||||
message={`„${toDelete.title}" wandert in den Papierkorb der Akquise und lässt sich von dort ${trashKeepDays} Tage lang zurückholen. Wer das Projekt behalten will, übernimmt es stattdessen als Opportunity.`}
|
||||
confirmLabel="In den Papierkorb"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
|
||||
+21
-4
@@ -246,6 +246,7 @@ export const dashboardApi = {
|
||||
*/
|
||||
export interface SidebarBadges {
|
||||
acquisition_archive: number;
|
||||
acquisition_trash: number;
|
||||
opportunity_trash: number;
|
||||
mail_trash: number;
|
||||
scanner_rejected: number | null;
|
||||
@@ -283,7 +284,7 @@ export type AcquisitionInput = Partial<
|
||||
fixed_price?: string | number | null;
|
||||
};
|
||||
|
||||
export type AcquisitionView = 'eingang' | 'archiv' | 'alle';
|
||||
export type AcquisitionView = 'eingang' | 'archiv' | 'papierkorb' | 'alle';
|
||||
|
||||
export const acquisitionsApi = {
|
||||
list: (view: AcquisitionView = 'eingang') =>
|
||||
@@ -292,16 +293,32 @@ export const acquisitionsApi = {
|
||||
request<Acquisition>('/acquisitions', { method: 'POST', body: JSON.stringify(input) }),
|
||||
update: (id: number, input: AcquisitionInput) =>
|
||||
request<Acquisition>(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
/** Legt den Eintrag in den Papierkorb - endgueltig loeschen tut `purge`. */
|
||||
remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }),
|
||||
|
||||
/**
|
||||
* Zurueck in den Eingang: Funddatum auf heute, und bei einem Rueckfaeller
|
||||
* wird die Uebernahme geloest (der Verweis auf die geloeschte Opportunity
|
||||
* faellt weg).
|
||||
* Aus dem Archiv zurueck in den Eingang: Funddatum auf heute, und bei einem
|
||||
* Rueckfaeller wird die Uebernahme geloest (der Verweis auf die geloeschte
|
||||
* Opportunity faellt weg). Nicht der Papierkorb - der ist `restore`.
|
||||
*/
|
||||
reopen: (id: number) =>
|
||||
request<Acquisition>(`/acquisitions/${id}/reopen`, { method: 'POST' }),
|
||||
|
||||
/**
|
||||
* Aus dem Papierkorb zurueckholen. Das Funddatum bleibt - der Eintrag landet
|
||||
* wieder dort, wo er hingehoert: im Eingang, wenn er jung ist, sonst im Archiv.
|
||||
*/
|
||||
restore: (id: number) =>
|
||||
request<Acquisition>(`/acquisitions/${id}/restore`, { method: 'POST' }),
|
||||
|
||||
/** Endgueltig loeschen; geht nur aus dem Papierkorb heraus. */
|
||||
purge: (id: number) =>
|
||||
request<{ ok: true }>(`/acquisitions/${id}/permanent`, { method: 'DELETE' }),
|
||||
|
||||
/** Papierkorb in einem Zug leeren. */
|
||||
emptyTrash: () =>
|
||||
request<{ ok: true; purged: number }>('/acquisitions/trash/empty', { method: 'POST' }),
|
||||
|
||||
/** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
|
||||
toOpportunity: (id: number) =>
|
||||
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {
|
||||
|
||||
+18
-2
@@ -2,6 +2,7 @@ export type ViewState =
|
||||
| 'Dashboard'
|
||||
| 'Akquise'
|
||||
| 'Archiv'
|
||||
| 'Akquise-Papierkorb'
|
||||
| 'Opportunities'
|
||||
| 'Opportunity-Papierkorb'
|
||||
| 'Interviews'
|
||||
@@ -52,8 +53,13 @@ export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
|
||||
/**
|
||||
* Akquise. "Akquise" ist der Eingang der letzten 14 Tage, "Archiv" alles
|
||||
* Aeltere. Beides ist dieselbe Liste, nur nach dem Funddatum geteilt.
|
||||
*
|
||||
* Der Papierkorb kommt aus einer anderen Ecke: dort liegt, was jemand von Hand
|
||||
* geloescht hat. Der Wert heisst "Akquise-Papierkorb", weil Mails und
|
||||
* Opportunities schon einen Papierkorb haben und der Ansichtsname eindeutig sein
|
||||
* muss; angezeigt wird er als "Papierkorb" (siehe VIEW_TITLES).
|
||||
*/
|
||||
export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv'];
|
||||
export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv', 'Akquise-Papierkorb'];
|
||||
|
||||
/**
|
||||
* Opportunities mit ihrem Papierkorb. Der Wert heisst "Opportunity-Papierkorb",
|
||||
@@ -69,6 +75,7 @@ export const OPPORTUNITY_VIEWS: ViewState[] = ['Opportunities', 'Opportunity-Pap
|
||||
*/
|
||||
export const VIEW_TITLES: Partial<Record<ViewState, string>> = {
|
||||
'Opportunity-Papierkorb': 'Papierkorb',
|
||||
'Akquise-Papierkorb': 'Papierkorb',
|
||||
};
|
||||
|
||||
export const viewTitle = (view: ViewState): string => VIEW_TITLES[view] ?? view;
|
||||
@@ -379,6 +386,13 @@ export interface Acquisition {
|
||||
opportunity_gone?: boolean;
|
||||
created_by: number | null;
|
||||
created_by_name?: string | null;
|
||||
/**
|
||||
* Gesetzt = im Papierkorb (UTC). Ein solcher Eintrag steht in keiner der
|
||||
* beiden Listen und in keiner Auswertung - nur im Papierkorb.
|
||||
*/
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: number | null;
|
||||
deleted_by_name?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -387,7 +401,9 @@ export interface Acquisition {
|
||||
export interface AcquisitionList {
|
||||
archive_after_days: number;
|
||||
delete_after_days: number;
|
||||
counts: { eingang: number; archiv: number };
|
||||
/** So lange liegt ein von Hand geloeschter Eintrag im Papierkorb. */
|
||||
trash_delete_after_days: number;
|
||||
counts: { eingang: number; archiv: number; papierkorb: number };
|
||||
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
|
||||
options: AcquisitionOptions;
|
||||
/** Schon benutzte Anbieter, der zuletzt verwendete zuerst - "Anbieter" ist Freitext. */
|
||||
|
||||
Reference in New Issue
Block a user