feat(opportunities): Uebernahme zurueck in die Akquise schieben

Im Dialog einer Opportunity aus der Akquise nimmt "Zurueck in die Akquise"
eine versehentliche Uebernahme zurueck: alle Opportunities aus dem Eintrag
wandern in den Papierkorb, der Eintrag steht mit seinem Funddatum wieder in
Eingang bzw. Archiv. Neue Spalte crm_opportunities.acquisition_id, damit bei
mehreren Beratern alle gefunden werden. Wiederherstellen haengt den Eintrag
wieder an die Opportunity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 13:12:03 +02:00
co-authored by Claude Opus 5
parent 0ad7b32dbf
commit 33dedaedfb
9 changed files with 270 additions and 19 deletions
+11
View File
@@ -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
+28 -5
View File
@@ -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
+1 -1
View File
@@ -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`) |
+9 -5
View File
@@ -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 = ?',
+130 -6
View File
@@ -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;
+20
View File
@@ -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.
@@ -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<OpportunityModalProps> = ({
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<OpportunityModalProps> = ({
}
};
/*
* 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 (
<>
<Modal
@@ -239,7 +267,22 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
onClose={onClose}
size="xl"
footer={
<div className="flex gap-3">
<div className="flex flex-wrap gap-3">
{/* Nur bei Opportunities aus der Akquise, deren Eintrag noch da ist. */}
{opportunity?.acquisition_available && (
<button
type="button"
onClick={() => setConfirmBack(true)}
disabled={isSaving}
title="Übernahme zurücknehmen: der Eintrag steht wieder in der Akquise"
className="flex items-center justify-center gap-2 rounded-xl border border-ink-200 px-4 py-3 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 disabled:opacity-60"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
Zurück in die Akquise
</button>
)}
<button
type="button"
onClick={onClose}
@@ -751,6 +794,16 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
onSaved={onSaved}
/>
)}
{confirmBack && opportunity && (
<ConfirmDialog
title="Zurück in die Akquise?"
message={`„${opportunity.title}" steht danach wieder in der Akquise, mit seinem ursprünglichen Funddatum. Die Opportunity wandert in den Papierkorb, bei mehreren Beratern alle aus diesem Eintrag. Ungespeicherte Änderungen gehen verloren.`}
confirmLabel="Zurückschieben"
tone="neutral"
onConfirm={moveBackToAcquisition}
onCancel={() => setConfirmBack(false)}
/>
)}
</>
);
};
+9
View File
@@ -223,6 +223,15 @@ export const opportunitiesApi = {
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
restore: (id: number) =>
request<Opportunity>(`/opportunities/${id}/restore`, { method: 'POST' }),
/**
* Nimmt die Uebernahme aus der Akquise zurueck: alle Opportunities aus dem
* Eintrag wandern in den Papierkorb, der Eintrag steht wieder in der Akquise.
*/
backToAcquisition: (id: number) =>
request<{ ok: true; acquisition_id: number; trashed: number[] }>(
`/opportunities/${id}/acquisition`,
{ method: 'POST' }
),
emptyTrash: () =>
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
};
+7
View File
@@ -296,6 +296,13 @@ export interface Opportunity {
* entstehen bei der Uebernahme drei Opportunities.
*/
consultant: string | null;
/**
* Eintrag der Akquise, aus dem die Opportunity uebernommen wurde - null bei
* von Hand angelegten. Bei drei Beratern zeigen alle drei auf denselben.
*/
acquisition_id: number | null;
/** Der Eintrag existiert noch und liegt nicht im Papierkorb: der Rueckweg ist offen. */
acquisition_available?: boolean;
description: string | null;
stage: OpportunityStage;
/** 0-100 */