feat(opportunities): Papierkorb statt endgueltigem Loeschen

Das Loeschsymbol gab es in der Kachelansicht schon, es war aber bis zum
Ueberfahren durchsichtig - ein Knopf, den man nur findet, wenn man ihn kennt.
Jetzt ist er sichtbar, in der Liste ebenso, und die Pipeline-Karte hat ihn
ueberhaupt zum ersten Mal.

Geloescht wird nicht mehr endgueltig: DELETE setzt deleted_at, die Zeile bleibt.
An einer Opportunity haengen Ansprechpartner, Verlauf und Mailzuordnungen, und
ein Fehlklick soll das nicht kosten. Der Papierkorb ist ein Unterpunkt unter
Opportunities und zeigt, wann und von wem geloescht wurde und wie lange der
Eintrag noch liegt. Zurueckholen darf, wer auch loeschen darf; das Leeren jeder
Angemeldete - was dort liegt, hat schon jemand mit den noetigen Rechten
weggeworfen. Beides fragt vorher nach.

Nach 90 Tagen raeumt das Backend selbst auf, beim Abruf der Liste und hoechstens
einmal pro Stunde je Prozess - dasselbe Muster wie in der Akquise. Ein eigener
Zeitplan waere eine weitere Stelle, die ausfallen kann, und die Abfragen filtern
Faelliges ohnehin heraus.

Im Papierkorb liegende Opportunities sind aus allen Listen, Summen und
Zuordnungsdialogen heraus und lassen sich nicht bearbeiten (409) - erst
zurueckholen, dann aendern.

