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:
co-authored by
Claude Opus 5
parent
33dedaedfb
commit
6724d0ab65
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user