From 6e66bd39d74d19ce4b988651bb998f65f3be81c4 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Thu, 3 Sep 2026 10:17:21 +0200 Subject: [PATCH] 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) --- docs/02-datenmodell.md | 13 +- docs/05-frontend.md | 4 +- docs/07-anleitung-benutzer.md | 50 ++- docs/09-quellcode.md | 9 +- docs/10-glossar.md | 1 + server/src/acquisitionRules.js | 17 + server/src/routes/acquisitions.js | 253 ++++++++++++-- server/src/routes/badges.js | 11 +- server/src/routes/dashboard.js | 7 +- server/src/schema.sql | 21 ++ src/App.tsx | 5 + src/components/Sidebar.tsx | 10 +- .../acquisition/AcquisitionTable.tsx | 12 +- .../acquisition/AcquisitionTrash.tsx | 321 ++++++++++++++++++ src/components/acquisition/Acquisitions.tsx | 40 ++- src/lib/api.ts | 25 +- src/types.ts | 20 +- 17 files changed, 732 insertions(+), 87 deletions(-) create mode 100644 src/components/acquisition/AcquisitionTrash.tsx diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index b369c42..0f96942 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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 diff --git a/docs/05-frontend.md b/docs/05-frontend.md index cf0532b..49f8de6 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -13,7 +13,9 @@ flowchart TD K --> AQ["Akquise"] AQ --> AQ1["Eingang
Funde der letzten 14 Tage"] AQ --> AQ2["Archiv
älter; nach 90 Tagen gelöscht"] - AQ2 -. "Wiederherstellen
(Funddatum = heute)" .-> AQ1 + AQ --> AQ3["Papierkorb
von Hand gelöscht; 90 Tage"] + AQ2 -. "Zurück in den Eingang
(Funddatum = heute)" .-> AQ1 + AQ3 -. "Wiederherstellen
(Funddatum bleibt)" .-> AQ2 K --> O["Opportunities"] K --> C["Interviews"] K --> L["Kontakte"] diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index bfd55c4..3c7cecc 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index a8c4035..0763e32 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 `` | | `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen | | `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart | diff --git a/docs/10-glossar.md b/docs/10-glossar.md index 3dd9f2c..87b2984 100644 --- a/docs/10-glossar.md +++ b/docs/10-glossar.md @@ -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`). | diff --git a/server/src/acquisitionRules.js b/server/src/acquisitionRules.js index cef4e41..c754ed6 100644 --- a/server/src/acquisitionRules.js +++ b/server/src/acquisitionRules.js @@ -10,6 +10,10 @@ * * Die Zeichenketten sind SQL-Bedingungen und erwarten die Aliase `a` fuer * crm_acquisitions und `opp` fuer die per LEFT JOIN angehaengte Opportunity. + * + * `NOT_DELETED` gehoert zu jeder dieser Abfragen dazu: der Papierkorb ist eine + * dritte Liste, und was dort liegt, hat in Eingang, Archiv und Auswertung + * nichts zu suchen. */ /** Grenze zwischen Eingang und Archiv, in Tagen. */ @@ -18,6 +22,19 @@ export const ARCHIVE_AFTER_DAYS = 14; /** Ab hier loescht das Archiv selbst. */ export const DELETE_AFTER_DAYS = 90; +/** So lange liegt ein von Hand geloeschter Eintrag im Papierkorb. */ +export const TRASH_DELETE_AFTER_DAYS = 90; + +/** + * Nicht im Papierkorb. Steht in jeder Abfrage von Eingang, Archiv, Zaehlern und + * Auswertung: ein geloeschter Eintrag ist ueberall weg, nur nicht in der + * Datenbank. + */ +export const NOT_DELETED = 'a.deleted_at IS NULL'; + +/** Liegt im Papierkorb. */ +export const IN_TRASH = 'a.deleted_at IS NOT NULL'; + /** * Die Opportunity zu einem Eintrag ist verschwunden - im Papierkorb oder * endgueltig geloescht. `opp.id IS NULL` deckt beides ab: nie uebernommen diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index 9905c58..b1cae3f 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -5,9 +5,12 @@ import { ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS, IN_ARCHIVE, + IN_TRASH, IS_OPEN, + NOT_DELETED, NOT_DUE, OPPORTUNITY_GONE, + TRASH_DELETE_AFTER_DAYS, } from '../acquisitionRules.js'; const router = express.Router(); @@ -16,13 +19,18 @@ router.use(requireAuth); /* * Akquise: gefundene Projekte, aus denen eine Opportunity werden kann. * - * Drei Zustaende, alle aus `found_at` und `converted_at` abgeleitet - es gibt - * keine Spalte, die ein Job pflegen muesste: + * Die Listen ergeben sich aus `found_at` und `converted_at` - es gibt keine + * Spalte, die ein Job pflegen muesste: * * Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen * Archiv aelter als 14 Tage, nicht uebernommen * weg aelter als 90 Tage (wird geloescht) oder uebernommen * + * Dazu kommt der Papierkorb, und der ist die eine Ausnahme: Loeschen von Hand + * ist eine Entscheidung, kein Ablauf, deshalb steht sie in `deleted_at`. Was + * dort liegt, taucht in keiner der drei Listen und in keiner Auswertung auf - + * dafuer sorgt `NOT_DELETED` in jeder Abfrage. + * * Uebernommene Eintraege bleiben in der Tabelle stehen: sie sind der Nachweis, * woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht. */ @@ -154,12 +162,14 @@ function mapAcquisition(row) { const SELECT_ACQUISITIONS = ` SELECT a.*, creator.name AS created_by_name, + deleter.name AS deleted_by_name, opp.title AS opportunity_title, opp.stage AS opportunity_stage, opp.deleted_at AS opportunity_deleted_at, ${OPPORTUNITY_GONE} AS opportunity_gone FROM crm_acquisitions a LEFT JOIN crm_users creator ON creator.id = a.created_by + LEFT JOIN crm_users deleter ON deleter.id = a.deleted_by LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id `; @@ -170,7 +180,8 @@ async function fetchAcquisition(id) { /** * Alles aelter als 90 Tage entfernen. Uebernommene Eintraege bleiben - sie - * gehoeren zur Opportunity, nicht zum Archiv. + * gehoeren zur Opportunity, nicht zum Archiv. Was im Papierkorb liegt, bleibt + * ebenfalls liegen: dort laeuft die eigene Frist ab `deleted_at`. * * Das laeuft beim Abruf der Liste, hoechstens aber einmal pro Stunde je Prozess. * Ein eigener Zeitplan waere eine weitere Stelle, die ausfallen kann; und die @@ -201,6 +212,7 @@ async function purgeOldEntries() { FROM crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id WHERE ${IS_OPEN} + AND ${NOT_DELETED} AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) GROUP BY a.found_at, COALESCE(a.consultant, '') ON DUPLICATE KEY UPDATE @@ -217,11 +229,30 @@ async function purgeOldEntries() { `DELETE a FROM crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id WHERE ${IS_OPEN} + AND ${NOT_DELETED} AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`, [DELETE_AFTER_DAYS] ); + + /* + * Der Papierkorb raeumt sich nach derselben Frist, aber ab dem Loeschen: + * ein alter Fund, den jemand heute wegwirft, soll nicht sofort verschwinden. + * Ohne Verlaufseintrag - von Hand geloescht heisst, es soll nicht mitzaehlen. + */ + const [purgedTrash] = await conn.query( + `DELETE FROM crm_acquisitions + WHERE deleted_at IS NOT NULL + AND deleted_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)`, + [TRASH_DELETE_AFTER_DAYS] + ); await conn.commit(); + if (purgedTrash.affectedRows > 0) { + console.log( + `[Akquise] ${purgedTrash.affectedRows} Eintrag/Eintraege nach ${TRASH_DELETE_AFTER_DAYS} Tagen aus dem Papierkorb geloescht.` + ); + } + if (result.affectedRows > 0) { console.log( `[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht (Anzahl im Verlauf gesichert).` @@ -463,13 +494,64 @@ router.delete('/options/:id', async (req, res, next) => { } }); -// GET /api/acquisitions?view=eingang|archiv|alle +/** + * Fristen, Auswahllisten und alle drei Zaehler - dasselbe Beiwerk fuer jede + * Ansicht. Die Zahlen kommen mit der Liste, weil die Oberflaeche sie am Menue + * zeigt und sonst drei Listen laden muesste, um drei Zahlen zu kennen. + */ +async function listMeta() { + const [[counts]] = await pool.query( + `SELECT + SUM(${IS_OPEN} AND ${NOT_DELETED} AND a.converted_at IS NULL AND ${NOT_DUE}) AS eingang, + SUM(${IS_OPEN} AND ${NOT_DELETED} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv, + SUM(${IN_TRASH}) AS papierkorb + FROM crm_acquisitions a + LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id`, + // Reihenfolge der Platzhalter: Eingangsfrist, Archivfrist, Loeschfrist. + [ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS] + ); + + // Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie + // sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere + // verschenkt. + return { + archive_after_days: ARCHIVE_AFTER_DAYS, + delete_after_days: DELETE_AFTER_DAYS, + trash_delete_after_days: TRASH_DELETE_AFTER_DAYS, + options: await loadOptions(), + provider_suggestions: await loadProviderSuggestions(), + counts: { + eingang: Number(counts.eingang ?? 0), + archiv: Number(counts.archiv ?? 0), + papierkorb: Number(counts.papierkorb ?? 0), + }, + }; +} + +// GET /api/acquisitions?view=eingang|archiv|papierkorb|alle router.get('/', async (req, res, next) => { try { await purgeOldEntries(); const view = String(req.query.view ?? 'eingang'); - const where = [IS_OPEN]; + + /* + * Der Papierkorb ist die einzige Liste, die geloeschte Eintraege zeigt - + * und die einzige ohne `IS_OPEN`: ob die Opportunity noch lebt, sagt hier + * nichts, weggeworfen ist weggeworfen. + */ + if (view === 'papierkorb') { + const [trash] = await pool.query( + `${SELECT_ACQUISITIONS} WHERE ${IN_TRASH} + ORDER BY a.deleted_at DESC, a.id DESC` + ); + return res.json({ + ...(await listMeta()), + entries: trash.map(mapAcquisition), + }); + } + + const where = [IS_OPEN, NOT_DELETED]; const params = []; /* @@ -497,31 +579,8 @@ router.get('/', async (req, res, next) => { params ); - // Beide Zaehler in einem Rutsch: die Oberflaeche zeigt sie am Menue und - // muesste sonst zwei Listen laden, um eine Zahl zu kennen. - const [[counts]] = await pool.query( - `SELECT - SUM(a.converted_at IS NULL AND ${NOT_DUE}) AS eingang, - SUM(${IN_ARCHIVE} AND ${NOT_DUE}) AS archiv - FROM crm_acquisitions a - LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id - WHERE ${IS_OPEN}`, - // Reihenfolge der Platzhalter: Eingangsfrist, Archivfrist, Loeschfrist. - [ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS] - ); - - // Auswahllisten und Anbieter-Vorschlaege kommen mit: die Eingabe braucht sie - // sofort, und ein zweiter Aufruf nur fuer ein paar kurze Listen waere - // verschenkt. res.json({ - archive_after_days: ARCHIVE_AFTER_DAYS, - delete_after_days: DELETE_AFTER_DAYS, - options: await loadOptions(), - provider_suggestions: await loadProviderSuggestions(), - counts: { - eingang: Number(counts.eingang ?? 0), - archiv: Number(counts.archiv ?? 0), - }, + ...(await listMeta()), entries: rows.map(mapAcquisition), }); } catch (err) { @@ -566,6 +625,12 @@ router.patch('/:id', async (req, res, next) => { error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.', }); } + // Im Papierkorb wird nichts gepflegt - erst zurückholen, dann ändern. + if (existing.deleted_at !== null) { + return res + .status(409) + .json({ error: 'Der Eintrag liegt im Papierkorb. Erst wiederherstellen, dann bearbeiten.' }); + } /* * Gesperrt ist der Eintrag nur, solange seine Opportunity lebt - dort wird * dann gepflegt. Ist sie im Papierkorb oder geloescht, ist der Eintrag @@ -607,9 +672,10 @@ router.patch('/:id', async (req, res, next) => { }); /* - * POST /api/acquisitions/:id/restore + * POST /api/acquisitions/:id/reopen * - * Zurueck in den Eingang. Eingang und Archiv trennt allein das Funddatum - + * Zurueck in den Eingang. Nicht zu verwechseln mit `/restore`: das holt aus dem + * Papierkorb zurueck, hier geht es um einen Eintrag, der nur alt ist. Eingang und Archiv trennt allein das Funddatum - * deshalb setzt das Wiederherstellen es auf heute. Eine eigene Spalte * "wiederhergestellt" waere ein zweiter Zustand, der zum Datum driften kann. * @@ -619,7 +685,7 @@ router.patch('/:id', async (req, res, next) => { * verloren - sie ist geloescht oder im Papierkorb, und der Eintrag ist wieder * das Original. */ -router.post('/:id/restore', async (req, res, next) => { +router.post('/:id/reopen', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' }); @@ -630,11 +696,16 @@ router.post('/:id/restore', async (req, res, next) => { if (!mayModify(req.user, existing)) { return res.status(403).json({ error: - 'Diesen Eintrag darf nur wiederherstellen, wer ihn angelegt hat, oder ein Administrator.', + 'Diesen Eintrag darf nur zurückholen, wer ihn angelegt hat, oder ein Administrator.', }); } + if (existing.deleted_at !== null) { + return res + .status(409) + .json({ error: 'Der Eintrag liegt im Papierkorb - dort steht das Wiederherstellen.' }); + } // Lebt die Opportunity noch, gehoert der Eintrag zu ihr und steht ohnehin - // in keiner der beiden Listen - dann gibt es nichts wiederherzustellen. + // in keiner der beiden Listen - dann gibt es nichts zurueckzuholen. if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) { return res .status(409) @@ -653,18 +724,85 @@ router.post('/:id/restore', async (req, res, next) => { } }); -// DELETE /api/acquisitions/:id +/* + * POST /api/acquisitions/trash/empty - Papierkorb endgueltig leeren + * + * Vor den Routen mit :id registriert, damit "trash" nicht als Id gelesen wird - + * dasselbe Muster wie bei den Opportunities. + * + * Ohne Verlaufseintrag: von Hand geloescht heisst, die Bewerbung soll in der + * Auswertung nicht mitzaehlen. + */ +router.post('/trash/empty', async (req, res, next) => { + try { + const [result] = await pool.query( + 'DELETE FROM crm_acquisitions WHERE deleted_at IS NOT NULL' + ); + res.json({ ok: true, purged: result.affectedRows }); + } catch (err) { + next(err); + } +}); + +/* + * DELETE /api/acquisitions/:id - in den Papierkorb legen + * + * Bewusst kein echtes Loeschen mehr: vorher war ein Fehlklick endgueltig, der + * Eintrag stand danach in keiner Liste, auch nicht im Archiv. Endgueltig + * entfernt ihn erst `DELETE /:id/permanent`, das Leeren des Papierkorbs oder + * der Ablauf der Frist. + */ router.delete('/:id', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' }); try { const [rows] = await pool.query( - 'SELECT created_by FROM crm_acquisitions WHERE id = ? LIMIT 1', + 'SELECT created_by, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1', [id] ); const existing = rows[0]; if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); + if (existing.deleted_at !== null) { + return res.status(409).json({ error: 'Dieser Eintrag liegt schon im Papierkorb.' }); + } + if (!mayModify(req.user, existing)) { + return res.status(403).json({ + error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.', + }); + } + + await pool.query( + 'UPDATE crm_acquisitions SET deleted_at = UTC_TIMESTAMP(), deleted_by = ? WHERE id = ?', + [req.user.id, id] + ); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +/* + * DELETE /api/acquisitions/:id/permanent - endgueltig loeschen + * + * Nur aus dem Papierkorb heraus. Wer einen Eintrag direkt aus Eingang oder + * Archiv endgueltig loeschen koennte, haette den Papierkorb gleich wieder + * ausgehebelt. + */ +router.delete('/:id/permanent', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' }); + + try { + const [rows] = await pool.query( + 'SELECT created_by, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1', + [id] + ); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); + if (existing.deleted_at === null) { + return res.status(409).json({ error: 'Dieser Eintrag liegt nicht im Papierkorb.' }); + } if (!mayModify(req.user, existing)) { return res.status(403).json({ error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.', @@ -678,6 +816,44 @@ router.delete('/:id', async (req, res, next) => { } }); +/* + * POST /api/acquisitions/:id/restore - aus dem Papierkorb zurueckholen + * + * Das Funddatum bleibt, wie es war: der Eintrag soll dort wieder auftauchen, wo + * er hingehoert - im Eingang, wenn er jung ist, sonst im Archiv. Aus dem Archiv + * in den Eingang holt ihn danach `/reopen`. + */ +router.post('/:id/restore', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' }); + + try { + const [rows] = await pool.query( + 'SELECT created_by, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1', + [id] + ); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); + if (existing.deleted_at === null) { + return res.status(409).json({ error: 'Dieser Eintrag liegt nicht im Papierkorb.' }); + } + if (!mayModify(req.user, existing)) { + return res.status(403).json({ + error: + 'Diesen Eintrag darf nur wiederherstellen, wer ihn angelegt hat, oder ein Administrator.', + }); + } + + await pool.query( + 'UPDATE crm_acquisitions SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', + [id] + ); + res.json(await fetchAcquisition(id)); + } catch (err) { + next(err); + } +}); + /* * POST /api/acquisitions/:id/opportunity * @@ -696,6 +872,11 @@ router.post('/:id/opportunity', async (req, res, next) => { const [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]); const entry = rows[0]; if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); + if (entry.deleted_at !== null) { + return res + .status(409) + .json({ error: 'Der Eintrag liegt im Papierkorb. Erst wiederherstellen, dann übernehmen.' }); + } // Noch einmal uebernehmen darf man, wenn die erste Opportunity weg ist. if (entry.converted_at !== null && !(await opportunityGone(entry.opportunity_id))) { return res.status(409).json({ diff --git a/server/src/routes/badges.js b/server/src/routes/badges.js index d179097..eeb5e6b 100644 --- a/server/src/routes/badges.js +++ b/server/src/routes/badges.js @@ -6,7 +6,9 @@ import { ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS, IN_ARCHIVE, + IN_TRASH, IS_OPEN, + NOT_DELETED, NOT_DUE, } from '../acquisitionRules.js'; @@ -14,8 +16,8 @@ const router = express.Router(); router.use(requireAuth); /* - * Zaehler fuer die Seitenleiste: Akquise-Archiv, Papierkorb der Opportunities, - * Papierkorb der Mails und die unpassenden Scanner-Treffer. + * Zaehler fuer die Seitenleiste: Akquise-Archiv und deren Papierkorb, Papierkorb + * der Opportunities, Papierkorb der Mails und die unpassenden Scanner-Treffer. * * Ein Endpunkt statt vier: die Zahlen stehen alle im Menue und sollen zusammen * kommen. Die Ansichten selbst laden weiter ihre eigenen Daten - hier gibt es @@ -35,7 +37,9 @@ router.get('/', async (req, res, next) => { damit Zahl und Liste nicht auseinanderlaufen. */ (SELECT COUNT(*) FROM crm_acquisitions a LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id - WHERE ${IS_OPEN} AND ${IN_ARCHIVE} AND ${NOT_DUE}) AS acquisition_archive, + WHERE ${IS_OPEN} AND ${NOT_DELETED} AND ${IN_ARCHIVE} + AND ${NOT_DUE}) AS acquisition_archive, + (SELECT COUNT(*) FROM crm_acquisitions a WHERE ${IN_TRASH}) AS acquisition_trash, (SELECT COUNT(*) FROM crm_opportunities WHERE deleted_at IS NOT NULL) AS opportunity_trash, (SELECT COUNT(*) FROM crm_mail_messages @@ -52,6 +56,7 @@ router.get('/', async (req, res, next) => { res.json({ acquisition_archive: Number(counts.acquisition_archive), + acquisition_trash: Number(counts.acquisition_trash), opportunity_trash: Number(counts.opportunity_trash), mail_trash: Number(counts.mail_trash), scanner_rejected: scannerRejected, diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index 72b1aaa..029a6db 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -60,7 +60,8 @@ router.get('/', async (req, res, next) => { const [applicationRows] = await pool.query( `SELECT ${BUCKETS[bucket]('found_at')} AS bucket, COUNT(*) AS count FROM crm_acquisitions - WHERE found_at BETWEEN ? AND ? + WHERE deleted_at IS NULL + AND found_at BETWEEN ? AND ? GROUP BY bucket`, [from, to] ); @@ -83,6 +84,7 @@ router.get('/', async (req, res, next) => { FROM crm_acquisitions a JOIN crm_opportunities opp ON opp.id = a.opportunity_id WHERE a.converted_at IS NOT NULL + AND a.deleted_at IS NULL AND opp.deleted_at IS NULL AND a.converted_at BETWEEN ? AND ? GROUP BY bucket`, @@ -102,7 +104,8 @@ router.get('/', async (req, res, next) => { `SELECT consultant, SUM(count) AS count FROM ( SELECT COALESCE(NULLIF(TRIM(consultant), ''), '') AS consultant, COUNT(*) AS count FROM crm_acquisitions - WHERE found_at BETWEEN ? AND ? + WHERE deleted_at IS NULL + AND found_at BETWEEN ? AND ? GROUP BY 1 UNION ALL SELECT consultant, SUM(applications) diff --git a/server/src/schema.sql b/server/src/schema.sql index 2d9a012..1d62944 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -424,15 +424,36 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions ( opportunity_id INT UNSIGNED NULL, converted_at DATETIME NULL, created_by INT UNSIGNED NULL, + deleted_at DATETIME NULL, -- im Papierkorb (UTC) + deleted_by INT UNSIGNED NULL, -- wer; bewusst ohne Fremdschluessel, s.u. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_crm_acq_found (found_at), KEY idx_crm_acq_converted (converted_at), + KEY idx_crm_acq_deleted (deleted_at), CONSTRAINT fk_crm_acq_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE SET NULL, CONSTRAINT fk_crm_acq_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/* + * Papierkorb der Akquise. Loeschen setzt `deleted_at`, die Zeile bleibt stehen - + * vorher war es ein echtes DELETE, und ein Fehlklick war damit endgueltig: der + * Eintrag tauchte in keiner Liste mehr auf, auch nicht im Archiv. + * + * Der Papierkorb hat seine eigene Uhr (`deleted_at`), nicht die des Funddatums: + * ein von Hand geloeschter Eintrag liegt 90 Tage dort, gleich wie alt der Fund + * ist. Der Aufraeumlauf ueber `found_at` laesst geloeschte Zeilen deshalb + * ausdruecklich liegen - sonst wuerde ein alter Fund im Papierkorb sofort + * verschwinden. + * + * `deleted_by` traegt bewusst keinen Fremdschluessel: Spalten lassen sich per + * ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht. + */ +ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by; +ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at; +ALTER TABLE crm_acquisitions ADD INDEX IF NOT EXISTS idx_crm_acq_deleted (deleted_at); + /* * Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren * Inhalte in den Einstellungen gepflegt werden. diff --git a/src/App.tsx b/src/App.tsx index 6467b1a..7ee7ee6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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' && ( loadData(true)} /> )} + {currentView === 'Akquise-Papierkorb' && ( + loadData(true)} /> + )} {currentView === 'Opportunity-Papierkorb' && ( loadData(true)} /> )} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 472cda1..fb64a34 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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'; diff --git a/src/components/acquisition/AcquisitionTable.tsx b/src/components/acquisition/AcquisitionTable.tsx index adb46a1..19d8684 100644 --- a/src/components/acquisition/AcquisitionTable.tsx +++ b/src/components/acquisition/AcquisitionTable.tsx @@ -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 = ({ onEdit, onDelete, onConvert, - onRestore, + onReopen, }) => (
@@ -230,12 +230,12 @@ export const AcquisitionTable: React.FC = ({
- {onRestore && ( + {onReopen && ( +
+ + {isLoading && entries.length === 0 ? ( +
+ +
+ ) : entries.length === 0 ? ( +
+ Hier liegt nichts. Gelöschte Akquise-Einträge erscheinen an dieser Stelle. +
+ ) : ( +
+
+ + + + {['Projekttitel', 'Anbieter', 'Berater', 'Gefunden', 'Gelöscht', 'Von', ''].map( + (label, index) => ( + + ) + )} + + + + + {entries.map((entry) => { + const deleted = formatDeleted(entry.deleted_at, keepDays); + const extraParts = extras(entry); + const isBusy = busyId === entry.id; + + return ( + + + + + + + + + + ); + })} + +
+ {label} +
+

{entry.title}

+ {extraParts.length > 0 && ( +

+ {extraParts.join(' · ')} +

+ )} +
+ {entry.provider || '—'} + {entry.consultant || '—'} + {formatFound(entry.found_at)} + +

{deleted.label}

+

{deleted.remaining}

+
+ {entry.deleted_by === null || entry.deleted_by === undefined ? ( + — + ) : ( +
+ + + {entry.deleted_by_name ?? 'unbekannt'} + +
+ )} +
+
+ + +
+
+
+
+ )} + + {toPurge && ( + setToPurge(null)} + /> + )} + + {isEmptyOpen && ( + setIsEmptyOpen(false)} + /> + )} +
+ ); +}; diff --git a/src/components/acquisition/Acquisitions.tsx b/src/components/acquisition/Acquisitions.tsx index 9a3ba17..71d07cc 100644 --- a/src/components/acquisition/Acquisitions.tsx +++ b/src/components/acquisition/Acquisitions.tsx @@ -53,7 +53,7 @@ export const Acquisitions: React.FC = ({ view = 'eingang', re const [search, setSearch] = useState(''); const [editing, setEditing] = useState(null); const [toDelete, setToDelete] = useState(null); - const [toRestore, setToRestore] = useState(null); + const [toReopen, setToReopen] = useState(null); const [busyId, setBusyId] = useState(null); const [sortConfig, setSortConfig] = useState({ key: 'found_at', @@ -82,7 +82,7 @@ export const Acquisitions: React.FC = ({ view = 'eingang', re useEffect(() => { setEditing(null); setSearch(''); - setToRestore(null); + setToReopen(null); }, [view]); const entries = useMemo(() => { @@ -145,10 +145,10 @@ export const Acquisitions: React.FC = ({ 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 = ({ view = 'eingang', re await load(true); } finally { setBusyId(null); - setToRestore(null); + setToReopen(null); } }; @@ -168,18 +168,20 @@ export const Acquisitions: React.FC = ({ 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 = ({ 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 (
@@ -218,6 +221,10 @@ export const Acquisitions: React.FC = ({ view = 'eingang', re zurück in die Akquise holen - er bekommt dann das heutige Datum. Neue Projekte werden unter Akquise eingetragen.

+

+ Gelöschte Einträge sind nicht verloren: sie liegen {trashKeepDays} Tage im{' '} + Papierkorb darunter und lassen sich dort zurückholen. +

Hier stehen auch Bewerbungen, deren Opportunity gelöscht{' '} wurde - sonst wären sie nirgends mehr zu finden. Wird die Opportunity aus ihrem @@ -297,30 +304,31 @@ export const Acquisitions: React.FC = ({ view = 'eingang', re onEdit={setEditing} onDelete={setToDelete} onConvert={convert} - onRestore={isArchive ? handleRestore : undefined} + onReopen={isArchive ? handleReopen : undefined} /> )} - {toRestore && ( + {toReopen && ( restore(toRestore)} - onCancel={() => setToRestore(null)} + onConfirm={() => reopen(toReopen)} + onCancel={() => setToReopen(null)} /> )} {toDelete && ( setToDelete(null)} /> diff --git a/src/lib/api.ts b/src/lib/api.ts index ad88758..b469163 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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('/acquisitions', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: AcquisitionInput) => request(`/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(`/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(`/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`, { diff --git a/src/types.ts b/src/types.ts index cb19bf2..30ba78a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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> = { '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. */