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
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user