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
+5
View File
@@ -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)} />
)}
+6 -4
View File
@@ -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>
);
};
+24 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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. */