feat(opportunities): Opportunity fuer andere Berater kopieren

Kopier-Symbol auf Kachel, Pipeline-Karte und Listenzeile sowie "Kopieren"
im Dialog. Ein Dialog fragt nach einem oder mehreren Beratern (aus der
Beraterliste der Akquise, ohne den eigenen) und der Phase der Kopie
(Standard: Neu). Neuer Endpoint POST /api/opportunities/:id/copy legt je
Berater eine Kopie mit Projektdaten, Ansprechpartnern, Zustaendigem und
acquisition_id an; Interaktion und naechster Schritt bleiben beim Original.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 13:31:59 +02:00
co-authored by Claude Opus 5
parent 33dedaedfb
commit 6724d0ab65
10 changed files with 392 additions and 3 deletions
+100
View File
@@ -701,4 +701,104 @@ router.post('/:id/acquisition', async (req, res, next) => {
}
});
/*
* POST /api/opportunities/:id/copy - fuer andere Berater kopieren
*
* Eine Opportunity laeuft fuer genau einen Berater. Passt dasselbe Projekt auch
* fuer einen Kollegen, entsteht je Berater eine Kopie mit eigener Phase - wie
* bei der Uebernahme aus der Akquise, nur nachtraeglich.
*
* Kopiert wird, was das Projekt beschreibt: Titel, Firma, Beschreibung, Zahlen,
* Zeitraum, Tags, Notizen, Ansprechpartner, Zustaendiger und die Herkunft aus
* der Akquise (damit "Zurueck in die Akquise" auch die Kopien findet). Nicht
* kopiert wird, was zum Verlauf des Originals gehoert: letzte Interaktion,
* naechster Schritt, Gewinndatum. Die Phase waehlt der Aufrufer, sonst "Neu".
*
* Body: { consultants: string[], stage?: string }
*/
router.post('/:id/copy', 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 raw = Array.isArray(req.body?.consultants) ? req.body.consultants : [];
// Getrimmt und ohne Dubletten - beim Vergleich ohne Gross-/Kleinschreibung.
const seen = new Set();
const consultants = [];
for (const entry of raw) {
const name = emptyToNull(entry);
if (name === null || seen.has(name.toLowerCase())) continue;
if (name.length > CONSULTANT_MAX_LENGTH) {
return res
.status(400)
.json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` });
}
seen.add(name.toLowerCase());
consultants.push(name);
}
if (consultants.length === 0) {
return res.status(400).json({ error: 'Mindestens ein Berater ist erforderlich.' });
}
if (consultants.length > 20) {
return res.status(400).json({ error: 'Höchstens 20 Berater auf einmal.' });
}
const stage = emptyToNull(req.body?.stage) ?? 'Neu';
if (!STAGES.has(stage)) return res.status(400).json({ error: 'Ungültige Phase.' });
const conn = await pool.getConnection();
try {
const [rows] = await conn.query('SELECT * FROM crm_opportunities WHERE id = ? LIMIT 1', [id]);
const source = rows[0];
if (!source) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
if (source.deleted_at !== null) {
return res.status(409).json({ error: 'Diese Opportunity liegt im Papierkorb.' });
}
const [contactRows] = await conn.query(
'SELECT lead_id, role FROM crm_opportunity_contacts WHERE opportunity_id = ?',
[id]
);
await conn.beginTransaction();
const createdIds = [];
for (const consultant of consultants) {
const [created] = await conn.query(
`INSERT INTO crm_opportunities
(title, company, consultant, acquisition_id, description, stage, won_at, probability,
daily_rate, duration_days, fixed_price, start_date, end_date, tags, notes,
owner_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
source.title,
source.company,
consultant,
source.acquisition_id,
source.description,
stage,
source.probability,
source.daily_rate,
source.duration_days,
source.fixed_price,
source.start_date,
source.end_date,
source.tags ?? '[]',
source.notes,
source.owner_id,
req.user.id,
]
);
await replaceContacts(conn, created.insertId, contactRows);
createdIds.push(created.insertId);
}
await conn.commit();
res.status(201).json({ ok: true, opportunity_ids: createdIds });
} catch (err) {
await conn.rollback().catch(() => {});
next(err);
} finally {
conn.release();
}
});
export default router;