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:
co-authored by
Claude Opus 5
parent
a866cd9a52
commit
62dce64739
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user