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:
co-authored by
Claude Opus 5
parent
8403f1c39d
commit
93fe8cfd73
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user