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`). |
+17
View File
@@ -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
+217 -36
View File
@@ -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({
+8 -3
View File
@@ -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,
+5 -2
View File
@@ -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)
+21
View File
@@ -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.
+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. */