From 93fe8cfd73db6f0a85b20906862ce8ca6d6d7b15 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Wed, 9 Sep 2026 14:07:23 +0200 Subject: [PATCH] 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) --- docs/02-datenmodell.md | 10 ++++ docs/07-anleitung-benutzer.md | 37 ++++++++---- server/src/db.js | 37 ++++++++++++ server/src/routes/acquisitions.js | 32 +++++----- server/src/routes/opportunities.js | 37 ++++++++++-- server/src/schema.sql | 20 +++++-- .../opportunities/Opportunities.tsx | 6 +- .../opportunities/OpportunityGrid.tsx | 7 +++ .../opportunities/OpportunityList.tsx | 14 +++++ .../opportunities/OpportunityModal.tsx | 60 ++++++++++++++++++- .../opportunities/OpportunityPipeline.tsx | 7 +++ .../opportunities/OpportunityTrash.tsx | 3 + src/types.ts | 6 ++ 13 files changed, 236 insertions(+), 40 deletions(-) diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 97e4ffd..90a7b32 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -65,6 +65,7 @@ erDiagram crm_opportunities { int id PK varchar title + varchar consultant "fuer welchen Berater" enum stage "Neu .. Beauftragt | Absage" tinyint probability "0-100" 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 Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden 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` nimmt eine kommagetrennte Liste auf (`VARCHAR(500)`), keine eigene Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index f66ac8a..b99b855 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 > Opportunity** - jeder durchläuft seinen eigenen Vorgang, einer kann gewonnen -> werden und ein anderer verloren. Ihre Titel bekommen den Namen in Klammern -> angehängt, damit sie sich in Pipeline und Liste auseinanderhalten lassen; bei -> einem einzigen Berater bleibt der Titel unverändert. Im Dashboard zählt der -> Eintrag bei **jedem** der Berater - drei Berater sind drei versendete Profile, -> also drei Bewerbungen. +> werden und ein anderer verloren. Alle tragen denselben Titel und unterscheiden +> sich durch die Spalte **Berater**, die in Pipeline, Liste und Kacheln steht. +> Im Dashboard zählt der Eintrag bei **jedem** der Berater - drei Berater sind +> drei versendete Profile, also drei Bewerbungen. **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 @@ -302,14 +301,21 @@ im Archiv. Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit, zuständig ist der angemeldete Benutzer. Mit übernommen werden Titel, Endkunde, Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt. -Sind **mehrere Berater** hinterlegt, entsteht **je Berater eine Opportunity** -mit denselben Projektdaten; ihr Titel trägt den Namen in Klammern. In den Notizen -steht neben dem eigenen Berater auch, mit wem er sich das Projekt teilt +Der **Berater** wandert in das gleichnamige Feld der Opportunity und steht damit +in Pipeline, Liste und Kacheln - für wen ein Projekt läuft, ist neben dem Titel +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*). -Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld - sie -landen als Herkunftsvermerk in den internen Notizen, damit nachvollziehbar -bleibt, woher das Projekt kam. +Anbieter, Von, Kanal und Link haben in der Opportunity kein Feld - sie landen als +Herkunftsvermerk in den internen Notizen, damit nachvollziehbar bleibt, woher das +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 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 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 Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Fehlt jemand noch im CRM, öffnet **Neuer Kontakt...** direkt neben der Auswahlliste diff --git a/server/src/db.js b/server/src/db.js index dcbc9f6..0e2e955 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -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 diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index abbc459..ba9a787 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -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, diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js index 5c32dc2..345ad9f 100644 --- a/server/src/routes/opportunities.js +++ b/server/src/routes/opportunities.js @@ -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); } diff --git a/server/src/schema.sql b/server/src/schema.sql index 44bcae0..22b4082 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -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; diff --git a/src/components/opportunities/Opportunities.tsx b/src/components/opportunities/Opportunities.tsx index 933416e..f3c7588 100644 --- a/src/components/opportunities/Opportunities.tsx +++ b/src/components/opportunities/Opportunities.tsx @@ -51,6 +51,7 @@ const VIEW_BUTTONS: { mode: OpportunityViewMode; label: string; icon: string }[] /** Sortierbare Felder in der Reihenfolge des Auswahlmenues. */ const SORT_OPTIONS: OpportunitySortKey[] = [ 'updated_at', + 'consultant', 'last_interaction', 'volume', 'daily_rate', @@ -69,6 +70,8 @@ function sortValue(opportunity: Opportunity, key: OpportunitySortKey): string | return opportunity.title.toLowerCase(); case 'company': return opportunity.company?.toLowerCase() ?? null; + case 'consultant': + return opportunity.consultant?.toLowerCase() ?? null; case 'stage': return OPPORTUNITY_STAGES.indexOf(opportunity.stage); case 'probability': @@ -128,6 +131,7 @@ export const Opportunities: React.FC = ({ const haystack = [ opportunity.title, opportunity.company, + opportunity.consultant, opportunity.description, opportunity.last_interaction_note, opportunity.next_step, @@ -223,7 +227,7 @@ export const Opportunities: React.FC = ({ type="search" value={searchTerm} 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" /> = ({ {opportunity.title}

{opportunity.company || 'Ohne Firma'}

+ {/* Fuer wen das Projekt laeuft - hervorgehoben, weil es die + Kachel sonst nicht hergibt. */} + {opportunity.consultant && ( +

+ {opportunity.consultant} +

+ )} {/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet, wenn man ihn schon kennt, ist keiner. */} diff --git a/src/components/opportunities/OpportunityList.tsx b/src/components/opportunities/OpportunityList.tsx index 5dd1fe0..308f30d 100644 --- a/src/components/opportunities/OpportunityList.tsx +++ b/src/components/opportunities/OpportunityList.tsx @@ -19,6 +19,7 @@ import { export type OpportunitySortKey = | 'title' | 'company' + | 'consultant' | 'stage' | 'probability' | 'volume' @@ -37,6 +38,7 @@ export interface OpportunitySortConfig { export const SORT_LABELS: Record = { title: 'Titel', company: 'Firma', + consultant: 'Berater', stage: 'Phase', probability: 'Chance', volume: 'Volumen', @@ -59,6 +61,9 @@ export const DESC_FIRST: OpportunitySortKey[] = [ const COLUMNS: { key: OpportunitySortKey | null; label: string; align?: 'right' }[] = [ { 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: 'probability', label: 'Chance' }, { key: 'volume', label: 'Volumen', align: 'right' }, @@ -159,6 +164,15 @@ export const OpportunityList: React.FC = ({ + + {opportunity.consultant ? ( + + {opportunity.consultant} + + ) : ( + — + )} + diff --git a/src/components/opportunities/OpportunityModal.tsx b/src/components/opportunities/OpportunityModal.tsx index 21b5308..dddf249 100644 --- a/src/components/opportunities/OpportunityModal.tsx +++ b/src/components/opportunities/OpportunityModal.tsx @@ -1,11 +1,11 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Modal } from '../ui/Modal'; import { LeadModal } from '../leads/LeadModal'; import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField'; import { Avatar, inputClass, labelClass } from '../leads/leadUi'; import { STAGE_COLORS, STAGE_HINTS, STAGE_LABELS } from './opportunityUi'; import { useToast } from '../Toast'; -import { opportunitiesApi } from '../../lib/api'; +import { acquisitionOptionsApi, opportunitiesApi } from '../../lib/api'; import { OPPORTUNITY_STAGES, contactFullName, @@ -35,6 +35,7 @@ interface ContactEntry { interface FormState { title: string; company: string; + consultant: string; description: string; stage: OpportunityStage; probability: string; @@ -60,6 +61,7 @@ function toFormState(opportunity: Opportunity | null, fallbackOwnerId: number | return { title: opportunity?.title ?? '', company: opportunity?.company ?? '', + consultant: opportunity?.consultant ?? '', description: opportunity?.description ?? '', stage: opportunity?.stage ?? 'Neu', probability: String(opportunity?.probability ?? 10), @@ -95,6 +97,29 @@ export const OpportunityModal: React.FC = ({ const [tagInput, setTagInput] = useState(''); 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([]); + 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 = (key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })); @@ -161,6 +186,7 @@ export const OpportunityModal: React.FC = ({ const payload = { title: form.title.trim(), company: form.company.trim() || null, + consultant: form.consultant.trim() || null, description: form.description.trim() || null, stage: form.stage, probability: Number(form.probability) || 0, @@ -264,6 +290,36 @@ export const OpportunityModal: React.FC = ({ className={inputClass} /> + {/* + 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. + */} +
+ + +