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
@@ -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<OpportunitiesProps> = ({
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<OpportunitiesProps> = ({
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"
/>
<svg
@@ -66,6 +66,13 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
{opportunity.title}
</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>
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet,
wenn man ihn schon kennt, ist keiner. */}
@@ -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<OpportunitySortKey, string> = {
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<OpportunityListProps> = ({
</div>
</div>
</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">
<StageBadge stage={opportunity.stage} />
</td>
@@ -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<OpportunityModalProps> = ({
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<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]) =>
setForm((prev) => ({ ...prev, [key]: value }));
@@ -161,6 +186,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
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<OpportunityModalProps> = ({
className={inputClass}
/>
</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>
<label className={labelClass} htmlFor="opp-stage">
Phase
@@ -129,6 +129,13 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
<p className="truncate text-[11px] text-ink-400">
{opportunity.company || 'Ohne Firma'}
</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>
<button
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-xs text-ink-400">
{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} Ansprechpartner`}
</p>
+6
View File
@@ -290,6 +290,12 @@ export interface Opportunity {
id: number;
title: string;
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;
stage: OpportunityStage;
/** 0-100 */