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
+37
View File
@@ -52,6 +52,7 @@ export async function initDatabase() {
await migrateOpportunityStages();
await migrateMailPool();
await widenAcquisitionConsultant();
await migrateOpportunityConsultant();
await seedAcquisitionOptions();
await dropProviderOptions();
await ensureAdminUser();
@@ -254,6 +255,42 @@ async function dropProviderOptions() {
* kurz ist, und ist damit bei jedem weiteren Start ein Blick in
* INFORMATION_SCHEMA und sonst nichts.
*/
/*
* Berater aus den Notizen in die eigene Spalte holen - einmalig.
*
* Vor dem 09.09.2026 hatte die Opportunity kein Feld dafuer, und die Uebernahme
* aus der Akquise schrieb den Namen in die Notizen ("Berater: Nico"). Der Text
* bleibt dort stehen: er ist der Herkunftsnachweis samt Anbieter, Kanal und
* Link, und ihn herauszuschneiden waere ein Eingriff in gepflegte Notizen.
*
* Angefasst werden nur Zeilen, deren Spalte noch leer ist. Damit ist der Lauf
* gefahrlos wiederholbar und ueberschreibt nichts, was seither von Hand gesetzt
* wurde - deshalb braucht es hier auch keinen Merker in crm_settings.
*/
async function migrateOpportunityConsultant() {
const [rows] = await pool.query(
`SELECT id, notes FROM crm_opportunities
WHERE (consultant IS NULL OR consultant = '')
AND notes LIKE '%Berater: %'`
);
if (rows.length === 0) return;
let filled = 0;
for (const row of rows) {
// Die Zeile steht als eigene Zeile im Herkunftsblock; alles bis zum
// Zeilenende ist der Name.
const match = /^Berater: (.+)$/m.exec(String(row.notes ?? ''));
const name = match?.[1]?.trim().slice(0, 190);
if (!name) continue;
await pool.query('UPDATE crm_opportunities SET consultant = ? WHERE id = ?', [name, row.id]);
filled++;
}
if (filled > 0) {
console.log(`[Opportunities] Berater bei ${filled} Opportunity(s) aus den Notizen uebernommen.`);
}
}
async function widenAcquisitionConsultant() {
const [rows] = await pool.query(
`SELECT CHARACTER_MAXIMUM_LENGTH AS length FROM INFORMATION_SCHEMA.COLUMNS
+17 -15
View File
@@ -1008,10 +1008,13 @@ router.post('/:id/opportunity', async (req, res, next) => {
/*
* Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter,
* Kanal, Absender, Link, Berater), wandert in die Notizen. Sonst wuesste
* hinterher niemand mehr, woher das Projekt kam. In den Notizen steht der
* Berater dieser Opportunity, nicht die ganze Liste - und dazu, mit wem er
* sich das Projekt teilt.
* Kanal, Absender, Link), wandert in die Notizen. Sonst wuesste hinterher
* niemand mehr, woher das Projekt kam.
*
* Der Berater steht seit dem 09.09.2026 in einer eigenen Spalte und wird
* hier trotzdem noch genannt: die Notiz ist der Herkunftsnachweis und soll
* fuer sich lesbar bleiben. Dazu, mit wem er sich das Projekt teilt - das
* hat sonst keinen Platz.
*/
const buildNotes = (consultant) => {
const others = targets.filter((name) => name !== null && name !== consultant);
@@ -1037,25 +1040,24 @@ router.post('/:id/opportunity', async (req, res, next) => {
};
/*
* Bei mehreren Beratern kommt der Name in den Titel: in Pipeline und Liste
* standen sonst drei Zeilen mit identischem Text, und die Opportunity hat
* kein eigenes Feld fuer den Berater. Bei einem einzigen Berater bleibt der
* Titel unveraendert - das ist der Normalfall und braucht keinen Zusatz.
* Der Titel bleibt der Titel - auch bei drei Beratern. Bis der Berater eine
* eigene Spalte hatte, wurde sein Name angehaengt, damit sich die drei
* Opportunities in Pipeline und Liste unterscheiden liessen; jetzt steht er
* dort in einer eigenen Spalte und der Zusatz waere nur Rauschen.
*/
const titleFor = (consultant) =>
targets.length > 1 && consultant ? `${entry.title} (${consultant})`.slice(0, 190) : entry.title;
await conn.beginTransaction();
const opportunityIds = [];
for (const consultant of targets) {
const [created] = await conn.query(
`INSERT INTO crm_opportunities
(title, company, 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, 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, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
titleFor(consultant),
entry.title,
entry.company,
consultant,
entry.description,
entry.daily_rate,
entry.duration_days,
+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);
}
+14 -6
View File
@@ -27,7 +27,7 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
-- Persoenliche Darstellung. Sie haengt am Benutzer und nicht am Browser: wer
-- sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei
-- Benutzer am selben Rechner kommen sich nicht in die Quere.
-- theme = Farbwelt ('perinco', 'pixel', 'outdoor'); NULL = 'perinco'
-- theme = Farbwelt ('perinco', 'pixel', 'outdoor', 'cyberpunk'); NULL = 'perinco'
-- theme_mode = 'light' oder 'dark'; NULL = der Systemeinstellung folgen
-- Bewusst VARCHAR und kein ENUM: ein weiteres Theme soll eine Zeile im
-- Frontend sein und keine Datenbankmigration. Geprueft wird in userFields.js.
@@ -177,12 +177,20 @@ ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTE
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
/*
* Zeitpunkt des Gewinns. Ohne ihn liesse sich "wie viele Projekte haben wir im
* Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt
* steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus.
* Fuer welchen Berater das Projekt laeuft.
*
* Bis zum 09.09.2026 stand der Berater nur in den Notizen: die Uebernahme aus
* der Akquise schrieb ihn dorthin, weil die Opportunity kein Feld dafuer hatte.
* In Pipeline, Liste und Kacheln war damit nicht zu sehen, fuer wen ein Projekt
* laeuft - neben dem Titel die wichtigste Angabe. Bestehende Opportunities aus
* der Akquise holt migrateOpportunityConsultant() in db.js aus ihren Notizen
* nach.
*
* Genau ein Name je Opportunity: passt ein Fund zu drei Beratern, entstehen bei
* der Uebernahme drei Opportunities. Deshalb 190 Zeichen wie die uebrigen
* Namensfelder und nicht 500 wie in der Akquise, wo eine Liste darin steht.
*/
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTER stage;
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS consultant VARCHAR(190) NULL AFTER company;
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by;
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;