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> = { 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 = () => {
setIsSidebarOpen((open) => !open)} /> @@ -192,6 +195,9 @@ const App: React.FC = () => { {currentView === 'Archiv' && ( loadData(true)} /> )} + {currentView === 'Opportunity-Papierkorb' && ( + loadData(true)} /> + )} {currentView === 'Opportunities' && ( void; } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ef25c85..6bb6e33 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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. diff --git a/src/components/opportunities/Opportunities.tsx b/src/components/opportunities/Opportunities.tsx index 3ffffe2..9e34328 100644 --- a/src/components/opportunities/Opportunities.tsx +++ b/src/components/opportunities/Opportunities.tsx @@ -202,7 +202,7 @@ export const Opportunities: React.FC = ({ 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 = ({ opportunities={filtered} onEdit={openEdit} onStageChange={changeStage} + onDelete={setToDelete} /> )} @@ -417,7 +418,7 @@ export const Opportunities: React.FC = ({ {toDelete && ( setToDelete(null)} /> diff --git a/src/components/opportunities/OpportunityGrid.tsx b/src/components/opportunities/OpportunityGrid.tsx index be504e7..80a61e0 100644 --- a/src/components/opportunities/OpportunityGrid.tsx +++ b/src/components/opportunities/OpportunityGrid.tsx @@ -67,12 +67,15 @@ export const OpportunityGrid: React.FC = ({

{opportunity.company || 'Ohne Firma'}

+ {/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet, + wenn man ihn schon kennt, ist keiner. */} +
diff --git a/src/components/opportunities/OpportunityTrash.tsx b/src/components/opportunities/OpportunityTrash.tsx new file mode 100644 index 0000000..2c3cb83 --- /dev/null +++ b/src/components/opportunities/OpportunityTrash.tsx @@ -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 = ({ reload }) => { + const { addToast } = useToast(); + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [busyId, setBusyId] = useState(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 ( +
+
+
+

+ {entries.length === 0 + ? 'Der Papierkorb ist leer.' + : `${entries.length} ${ + entries.length === 1 ? 'gelöschte Opportunity' : 'gelöschte Opportunities' + }`} +

+

+ Gelöschte Opportunities liegen hier {DELETE_AFTER_DAYS} Tage und lassen sich + zurückholen - mit Ansprechpartnern, Verlauf und Mailzuordnungen. Danach werden sie + automatisch entfernt. +

+
+ + +
+ + {isLoading && entries.length === 0 ? ( +
+ +
+ ) : entries.length === 0 ? ( +
+ Hier liegt nichts. Gelöschte Opportunities erscheinen an dieser Stelle. +
+ ) : ( +
+
+ + + + {['Opportunity', 'Phase', 'Volumen', 'Gelöscht', 'Von', ''].map((label, index) => ( + + ))} + + + + + {entries.map((opportunity) => { + const deleted = formatDeleted(opportunity.deleted_at); + + return ( + + + + + + + + + ); + })} + +
+ {label} +
+

{opportunity.title}

+

+ {opportunity.company || 'Ohne Firma'} + {opportunity.contacts.length > 0 && + ` · ${opportunity.contacts.length} Ansprechpartner`} +

+
+ + + {formatEuro(opportunityVolume(opportunity))} + +

{deleted.label}

+

{deleted.remaining}

+
+ {opportunity.deleted_by === null ? ( + — + ) : ( +
+ + + {opportunity.deleted_by_name ?? 'unbekannt'} + +
+ )} +
+ +
+
+
+ )} + + {isEmptyOpen && ( + setIsEmptyOpen(false)} + /> + )} +
+ ); +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index 53bf89f..ab5361d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -194,12 +194,19 @@ export type OpportunityInput = Partial< }; export const opportunitiesApi = { - list: () => request('/opportunities'), + /** `trash` zeigt genau den Papierkorb, sonst ist er ausgeblendet. */ + list: (options: { trash?: boolean } = {}) => + request(`/opportunities${options.trash ? '?trash=true' : ''}`), create: (input: OpportunityInput) => request('/opportunities', { method: 'POST', body: JSON.stringify(input) }), update: (id: number, input: OpportunityInput) => request(`/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(`/opportunities/${id}/restore`, { method: 'POST' }), + emptyTrash: () => + request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }), }; // ---------- Akquise ---------- diff --git a/src/types.ts b/src/types.ts index f4a01ad..b8f2d71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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> = { + '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[]; }