diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 90a7b32..d9f8577 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -66,6 +66,7 @@ erDiagram int id PK varchar title varchar consultant "fuer welchen Berater" + int acquisition_id "Herkunft in der Akquise, ohne FK" enum stage "Neu .. Beauftragt | Absage" tinyint probability "0-100" decimal daily_rate @@ -199,6 +200,16 @@ Acht Eigenheiten, die man kennen muss: stehen, gleich welches Datum er trägt. Ein Kennzeichen „wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle gerade nicht führt. +- **Der Rückweg von der Opportunity** (`POST /api/opportunities/:id/acquisition`) + braucht die Gegenrichtung: `crm_acquisitions.opportunity_id` kennt nur die + *erste* Opportunity einer Übernahme, bei mehreren Beratern entstehen aber + mehrere. Deshalb trägt jede Opportunity `acquisition_id` (seit 17.09.2026, + ohne Fremdschlüssel - die Akquise löscht nach 90 Tagen). `schema.sql` trägt + den Verweis für ältere Übernahmen nach, kann das aber nur für die erste. + Der Rückweg legt alle lebenden Opportunities mit dieser `acquisition_id` in + den Papierkorb und leert beim Eintrag `converted_at` und `opportunity_id`; + `found_at` bleibt. Wird eine davon wiederhergestellt, setzt `/restore` den + Verweis wieder, sofern der Eintrag frei ist. - **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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 86c861a..44a2582 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -266,12 +266,35 @@ zum Löschen wieder von vorn. Bei einem **Rückfaller** (die Bewerbung war schon eine Opportunity) fragt das CRM nach: mit dem Wiederherstellen fällt der Verweis auf die gelöschte Opportunity -weg. Der Eintrag ist danach wieder ein gewöhnlicher Fund - liegt die Opportunity -noch im Papierkorb und wird sie zurückgeholt, steht sie ab dann *neben* dem -Eintrag statt an seiner Stelle. +weg. Der Eintrag ist danach wieder ein gewöhnlicher Fund. Liegt die Opportunity +noch im Papierkorb und wird sie zurückgeholt, hängt der Eintrag wieder an ihr und +verschwindet aus der Akquise - dasselbe Projekt steht nie an zwei Stellen. -Ein Eintrag, dessen Opportunity noch lebt, lässt sich nicht zurückholen - er -steht ohnehin in keiner der beiden Listen. +Ein Eintrag, dessen Opportunity noch lebt, lässt sich hier nicht zurückholen - er +steht ohnehin in keiner der beiden Listen. Dafür gibt es den Weg von der +Opportunity aus (nächster Abschnitt). + +### Übernahme zurücknehmen + +Aus Versehen übernommen? Im Dialog der Opportunity steht unten links +**Zurück in die Akquise** - nur bei Opportunities, die aus der Akquise kommen und +deren Eintrag dort noch existiert. Nach einer Rückfrage passiert: + +* Der Eintrag steht wieder in der Akquise, als wäre er nie übernommen worden. Das + **Funddatum bleibt**: jung im Eingang, älter als 14 Tage im Archiv. In der + Auswertung zählt er nicht mehr als *übernommen*. +* Die Opportunity wandert in den **Papierkorb** der Opportunities. Waren beim + Eintrag **mehrere Berater** eingetragen, sind bei der Übernahme mehrere + Opportunities entstanden - sie wandern **alle** mit. +* Ungespeicherte Änderungen im Dialog gehen verloren. + +Zurücknehmen darf, wer alle betroffenen Opportunities löschen darf (Ersteller, +Zuständiger oder Administrator). Holt jemand eine davon aus dem Papierkorb +zurück, hängt der Eintrag wieder an ihr und verschwindet aus der Akquise. + +> Bei Übernahmen **vor dem 17.09.2026** kennt nur die jeweils *erste* +> Opportunity ihren Eintrag. Bei mehreren Beratern bleiben die übrigen dann +> stehen und müssen von Hand gelöscht werden. ### Papierkorb diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index a4f14a5..911a0f5 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -99,7 +99,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 /?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 /:id/acquisition`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist | | `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist | | `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt | | `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) | diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index ba9a787..b27aa2f 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -1050,14 +1050,17 @@ router.post('/:id/opportunity', async (req, res, next) => { for (const consultant of targets) { const [created] = await conn.query( `INSERT INTO crm_opportunities - (title, company, consultant, description, stage, probability, daily_rate, - duration_days, fixed_price, start_date, end_date, next_step, tags, notes, - owner_id, created_by) - VALUES (?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (title, company, consultant, acquisition_id, description, stage, probability, + daily_rate, duration_days, fixed_price, start_date, end_date, next_step, tags, + notes, owner_id, created_by) + VALUES (?, ?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ entry.title, entry.company, consultant, + // Jede der Opportunities kennt ihren Eintrag - sonst faende das + // Zuruecknehmen der Uebernahme nur die erste. + id, entry.description, entry.daily_rate, entry.duration_days, @@ -1078,7 +1081,8 @@ router.post('/:id/opportunity', async (req, res, next) => { * `opportunity_id` ist eine Spalte, also merkt sich der Eintrag die erste. * Sie entscheidet nur darueber, ob er wieder im Eingang auftaucht: wird sie * geloescht, laesst sich erneut uebernehmen. Die uebrigen bleiben davon - * unberuehrt - dafuer steht ihre Herkunft in den Notizen. + * unberuehrt - dafuer steht ihre Herkunft in den Notizen und in ihrer + * eigenen `acquisition_id` (Rueckweg: POST /api/opportunities/:id/acquisition). */ await conn.query( 'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?', diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js index 345ad9f..dc1dd76 100644 --- a/server/src/routes/opportunities.js +++ b/server/src/routes/opportunities.js @@ -89,11 +89,13 @@ const SELECT_OPPORTUNITIES = ` owner.name AS owner_name, owner.color AS owner_color, owner.avatar AS owner_avatar, - deleter.name AS deleted_by_name + deleter.name AS deleted_by_name, + (src.id IS NOT NULL AND src.deleted_at IS NULL) AS acquisition_available 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 + LEFT JOIN crm_acquisitions src ON src.id = o.acquisition_id `; /** Ab hier raeumt der Papierkorb selbst auf. */ @@ -136,6 +138,8 @@ function mapOpportunity(row, contacts) { duration_days: row.duration_days === null ? null : Number(row.duration_days), fixed_price: row.fixed_price === null ? null : Number(row.fixed_price), probability: Number(row.probability), + // Aus der Abfrage kommt 0/1 - im Frontend soll es ein Schalter sein. + acquisition_available: Boolean(Number(row.acquisition_available)), tags: parseTags(row.tags), contacts: contacts ?? [], }; @@ -552,7 +556,7 @@ router.post('/:id/restore', async (req, res, next) => { try { const [rows] = await pool.query( - 'SELECT created_by, owner_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1', + 'SELECT created_by, owner_id, acquisition_id, deleted_at FROM crm_opportunities WHERE id = ? LIMIT 1', [id] ); const existing = rows[0]; @@ -567,14 +571,134 @@ router.post('/:id/restore', async (req, res, next) => { }); } - await pool.query( - 'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', - [id] - ); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + 'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', + [id] + ); + /* + * Kommt die Opportunity aus der Akquise, haengt der Eintrag wieder an ihr. + * Sonst stuende dasselbe Projekt nach einem "Zurueck in die Akquise" und + * dem Wiederherstellen doppelt da: als Opportunity und im Eingang. + * + * Nur wenn der Eintrag frei ist - nie uebernommen oder seine Opportunity + * weg. Haengt er an einer anderen, lebenden Opportunity, bleibt er dort. + * `converted_at` bleibt, wenn es schon gesetzt ist, sonst gilt jetzt. + */ + if (existing.acquisition_id !== null) { + await conn.query( + `UPDATE crm_acquisitions a + LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id + SET a.opportunity_id = ?, + a.converted_at = COALESCE(a.converted_at, UTC_TIMESTAMP()) + WHERE a.id = ? + AND a.deleted_at IS NULL + AND (a.converted_at IS NULL OR opp.id IS NULL OR opp.id = ? OR opp.deleted_at IS NOT NULL)`, + [id, existing.acquisition_id, id] + ); + } + await conn.commit(); + } catch (err) { + await conn.rollback().catch(() => {}); + throw err; + } finally { + conn.release(); + } res.json(await fetchOpportunity(id)); } catch (err) { next(err); } }); +/* + * POST /api/opportunities/:id/acquisition - zurueck in die Akquise + * + * Nimmt eine Uebernahme zurueck, die aus Versehen passiert ist. Der Eintrag der + * Akquise wird wieder frei (`converted_at` und `opportunity_id` leer) und steht + * dort, wo sein Funddatum ihn hinstellt: jung im Eingang, sonst im Archiv. Das + * Datum bleibt, wie es war - es ist der Tag der Bewerbung und zaehlt so in der + * Auswertung. + * + * Die Opportunities wandern in den Papierkorb, und zwar alle aus demselben + * Eintrag: bei drei Beratern sind bei der Uebernahme drei entstanden, und wer + * sie zuruecknimmt, meint alle. Papierkorb statt echtem Loeschen, weil an einer + * Opportunity inzwischen Ansprechpartner oder Mails haengen koennen. Wird eine + * davon wiederhergestellt, haengt der Eintrag wieder an ihr (siehe /restore). + * + * Alles in einer Transaktion - ein Eintrag im Eingang neben einer noch lebenden + * Opportunity waere genau die Dublette, die hier vermieden werden soll. + */ +router.post('/:id/acquisition', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' }); + + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query( + 'SELECT id, created_by, owner_id, acquisition_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 im Papierkorb.' }); + } + if (existing.acquisition_id === null) { + return res + .status(409) + .json({ error: 'Diese Opportunity wurde nicht aus der Akquise übernommen.' }); + } + + const [entries] = await conn.query( + 'SELECT id, title, deleted_at FROM crm_acquisitions WHERE id = ? LIMIT 1', + [existing.acquisition_id] + ); + const entry = entries[0]; + if (!entry) { + return res + .status(409) + .json({ error: 'Der Eintrag in der Akquise existiert nicht mehr.' }); + } + if (entry.deleted_at !== null) { + return res.status(409).json({ + error: 'Der Eintrag liegt im Papierkorb der Akquise. Dort erst wiederherstellen.', + }); + } + + const [siblings] = await conn.query( + `SELECT id, created_by, owner_id FROM crm_opportunities + WHERE acquisition_id = ? AND deleted_at IS NULL`, + [entry.id] + ); + // Wer eine davon nicht loeschen darf, darf die Uebernahme nicht zuruecknehmen. + if (!siblings.every((row) => mayModify(req.user, row))) { + return res.status(403).json({ + error: + 'Zurücknehmen darf nur, wer alle Opportunities aus diesem Eintrag löschen darf (Ersteller, Zuständiger oder Administrator).', + }); + } + + await conn.beginTransaction(); + await conn.query( + `UPDATE crm_opportunities SET deleted_at = UTC_TIMESTAMP(), deleted_by = ? + WHERE acquisition_id = ? AND deleted_at IS NULL`, + [req.user.id, entry.id] + ); + await conn.query( + 'UPDATE crm_acquisitions SET converted_at = NULL, opportunity_id = NULL WHERE id = ?', + [entry.id] + ); + await conn.commit(); + + res.json({ ok: true, acquisition_id: entry.id, trashed: siblings.map((row) => row.id) }); + } catch (err) { + await conn.rollback().catch(() => {}); + next(err); + } finally { + conn.release(); + } +}); + export default router; diff --git a/server/src/schema.sql b/server/src/schema.sql index 22b4082..3661b64 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -472,6 +472,26 @@ ALTER TABLE crm_acquisitions ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL A 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); +/* + * Rueckweg von der Opportunity in die Akquise. + * + * `crm_acquisitions.opportunity_id` merkt sich nur die erste Opportunity einer + * Uebernahme - bei drei Beratern entstehen aber drei. Wer eine Uebernahme + * zuruecknimmt, muss alle finden, deshalb zeigt jede Opportunity selbst auf + * ihren Eintrag. Ohne Fremdschluessel, wie `deleted_by`: die Akquise loescht + * nach 90 Tagen, und ein ADD COLUMN IF NOT EXISTS laesst sich wiederholen. + * + * Das UPDATE traegt den Verweis fuer Uebernahmen von vor dieser Spalte nach - + * aber nur fuer die erste Opportunity, von den weiteren weiss die Datenbank + * nichts. Es fasst nur leere Felder an und ist damit bei jedem Start harmlos. + */ +ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS acquisition_id INT UNSIGNED NULL AFTER consultant; +ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_acquisition (acquisition_id); +UPDATE crm_opportunities o + JOIN crm_acquisitions a ON a.opportunity_id = o.id + SET o.acquisition_id = a.id + WHERE o.acquisition_id IS NULL; + /* * Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren * Inhalte in den Einstellungen gepflegt werden. diff --git a/src/components/opportunities/OpportunityModal.tsx b/src/components/opportunities/OpportunityModal.tsx index 01ebf10..9fad3fb 100644 --- a/src/components/opportunities/OpportunityModal.tsx +++ b/src/components/opportunities/OpportunityModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Modal } from '../ui/Modal'; +import { ConfirmDialog, Modal } from '../ui/Modal'; import { LeadModal } from '../leads/LeadModal'; import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField'; import { Avatar, inputClass, labelClass, labelRowClass, labelTextClass } from '../leads/leadUi'; @@ -96,6 +96,8 @@ export const OpportunityModal: React.FC = ({ const [isCreatingLead, setIsCreatingLead] = useState(false); const [tagInput, setTagInput] = useState(''); const [isSaving, setIsSaving] = useState(false); + // Rueckfrage vor "Zurueck in die Akquise" - der Schritt raeumt die Opportunity weg. + const [confirmBack, setConfirmBack] = useState(false); /* * Die Berater kommen aus derselben Auswahlliste wie in der Akquise. Geladen @@ -223,6 +225,32 @@ export const OpportunityModal: React.FC = ({ } }; + /* + * Uebernahme zuruecknehmen. Ungespeicherte Aenderungen im Formular gehen dabei + * verloren - die Opportunity landet ohnehin im Papierkorb, und die Rueckfrage + * sagt, was passiert. + */ + const moveBackToAcquisition = async () => { + if (!opportunity) return; + setConfirmBack(false); + setIsSaving(true); + try { + const result = await opportunitiesApi.backToAcquisition(opportunity.id); + addToast( + result.trashed.length > 1 + ? `„${opportunity.title}" steht wieder in der Akquise, die ${result.trashed.length} Opportunities liegen im Papierkorb.` + : `„${opportunity.title}" steht wieder in der Akquise, die Opportunity liegt im Papierkorb.`, + 'success' + ); + onSaved(); + onClose(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Zurücknehmen fehlgeschlagen.', 'error'); + } finally { + setIsSaving(false); + } + }; + return ( <> = ({ onClose={onClose} size="xl" footer={ -
+
+ {/* Nur bei Opportunities aus der Akquise, deren Eintrag noch da ist. */} + {opportunity?.acquisition_available && ( + + )}