diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md
index 024dc54..be6d670 100644
--- a/docs/02-datenmodell.md
+++ b/docs/02-datenmodell.md
@@ -71,6 +71,7 @@ erDiagram
date last_interaction_at
varchar next_step
int owner_id FK
+ datetime deleted_at "Papierkorb; nach 90 Tagen weg"
}
crm_opportunity_contacts {
int opportunity_id PK,FK
@@ -109,7 +110,7 @@ erDiagram
}
```
-Sechs Eigenheiten, die man kennen muss:
+Sieben Eigenheiten, die man kennen muss:
- **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
@@ -126,6 +127,12 @@ Sechs Eigenheiten, die man kennen muss:
- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
wenige Kilobyte.
+- **Gelöschte Opportunities bleiben stehen.** `crm_opportunities.deleted_at`
+ setzt sie in den Papierkorb; alle Abfragen blenden sie aus. An einer
+ Opportunity hängen Ansprechpartner, Verlauf und Mailzuordnungen - ein Fehlklick
+ soll das nicht kosten. Endgültig entfernt sie erst das Leeren des Papierkorbs
+ oder der Ablauf von 90 Tagen; dann räumen `crm_opportunity_contacts` und
+ `crm_mail_links` per CASCADE mit auf.
- **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in drei.**
`crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die
Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im ENUM
diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md
index d519713..0aa695a 100644
--- a/docs/07-anleitung-benutzer.md
+++ b/docs/07-anleitung-benutzer.md
@@ -35,7 +35,9 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
│ Akquise ▾ │ │
│ Eingang │ │
│ Archiv │ │
-│ Opportunities├──────────────────────────────────────────┤
+│ Opportuni…▾ │───────────────────────────────────────────┤
+│ Alle │ │
+│ Papierkorb │ │
│ Interviews │ │
│ Kontakte ▾ │ Inhalt │
│ Alle Kont. │ │
@@ -201,6 +203,26 @@ verlässt.
**Administrator**. Wer keines davon ist, bekommt eine Fehlermeldung - das prüft
der Server, nicht nur die Oberfläche.
+### Löschen und Papierkorb
+
+Das **Papierkorbsymbol** an der Kachel, in der Listenzeile und auf der
+Pipeline-Karte legt eine Opportunity in den **Papierkorb** - den Unterpunkt unter
+*Opportunities*. Vorher fragt eine Warnung nach.
+
+Im Papierkorb steht, wann und von wem gelöscht wurde und wie lange der Eintrag
+noch liegt:
+
+* **Wiederherstellen** holt die Opportunity zurück - mit Ansprechpartnern,
+ Verlauf und Mailzuordnungen.
+* **Papierkorb leeren** entfernt alles darin **endgültig**, ebenfalls nach einer
+ Warnung. Dabei gehen die Zuordnungen der Ansprechpartner und der Mails
+ verloren; die Kontakte selbst und die Mails bleiben erhalten.
+* Ohne Zutun wird ein Eintrag **nach 90 Tagen** endgültig gelöscht.
+
+Solange eine Opportunity im Papierkorb liegt, erscheint sie in keiner Liste, in
+keiner Summe und in keinem Zuordnungsdialog - und sie lässt sich nicht
+bearbeiten. Erst zurückholen, dann ändern.
+
Auf dem Tablet oder ohne Lust auf Ziehen: die Phase lässt sich auch über das
Auswahlfeld direkt auf der Karte ändern.
diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md
index aef577e..1230628 100644
--- a/docs/09-quellcode.md
+++ b/docs/09-quellcode.md
@@ -91,7 +91,7 @@ Alle unter `router.use(requireAuth)`.
| `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | 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 |
-| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | ändern/löschen nur Ersteller, Zuständiger 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`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete |
| `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 |
@@ -265,6 +265,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| Datei | Inhalt |
| --- | --- |
+| `components/opportunities/OpportunityTrash.tsx` | Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag |
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen |
| `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 |
@@ -290,7 +291,8 @@ nicht auseinanderlaufen:
| Export | Zweck |
| --- | --- |
-| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS`, `MAIL_VIEWS`, `ACQUISITION_VIEWS`, `CONTACT_VIEWS` | Ansichten, Reiter und aufklappbare Gruppen |
+| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS`, `MAIL_VIEWS`, `ACQUISITION_VIEWS`, `OPPORTUNITY_VIEWS`, `CONTACT_VIEWS` | Ansichten, Reiter und aufklappbare Gruppen |
+| `VIEW_TITLES`, `viewTitle()` | abweichende Beschriftung, wo der Ansichtsname nur intern eindeutig sein muss (`Opportunity-Papierkorb` → Papierkorb) |
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert |
diff --git a/docs/10-glossar.md b/docs/10-glossar.md
index 5a1de22..942202d 100644
--- a/docs/10-glossar.md
+++ b/docs/10-glossar.md
@@ -24,7 +24,8 @@ Quelltext.
| **Akquise** | Eingang für gefundene Projekte, bevor daraus eine Opportunity wird. Tabelle `crm_acquisitions`. Nach 14 Tagen im **Archiv**, nach 90 Tagen gelöscht. |
| **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** | 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** (Opportunities) | Gelöschte Opportunities, `crm_opportunities.deleted_at`. Wiederherstellbar; endgültig weg nach 90 Tagen oder beim Leeren. |
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
| **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). |
| **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. |
diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js
index 6b5a650..2b4e7e7 100644
--- a/server/src/routes/opportunities.js
+++ b/server/src/routes/opportunities.js
@@ -70,12 +70,43 @@ const SELECT_OPPORTUNITIES = `
creator.name AS created_by_name,
owner.name AS owner_name,
owner.color AS owner_color,
- owner.avatar AS owner_avatar
+ owner.avatar AS owner_avatar,
+ deleter.name AS deleted_by_name
FROM crm_opportunities o
LEFT JOIN crm_users creator ON creator.id = o.created_by
LEFT JOIN crm_users owner ON owner.id = o.owner_id
+ LEFT JOIN crm_users deleter ON deleter.id = o.deleted_by
`;
+/** Ab hier raeumt der Papierkorb selbst auf. */
+const DELETE_AFTER_DAYS = 90;
+
+/**
+ * Endgueltig entfernen, was lange genug im Papierkorb liegt.
+ *
+ * Laeuft beim Abruf der Liste, hoechstens aber einmal pro Stunde je Prozess. Ein
+ * eigener Zeitplan waere eine weitere Stelle, die ausfallen kann; und die
+ * Abfragen filtern die Faelligen ohnehin heraus, der Loeschlauf raeumt nur
+ * hinterher. Dasselbe Muster wie in der Akquise.
+ */
+let lastPurgeAt = 0;
+async function purgeOldTrash() {
+ if (Date.now() - lastPurgeAt < 3_600_000) return;
+ lastPurgeAt = Date.now();
+
+ const [result] = await pool.query(
+ `DELETE FROM crm_opportunities
+ WHERE deleted_at IS NOT NULL
+ AND deleted_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)`,
+ [DELETE_AFTER_DAYS]
+ );
+ if (result.affectedRows > 0) {
+ console.log(
+ `[Opportunities] ${result.affectedRows} Eintrag/Eintraege nach ${DELETE_AFTER_DAYS} Tagen aus dem Papierkorb geloescht.`
+ );
+ }
+}
+
/**
* DECIMAL kommt aus mysql2 als String zurueck - fuer das Frontend werden daraus
* Zahlen, damit dort nicht ueberall geparst werden muss.
@@ -187,11 +218,17 @@ function mayModify(user, opportunity) {
);
}
-// GET /api/opportunities
+// GET /api/opportunities?trash=true
router.get('/', async (req, res, next) => {
try {
+ await purgeOldTrash();
+
+ // Der Papierkorb ist ausgeblendet, solange er nicht ausdruecklich gefragt ist.
+ const trash = req.query.trash === 'true';
const [rows] = await pool.query(
- `${SELECT_OPPORTUNITIES} ORDER BY o.updated_at DESC, o.id DESC`
+ `${SELECT_OPPORTUNITIES}
+ WHERE o.deleted_at IS ${trash ? 'NOT NULL' : 'NULL'}
+ ORDER BY ${trash ? 'o.deleted_at DESC' : 'o.updated_at DESC'}, o.id DESC`
);
const contacts = await loadContacts(rows.map((row) => row.id));
res.json(rows.map((row) => mapOpportunity(row, contacts.get(row.id))));
@@ -200,6 +237,22 @@ router.get('/', async (req, res, next) => {
}
});
+/*
+ * POST /api/opportunities/trash/empty - Papierkorb endgueltig leeren
+ *
+ * Vor den Routen mit :id registriert, damit "trash" nicht als Id gelesen wird.
+ * Erlaubt ist es jedem Angemeldeten: was hier liegt, hat schon jemand mit den
+ * noetigen Rechten geloescht. Vor dem endgueltigen Schritt warnt die Oberflaeche.
+ */
+router.post('/trash/empty', async (req, res, next) => {
+ try {
+ const [result] = await pool.query('DELETE FROM crm_opportunities WHERE deleted_at IS NOT NULL');
+ res.json({ ok: true, purged: result.affectedRows });
+ } catch (err) {
+ next(err);
+ }
+});
+
// POST /api/opportunities
router.post('/', async (req, res, next) => {
const title = emptyToNull(req.body?.title);
@@ -302,6 +355,11 @@ router.patch('/:id', async (req, res, next) => {
);
const existing = existingRows[0];
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
+ if (existing.deleted_at !== null) {
+ return res
+ .status(409)
+ .json({ error: 'Diese Opportunity liegt im Papierkorb - erst wiederherstellen.' });
+ }
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator ändern.',
@@ -403,29 +461,75 @@ router.patch('/:id', async (req, res, next) => {
}
});
-// DELETE /api/opportunities/:id - Ansprechpartner raeumt die DB per CASCADE
+/*
+ * DELETE /api/opportunities/:id - in den Papierkorb legen
+ *
+ * Bewusst kein echtes Loeschen: an einer Opportunity haengen Verlauf,
+ * Ansprechpartner und Mailzuordnungen, die ein Fehlklick nicht kosten soll.
+ * Endgueltig entfernt sie erst das Leeren des Papierkorbs oder der Ablauf der
+ * 90 Tage - dann raeumt die Datenbank Ansprechpartner und Mailbezuege per
+ * CASCADE mit auf.
+ */
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 Opportunity-ID.' });
try {
const [rows] = await pool.query(
- 'SELECT created_by, owner_id FROM crm_opportunities WHERE id = ? LIMIT 1',
+ 'SELECT created_by, owner_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
+ if (existing.deleted_at !== null) {
+ return res.status(409).json({ error: 'Diese Opportunity liegt schon im Papierkorb.' });
+ }
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error: 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator löschen.',
});
}
- await pool.query('DELETE FROM crm_opportunities WHERE id = ?', [id]);
+ await pool.query(
+ 'UPDATE crm_opportunities SET deleted_at = UTC_TIMESTAMP(), deleted_by = ? WHERE id = ?',
+ [req.user.id, id]
+ );
res.json({ ok: true });
} catch (err) {
next(err);
}
});
+// POST /api/opportunities/:id/restore - aus dem Papierkorb zurueckholen
+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 Opportunity-ID.' });
+
+ try {
+ const [rows] = await pool.query(
+ 'SELECT created_by, owner_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1',
+ [id]
+ );
+ const existing = rows[0];
+ if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
+ if (existing.deleted_at === null) {
+ return res.status(409).json({ error: 'Diese Opportunity liegt nicht im Papierkorb.' });
+ }
+ if (!mayModify(req.user, existing)) {
+ return res.status(403).json({
+ error:
+ 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator wiederherstellen.',
+ });
+ }
+
+ await pool.query(
+ 'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?',
+ [id]
+ );
+ res.json(await fetchOpportunity(id));
+ } catch (err) {
+ next(err);
+ }
+});
+
export default router;
diff --git a/server/src/schema.sql b/server/src/schema.sql
index b1eafb5..6b4db4c 100644
--- a/server/src/schema.sql
+++ b/server/src/schema.sql
@@ -129,9 +129,12 @@ CREATE TABLE IF NOT EXISTS crm_opportunities (
notes TEXT NULL,
owner_id 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,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
+ KEY idx_crm_opp_deleted (deleted_at),
KEY idx_crm_opp_stage (stage),
KEY idx_crm_opp_owner (owner_id),
KEY idx_crm_opp_interaction (last_interaction_at),
@@ -143,6 +146,19 @@ CREATE TABLE IF NOT EXISTS crm_opportunities (
-- Projektpreis gesetzt, gilt er als Volumen (siehe opportunityVolume()).
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS fixed_price DECIMAL(12,2) NULL AFTER duration_days;
+/*
+ * Papierkorb der Opportunities. Loeschen setzt `deleted_at`, die Zeile bleibt -
+ * eine Opportunity traegt Verlauf, Ansprechpartner und Mailbezuege, und ein
+ * Fehlklick soll das nicht kosten. Nach 90 Tagen raeumt das Backend selbst auf,
+ * vorher laesst sich der Papierkorb von Hand leeren.
+ *
+ * `deleted_by` traegt bewusst keinen Fremdschluessel: Spalten lassen sich per
+ * ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht.
+ */
+ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by;
+ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;
+ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_deleted (deleted_at);
+
CREATE TABLE IF NOT EXISTS crm_opportunity_contacts (
opportunity_id INT UNSIGNED NOT NULL,
lead_id INT UNSIGNED NOT NULL,
diff --git a/src/App.tsx b/src/App.tsx
index 830b477..3355d64 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,6 +6,7 @@ import { Leads } from './components/leads/Leads';
import { MailPoolLeads } from './components/leads/MailPoolLeads';
import { Acquisitions } from './components/acquisition/Acquisitions';
import { Opportunities } from './components/opportunities/Opportunities';
+import { OpportunityTrash } from './components/opportunities/OpportunityTrash';
import { Calendar } from './components/calendar/Calendar';
import { parseSqlDateTime } from './components/calendar/calendarUtils';
import { Settings } from './components/settings/Settings';
@@ -22,6 +23,7 @@ import {
OPEN_STAGES,
formatEuro,
opportunityVolume,
+ viewTitle,
type AppUser,
type CalendarEvent,
type Lead,
@@ -140,6 +142,7 @@ const App: React.FC = () => {
// mit, deshalb steht hier nur, worum es auf der Seite geht.
const STATIC_SUBTITLES: Partial
{opportunity.company || 'Ohne Firma'}
+ {/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet, + wenn man ihn schon kennt, ist keiner. */}