feat(akquise): mehrere Berater je Eintrag, je Berater eine Opportunity

Ein gefundenes Projekt passt oft zu mehr als einem Berater, und fuer
jeden geht eine eigene Bewerbung heraus. Das Feld "Berater" liess bisher
nur einen Namen zu.

Gespeichert wird eine kommagetrennte Liste in crm_acquisitions.consultant
(auf VARCHAR(500) erweitert, Migration in db.js) und keine eigene
Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in
crm_acquisition_options ist eine Pflegehilfe und kein Fremdschluessel.
Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem
Feld. Die Funktionen zum Zerlegen stehen in acquisitionRules.js, das
Gegenstueck fuer das Frontend in src/types.ts, geprueft von
server/test/acquisition.test.mjs.

Zerlegt wird an vier Stellen:

- Uebernahme: je Berater eine Opportunity, alle in einer Transaktion.
  Ihre Titel tragen den Namen in Klammern, weil die Opportunity kein
  eigenes Feld fuer den Berater hat und in Pipeline und Liste sonst
  identische Zeilen stehen; bei einem einzigen Berater bleibt der Titel
  unveraendert. In den Notizen steht neben dem eigenen Berater auch, mit
  wem er sich das Projekt teilt. Der Eintrag merkt sich die erste
  Opportunity - sie entscheidet nur darueber, ob er wieder im Eingang
  auftaucht.
- Verlauf (crm_acquisition_history): ein Berater je Zeile. Ein Eintrag
  mit drei Beratern sind drei Bewerbungen, "Nico, Lisa" waere sonst ein
  eigener Schluessel neben "Nico" und "Lisa".
- Dashboard: dieselbe Zaehlung. Die Summe der Balken liegt damit ueber
  der Gesamtzahl "Bewerbungen", die weiter die Eintraege zaehlt.
- Auswahlliste pflegen: Umbenennen ersetzt den Namen innerhalb der
  Liste, Loeschen zaehlt auch, wer nur einer von mehreren ist. Ein
  Gleichheitsvergleich uebersah beides.

