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:
co-authored by
Claude Opus 5
parent
f319b8207b
commit
6e66bd39d7
+12
-1
@@ -168,12 +168,23 @@ Acht Eigenheiten, die man kennen muss:
|
|||||||
Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert:
|
Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert:
|
||||||
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln
|
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln
|
||||||
dafür stehen in `server/src/acquisitionRules.js`.
|
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` -
|
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
|
ohne das Zurücksetzen von `converted_at` bliebe ein Rückfaller im Archiv
|
||||||
stehen, gleich welches Datum er trägt. Ein Kennzeichen
|
stehen, gleich welches Datum er trägt. Ein Kennzeichen
|
||||||
„wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle
|
„wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle
|
||||||
gerade nicht führt.
|
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
|
### Postfach crm@perinco.de
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -13,7 +13,9 @@ flowchart TD
|
|||||||
K --> AQ["Akquise"]
|
K --> AQ["Akquise"]
|
||||||
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
|
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
|
||||||
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
|
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 --> O["Opportunities"]
|
||||||
K --> C["Interviews"]
|
K --> C["Interviews"]
|
||||||
K --> L["Kontakte"]
|
K --> L["Kontakte"]
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
|||||||
│ Akquise ▾ │ │
|
│ Akquise ▾ │ │
|
||||||
│ Eingang │ │
|
│ Eingang │ │
|
||||||
│ Archiv 3│ │
|
│ Archiv 3│ │
|
||||||
|
│ Papierkorb2│ │
|
||||||
│ Opportuni…▾ │───────────────────────────────────────────┤
|
│ Opportuni…▾ │───────────────────────────────────────────┤
|
||||||
│ Alle │ │
|
│ Alle │ │
|
||||||
│ Papierkorb2│ │
|
│ Papierkorb2│ │
|
||||||
@@ -70,7 +71,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
|||||||
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
|
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
|
||||||
**Scanner** klappen beim Anklicken ihre Unterpunkte auf. Hinter einigen steht
|
**Scanner** klappen beim Anklicken ihre Unterpunkte auf. Hinter einigen steht
|
||||||
eine Zahl: *Aus Mails* (Funde, die auf eine Entscheidung warten), *Archiv*
|
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
|
(verworfene Scanner-Treffer). Bei 0 steht dort nichts - eine Null wäre nur
|
||||||
Rauschen.
|
Rauschen.
|
||||||
* Jedes Fenster, das sich in den Vordergrund legt, schließt auf drei Wege: das
|
* 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
|
## Akquise
|
||||||
|
|
||||||
Der Eingang für gefundene Projekte - alles, was per Mail, Telefon oder über ein
|
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**
|
Portal hereinkommt, bevor daraus ein Geschäft wird. Drei Unterpunkte: **Eingang**
|
||||||
mit den Funden der letzten 14 Tage und **Archiv** mit allem Älteren.
|
mit den Funden der letzten 14 Tage, **Archiv** mit allem Älteren und der
|
||||||
|
**Papierkorb** mit dem, was jemand von Hand gelöscht hat.
|
||||||
|
|
||||||
### Eintragen
|
### 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 |
|
| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
|
||||||
| **Berater** | für wen |
|
| **Berater** | für wen |
|
||||||
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Erstgespräch Berater* |
|
| **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,
|
Die Spaltenköpfe sortieren, die Suche geht über Titel, Anbieter, Berater,
|
||||||
Endkunde, Beschreibung und Notizen.
|
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
|
* 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
|
bearbeiten und jederzeit übernehmen - der Stift klappt das Formular auch im
|
||||||
Archiv auf.
|
Archiv auf.
|
||||||
* Nach **90 Tagen** wird er **gelöscht**. Wer ein Projekt behalten will,
|
* Nach **90 Tagen** wird er **gelöscht** - endgültig, und nicht über den
|
||||||
übernimmt es als Opportunity.
|
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
|
* Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er
|
||||||
lebt als Opportunity weiter.
|
lebt als Opportunity weiter.
|
||||||
* Wird diese **Opportunity gelöscht**, taucht die Bewerbung wieder im **Archiv**
|
* 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
|
noch im Papierkorb und wird sie zurückgeholt, steht sie ab dann *neben* dem
|
||||||
Eintrag statt an seiner Stelle.
|
Eintrag statt an seiner Stelle.
|
||||||
|
|
||||||
Ein Eintrag, dessen Opportunity noch lebt, lässt sich nicht wiederherstellen -
|
Ein Eintrag, dessen Opportunity noch lebt, lässt sich nicht zurückholen - er
|
||||||
er steht ohnehin in keiner der beiden Listen.
|
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
|
### Aus dem Eintrag wird eine Opportunity
|
||||||
|
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ Alle unter `router.use(requireAuth)`.
|
|||||||
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
|
| `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`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen |
|
| `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 |
|
| `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 |
|
| `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/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/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/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/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/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
|
||||||
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
|
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
|
||||||
|
|||||||
@@ -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. |
|
| **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. |
|
| **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** (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. |
|
| **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`). |
|
| **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`). |
|
| **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). |
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
*
|
*
|
||||||
* Die Zeichenketten sind SQL-Bedingungen und erwarten die Aliase `a` fuer
|
* Die Zeichenketten sind SQL-Bedingungen und erwarten die Aliase `a` fuer
|
||||||
* crm_acquisitions und `opp` fuer die per LEFT JOIN angehaengte Opportunity.
|
* 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. */
|
/** Grenze zwischen Eingang und Archiv, in Tagen. */
|
||||||
@@ -18,6 +22,19 @@ export const ARCHIVE_AFTER_DAYS = 14;
|
|||||||
/** Ab hier loescht das Archiv selbst. */
|
/** Ab hier loescht das Archiv selbst. */
|
||||||
export const DELETE_AFTER_DAYS = 90;
|
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
|
* Die Opportunity zu einem Eintrag ist verschwunden - im Papierkorb oder
|
||||||
* endgueltig geloescht. `opp.id IS NULL` deckt beides ab: nie uebernommen
|
* endgueltig geloescht. `opp.id IS NULL` deckt beides ab: nie uebernommen
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
ARCHIVE_AFTER_DAYS,
|
ARCHIVE_AFTER_DAYS,
|
||||||
DELETE_AFTER_DAYS,
|
DELETE_AFTER_DAYS,
|
||||||
IN_ARCHIVE,
|
IN_ARCHIVE,
|
||||||
|
IN_TRASH,
|
||||||
IS_OPEN,
|
IS_OPEN,
|
||||||
|
NOT_DELETED,
|
||||||
NOT_DUE,
|
NOT_DUE,
|
||||||
OPPORTUNITY_GONE,
|
OPPORTUNITY_GONE,
|
||||||
|
TRASH_DELETE_AFTER_DAYS,
|
||||||
} from '../acquisitionRules.js';
|
} from '../acquisitionRules.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -16,13 +19,18 @@ router.use(requireAuth);
|
|||||||
/*
|
/*
|
||||||
* Akquise: gefundene Projekte, aus denen eine Opportunity werden kann.
|
* Akquise: gefundene Projekte, aus denen eine Opportunity werden kann.
|
||||||
*
|
*
|
||||||
* Drei Zustaende, alle aus `found_at` und `converted_at` abgeleitet - es gibt
|
* Die Listen ergeben sich aus `found_at` und `converted_at` - es gibt keine
|
||||||
* keine Spalte, die ein Job pflegen muesste:
|
* Spalte, die ein Job pflegen muesste:
|
||||||
*
|
*
|
||||||
* Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen
|
* Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen
|
||||||
* Archiv aelter als 14 Tage, nicht uebernommen
|
* Archiv aelter als 14 Tage, nicht uebernommen
|
||||||
* weg aelter als 90 Tage (wird geloescht) oder 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,
|
* Uebernommene Eintraege bleiben in der Tabelle stehen: sie sind der Nachweis,
|
||||||
* woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht.
|
* woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht.
|
||||||
*/
|
*/
|
||||||
@@ -154,12 +162,14 @@ function mapAcquisition(row) {
|
|||||||
const SELECT_ACQUISITIONS = `
|
const SELECT_ACQUISITIONS = `
|
||||||
SELECT a.*,
|
SELECT a.*,
|
||||||
creator.name AS created_by_name,
|
creator.name AS created_by_name,
|
||||||
|
deleter.name AS deleted_by_name,
|
||||||
opp.title AS opportunity_title,
|
opp.title AS opportunity_title,
|
||||||
opp.stage AS opportunity_stage,
|
opp.stage AS opportunity_stage,
|
||||||
opp.deleted_at AS opportunity_deleted_at,
|
opp.deleted_at AS opportunity_deleted_at,
|
||||||
${OPPORTUNITY_GONE} AS opportunity_gone
|
${OPPORTUNITY_GONE} AS opportunity_gone
|
||||||
FROM crm_acquisitions a
|
FROM crm_acquisitions a
|
||||||
LEFT JOIN crm_users creator ON creator.id = a.created_by
|
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
|
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
|
* 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.
|
* 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
|
* Ein eigener Zeitplan waere eine weitere Stelle, die ausfallen kann; und die
|
||||||
@@ -201,6 +212,7 @@ async function purgeOldEntries() {
|
|||||||
FROM crm_acquisitions a
|
FROM crm_acquisitions a
|
||||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||||
WHERE ${IS_OPEN}
|
WHERE ${IS_OPEN}
|
||||||
|
AND ${NOT_DELETED}
|
||||||
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
GROUP BY a.found_at, COALESCE(a.consultant, '')
|
GROUP BY a.found_at, COALESCE(a.consultant, '')
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
@@ -217,11 +229,30 @@ async function purgeOldEntries() {
|
|||||||
`DELETE a FROM crm_acquisitions a
|
`DELETE a FROM crm_acquisitions a
|
||||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||||
WHERE ${IS_OPEN}
|
WHERE ${IS_OPEN}
|
||||||
|
AND ${NOT_DELETED}
|
||||||
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
|
AND a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`,
|
||||||
[DELETE_AFTER_DAYS]
|
[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();
|
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) {
|
if (result.affectedRows > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht (Anzahl im Verlauf gesichert).`
|
`[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) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await purgeOldEntries();
|
await purgeOldEntries();
|
||||||
|
|
||||||
const view = String(req.query.view ?? 'eingang');
|
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 = [];
|
const params = [];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -497,31 +579,8 @@ router.get('/', async (req, res, next) => {
|
|||||||
params
|
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({
|
res.json({
|
||||||
archive_after_days: ARCHIVE_AFTER_DAYS,
|
...(await listMeta()),
|
||||||
delete_after_days: DELETE_AFTER_DAYS,
|
|
||||||
options: await loadOptions(),
|
|
||||||
provider_suggestions: await loadProviderSuggestions(),
|
|
||||||
counts: {
|
|
||||||
eingang: Number(counts.eingang ?? 0),
|
|
||||||
archiv: Number(counts.archiv ?? 0),
|
|
||||||
},
|
|
||||||
entries: rows.map(mapAcquisition),
|
entries: rows.map(mapAcquisition),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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.',
|
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
|
* Gesperrt ist der Eintrag nur, solange seine Opportunity lebt - dort wird
|
||||||
* dann gepflegt. Ist sie im Papierkorb oder geloescht, ist der Eintrag
|
* 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
|
* deshalb setzt das Wiederherstellen es auf heute. Eine eigene Spalte
|
||||||
* "wiederhergestellt" waere ein zweiter Zustand, der zum Datum driften kann.
|
* "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
|
* verloren - sie ist geloescht oder im Papierkorb, und der Eintrag ist wieder
|
||||||
* das Original.
|
* das Original.
|
||||||
*/
|
*/
|
||||||
router.post('/:id/restore', async (req, res, next) => {
|
router.post('/:id/reopen', async (req, res, next) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-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)) {
|
if (!mayModify(req.user, existing)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error:
|
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
|
// 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))) {
|
if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) {
|
||||||
return res
|
return res
|
||||||
.status(409)
|
.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) => {
|
router.delete('/:id', async (req, res, next) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
|
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
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]
|
[id]
|
||||||
);
|
);
|
||||||
const existing = rows[0];
|
const existing = rows[0];
|
||||||
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
|
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)) {
|
if (!mayModify(req.user, existing)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.',
|
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
|
* 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 [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]);
|
||||||
const entry = rows[0];
|
const entry = rows[0];
|
||||||
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
|
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.
|
// Noch einmal uebernehmen darf man, wenn die erste Opportunity weg ist.
|
||||||
if (entry.converted_at !== null && !(await opportunityGone(entry.opportunity_id))) {
|
if (entry.converted_at !== null && !(await opportunityGone(entry.opportunity_id))) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
ARCHIVE_AFTER_DAYS,
|
ARCHIVE_AFTER_DAYS,
|
||||||
DELETE_AFTER_DAYS,
|
DELETE_AFTER_DAYS,
|
||||||
IN_ARCHIVE,
|
IN_ARCHIVE,
|
||||||
|
IN_TRASH,
|
||||||
IS_OPEN,
|
IS_OPEN,
|
||||||
|
NOT_DELETED,
|
||||||
NOT_DUE,
|
NOT_DUE,
|
||||||
} from '../acquisitionRules.js';
|
} from '../acquisitionRules.js';
|
||||||
|
|
||||||
@@ -14,8 +16,8 @@ const router = express.Router();
|
|||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Zaehler fuer die Seitenleiste: Akquise-Archiv, Papierkorb der Opportunities,
|
* Zaehler fuer die Seitenleiste: Akquise-Archiv und deren Papierkorb, Papierkorb
|
||||||
* Papierkorb der Mails und die unpassenden Scanner-Treffer.
|
* der Opportunities, Papierkorb der Mails und die unpassenden Scanner-Treffer.
|
||||||
*
|
*
|
||||||
* Ein Endpunkt statt vier: die Zahlen stehen alle im Menue und sollen zusammen
|
* 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
|
* 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. */
|
damit Zahl und Liste nicht auseinanderlaufen. */
|
||||||
(SELECT COUNT(*) FROM crm_acquisitions a
|
(SELECT COUNT(*) FROM crm_acquisitions a
|
||||||
LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
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
|
(SELECT COUNT(*) FROM crm_opportunities
|
||||||
WHERE deleted_at IS NOT NULL) AS opportunity_trash,
|
WHERE deleted_at IS NOT NULL) AS opportunity_trash,
|
||||||
(SELECT COUNT(*) FROM crm_mail_messages
|
(SELECT COUNT(*) FROM crm_mail_messages
|
||||||
@@ -52,6 +56,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
acquisition_archive: Number(counts.acquisition_archive),
|
acquisition_archive: Number(counts.acquisition_archive),
|
||||||
|
acquisition_trash: Number(counts.acquisition_trash),
|
||||||
opportunity_trash: Number(counts.opportunity_trash),
|
opportunity_trash: Number(counts.opportunity_trash),
|
||||||
mail_trash: Number(counts.mail_trash),
|
mail_trash: Number(counts.mail_trash),
|
||||||
scanner_rejected: scannerRejected,
|
scanner_rejected: scannerRejected,
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ router.get('/', async (req, res, next) => {
|
|||||||
const [applicationRows] = await pool.query(
|
const [applicationRows] = await pool.query(
|
||||||
`SELECT ${BUCKETS[bucket]('found_at')} AS bucket, COUNT(*) AS count
|
`SELECT ${BUCKETS[bucket]('found_at')} AS bucket, COUNT(*) AS count
|
||||||
FROM crm_acquisitions
|
FROM crm_acquisitions
|
||||||
WHERE found_at BETWEEN ? AND ?
|
WHERE deleted_at IS NULL
|
||||||
|
AND found_at BETWEEN ? AND ?
|
||||||
GROUP BY bucket`,
|
GROUP BY bucket`,
|
||||||
[from, to]
|
[from, to]
|
||||||
);
|
);
|
||||||
@@ -83,6 +84,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
FROM crm_acquisitions a
|
FROM crm_acquisitions a
|
||||||
JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
JOIN crm_opportunities opp ON opp.id = a.opportunity_id
|
||||||
WHERE a.converted_at IS NOT NULL
|
WHERE a.converted_at IS NOT NULL
|
||||||
|
AND a.deleted_at IS NULL
|
||||||
AND opp.deleted_at IS NULL
|
AND opp.deleted_at IS NULL
|
||||||
AND a.converted_at BETWEEN ? AND ?
|
AND a.converted_at BETWEEN ? AND ?
|
||||||
GROUP BY bucket`,
|
GROUP BY bucket`,
|
||||||
@@ -102,7 +104,8 @@ router.get('/', async (req, res, next) => {
|
|||||||
`SELECT consultant, SUM(count) AS count FROM (
|
`SELECT consultant, SUM(count) AS count FROM (
|
||||||
SELECT COALESCE(NULLIF(TRIM(consultant), ''), '') AS consultant, COUNT(*) AS count
|
SELECT COALESCE(NULLIF(TRIM(consultant), ''), '') AS consultant, COUNT(*) AS count
|
||||||
FROM crm_acquisitions
|
FROM crm_acquisitions
|
||||||
WHERE found_at BETWEEN ? AND ?
|
WHERE deleted_at IS NULL
|
||||||
|
AND found_at BETWEEN ? AND ?
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT consultant, SUM(applications)
|
SELECT consultant, SUM(applications)
|
||||||
|
|||||||
@@ -424,15 +424,36 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions (
|
|||||||
opportunity_id INT UNSIGNED NULL,
|
opportunity_id INT UNSIGNED NULL,
|
||||||
converted_at DATETIME NULL,
|
converted_at DATETIME NULL,
|
||||||
created_by INT UNSIGNED 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,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY idx_crm_acq_found (found_at),
|
KEY idx_crm_acq_found (found_at),
|
||||||
KEY idx_crm_acq_converted (converted_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_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
|
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;
|
) 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
|
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
|
||||||
* Inhalte in den Einstellungen gepflegt werden.
|
* Inhalte in den Einstellungen gepflegt werden.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Leads } from './components/leads/Leads';
|
|||||||
import { MailPoolLeads } from './components/leads/MailPoolLeads';
|
import { MailPoolLeads } from './components/leads/MailPoolLeads';
|
||||||
import { Dashboard } from './components/dashboard/Dashboard';
|
import { Dashboard } from './components/dashboard/Dashboard';
|
||||||
import { Acquisitions } from './components/acquisition/Acquisitions';
|
import { Acquisitions } from './components/acquisition/Acquisitions';
|
||||||
|
import { AcquisitionTrash } from './components/acquisition/AcquisitionTrash';
|
||||||
import { Opportunities } from './components/opportunities/Opportunities';
|
import { Opportunities } from './components/opportunities/Opportunities';
|
||||||
import { OpportunityTrash } from './components/opportunities/OpportunityTrash';
|
import { OpportunityTrash } from './components/opportunities/OpportunityTrash';
|
||||||
import { Calendar } from './components/calendar/Calendar';
|
import { Calendar } from './components/calendar/Calendar';
|
||||||
@@ -204,6 +205,7 @@ const App: React.FC = () => {
|
|||||||
counts={{
|
counts={{
|
||||||
'Aus Mails': mailPoolLeads.length,
|
'Aus Mails': mailPoolLeads.length,
|
||||||
Archiv: badges?.acquisition_archive,
|
Archiv: badges?.acquisition_archive,
|
||||||
|
'Akquise-Papierkorb': badges?.acquisition_trash,
|
||||||
'Opportunity-Papierkorb': badges?.opportunity_trash,
|
'Opportunity-Papierkorb': badges?.opportunity_trash,
|
||||||
Papierkorb: badges?.mail_trash,
|
Papierkorb: badges?.mail_trash,
|
||||||
Unpassend: badges?.scanner_rejected,
|
Unpassend: badges?.scanner_rejected,
|
||||||
@@ -225,6 +227,9 @@ const App: React.FC = () => {
|
|||||||
{currentView === 'Archiv' && (
|
{currentView === 'Archiv' && (
|
||||||
<Acquisitions view="archiv" reload={() => loadData(true)} />
|
<Acquisitions view="archiv" reload={() => loadData(true)} />
|
||||||
)}
|
)}
|
||||||
|
{currentView === 'Akquise-Papierkorb' && (
|
||||||
|
<AcquisitionTrash reload={() => loadData(true)} />
|
||||||
|
)}
|
||||||
{currentView === 'Opportunity-Papierkorb' && (
|
{currentView === 'Opportunity-Papierkorb' && (
|
||||||
<OpportunityTrash reload={() => loadData(true)} />
|
<OpportunityTrash reload={() => loadData(true)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,10 +70,14 @@ const ACQUISITION_ICON =
|
|||||||
const ARCHIVE_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';
|
'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
|
* 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
|
* 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 = {
|
const ACQUISITION_GROUP: MenuGroup = {
|
||||||
item: { name: 'Akquise', icon: ACQUISITION_ICON },
|
item: { name: 'Akquise', icon: ACQUISITION_ICON },
|
||||||
@@ -81,12 +85,10 @@ const ACQUISITION_GROUP: MenuGroup = {
|
|||||||
children: [
|
children: [
|
||||||
{ name: 'Akquise', label: 'Eingang', icon: ACQUISITION_ICON },
|
{ name: 'Akquise', label: 'Eingang', icon: ACQUISITION_ICON },
|
||||||
{ name: 'Archiv', icon: ARCHIVE_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 =
|
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';
|
'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;
|
onEdit: (entry: Acquisition) => void;
|
||||||
onDelete: (entry: Acquisition) => void;
|
onDelete: (entry: Acquisition) => void;
|
||||||
onConvert: (entry: Acquisition) => void;
|
onConvert: (entry: Acquisition) => void;
|
||||||
/** Nur im Archiv gesetzt - im Eingang gibt es nichts wiederherzustellen. */
|
/** Nur im Archiv gesetzt - im Eingang gibt es nichts zurueckzuholen. */
|
||||||
onRestore?: (entry: Acquisition) => void;
|
onReopen?: (entry: Acquisition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMNS: { key: AcquisitionSortKey | null; label: string; className?: string }[] = [
|
const COLUMNS: { key: AcquisitionSortKey | null; label: string; className?: string }[] = [
|
||||||
@@ -118,7 +118,7 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onConvert,
|
onConvert,
|
||||||
onRestore,
|
onReopen,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
|
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
|
||||||
<div className="scrollbar-slim overflow-x-auto">
|
<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">
|
<td className="whitespace-nowrap px-4 py-3 text-right">
|
||||||
<div className="inline-flex gap-1">
|
<div className="inline-flex gap-1">
|
||||||
{onRestore && (
|
{onReopen && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onRestore(entry)}
|
onClick={() => onReopen(entry)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title="Zurück in die Akquise: der Eintrag bekommt das heutige Datum und steht wieder im Eingang."
|
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"
|
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">
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -53,7 +53,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [editing, setEditing] = useState<Acquisition | null>(null);
|
const [editing, setEditing] = useState<Acquisition | null>(null);
|
||||||
const [toDelete, setToDelete] = 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 [busyId, setBusyId] = useState<number | null>(null);
|
||||||
const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({
|
const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({
|
||||||
key: 'found_at',
|
key: 'found_at',
|
||||||
@@ -82,7 +82,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setToRestore(null);
|
setToReopen(null);
|
||||||
}, [view]);
|
}, [view]);
|
||||||
|
|
||||||
const entries = useMemo(() => {
|
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);
|
setBusyId(entry.id);
|
||||||
try {
|
try {
|
||||||
await acquisitionsApi.restore(entry.id);
|
await acquisitionsApi.reopen(entry.id);
|
||||||
addToast(`„${entry.title}“ steht wieder in der Akquise.`, 'success');
|
addToast(`„${entry.title}“ steht wieder in der Akquise.`, 'success');
|
||||||
if (editing?.id === entry.id) setEditing(null);
|
if (editing?.id === entry.id) setEditing(null);
|
||||||
await load(true);
|
await load(true);
|
||||||
@@ -159,7 +159,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
|||||||
await load(true);
|
await load(true);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
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
|
* bekommt nur ein neues Funddatum. Bei einem Rueckfaeller geht dabei der
|
||||||
* Verweis auf die (geloeschte) Opportunity verloren, das wird gefragt.
|
* Verweis auf die (geloeschte) Opportunity verloren, das wird gefragt.
|
||||||
*/
|
*/
|
||||||
const handleRestore = (entry: Acquisition) => {
|
const handleReopen = (entry: Acquisition) => {
|
||||||
if (entry.converted_at === null) void restore(entry);
|
if (entry.converted_at === null) void reopen(entry);
|
||||||
else setToRestore(entry);
|
else setToReopen(entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!toDelete) return;
|
if (!toDelete) return;
|
||||||
try {
|
try {
|
||||||
await acquisitionsApi.remove(toDelete.id);
|
await acquisitionsApi.remove(toDelete.id);
|
||||||
addToast('Eintrag gelöscht.', 'success');
|
addToast('Eintrag in den Papierkorb gelegt.', 'success');
|
||||||
if (editing?.id === toDelete.id) setEditing(null);
|
if (editing?.id === toDelete.id) setEditing(null);
|
||||||
await load(true);
|
await load(true);
|
||||||
|
// Die Zahl am Menuepunkt "Papierkorb" haengt an den Daten der App.
|
||||||
|
reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -189,6 +191,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
|
|||||||
|
|
||||||
const archiveAfter = data?.archive_after_days ?? 14;
|
const archiveAfter = data?.archive_after_days ?? 14;
|
||||||
const deleteAfter = data?.delete_after_days ?? 90;
|
const deleteAfter = data?.delete_after_days ?? 90;
|
||||||
|
const trashKeepDays = data?.trash_delete_after_days ?? 90;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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
|
zurück in die Akquise holen - er bekommt dann das heutige Datum. Neue Projekte
|
||||||
werden unter <em>Akquise</em> eingetragen.
|
werden unter <em>Akquise</em> eingetragen.
|
||||||
</p>
|
</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">
|
<p className="mt-1 text-xs text-ink-500">
|
||||||
Hier stehen auch Bewerbungen, deren <strong className="font-bold">Opportunity gelöscht</strong>{' '}
|
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
|
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}
|
onEdit={setEditing}
|
||||||
onDelete={setToDelete}
|
onDelete={setToDelete}
|
||||||
onConvert={convert}
|
onConvert={convert}
|
||||||
onRestore={isArchive ? handleRestore : undefined}
|
onReopen={isArchive ? handleReopen : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toRestore && (
|
{toReopen && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Zurück in die Akquise?"
|
title="Zurück in die Akquise?"
|
||||||
message={
|
message={
|
||||||
`„${toRestore.title}“ bekommt das heutige Datum und steht wieder im Eingang. ` +
|
`„${toReopen.title}“ bekommt das heutige Datum und steht wieder im Eingang. ` +
|
||||||
(toRestore.opportunity_deleted_at
|
(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 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.')
|
: 'Der Verweis auf die gelöschte Opportunity geht dabei verloren.')
|
||||||
}
|
}
|
||||||
confirmLabel="Wiederherstellen"
|
confirmLabel="Wiederherstellen"
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
onConfirm={() => restore(toRestore)}
|
onConfirm={() => reopen(toReopen)}
|
||||||
onCancel={() => setToRestore(null)}
|
onCancel={() => setToReopen(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toDelete && (
|
{toDelete && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Eintrag löschen?"
|
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}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => setToDelete(null)}
|
onCancel={() => setToDelete(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+21
-4
@@ -246,6 +246,7 @@ export const dashboardApi = {
|
|||||||
*/
|
*/
|
||||||
export interface SidebarBadges {
|
export interface SidebarBadges {
|
||||||
acquisition_archive: number;
|
acquisition_archive: number;
|
||||||
|
acquisition_trash: number;
|
||||||
opportunity_trash: number;
|
opportunity_trash: number;
|
||||||
mail_trash: number;
|
mail_trash: number;
|
||||||
scanner_rejected: number | null;
|
scanner_rejected: number | null;
|
||||||
@@ -283,7 +284,7 @@ export type AcquisitionInput = Partial<
|
|||||||
fixed_price?: string | number | null;
|
fixed_price?: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AcquisitionView = 'eingang' | 'archiv' | 'alle';
|
export type AcquisitionView = 'eingang' | 'archiv' | 'papierkorb' | 'alle';
|
||||||
|
|
||||||
export const acquisitionsApi = {
|
export const acquisitionsApi = {
|
||||||
list: (view: AcquisitionView = 'eingang') =>
|
list: (view: AcquisitionView = 'eingang') =>
|
||||||
@@ -292,16 +293,32 @@ export const acquisitionsApi = {
|
|||||||
request<Acquisition>('/acquisitions', { method: 'POST', body: JSON.stringify(input) }),
|
request<Acquisition>('/acquisitions', { method: 'POST', body: JSON.stringify(input) }),
|
||||||
update: (id: number, input: AcquisitionInput) =>
|
update: (id: number, input: AcquisitionInput) =>
|
||||||
request<Acquisition>(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
|
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' }),
|
remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zurueck in den Eingang: Funddatum auf heute, und bei einem Rueckfaeller
|
* Aus dem Archiv zurueck in den Eingang: Funddatum auf heute, und bei einem
|
||||||
* wird die Uebernahme geloest (der Verweis auf die geloeschte Opportunity
|
* Rueckfaeller wird die Uebernahme geloest (der Verweis auf die geloeschte
|
||||||
* faellt weg).
|
* 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) =>
|
restore: (id: number) =>
|
||||||
request<Acquisition>(`/acquisitions/${id}/restore`, { method: 'POST' }),
|
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. */
|
/** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
|
||||||
toOpportunity: (id: number) =>
|
toOpportunity: (id: number) =>
|
||||||
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {
|
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {
|
||||||
|
|||||||
+18
-2
@@ -2,6 +2,7 @@ export type ViewState =
|
|||||||
| 'Dashboard'
|
| 'Dashboard'
|
||||||
| 'Akquise'
|
| 'Akquise'
|
||||||
| 'Archiv'
|
| 'Archiv'
|
||||||
|
| 'Akquise-Papierkorb'
|
||||||
| 'Opportunities'
|
| 'Opportunities'
|
||||||
| 'Opportunity-Papierkorb'
|
| 'Opportunity-Papierkorb'
|
||||||
| 'Interviews'
|
| 'Interviews'
|
||||||
@@ -52,8 +53,13 @@ export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
|
|||||||
/**
|
/**
|
||||||
* Akquise. "Akquise" ist der Eingang der letzten 14 Tage, "Archiv" alles
|
* Akquise. "Akquise" ist der Eingang der letzten 14 Tage, "Archiv" alles
|
||||||
* Aeltere. Beides ist dieselbe Liste, nur nach dem Funddatum geteilt.
|
* 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",
|
* 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>> = {
|
export const VIEW_TITLES: Partial<Record<ViewState, string>> = {
|
||||||
'Opportunity-Papierkorb': 'Papierkorb',
|
'Opportunity-Papierkorb': 'Papierkorb',
|
||||||
|
'Akquise-Papierkorb': 'Papierkorb',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewTitle = (view: ViewState): string => VIEW_TITLES[view] ?? view;
|
export const viewTitle = (view: ViewState): string => VIEW_TITLES[view] ?? view;
|
||||||
@@ -379,6 +386,13 @@ export interface Acquisition {
|
|||||||
opportunity_gone?: boolean;
|
opportunity_gone?: boolean;
|
||||||
created_by: number | null;
|
created_by: number | null;
|
||||||
created_by_name?: string | 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;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -387,7 +401,9 @@ export interface Acquisition {
|
|||||||
export interface AcquisitionList {
|
export interface AcquisitionList {
|
||||||
archive_after_days: number;
|
archive_after_days: number;
|
||||||
delete_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. */
|
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
|
||||||
options: AcquisitionOptions;
|
options: AcquisitionOptions;
|
||||||
/** Schon benutzte Anbieter, der zuletzt verwendete zuerst - "Anbieter" ist Freitext. */
|
/** Schon benutzte Anbieter, der zuletzt verwendete zuerst - "Anbieter" ist Freitext. */
|
||||||
|
|||||||
Reference in New Issue
Block a user