fix(opportunities): Berater als eigenes Feld statt nur in den Notizen

Bei der Uebernahme aus der Akquise verschwand die wichtigste Angabe nach
dem Titel: fuer wen das Projekt laeuft. Die Opportunity hatte kein Feld
dafuer, also schrieb die Uebernahme den Namen in den Herkunftsvermerk der
Notizen - in Pipeline, Liste und Kacheln war er damit nicht zu sehen, und
suchen liess sich nur im Notiztext.

crm_opportunities.consultant traegt ihn jetzt selbst. Genau ein Name je
Opportunity: passt ein Fund zu drei Beratern, entstehen bei der
Uebernahme drei Opportunities. Sichtbar in Pipeline-Karte, Kachel,
Papierkorb und als sortierbare Spalte direkt hinter dem Titel; die Suche
fasst ihn mit ein, und im Dialog kommt die Auswahl aus derselben Liste
wie in der Akquise (Einstellungen -> Akquise), damit nicht "Nico" und
"Nico N." nebeneinander entstehen.

Bestehende Opportunities holt migrateOpportunityConsultant() beim Start
aus ihren Notizen nach - nur dort, wo die Spalte leer ist. Damit ist der
Lauf wiederholbar und ueberschreibt nichts, was seither von Hand gesetzt
wurde; ein Merker in crm_settings ist dafuer nicht noetig. Der Text in
den Notizen bleibt stehen: er ist der Herkunftsnachweis samt Anbieter,
Kanal und Link.

Der Namenszusatz im Titel entfaellt wieder. Er war gestern dazugekommen,
damit sich mehrere Opportunities aus einem Fund unterscheiden liessen -
mit einer eigenen Spalte ist er nur noch Rauschen.

Nebenbei: der Block, der won_at nachzieht, stand zweimal in schema.sql.
Idempotent und damit harmlos, aber eine Kopie - jetzt einmal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-09 14:07:23 +02:00
co-authored by Claude Opus 5
parent 8403f1c39d
commit 93fe8cfd73
13 changed files with 236 additions and 40 deletions
+32 -5
View File
@@ -18,7 +18,21 @@ const STAGES = new Set([
'Absage',
]);
const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes'];
const TEXT_FIELDS = [
'title',
'company',
// Fuer welchen Berater das Projekt laeuft. Gepflegt wird die Auswahl unter
// Einstellungen -> Akquise; geprueft wird hier nur die Laenge, damit ein aus
// der Liste entfernter Name eine alte Opportunity nicht unaenderbar macht.
'consultant',
'description',
'last_interaction_note',
'next_step',
'notes',
];
/** Laengengrenze der Spalte - abgewiesen statt stillschweigend abgeschnitten. */
const CONSULTANT_MAX_LENGTH = 190;
const DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at'];
const NUMBER_FIELDS = ['daily_rate', 'duration_days', 'fixed_price'];
@@ -303,6 +317,13 @@ router.post('/', async (req, res, next) => {
return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
}
const consultant = emptyToNull(req.body?.consultant);
if (consultant !== null && consultant.length > CONSULTANT_MAX_LENGTH) {
return res
.status(400)
.json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` });
}
const contacts = await normalizeContacts(req.body?.contacts ?? []);
if (contacts.error) return res.status(400).json({ error: contacts.error });
@@ -313,13 +334,14 @@ router.post('/', async (req, res, next) => {
// Wer direkt in 'Beauftragt' anlegt, hat gerade gewonnen - dann steht das
// Datum sofort.
`INSERT INTO crm_opportunities
(title, company, description, stage, won_at, probability, daily_rate, duration_days,
fixed_price, start_date, end_date, last_interaction_at, last_interaction_note,
next_step, tags, notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(title, company, consultant, description, stage, won_at, probability, daily_rate,
duration_days, fixed_price, start_date, end_date, last_interaction_at,
last_interaction_note, next_step, tags, notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
title,
emptyToNull(req.body?.company),
consultant,
emptyToNull(req.body?.description),
stage,
Math.round(probability.value ?? 10),
@@ -381,6 +403,11 @@ router.patch('/:id', async (req, res, next) => {
if (field === 'title' && !value) {
return res.status(400).json({ error: 'Der Titel darf nicht leer sein.' });
}
if (field === 'consultant' && value !== null && value.length > CONSULTANT_MAX_LENGTH) {
return res
.status(400)
.json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` });
}
fields.push(`${field} = ?`);
values.push(value);
}