In der Oberflaeche haengt das Auswahlfeld jeweils einen Namen an, die
Gewaehlten stehen als Plaketten darunter und lassen sich einzeln
entfernen; ein bereits gewaehlter Berater verschwindet aus der Liste.
Bewusst kein <select multiple>: Strg-Klick ist mit der Maus unauffindbar
und mit einer Beruehrung gar nicht bedienbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-09 10:35:43 +02:00
co-authored by Claude Opus 5
parent a866cd9a52
commit 62dce64739
18 changed files with 684 additions and 91 deletions
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { DictateTextarea } from '../ui/DictationField';
import { OptionSelect, fieldClass } from './acquisitionUi';
import { ConsultantSelect, OptionSelect, fieldClass } from './acquisitionUi';
import { labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth';
@@ -330,7 +330,9 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
className={fieldClass}
/>
</div>
<OptionSelect
{/* Mehrfachauswahl: ein Projekt passt oft zu mehreren Beratern, und
beim Uebernehmen entsteht dann je Berater eine Opportunity. */}
<ConsultantSelect
id="acq-consultant"
label={ACQUISITION_FIELD_LABELS.consultant}
value={form.consultant}
@@ -1,5 +1,6 @@
import React from 'react';
import { formatEuro, type Acquisition } from '../../types';
import { formatEuro, splitConsultants, type Acquisition } from '../../types';
import { ConsultantList } from './acquisitionUi';
/*
* Die Liste der gefundenen Projekte. Genau die Spalten, die beim Durchgehen
@@ -155,6 +156,9 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
const extraParts = extras(entry);
const returned = returnedNote(entry);
const isBusy = busyId === entry.id;
// Entscheidet nur die Beschriftung: bei mehreren Beratern entsteht
// beim Uebernehmen je Berater eine Opportunity.
const consultantCount = splitConsultants(entry.consultant).length;
return (
<tr
@@ -212,13 +216,19 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
)}
</td>
<td className="px-4 py-3 text-sm text-ink-700">{entry.consultant || '—'}</td>
<td className="px-4 py-3 text-sm text-ink-700">
<ConsultantList value={entry.consultant} />
</td>
<td className="px-4 py-3">
<button
onClick={() => onConvert(entry)}
disabled={isBusy}
title="Als neue Opportunity in der Phase „Erstgespräch Berater“ anlegen. Der Eintrag verschwindet danach aus dieser Liste."
title={
consultantCount > 1
? `Je Berater eine Opportunity in der Phase „Erstgespräch Berater“ anlegen - ${consultantCount} insgesamt. Der Eintrag verschwindet danach aus dieser Liste.`
: 'Als neue Opportunity in der Phase „Erstgespräch Berater“ anlegen. Der Eintrag verschwindet danach aus dieser Liste.'
}
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-50"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -4,6 +4,7 @@ import { UserAvatar } from '../ui/UserAvatar';
import { useToast } from '../Toast';
import { acquisitionsApi } from '../../lib/api';
import { formatEuro, type Acquisition } from '../../types';
import { ConsultantList } from './acquisitionUi';
/*
* Papierkorb der Akquise.
@@ -227,7 +228,9 @@ export const AcquisitionTrash: React.FC<AcquisitionTrashProps> = ({ reload }) =>
<td className="px-4 py-3 text-sm font-bold text-ink-800">
{entry.provider || '—'}
</td>
<td className="px-4 py-3 text-sm text-ink-700">{entry.consultant || '—'}</td>
<td className="px-4 py-3 text-sm text-ink-700">
<ConsultantList value={entry.consultant} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm tabular-nums text-ink-700">
{formatFound(entry.found_at)}
</td>
+9 -2
View File
@@ -131,8 +131,15 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
const convert = async (entry: Acquisition) => {
setBusyId(entry.id);
try {
await acquisitionsApi.toOpportunity(entry.id);
addToast(`Opportunity „${entry.title}" angelegt - Phase „Erstgespräch Berater".`, 'success');
const result = await acquisitionsApi.toOpportunity(entry.id);
// Bei mehreren Beratern entsteht je Berater eine Opportunity - das muss in
// der Meldung stehen, sonst wundert man sich ueber drei neue Eintraege.
addToast(
result.opportunity_ids.length > 1
? `${result.opportunity_ids.length} Opportunities zu „${entry.title}" angelegt - je Berater eine, Phase „Erstgespräch Berater".`
: `Opportunity „${entry.title}" angelegt - Phase „Erstgespräch Berater".`,
'success'
);
if (editing?.id === entry.id) setEditing(null);
await load(true);
// Die Opportunity-Liste der App kennt den neuen Eintrag noch nicht.
+136 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { labelClass } from '../leads/leadUi';
import type { AcquisitionOption } from '../../types';
import { joinConsultants, splitConsultants, type AcquisitionOption } from '../../types';
/*
* Gemeinsame Bausteine der Akquise.
@@ -85,3 +85,138 @@ export const OptionSelect: React.FC<OptionSelectProps> = ({
</div>
);
};
interface ConsultantSelectProps {
id: string;
label: string;
/** Kommagetrennte Liste, genau so wie sie gespeichert wird. */
value: string;
options: AcquisitionOption[];
onChange: (value: string) => void;
/** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */
onOpenSettings: () => void;
}
/**
* Auswahl mehrerer Berater. Ein Fund passt oft zu mehr als einem Berater, und
* fuer jeden geht eine eigene Bewerbung heraus.
*
* Bewusst kein `<select multiple>` und kein eigenes Aufklappmenue: Mehrfachwahl
* per Strg-Klick ist mit der Maus unauffindbar und mit einer Berührung gar nicht
* bedienbar, ein selbstgebautes Menue braucht Klick-nach-draussen, Fokusfalle
* und Tastaturpfeile. Stattdessen zwei bekannte Bausteine: ein normales
* Auswahlfeld, das jeweils einen Namen hinzufuegt, und die Gewaehlten als
* Plaketten mit einem Kreuz zum Entfernen. Ein bereits gewaehlter Name steht
* nicht mehr in der Liste - damit ist das Feld immer "der naechste Berater".
*/
export const ConsultantSelect: React.FC<ConsultantSelectProps> = ({
id,
label,
value,
options,
onChange,
onOpenSettings,
}) => {
const selected = splitConsultants(value);
const chosen = new Set(selected.map((name) => name.toLowerCase()));
// Was noch zu haben ist. Ein Wert aus einem alten Eintrag, den die Liste nicht
// mehr kennt, bleibt als Plakette stehen - nur nachwaehlen kann man ihn nicht.
const open = options.filter((option) => !chosen.has(option.value.toLowerCase()));
const add = (name: string) => {
if (name === '') return;
onChange(joinConsultants([...selected, name]));
};
const remove = (name: string) => {
onChange(joinConsultants(selected.filter((entry) => entry !== name)));
};
return (
<div>
<label className={labelClass} htmlFor={id}>
{label}
{selected.length > 1 && (
<span className="ml-1.5 font-bold normal-case tracking-normal text-ink-400">
{selected.length} ausgewählt
</span>
)}
</label>
{/* Das Auswahlfeld zeigt nie einen Wert an: es fuegt hinzu und springt
zurueck auf die Aufforderung. Der Stand steht in den Plaketten. */}
<select
id={id}
value=""
onChange={(event) => add(event.target.value)}
className={fieldClass}
>
<option value="">{selected.length > 0 ? 'weiteren hinzufügen …' : '—'}</option>
{open.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
</select>
{selected.length > 0 && (
<ul className="mt-2 flex flex-wrap gap-1.5">
{selected.map((name) => (
<li key={name}>
<span className="inline-flex items-center gap-1 rounded-lg bg-brand-500/10 py-1 pl-2.5 pr-1 text-xs font-bold text-brand-700">
{name}
<button
type="button"
onClick={() => remove(name)}
aria-label={`${name} entfernen`}
title={`${name} entfernen`}
className="rounded px-1 text-brand-700/60 transition hover:bg-brand-500/20 hover:text-brand-700"
>
✕
</button>
</span>
</li>
))}
</ul>
)}
{options.length === 0 && (
<p className="mt-1.5 text-[11px] text-ink-400">
Liste ist leer -{' '}
<button type="button" onClick={onOpenSettings} className="font-bold underline">
unter Einstellungen pflegen
</button>
</p>
)}
{options.length > 0 && open.length === 0 && (
<p className="mt-1.5 text-[11px] text-ink-400">Alle Berater aus der Liste sind gewählt.</p>
)}
</div>
);
};
/**
* Die Berater eines Eintrags in der Liste. Ein einzelner Name bleibt schlichter
* Text - so sah die Spalte immer aus. Erst bei mehreren werden Plaketten
* daraus: in einer schmalen Spalte laesst sich "Nico, Ariane Wagner, Lisa"
* sonst nicht auf einen Blick trennen.
*/
export const ConsultantList: React.FC<{ value: string | null }> = ({ value }) => {
const names = splitConsultants(value);
if (names.length === 0) return <>—</>;
if (names.length === 1) return <>{names[0]}</>;
return (
<span className="flex flex-wrap gap-1">
{names.map((name) => (
<span
key={name}
className="rounded-md bg-ink-100 px-1.5 py-0.5 text-xs font-bold text-ink-600"
>
{name}
</span>
))}
</span>
);
};
+12 -2
View File
@@ -319,9 +319,19 @@ export const acquisitionsApi = {
emptyTrash: () =>
request<{ ok: true; purged: number }>('/acquisitions/trash/empty', { method: 'POST' }),
/** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
/**
* Legt die Opportunity an und vermerkt den Eintrag als uebernommen. Stehen
* mehrere Berater im Eintrag, entsteht je Berater eine - `opportunity_ids`
* zaehlt alle auf, `opportunity_id` ist die erste (die der Eintrag sich
* merkt).
*/
toOpportunity: (id: number) =>
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {
request<{
ok: true;
opportunity_id: number;
opportunity_ids: number[];
consultants: string[];
}>(`/acquisitions/${id}/opportunity`, {
method: 'POST',
}),
};
+39 -1
View File
@@ -333,6 +333,41 @@ export interface AcquisitionOption {
value: string;
}
/*
* "Berater" nimmt mehrere Namen auf - ein Fund passt oft zu mehr als einem, und
* fuer jeden geht eine Bewerbung heraus. Gespeichert wird eine kommagetrennte
* Liste in einem Feld; das Gegenstueck im Backend steht in
* server/src/acquisitionRules.js. Wer hier etwas aendert, muss dort mitziehen.
*/
/** Trennzeichen der gespeicherten Liste - mit Leerzeichen, das ist auch die Anzeige. */
export const CONSULTANT_SEPARATOR = ', ';
/**
* Zerlegt das Feld in einzelne Namen. Leere Stuecke und Dubletten fallen weg -
* verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung, erhalten
* bleibt die erste Schreibweise.
*/
export function splitConsultants(value: string | null | undefined): string[] {
if (!value) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const part of value.split(',')) {
const name = part.trim();
if (name === '') continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
names.push(name);
}
return names;
}
/** Setzt die Namen wieder zur gespeicherten Form zusammen. */
export function joinConsultants(names: string[]): string {
return names.filter((name) => name.trim() !== '').join(CONSULTANT_SEPARATOR);
}
export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOption[]>;
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
@@ -357,7 +392,10 @@ export interface Acquisition {
provider: string | null;
channel: string | null;
title: string;
/** "Berater", fuer den es passt. */
/**
* "Berater", fuer den es passt - bei mehreren eine kommagetrennte Liste
* (`splitConsultants`).
*/
consultant: string | null;
/** Ausschreibung; ist er gesetzt, wird der Titel in der Liste klickbar. */
link: string | null;