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
+10
View File
@@ -65,6 +65,7 @@ erDiagram
crm_opportunities { crm_opportunities {
int id PK int id PK
varchar title varchar title
varchar consultant "fuer welchen Berater"
enum stage "Neu .. Beauftragt | Absage" enum stage "Neu .. Beauftragt | Absage"
tinyint probability "0-100" tinyint probability "0-100"
decimal daily_rate decimal daily_rate
@@ -135,6 +136,15 @@ Acht Eigenheiten, die man kennen muss:
- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes - **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
wenige Kilobyte. wenige Kilobyte.
- **Der Berater steht an der Opportunity, nicht nur in ihren Notizen.** Bis zum
09.09.2026 schrieb die Übernahme ihn in den Herkunftsvermerk, weil
`crm_opportunities` kein Feld dafür hatte - in Pipeline, Liste und Kacheln war
damit nicht zu sehen, für wen ein Projekt läuft. Jetzt trägt
`crm_opportunities.consultant` genau einen Namen; bei mehreren Beratern in der
Akquise entstehen mehrere Opportunities. Bestehende Zeilen füllt
`migrateOpportunityConsultant()` in `db.js` aus den Notizen nach - nur dort, wo
die Spalte leer ist, damit der Lauf wiederholbar bleibt und nichts
überschreibt.
- **Mehrere Berater stehen in einem Feld.** `crm_acquisitions.consultant` - **Mehrere Berater stehen in einem Feld.** `crm_acquisitions.consultant`
nimmt eine kommagetrennte Liste auf (`VARCHAR(500)`), keine eigene nimmt eine kommagetrennte Liste auf (`VARCHAR(500)`), keine eigene
Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in
+26 -11
View File
@@ -182,11 +182,10 @@ mehr im Auswahlfeld, es zeigt also immer nur, wer noch zu haben ist.
> **Was mehrere Berater bedeuten:** Beim Übernehmen entsteht **je Berater eine > **Was mehrere Berater bedeuten:** Beim Übernehmen entsteht **je Berater eine
> Opportunity** - jeder durchläuft seinen eigenen Vorgang, einer kann gewonnen > Opportunity** - jeder durchläuft seinen eigenen Vorgang, einer kann gewonnen
> werden und ein anderer verloren. Ihre Titel bekommen den Namen in Klammern > werden und ein anderer verloren. Alle tragen denselben Titel und unterscheiden
> angehängt, damit sie sich in Pipeline und Liste auseinanderhalten lassen; bei > sich durch die Spalte **Berater**, die in Pipeline, Liste und Kacheln steht.
> einem einzigen Berater bleibt der Titel unverändert. Im Dashboard zählt der > Im Dashboard zählt der Eintrag bei **jedem** der Berater - drei Berater sind
> Eintrag bei **jedem** der Berater - drei Berater sind drei versendete Profile, > drei versendete Profile, also drei Bewerbungen.
> also drei Bewerbungen.
**Von** ist mit dem **angemeldeten Benutzer** vorbelegt - in der Regel trägt **Von** ist mit dem **angemeldeten Benutzer** vorbelegt - in der Regel trägt
jeder seine eigenen Funde ein. Trägt Markus etwas für Artur ein, wählt er Artur jeder seine eigenen Funde ein. Trägt Markus etwas für Artur ein, wählt er Artur
@@ -302,14 +301,21 @@ im Archiv.
Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit, Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit,
zuständig ist der angemeldete Benutzer. Mit übernommen werden Titel, Endkunde, zuständig ist der angemeldete Benutzer. Mit übernommen werden Titel, Endkunde,
Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt. Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt.
Sind **mehrere Berater** hinterlegt, entsteht **je Berater eine Opportunity** Der **Berater** wandert in das gleichnamige Feld der Opportunity und steht damit
mit denselben Projektdaten; ihr Titel trägt den Namen in Klammern. In den Notizen in Pipeline, Liste und Kacheln - für wen ein Projekt läuft, ist neben dem Titel
steht neben dem eigenen Berater auch, mit wem er sich das Projekt teilt die wichtigste Angabe. Sind **mehrere Berater** hinterlegt, entsteht **je Berater
eine Opportunity** mit denselben Projektdaten, jede mit ihrem eigenen Namen im
Feld. In den Notizen steht zusätzlich, mit wem er sich das Projekt teilt
(*Auch beworben*). (*Auch beworben*).
Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld - sie Anbieter, Von, Kanal und Link haben in der Opportunity kein Feld - sie landen als
landen als Herkunftsvermerk in den internen Notizen, damit nachvollziehbar Herkunftsvermerk in den internen Notizen, damit nachvollziehbar bleibt, woher das
bleibt, woher das Projekt kam. Projekt kam.
> **Opportunities von vor dem 09.09.2026:** Bis dahin hatte die Opportunity kein
> Feld für den Berater, der Name stand nur im Herkunftsvermerk. Beim ersten Start
> nach der Umstellung holt das CRM ihn von dort in das Feld - von Hand ist nichts
> zu tun.
Ansprechpartner hängt man danach in der Opportunity dran; dort geht das auch für Ansprechpartner hängt man danach in der Opportunity dran; dort geht das auch für
Kontakte, die es noch nicht gibt. Kontakte, die es noch nicht gibt.
@@ -375,6 +381,15 @@ Vorschau im Formular schreibt dazu, welche Grundlage gerade zählt
Tagessatzes den Projektpreis. Tagessatz und Laufzeit dürfen trotzdem gefüllt Tagessatzes den Projektpreis. Tagessatz und Laufzeit dürfen trotzdem gefüllt
bleiben, etwa zur eigenen Kalkulation. bleiben, etwa zur eigenen Kalkulation.
Das Feld **Berater** sagt, für wen das Projekt läuft. Die Auswahl ist dieselbe
Liste wie in der Akquise, gepflegt unter *Einstellungen → Akquise* - damit hier
nicht „Nico" und dort „Nico N." entsteht. Der Name steht auf der Kachel, in der
Listenspalte *Berater* (sortierbar), auf der Pipeline-Karte und im Papierkorb; die
Suche findet ihn ebenfalls. Genau **ein** Berater je Opportunity: passt ein Fund
der Akquise zu dreien, entstehen bei der Übernahme drei Opportunities. Steht in
einer alten Opportunity ein Name, den die Liste nicht mehr kennt, bleibt er
wählbar und ist als *(nicht in der Liste)* gekennzeichnet.
Zu jeder Opportunity gehören beliebig viele **Ansprechpartner** aus den Zu jeder Opportunity gehören beliebig viele **Ansprechpartner** aus den
Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Fehlt Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Fehlt
jemand noch im CRM, öffnet **Neuer Kontakt...** direkt neben der Auswahlliste jemand noch im CRM, öffnet **Neuer Kontakt...** direkt neben der Auswahlliste
+37
View File
@@ -52,6 +52,7 @@ export async function initDatabase() {
await migrateOpportunityStages(); await migrateOpportunityStages();
await migrateMailPool(); await migrateMailPool();
await widenAcquisitionConsultant(); await widenAcquisitionConsultant();
await migrateOpportunityConsultant();
await seedAcquisitionOptions(); await seedAcquisitionOptions();
await dropProviderOptions(); await dropProviderOptions();
await ensureAdminUser(); await ensureAdminUser();
@@ -254,6 +255,42 @@ async function dropProviderOptions() {
* kurz ist, und ist damit bei jedem weiteren Start ein Blick in * kurz ist, und ist damit bei jedem weiteren Start ein Blick in
* INFORMATION_SCHEMA und sonst nichts. * 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() { async function widenAcquisitionConsultant() {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT CHARACTER_MAXIMUM_LENGTH AS length FROM INFORMATION_SCHEMA.COLUMNS `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, * Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter,
* Kanal, Absender, Link, Berater), wandert in die Notizen. Sonst wuesste * Kanal, Absender, Link), wandert in die Notizen. Sonst wuesste hinterher
* hinterher niemand mehr, woher das Projekt kam. In den Notizen steht der * niemand mehr, woher das Projekt kam.
* Berater dieser Opportunity, nicht die ganze Liste - und dazu, mit wem er *
* sich das Projekt teilt. * 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 buildNotes = (consultant) => {
const others = targets.filter((name) => name !== null && name !== 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 * Der Titel bleibt der Titel - auch bei drei Beratern. Bis der Berater eine
* standen sonst drei Zeilen mit identischem Text, und die Opportunity hat * eigene Spalte hatte, wurde sein Name angehaengt, damit sich die drei
* kein eigenes Feld fuer den Berater. Bei einem einzigen Berater bleibt der * Opportunities in Pipeline und Liste unterscheiden liessen; jetzt steht er
* Titel unveraendert - das ist der Normalfall und braucht keinen Zusatz. * 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(); await conn.beginTransaction();
const opportunityIds = []; const opportunityIds = [];
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, description, stage, probability, daily_rate, duration_days, (title, company, consultant, description, stage, probability, daily_rate,
fixed_price, start_date, end_date, next_step, tags, notes, owner_id, created_by) duration_days, fixed_price, start_date, end_date, next_step, tags, notes,
VALUES (?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, owner_id, created_by)
VALUES (?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
titleFor(consultant), entry.title,
entry.company, entry.company,
consultant,
entry.description, entry.description,
entry.daily_rate, entry.daily_rate,
entry.duration_days, entry.duration_days,
+32 -5
View File
@@ -18,7 +18,21 @@ const STAGES = new Set([
'Absage', '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 DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at'];
const NUMBER_FIELDS = ['daily_rate', 'duration_days', 'fixed_price']; 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.' }); 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 ?? []); const contacts = await normalizeContacts(req.body?.contacts ?? []);
if (contacts.error) return res.status(400).json({ error: contacts.error }); 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 // Wer direkt in 'Beauftragt' anlegt, hat gerade gewonnen - dann steht das
// Datum sofort. // Datum sofort.
`INSERT INTO crm_opportunities `INSERT INTO crm_opportunities
(title, company, description, stage, won_at, probability, daily_rate, duration_days, (title, company, consultant, description, stage, won_at, probability, daily_rate,
fixed_price, start_date, end_date, last_interaction_at, last_interaction_note, duration_days, fixed_price, start_date, end_date, last_interaction_at,
next_step, tags, notes, owner_id, created_by) last_interaction_note, next_step, tags, notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
title, title,
emptyToNull(req.body?.company), emptyToNull(req.body?.company),
consultant,
emptyToNull(req.body?.description), emptyToNull(req.body?.description),
stage, stage,
Math.round(probability.value ?? 10), Math.round(probability.value ?? 10),
@@ -381,6 +403,11 @@ router.patch('/:id', async (req, res, next) => {
if (field === 'title' && !value) { if (field === 'title' && !value) {
return res.status(400).json({ error: 'Der Titel darf nicht leer sein.' }); 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} = ?`); fields.push(`${field} = ?`);
values.push(value); 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 -- Persoenliche Darstellung. Sie haengt am Benutzer und nicht am Browser: wer
-- sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei -- sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei
-- Benutzer am selben Rechner kommen sich nicht in die Quere. -- 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 -- theme_mode = 'light' oder 'dark'; NULL = der Systemeinstellung folgen
-- Bewusst VARCHAR und kein ENUM: ein weiteres Theme soll eine Zeile im -- Bewusst VARCHAR und kein ENUM: ein weiteres Theme soll eine Zeile im
-- Frontend sein und keine Datenbankmigration. Geprueft wird in userFields.js. -- 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); 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 * Fuer welchen Berater das Projekt laeuft.
* Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt *
* steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus. * 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 COLUMN IF NOT EXISTS consultant VARCHAR(190) NULL AFTER company;
ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at);
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_at DATETIME NULL AFTER created_by;
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at; ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at;
@@ -51,6 +51,7 @@ const VIEW_BUTTONS: { mode: OpportunityViewMode; label: string; icon: string }[]
/** Sortierbare Felder in der Reihenfolge des Auswahlmenues. */ /** Sortierbare Felder in der Reihenfolge des Auswahlmenues. */
const SORT_OPTIONS: OpportunitySortKey[] = [ const SORT_OPTIONS: OpportunitySortKey[] = [
'updated_at', 'updated_at',
'consultant',
'last_interaction', 'last_interaction',
'volume', 'volume',
'daily_rate', 'daily_rate',
@@ -69,6 +70,8 @@ function sortValue(opportunity: Opportunity, key: OpportunitySortKey): string |
return opportunity.title.toLowerCase(); return opportunity.title.toLowerCase();
case 'company': case 'company':
return opportunity.company?.toLowerCase() ?? null; return opportunity.company?.toLowerCase() ?? null;
case 'consultant':
return opportunity.consultant?.toLowerCase() ?? null;
case 'stage': case 'stage':
return OPPORTUNITY_STAGES.indexOf(opportunity.stage); return OPPORTUNITY_STAGES.indexOf(opportunity.stage);
case 'probability': case 'probability':
@@ -128,6 +131,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
const haystack = [ const haystack = [
opportunity.title, opportunity.title,
opportunity.company, opportunity.company,
opportunity.consultant,
opportunity.description, opportunity.description,
opportunity.last_interaction_note, opportunity.last_interaction_note,
opportunity.next_step, opportunity.next_step,
@@ -223,7 +227,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
type="search" type="search"
value={searchTerm} value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)} onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Titel, Firma, Ansprechpartner (Komma = UND)" placeholder="Titel, Firma, Berater, Ansprechpartner (Komma = UND)"
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25" className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
/> />
<svg <svg
@@ -66,6 +66,13 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
{opportunity.title} {opportunity.title}
</p> </p>
<p className="truncate text-xs text-ink-400">{opportunity.company || 'Ohne Firma'}</p> <p className="truncate text-xs text-ink-400">{opportunity.company || 'Ohne Firma'}</p>
{/* Fuer wen das Projekt laeuft - hervorgehoben, weil es die
Kachel sonst nicht hergibt. */}
{opportunity.consultant && (
<p className="mt-1 truncate text-xs font-bold text-brand-700">
{opportunity.consultant}
</p>
)}
</div> </div>
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet, {/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet,
wenn man ihn schon kennt, ist keiner. */} wenn man ihn schon kennt, ist keiner. */}
@@ -19,6 +19,7 @@ import {
export type OpportunitySortKey = export type OpportunitySortKey =
| 'title' | 'title'
| 'company' | 'company'
| 'consultant'
| 'stage' | 'stage'
| 'probability' | 'probability'
| 'volume' | 'volume'
@@ -37,6 +38,7 @@ export interface OpportunitySortConfig {
export const SORT_LABELS: Record<OpportunitySortKey, string> = { export const SORT_LABELS: Record<OpportunitySortKey, string> = {
title: 'Titel', title: 'Titel',
company: 'Firma', company: 'Firma',
consultant: 'Berater',
stage: 'Phase', stage: 'Phase',
probability: 'Chance', probability: 'Chance',
volume: 'Volumen', volume: 'Volumen',
@@ -59,6 +61,9 @@ export const DESC_FIRST: OpportunitySortKey[] = [
const COLUMNS: { key: OpportunitySortKey | null; label: string; align?: 'right' }[] = [ const COLUMNS: { key: OpportunitySortKey | null; label: string; align?: 'right' }[] = [
{ key: 'title', label: 'Opportunity' }, { key: 'title', label: 'Opportunity' },
// Direkt hinter dem Titel: fuer wen das Projekt laeuft, ist die Frage nach
// dem Was.
{ key: 'consultant', label: 'Berater' },
{ key: 'stage', label: 'Phase' }, { key: 'stage', label: 'Phase' },
{ key: 'probability', label: 'Chance' }, { key: 'probability', label: 'Chance' },
{ key: 'volume', label: 'Volumen', align: 'right' }, { key: 'volume', label: 'Volumen', align: 'right' },
@@ -159,6 +164,15 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3">
{opportunity.consultant ? (
<span className="whitespace-nowrap text-sm font-bold text-ink-700">
{opportunity.consultant}
</span>
) : (
<span className="text-sm text-ink-400">—</span>
)}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<StageBadge stage={opportunity.stage} /> <StageBadge stage={opportunity.stage} />
</td> </td>
@@ -1,11 +1,11 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Modal } from '../ui/Modal'; import { 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 } from '../leads/leadUi'; import { Avatar, inputClass, labelClass } from '../leads/leadUi';
import { STAGE_COLORS, STAGE_HINTS, STAGE_LABELS } from './opportunityUi'; import { STAGE_COLORS, STAGE_HINTS, STAGE_LABELS } from './opportunityUi';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { opportunitiesApi } from '../../lib/api'; import { acquisitionOptionsApi, opportunitiesApi } from '../../lib/api';
import { import {
OPPORTUNITY_STAGES, OPPORTUNITY_STAGES,
contactFullName, contactFullName,
@@ -35,6 +35,7 @@ interface ContactEntry {
interface FormState { interface FormState {
title: string; title: string;
company: string; company: string;
consultant: string;
description: string; description: string;
stage: OpportunityStage; stage: OpportunityStage;
probability: string; probability: string;
@@ -60,6 +61,7 @@ function toFormState(opportunity: Opportunity | null, fallbackOwnerId: number |
return { return {
title: opportunity?.title ?? '', title: opportunity?.title ?? '',
company: opportunity?.company ?? '', company: opportunity?.company ?? '',
consultant: opportunity?.consultant ?? '',
description: opportunity?.description ?? '', description: opportunity?.description ?? '',
stage: opportunity?.stage ?? 'Neu', stage: opportunity?.stage ?? 'Neu',
probability: String(opportunity?.probability ?? 10), probability: String(opportunity?.probability ?? 10),
@@ -95,6 +97,29 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
/*
* Die Berater kommen aus derselben Auswahlliste wie in der Akquise. Geladen
* wird sie beim Oeffnen des Dialogs und nicht in der App gehalten: es ist eine
* kurze Liste, und sie hier zu holen erspart es, sie durch drei Ebenen
* durchzureichen. Schlaegt der Abruf fehl, bleibt das Feld benutzbar - der
* gespeicherte Name steht weiter zur Auswahl.
*/
const [consultants, setConsultants] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
acquisitionOptionsApi
.list()
.then((options) => {
if (!cancelled) setConsultants(options.consultant.map((option) => option.value));
})
.catch(() => {
/* Ohne Liste bleibt die Auswahl leer; der gesetzte Wert bleibt waehlbar. */
});
return () => {
cancelled = true;
};
}, []);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
@@ -161,6 +186,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
const payload = { const payload = {
title: form.title.trim(), title: form.title.trim(),
company: form.company.trim() || null, company: form.company.trim() || null,
consultant: form.consultant.trim() || null,
description: form.description.trim() || null, description: form.description.trim() || null,
stage: form.stage, stage: form.stage,
probability: Number(form.probability) || 0, probability: Number(form.probability) || 0,
@@ -264,6 +290,36 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
className={inputClass} className={inputClass}
/> />
</div> </div>
{/*
Berater. Die Liste ist dieselbe wie in der Akquise (gepflegt unter
Einstellungen -> Akquise), damit hier nicht "Nico" und dort "Nico N."
entsteht. Genau ein Name: aus einem Fund fuer drei Berater werden bei
der Uebernahme drei Opportunities.
*/}
<div>
<label className={labelClass} htmlFor="opp-consultant">
Berater
</label>
<select
id="opp-consultant"
value={form.consultant}
onChange={(event) => set('consultant', event.target.value)}
className={inputClass}
>
<option value="">—</option>
{consultants.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
{/* Ein Name, den die Liste nicht mehr kennt, bleibt waehlbar -
sonst wuerde das Oeffnen einer alten Opportunity ihn still
auf den ersten Listeneintrag aendern. */}
{form.consultant !== '' && !consultants.includes(form.consultant) && (
<option value={form.consultant}>{form.consultant} (nicht in der Liste)</option>
)}
</select>
</div>
<div> <div>
<label className={labelClass} htmlFor="opp-stage"> <label className={labelClass} htmlFor="opp-stage">
Phase Phase
@@ -129,6 +129,13 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
<p className="truncate text-[11px] text-ink-400"> <p className="truncate text-[11px] text-ink-400">
{opportunity.company || 'Ohne Firma'} {opportunity.company || 'Ohne Firma'}
</p> </p>
{/* Auf der Karte die zweite Kernfrage nach dem
Titel: fuer wen laeuft das? */}
{opportunity.consultant && (
<p className="truncate text-[11px] font-bold text-brand-700">
{opportunity.consultant}
</p>
)}
</div> </div>
<button <button
onClick={(event) => { onClick={(event) => {
@@ -166,6 +166,9 @@ export const OpportunityTrash: React.FC<OpportunityTrashProps> = ({ reload }) =>
<p className="truncate text-sm font-bold text-ink-900">{opportunity.title}</p> <p className="truncate text-sm font-bold text-ink-900">{opportunity.title}</p>
<p className="truncate text-xs text-ink-400"> <p className="truncate text-xs text-ink-400">
{opportunity.company || 'Ohne Firma'} {opportunity.company || 'Ohne Firma'}
{/* Auch im Papierkorb: vor dem Wiederherstellen ist
die Frage, fuer wen das Projekt lief. */}
{opportunity.consultant && ` · ${opportunity.consultant}`}
{opportunity.contacts.length > 0 && {opportunity.contacts.length > 0 &&
` · ${opportunity.contacts.length} Ansprechpartner`} ` · ${opportunity.contacts.length} Ansprechpartner`}
</p> </p>
+6
View File
@@ -290,6 +290,12 @@ export interface Opportunity {
id: number; id: number;
title: string; title: string;
company: string | null; company: string | null;
/**
* Fuer welchen Berater das Projekt laeuft - neben dem Titel die wichtigste
* Angabe. Genau ein Name: passt ein Fund der Akquise zu drei Beratern,
* entstehen bei der Uebernahme drei Opportunities.
*/
consultant: string | null;
description: string | null; description: string | null;
stage: OpportunityStage; stage: OpportunityStage;
/** 0-100 */ /** 0-100 */