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:
co-authored by
Claude Opus 5
parent
0ad7b32dbf
commit
33dedaedfb
@@ -66,6 +66,7 @@ erDiagram
|
|||||||
int id PK
|
int id PK
|
||||||
varchar title
|
varchar title
|
||||||
varchar consultant "fuer welchen Berater"
|
varchar consultant "fuer welchen Berater"
|
||||||
|
int acquisition_id "Herkunft in der Akquise, ohne FK"
|
||||||
enum stage "Neu .. Beauftragt | Absage"
|
enum stage "Neu .. Beauftragt | Absage"
|
||||||
tinyint probability "0-100"
|
tinyint probability "0-100"
|
||||||
decimal daily_rate
|
decimal daily_rate
|
||||||
@@ -199,6 +200,16 @@ Acht Eigenheiten, die man kennen muss:
|
|||||||
stehen, gleich welches Datum er trägt. Ein Kennzeichen
|
stehen, gleich welches Datum er trägt. Ein Kennzeichen
|
||||||
„wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle
|
„wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle
|
||||||
gerade nicht führt.
|
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
|
- **Der Papierkorb ist die Ausnahme davon.** Löschen von Hand ist eine
|
||||||
Entscheidung und kein Ablauf, deshalb steht sie in `deleted_at` (plus
|
Entscheidung und kein Ablauf, deshalb steht sie in `deleted_at` (plus
|
||||||
`deleted_by`, ohne Fremdschlüssel - siehe `schema.sql`). Vorher war das
|
`deleted_by`, ohne Fremdschlüssel - siehe `schema.sql`). Vorher war das
|
||||||
|
|||||||
@@ -266,12 +266,35 @@ zum Löschen wieder von vorn.
|
|||||||
|
|
||||||
Bei einem **Rückfaller** (die Bewerbung war schon eine Opportunity) fragt das CRM
|
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
|
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
|
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
|
noch im Papierkorb und wird sie zurückgeholt, hängt der Eintrag wieder an ihr und
|
||||||
Eintrag statt an seiner Stelle.
|
verschwindet aus der Akquise - dasselbe Projekt steht nie an zwei Stellen.
|
||||||
|
|
||||||
Ein Eintrag, dessen Opportunity noch lebt, lässt sich nicht zurückholen - er
|
Ein Eintrag, dessen Opportunity noch lebt, lässt sich hier nicht zurückholen - er
|
||||||
steht ohnehin in keiner der beiden Listen.
|
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
|
### Papierkorb
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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`) |
|
| `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`) |
|
||||||
|
|||||||
@@ -1050,14 +1050,17 @@ router.post('/:id/opportunity', async (req, res, next) => {
|
|||||||
for (const consultant of targets) {
|
for (const consultant of targets) {
|
||||||
const [created] = await conn.query(
|
const [created] = await conn.query(
|
||||||
`INSERT INTO crm_opportunities
|
`INSERT INTO crm_opportunities
|
||||||
(title, company, consultant, description, stage, probability, daily_rate,
|
(title, company, consultant, acquisition_id, description, stage, probability,
|
||||||
duration_days, fixed_price, start_date, end_date, next_step, tags, notes,
|
daily_rate, duration_days, fixed_price, start_date, end_date, next_step, tags,
|
||||||
owner_id, created_by)
|
notes, owner_id, created_by)
|
||||||
VALUES (?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
entry.title,
|
entry.title,
|
||||||
entry.company,
|
entry.company,
|
||||||
consultant,
|
consultant,
|
||||||
|
// Jede der Opportunities kennt ihren Eintrag - sonst faende das
|
||||||
|
// Zuruecknehmen der Uebernahme nur die erste.
|
||||||
|
id,
|
||||||
entry.description,
|
entry.description,
|
||||||
entry.daily_rate,
|
entry.daily_rate,
|
||||||
entry.duration_days,
|
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.
|
* `opportunity_id` ist eine Spalte, also merkt sich der Eintrag die erste.
|
||||||
* Sie entscheidet nur darueber, ob er wieder im Eingang auftaucht: wird sie
|
* Sie entscheidet nur darueber, ob er wieder im Eingang auftaucht: wird sie
|
||||||
* geloescht, laesst sich erneut uebernehmen. Die uebrigen bleiben davon
|
* 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(
|
await conn.query(
|
||||||
'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?',
|
'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?',
|
||||||
|
|||||||
@@ -89,11 +89,13 @@ const SELECT_OPPORTUNITIES = `
|
|||||||
owner.name AS owner_name,
|
owner.name AS owner_name,
|
||||||
owner.color AS owner_color,
|
owner.color AS owner_color,
|
||||||
owner.avatar AS owner_avatar,
|
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
|
FROM crm_opportunities o
|
||||||
LEFT JOIN crm_users creator ON creator.id = o.created_by
|
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 owner ON owner.id = o.owner_id
|
||||||
LEFT JOIN crm_users deleter ON deleter.id = o.deleted_by
|
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. */
|
/** 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),
|
duration_days: row.duration_days === null ? null : Number(row.duration_days),
|
||||||
fixed_price: row.fixed_price === null ? null : Number(row.fixed_price),
|
fixed_price: row.fixed_price === null ? null : Number(row.fixed_price),
|
||||||
probability: Number(row.probability),
|
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),
|
tags: parseTags(row.tags),
|
||||||
contacts: contacts ?? [],
|
contacts: contacts ?? [],
|
||||||
};
|
};
|
||||||
@@ -552,7 +556,7 @@ router.post('/:id/restore', async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
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]
|
[id]
|
||||||
);
|
);
|
||||||
const existing = rows[0];
|
const existing = rows[0];
|
||||||
@@ -567,14 +571,134 @@ router.post('/:id/restore', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
const conn = await pool.getConnection();
|
||||||
'UPDATE crm_opportunities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?',
|
try {
|
||||||
[id]
|
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));
|
res.json(await fetchOpportunity(id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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;
|
export default router;
|
||||||
|
|||||||
@@ -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 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);
|
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
|
* Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
|
||||||
* Inhalte in den Einstellungen gepflegt werden.
|
* Inhalte in den Einstellungen gepflegt werden.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { ConfirmDialog, Modal } from '../ui/Modal';
|
||||||
import { LeadModal } from '../leads/LeadModal';
|
import { LeadModal } from '../leads/LeadModal';
|
||||||
import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField';
|
import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField';
|
||||||
import { Avatar, inputClass, labelClass, labelRowClass, labelTextClass } from '../leads/leadUi';
|
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 [isCreatingLead, setIsCreatingLead] = useState(false);
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
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
|
* 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -239,7 +267,22 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="xl"
|
size="xl"
|
||||||
footer={
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -751,6 +794,16 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
|||||||
onSaved={onSaved}
|
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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -223,6 +223,15 @@ export const opportunitiesApi = {
|
|||||||
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
|
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
|
||||||
restore: (id: number) =>
|
restore: (id: number) =>
|
||||||
request<Opportunity>(`/opportunities/${id}/restore`, { method: 'POST' }),
|
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: () =>
|
emptyTrash: () =>
|
||||||
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
|
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -296,6 +296,13 @@ export interface Opportunity {
|
|||||||
* entstehen bei der Uebernahme drei Opportunities.
|
* entstehen bei der Uebernahme drei Opportunities.
|
||||||
*/
|
*/
|
||||||
consultant: string | null;
|
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;
|
description: string | null;
|
||||||
stage: OpportunityStage;
|
stage: OpportunityStage;
|
||||||
/** 0-100 */
|
/** 0-100 */
|
||||||
|
|||||||
Reference in New Issue
Block a user