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
+12 -1
View File
@@ -168,12 +168,23 @@ Acht Eigenheiten, die man kennen muss:
Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert:
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln
dafür stehen in `server/src/acquisitionRules.js`.
Aus derselben Logik folgt, was **Wiederherstellen** (`POST /:id/restore`) tut:
Aus derselben Logik folgt, was **Zurück in den Eingang** (`POST /:id/reopen`) tut:
Es setzt `found_at` auf heute und leert `converted_at` samt `opportunity_id` -
ohne das Zurücksetzen von `converted_at` bliebe ein Rückfaller im Archiv
stehen, gleich welches Datum er trägt. Ein Kennzeichen
„wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle
gerade nicht führt.
- **Der Papierkorb ist die Ausnahme davon.** Löschen von Hand ist eine
Entscheidung und kein Ablauf, deshalb steht sie in `deleted_at` (plus
`deleted_by`, ohne Fremdschlüssel - siehe `schema.sql`). Vorher war das
Löschen ein echtes `DELETE` und damit endgültig; jetzt bleibt die Zeile
90 Tage stehen. Was dort liegt, blenden **alle** anderen Abfragen aus
(`NOT_DELETED`): Eingang, Archiv, Menüzahlen und Dashboard.
Der Papierkorb rechnet ab `deleted_at`, nicht ab `found_at` - der
Aufräumlauf über das Funddatum lässt gelöschte Zeilen deshalb ausdrücklich
liegen, sonst wäre ein alter Fund im Papierkorb sofort weg.
`POST /:id/restore` räumt nur `deleted_at`/`deleted_by` weg und lässt das
Funddatum in Ruhe: der Eintrag soll dort wieder auftauchen, wo er hingehört.
### Postfach crm@perinco.de
+3 -1
View File
@@ -13,7 +13,9 @@ flowchart TD
K --> AQ["Akquise"]
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
AQ2 -. "Wiederherstellen<br/>(Funddatum = heute)" .-> AQ1
AQ --> AQ3["Papierkorb<br/>von Hand gelöscht; 90 Tage"]
AQ2 -. "Zurück in den Eingang<br/>(Funddatum = heute)" .-> AQ1
AQ3 -. "Wiederherstellen<br/>(Funddatum bleibt)" .-> AQ2
K --> O["Opportunities"]
K --> C["Interviews"]
K --> L["Kontakte"]
+42 -8
View File
@@ -37,6 +37,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
│ Akquise ▾ │ │
│ Eingang │ │
│ Archiv 3│ │
│ Papierkorb2│ │
│ Opportuni…▾ │───────────────────────────────────────────┤
│ Alle │ │
│ Papierkorb2│ │
@@ -70,7 +71,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
**Scanner** klappen beim Anklicken ihre Unterpunkte auf. Hinter einigen steht
eine Zahl: *Aus Mails* (Funde, die auf eine Entscheidung warten), *Archiv*
(Akquise-Einträge älter als 14 Tage), beide *Papierkörbe* und *Unpassend*
(Akquise-Einträge älter als 14 Tage), alle drei *Papierkörbe* und *Unpassend*
(verworfene Scanner-Treffer). Bei 0 steht dort nichts - eine Null wäre nur
Rauschen.
* Jedes Fenster, das sich in den Vordergrund legt, schließt auf drei Wege: das
@@ -129,8 +130,9 @@ Volumen und Zuständigem - also alles, wo gerade der Kunde am Zug ist.
## Akquise
Der Eingang für gefundene Projekte - alles, was per Mail, Telefon oder über ein
Portal hereinkommt, bevor daraus ein Geschäft wird. Zwei Unterpunkte: **Eingang**
mit den Funden der letzten 14 Tage und **Archiv** mit allem Älteren.
Portal hereinkommt, bevor daraus ein Geschäft wird. Drei Unterpunkte: **Eingang**
mit den Funden der letzten 14 Tage, **Archiv** mit allem Älteren und der
**Papierkorb** mit dem, was jemand von Hand gelöscht hat.
### Eintragen
@@ -183,7 +185,7 @@ wird geleert - für das nächste Projekt aus derselben Quelle.
| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
| **Berater** | für wen |
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Erstgespräch Berater* |
| Pfeil / Stift / Papierkorb | zurück in die Akquise (nur im Archiv), bearbeiten (füllt das Formular oben) und löschen |
| Pfeil / Stift / Papierkorb | zurück in die Akquise (nur im Archiv), bearbeiten (füllt das Formular oben) und löschen (legt den Eintrag in den **Papierkorb**) |
Die Spaltenköpfe sortieren, die Suche geht über Titel, Anbieter, Berater,
Endkunde, Beschreibung und Notizen.
@@ -193,8 +195,11 @@ Endkunde, Beschreibung und Notizen.
* Nach **14 Tagen** rutscht ein Eintrag ins **Archiv**. Dort lässt er sich weiter
bearbeiten und jederzeit übernehmen - der Stift klappt das Formular auch im
Archiv auf.
* Nach **90 Tagen** wird er **gelöscht**. Wer ein Projekt behalten will,
übernimmt es als Opportunity.
* Nach **90 Tagen** wird er **gelöscht** - endgültig, und nicht über den
Papierkorb: das ist der Ablauf der Frist, keine Entscheidung. Gesichert wird
vorher nur die Zahl für das Dashboard (Tag, Berater, Anzahl), die
Projektdaten sind dann weg. Wer ein Projekt behalten will, übernimmt es als
Opportunity.
* Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er
lebt als Opportunity weiter.
* Wird diese **Opportunity gelöscht**, taucht die Bewerbung wieder im **Archiv**
@@ -226,8 +231,37 @@ weg. Der Eintrag ist danach wieder ein gewöhnlicher Fund - liegt die Opportunit
noch im Papierkorb und wird sie zurückgeholt, steht sie ab dann *neben* dem
Eintrag statt an seiner Stelle.
Ein Eintrag, dessen Opportunity noch lebt, lässt sich nicht wiederherstellen -
er steht ohnehin in keiner der beiden Listen.
Ein Eintrag, dessen Opportunity noch lebt, lässt sich nicht zurückholen - er
steht ohnehin in keiner der beiden Listen.
### Papierkorb
Der **Papierkorb** unter *Akquise* fängt auf, was jemand von Hand löscht. Vorher
war ein Fehlklick endgültig: der Eintrag stand danach in keiner Liste, auch nicht
im Archiv.
| Spalte | Bedeutung |
| --- | --- |
| **Projekttitel** | darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
| **Anbieter**, **Berater** | wie in der Akquise |
| **Gefunden** | das Funddatum - es entscheidet, wo der Eintrag nach dem Zurückholen landet |
| **Gelöscht** | wann, und wie lange er noch liegt |
| **Von** | wer gelöscht hat |
| **Wiederherstellen** / Papierkorb-Symbol | zurückholen oder endgültig löschen |
* Ein gelöschter Eintrag liegt **90 Tage** im Papierkorb, danach räumt das CRM
selbst auf. Die Frist zählt ab dem Löschen, nicht ab dem Funddatum - ein alter
Fund, den jemand heute wegwirft, verschwindet also nicht sofort.
* **Wiederherstellen** ändert das Funddatum **nicht**. Der Eintrag geht dorthin,
wo er nach seinem Alter hingehört: in den *Eingang*, wenn er jünger als 14 Tage
ist, sonst ins *Archiv*. Wer ihn im Eingang haben will, holt ihn dort mit dem
Pfeil weiter (siehe oben).
* **Endgültig löschen** und **Papierkorb leeren** sind unumkehrbar.
* In der **Auswertung** zählt ein Eintrag im Papierkorb nicht mit - ein
Fehleintrag soll die Zahl der Bewerbungen nicht verfälschen. Wird er
zurückgeholt, zählt er wieder.
* Im Papierkorb wird nicht gepflegt: Bearbeiten und die Übernahme in eine
Opportunity gehen erst nach dem Zurückholen.
### Aus dem Eintrag wird eine Opportunity
+5 -4
View File
@@ -92,9 +92,9 @@ Alle unter `router.use(requireAuth)`.
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete |
| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen/wiederherstellen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete. `/restore` setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest; bei lebender Opportunity antwortet es 409 |
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen |
| `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist |
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen |
| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin |
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin |
@@ -274,7 +274,8 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/dashboard/dashboardUi.tsx` | `useCountUp()` (Zahl läuft hoch, respektiert `prefers-reduced-motion`), `StatCard`, `SeriesChart`, `BarList`, `Panel` - von Hand statt Diagrammbibliothek |
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen, Wiederherstellen. Das Formular steht im Archiv nur beim Bearbeiten - ohne diesen Zweig blieb der Stift dort ohne Wirkung |
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden. Der Pfeil zum Wiederherstellen erscheint nur, wenn `onRestore` gesetzt ist - im Eingang gibt es nichts wiederherzustellen |
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden. Der Pfeil zurück in den Eingang erscheint nur, wenn `onReopen` gesetzt ist - im Eingang gibt es nichts zurückzuholen |
| `components/acquisition/AcquisitionTrash.tsx` | Papierkorb der Akquise: zurückholen, endgültig löschen, leeren. Die Meldung nach dem Zurückholen nennt das Ziel (Eingang oder Archiv) - das Funddatum bleibt, also landet ein alter Fund im Archiv |
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
+1
View File
@@ -26,6 +26,7 @@ Quelltext.
| **Auswahllisten** (Akquise) | Inhalte der Dropdowns *Von*, *Kanal*, *Berater*. Tabelle `crm_acquisition_options`, gepflegt unter *Einstellungen → Akquise*. *Anbieter* ist Freitext mit Vorschlägen. |
| **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. |
| **Papierkorb** (Mails) | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. |
| **Papierkorb** (Akquise) | Von Hand gelöschte Akquise-Einträge, `crm_acquisitions.deleted_at`. Wiederherstellbar; endgültig weg nach 90 Tagen oder beim Leeren. Zählt in der Auswertung nicht mit. |
| **Papierkorb** (Opportunities) | Gelöschte Opportunities, `crm_opportunities.deleted_at`. Wiederherstellbar; endgültig weg nach 90 Tagen oder beim Leeren. |
| **Phase** / Stage | Fortschritt einer Opportunity im Vermittlungsprozess: Neu, Beworben, Erstgespräch Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt, Absage. Offen ist alles bis *Zusage*. In der Datenbank steht `Erstgespraech Berater` ohne Umlaut; angezeigt wird er (`STAGE_LABELS`). |
| **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). |