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
+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. */