Nebenbei: der Header bekommt seinen Titel jetzt ueber viewTitle(). Der
Ansichtsname musste eindeutig sein ("Opportunity-Papierkorb", weil die Mails
schon einen Papierkorb haben), angezeigt wird beides als "Papierkorb".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 14:16:10 +02:00
co-authored by Claude Opus 5
parent 2717462c3a
commit 1ac0e2d2c7
16 changed files with 503 additions and 30 deletions
+8 -1
View File
@@ -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
+23 -1
View File
@@ -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.
+4 -2
View File
@@ -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 |
+2 -1
View File
@@ -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. |
+110 -6
View File
@@ -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;
+16
View File
@@ -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,
+7 -1
View File
@@ -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<Record<ViewState, string>> = {
Akquise: 'Gefundene Projekte - eintragen, prüfen, zur Opportunity machen',
'Opportunity-Papierkorb': 'Gelöschte Opportunities - nach 90 Tagen endgültig entfernt',
Archiv: 'Akquise-Einträge älter als 14 Tage - nach 90 Tagen werden sie gelöscht',
Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen',
Papierkorb: 'Im CRM gelöschte Mails - im Postfach crm@perinco.de bleiben sie erhalten',
@@ -182,7 +185,7 @@ const App: React.FC = () => {
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Header
title={currentView}
title={viewTitle(currentView)}
subtitle={subtitle}
onToggleSidebar={() => setIsSidebarOpen((open) => !open)}
/>
@@ -192,6 +195,9 @@ const App: React.FC = () => {
{currentView === 'Archiv' && (
<Acquisitions view="archiv" reload={() => loadData(true)} />
)}
{currentView === 'Opportunity-Papierkorb' && (
<OpportunityTrash reload={() => loadData(true)} />
)}
{currentView === 'Opportunities' && (
<Opportunities
opportunities={opportunities}
+2 -2
View File
@@ -1,9 +1,9 @@
import React from 'react';
import type { ViewState } from '../types';
import { useTheme } from '../lib/theme';
interface HeaderProps {
title: ViewState;
/** Beschriftung der Ansicht - meist ihr Name, siehe viewTitle(). */
title: string;
subtitle?: string;
onToggleSidebar: () => void;
}
+26 -5
View File
@@ -1,5 +1,12 @@
import React, { useEffect, useState } from 'react';
import { ACQUISITION_VIEWS, CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import {
ACQUISITION_VIEWS,
CONTACT_VIEWS,
MAIL_VIEWS,
OPPORTUNITY_VIEWS,
SCANNER_VIEWS,
type ViewState,
} from '../types';
import { UserAvatar } from './ui/UserAvatar';
import { useAuth } from '../lib/auth';
@@ -73,6 +80,23 @@ const ACQUISITION_GROUP: MenuGroup = {
const TRASH_ICON =
'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16';
const OPPORTUNITY_ICON =
'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z';
/**
* Opportunities mit ihrem Papierkorb. Geloeschte Opportunities liegen dort 90
* Tage, damit ein Fehlklick nicht Verlauf, Ansprechpartner und Mailbezuege
* kostet.
*/
const OPPORTUNITIES_GROUP: MenuGroup = {
item: { name: 'Opportunities', icon: OPPORTUNITY_ICON },
views: OPPORTUNITY_VIEWS,
children: [
{ name: 'Opportunities', label: 'Alle', icon: OPPORTUNITY_ICON },
{ name: 'Opportunity-Papierkorb', label: 'Papierkorb', icon: TRASH_ICON },
],
};
/**
* Mails. Darunter klappt der Papierkorb auf: Mails, die im CRM geloescht wurden.
* Im Postfach bleiben sie stehen - der Abgleich holt sie aber nicht wieder.
@@ -119,10 +143,7 @@ const SCANNER_GROUP: MenuGroup = {
// Einstellungen bleibt separat am unteren Rand.
const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [
ACQUISITION_GROUP,
{
name: 'Opportunities',
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',
},
OPPORTUNITIES_GROUP,
{
// Der Terminkalender heisst im CRM "Interviews": genutzt wird er fuer
// Gespraechstermine, nicht als allgemeiner Kalender.
@@ -202,7 +202,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
if (!toDelete) return;
try {
await opportunitiesApi.remove(toDelete.id);
addToast(`"${toDelete.title}" wurde gelöscht.`, 'success');
addToast(`„${toDelete.title}" liegt im Papierkorb.`, 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
@@ -401,6 +401,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
opportunities={filtered}
onEdit={openEdit}
onStageChange={changeStage}
onDelete={setToDelete}
/>
)}
@@ -417,7 +418,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
{toDelete && (
<ConfirmDialog
title="Opportunity löschen?"
message={`"${toDelete.title}" wird unwiderruflich entfernt - inklusive der Zuordnung der Ansprechpartner. Die Kontakte selbst bleiben erhalten.`}
message={`„${toDelete.title}" wandert in den Papierkorb und verschwindet aus dieser Liste. Von dort lässt sie sich zurückholen; nach 90 Tagen wird sie endgültig gelöscht.`}
onConfirm={confirmDelete}
onCancel={() => setToDelete(null)}
/>
@@ -67,12 +67,15 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
</p>
<p className="truncate text-xs text-ink-400">{opportunity.company || 'Ohne Firma'}</p>
</div>
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet,
wenn man ihn schon kennt, ist keiner. */}
<button
onClick={(event) => {
event.stopPropagation();
onDelete(opportunity);
}}
className="shrink-0 rounded-lg p-1.5 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
title="In den Papierkorb legen"
aria-label={`${opportunity.title} löschen`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -245,7 +245,8 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
event.stopPropagation();
onDelete(opportunity);
}}
className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
title="In den Papierkorb legen"
aria-label={`${opportunity.title} löschen`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -15,6 +15,8 @@ interface OpportunityPipelineProps {
onEdit: (opportunity: Opportunity) => void;
/** Verschiebt eine Karte in eine andere Phase. */
onStageChange: (opportunity: Opportunity, stage: OpportunityStage) => void;
/** Legt die Opportunity in den Papierkorb. */
onDelete: (opportunity: Opportunity) => void;
}
/**
@@ -25,6 +27,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
opportunities,
onEdit,
onStageChange,
onDelete,
}) => {
const [draggedId, setDraggedId] = useState<number | null>(null);
const [hoverStage, setHoverStage] = useState<OpportunityStage | null>(null);
@@ -110,12 +113,34 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
draggedId === opportunity.id ? 'opacity-40' : ''
}`}
>
<p className="truncate text-xs font-extrabold text-ink-900">
{opportunity.title}
</p>
<p className="truncate text-[11px] text-ink-400">
{opportunity.company || 'Ohne Firma'}
</p>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-extrabold text-ink-900">
{opportunity.title}
</p>
<p className="truncate text-[11px] text-ink-400">
{opportunity.company || 'Ohne Firma'}
</p>
</div>
<button
onClick={(event) => {
event.stopPropagation();
onDelete(opportunity);
}}
className="shrink-0 rounded-lg p-1 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
title="In den Papierkorb legen"
aria-label={`${opportunity.title} löschen`}
>
<svg className="h-3.5 w-3.5" 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>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-xs font-bold text-ink-800">
@@ -0,0 +1,234 @@
import React, { useCallback, useEffect, useState } from 'react';
import { StageBadge } from './opportunityUi';
import { ConfirmDialog } from '../ui/Modal';
import { UserAvatar } from '../ui/UserAvatar';
import { useToast } from '../Toast';
import { opportunitiesApi } from '../../lib/api';
import { formatEuro, opportunityVolume, type Opportunity } from '../../types';
/*
* Papierkorb der Opportunities.
*
* Bewusst eine eigene, schlanke Ansicht und nicht die Opportunity-Liste mit einem
* Schalter: hier zaehlt nur, was weg ist, wer es weggeworfen hat und wann - Filter,
* Sortierung und drei Darstellungen waeren dafuer Beiwerk.
*
* Nach 90 Tagen raeumt das Backend selbst auf; das Leeren von Hand ist der
* schnellere Weg und fragt vorher nach.
*/
interface OpportunityTrashProps {
/** Daten der App neu laden - nach einer Wiederherstellung. */
reload: () => void;
}
/** Wie lange etwas im Papierkorb liegt - der Server loescht nach 90 Tagen. */
const DELETE_AFTER_DAYS = 90;
/** MariaDB-DATETIME steht in UTC; ohne das angehaengte Z liest der Browser Ortszeit. */
function parseUtc(value: string | null): 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): { 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, DELETE_AFTER_DAYS - 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`,
};
}
export const OpportunityTrash: React.FC<OpportunityTrashProps> = ({ reload }) => {
const { addToast } = useToast();
const [entries, setEntries] = useState<Opportunity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
const [isEmptyOpen, setIsEmptyOpen] = useState(false);
const load = useCallback(
async (silent = false) => {
if (!silent) setIsLoading(true);
try {
setEntries(await opportunitiesApi.list({ trash: true }));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Papierkorb nicht abrufbar.', 'error');
} finally {
setIsLoading(false);
}
},
[addToast]
);
useEffect(() => {
load();
}, [load]);
const restore = async (opportunity: Opportunity) => {
setBusyId(opportunity.id);
try {
await opportunitiesApi.restore(opportunity.id);
addToast(`„${opportunity.title}" wiederhergestellt.`, 'success');
await load(true);
// Die Opportunity-Liste der App kennt den Eintrag noch nicht wieder.
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Wiederherstellen fehlgeschlagen.', 'error');
await load(true);
} finally {
setBusyId(null);
}
};
const emptyTrash = async () => {
setIsEmptyOpen(false);
try {
const result = await opportunitiesApi.emptyTrash();
addToast(
result.purged === 1
? '1 Opportunity endgültig gelöscht.'
: `${result.purged} Opportunities 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öschte Opportunity' : 'gelöschte Opportunities'
}`}
</p>
<p className="mt-1 text-xs text-ink-500">
Gelöschte Opportunities liegen hier {DELETE_AFTER_DAYS} Tage und lassen sich
zurückholen - mit Ansprechpartnern, Verlauf und Mailzuordnungen. Danach werden sie
automatisch entfernt.
</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 Opportunities 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-[860px] text-left">
<thead className="border-b border-ink-200 bg-ink-50">
<tr>
{['Opportunity', 'Phase', 'Volumen', '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 === 'Volumen' ? 'text-right' : ''
}`}
>
{label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{entries.map((opportunity) => {
const deleted = formatDeleted(opportunity.deleted_at);
return (
<tr key={opportunity.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">{opportunity.title}</p>
<p className="truncate text-xs text-ink-400">
{opportunity.company || 'Ohne Firma'}
{opportunity.contacts.length > 0 &&
` · ${opportunity.contacts.length} Ansprechpartner`}
</p>
</td>
<td className="px-4 py-3">
<StageBadge stage={opportunity.stage} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-bold text-ink-800">
{formatEuro(opportunityVolume(opportunity))}
</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">
{opportunity.deleted_by === null ? (
<span className="text-sm text-ink-400">—</span>
) : (
<div className="flex items-center gap-2">
<UserAvatar
user={{
id: opportunity.deleted_by,
name: opportunity.deleted_by_name ?? '?',
color: null,
avatar: null,
}}
size={24}
/>
<span className="text-sm text-ink-700">
{opportunity.deleted_by_name ?? 'unbekannt'}
</span>
</div>
)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right">
<button
onClick={() => restore(opportunity)}
disabled={busyId === opportunity.id}
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"
>
{busyId === opportunity.id ? 'Hole zurück...' : 'Wiederherstellen'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{isEmptyOpen && (
<ConfirmDialog
title="Papierkorb endgültig leeren?"
message={`${entries.length} ${
entries.length === 1 ? 'Opportunity wird' : 'Opportunities werden'
} unwiderruflich gelöscht - mit der Zuordnung ihrer Ansprechpartner und ihrer Mails. Die Kontakte selbst und die Mails bleiben erhalten.`}
confirmLabel="Endgültig löschen"
onConfirm={emptyTrash}
onCancel={() => setIsEmptyOpen(false)}
/>
)}
</div>
);
};
+8 -1
View File
@@ -194,12 +194,19 @@ export type OpportunityInput = Partial<
};
export const opportunitiesApi = {
list: () => request<Opportunity[]>('/opportunities'),
/** `trash` zeigt genau den Papierkorb, sonst ist er ausgeblendet. */
list: (options: { trash?: boolean } = {}) =>
request<Opportunity[]>(`/opportunities${options.trash ? '?trash=true' : ''}`),
create: (input: OpportunityInput) =>
request<Opportunity>('/opportunities', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: OpportunityInput) =>
request<Opportunity>(`/opportunities/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
/** Legt die Opportunity in den Papierkorb - endgueltig entfernt sie erst das Leeren. */
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
restore: (id: number) =>
request<Opportunity>(`/opportunities/${id}/restore`, { method: 'POST' }),
emptyTrash: () =>
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
};
// ---------- Akquise ----------
+23
View File
@@ -2,6 +2,7 @@ export type ViewState =
| 'Akquise'
| 'Archiv'
| 'Opportunities'
| 'Opportunity-Papierkorb'
| 'Interviews'
| 'Kontakte'
| 'Aus Mails'
@@ -53,6 +54,24 @@ export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
*/
export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv'];
/**
* Opportunities mit ihrem Papierkorb. Der Wert heisst "Opportunity-Papierkorb",
* weil die Mails schon einen "Papierkorb" haben und der Ansichtsname eindeutig
* sein muss; angezeigt wird beides als "Papierkorb" (siehe VIEW_TITLES).
*/
export const OPPORTUNITY_VIEWS: ViewState[] = ['Opportunities', 'Opportunity-Papierkorb'];
/**
* Abweichende Beschriftung einer Ansicht. Ohne Eintrag ist der Name der Ansicht
* gleichzeitig ihr Titel - das gilt fuer alle bis auf die, deren Name nur
* intern eindeutig sein muss.
*/
export const VIEW_TITLES: Partial<Record<ViewState, string>> = {
'Opportunity-Papierkorb': 'Papierkorb',
};
export const viewTitle = (view: ViewState): string => VIEW_TITLES[view] ?? view;
export type Role = 'admin' | 'user';
export interface AppUser {
@@ -236,6 +255,9 @@ export interface Opportunity {
notes: string | null;
owner_id: number | null;
created_by: number | null;
/** Gesetzt = liegt im Papierkorb; nach 90 Tagen wird sie dort geloescht. */
deleted_at: string | null;
deleted_by: number | null;
created_at?: string;
updated_at?: string;
/** Angereichert vom Backend */
@@ -243,6 +265,7 @@ export interface Opportunity {
owner_name?: string | null;
owner_color?: string | null;
owner_avatar?: string | null;
deleted_by_name?: string | null;
contacts: OpportunityContact[];
}