Files
perinco-CRM/src/components/acquisition/AcquisitionForm.tsx
T
Sascha WolfandClaude Opus 5 62dce64739 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>
2026-09-09 10:35:43 +02:00

522 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { DictateTextarea } from '../ui/DictationField';
import { ConsultantSelect, OptionSelect, fieldClass } from './acquisitionUi';
import { labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth';
import { useNavigation } from '../../lib/navigation';
import { acquisitionsApi, type AcquisitionInput } from '../../lib/api';
import {
ACQUISITION_FIELD_LABELS,
formatEuro,
type Acquisition,
type AcquisitionOptions,
} from '../../types';
/*
* Eingabe der Akquise, direkt ueber der Liste. Bewusst kein Dialog: Projekte
* kommen in Serie herein, und ein Fenster, das nach jedem Eintrag zugeht und
* wieder aufgemacht werden muss, kostet bei zehn Projekten zwanzig Klicks.
*
* Die Pflichtangaben stehen in einer Zeile, alles Weitere klappt darunter auf.
* Das Aufklappbare sind genau die Felder, die spaeter in der Opportunity stehen
* und in der Liste keine Spalte haben.
*/
interface AcquisitionFormProps {
/** Gesetzt = Bearbeiten, sonst Neuanlage. */
editing: Acquisition | null;
/** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */
options: AcquisitionOptions;
/** Schon benutzte Anbieter - das Feld bleibt Freitext. */
providerSuggestions: string[];
onSaved: () => void;
onCancelEdit: () => void;
}
interface FormState {
found_at: string;
sender: string;
provider: string;
channel: string;
title: string;
consultant: string;
link: string;
company: string;
description: string;
daily_rate: string;
duration_days: string;
fixed_price: string;
start_date: string;
end_date: string;
next_step: string;
notes: string;
}
const today = () => new Date().toISOString().slice(0, 10);
/** Zahlen kommen als String ins Formular, damit leere Felder leer bleiben. */
const numberToInput = (value: number | null) =>
value === null ? '' : String(value).replace('.', ',');
/**
* `ownName` ist der Name des angemeldeten Benutzers und belegt "Von" vor: in der
* Regel traegt jeder seine eigenen Funde ein. Wer fuer jemand anderen eintraegt,
* waehlt ihn in der Liste aus. Beim Bearbeiten gilt der gespeicherte Wert - der
* Eintrag wuerde sonst beim Oeffnen den Finder wechseln.
*/
function toFormState(entry: Acquisition | null, ownName: string): FormState {
return {
found_at: entry?.found_at?.slice(0, 10) ?? today(),
sender: entry?.sender ?? ownName,
provider: entry?.provider ?? '',
channel: entry?.channel ?? '',
title: entry?.title ?? '',
consultant: entry?.consultant ?? '',
link: entry?.link ?? '',
company: entry?.company ?? '',
description: entry?.description ?? '',
daily_rate: numberToInput(entry?.daily_rate ?? null),
duration_days: numberToInput(entry?.duration_days ?? null),
fixed_price: numberToInput(entry?.fixed_price ?? null),
start_date: entry?.start_date?.slice(0, 10) ?? '',
end_date: entry?.end_date?.slice(0, 10) ?? '',
next_step: entry?.next_step ?? '',
notes: entry?.notes ?? '',
};
}
export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
editing,
options,
providerSuggestions,
onSaved,
onCancelEdit,
}) => {
const { addToast } = useToast();
const { user } = useAuth();
const { goTo } = useNavigation();
const openSettings = () => goTo('Einstellungen', { tab: 'akquise' });
/*
* Steht der eigene Name schon in der Liste, gilt deren Schreibweise - sonst
* haette man "Markus" aus der Liste und "Markus Meier" vom Konto als zwei
* Eintraege in derselben Spalte.
*/
const ownName = useMemo(() => {
const name = user?.name?.trim() ?? '';
if (!name) return '';
const listed = options.sender.find(
(option) => option.value.toLowerCase() === name.toLowerCase()
);
return listed?.value ?? name;
}, [user?.name, options.sender]);
const [form, setForm] = useState<FormState>(() => toFormState(editing, ownName));
const [showMore, setShowMore] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
/*
* Beim Wechsel in den Bearbeitungsmodus das Formular fuellen, aufklappen, wenn
* dort Angaben stehen, und hinscrollen - der Stift steht weit unten in der
* Liste, das Formular oben.
*/
useEffect(() => {
setForm(toFormState(editing, ownName));
if (editing) {
const hasExtras = Boolean(
editing.company ||
editing.description ||
editing.daily_rate !== null ||
editing.duration_days !== null ||
editing.fixed_price !== null ||
editing.start_date ||
editing.end_date ||
editing.next_step ||
editing.notes
);
setShowMore(hasExtras);
rootRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
titleRef.current?.focus();
}
// `ownName` steht erst, wenn die Auswahllisten geladen sind - deshalb haengt
// der Effekt daran. Ein angefangenes Formular wird dabei zurueckgesetzt; das
// passiert nur direkt beim Oeffnen der Ansicht.
}, [editing, ownName]);
// Volumen mitrechnen wie in der Opportunity: Festpreis schlaegt Tagessatz.
const volume = useMemo(() => {
const toNumber = (value: string) => Number(value.replace(',', '.'));
if (form.fixed_price.trim() !== '') {
const fixed = toNumber(form.fixed_price);
return Number.isFinite(fixed) ? fixed : null;
}
if (form.daily_rate.trim() === '' || form.duration_days.trim() === '') return null;
const rate = toNumber(form.daily_rate);
const days = toNumber(form.duration_days);
return Number.isFinite(rate) && Number.isFinite(days) ? rate * days : null;
}, [form.daily_rate, form.duration_days, form.fixed_price]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!form.title.trim()) {
addToast('Ein Projekttitel ist erforderlich.', 'error');
return;
}
if (form.start_date && form.end_date && form.end_date < form.start_date) {
addToast('Das Projektende darf nicht vor dem Start liegen.', 'error');
return;
}
const payload: AcquisitionInput = {
found_at: form.found_at || today(),
sender: form.sender.trim() || null,
provider: form.provider.trim() || null,
channel: form.channel || null,
title: form.title.trim(),
consultant: form.consultant.trim() || null,
link: form.link.trim() || null,
company: form.company.trim() || null,
description: form.description.trim() || null,
daily_rate: form.daily_rate.trim() === '' ? null : form.daily_rate.replace(',', '.'),
duration_days: form.duration_days.trim() === '' ? null : form.duration_days.replace(',', '.'),
fixed_price: form.fixed_price.trim() === '' ? null : form.fixed_price.replace(',', '.'),
start_date: form.start_date || null,
end_date: form.end_date || null,
next_step: form.next_step.trim() || null,
notes: form.notes.trim() || null,
};
setIsSaving(true);
try {
if (editing) {
await acquisitionsApi.update(editing.id, payload);
addToast('Eintrag aktualisiert.', 'success');
onCancelEdit();
} else {
await acquisitionsApi.create(payload);
addToast('Projekt eingetragen.', 'success');
/*
* Datum, Anbieter und Kanal bleiben stehen: wer eine Mail mit drei
* Projekten abarbeitet, aendert nur Titel und Berater. Zurueckgesetzt
* wird, was je Projekt anders ist.
*/
setForm((prev) => ({
...prev,
title: '',
link: '',
company: '',
description: '',
daily_rate: '',
duration_days: '',
fixed_price: '',
start_date: '',
end_date: '',
next_step: '',
notes: '',
}));
titleRef.current?.focus();
}
onSaved();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
return (
<section
ref={rootRef}
className={`rounded-2xl border bg-surface p-5 shadow-card ${
editing ? 'border-brand-300 ring-2 ring-brand-500/20' : 'border-ink-200'
}`}
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base text-ink-900">
{editing ? 'Eintrag bearbeiten' : 'Projekt eintragen'}
</h3>
<p className="mt-0.5 text-xs text-ink-400">
{editing
? `Angelegt von ${editing.created_by_name ?? 'unbekannt'}`
: 'Datum, Anbieter und Kanal bleiben nach dem Eintragen stehen - für mehrere Projekte aus derselben Quelle.'}
</p>
</div>
{editing && (
<button
type="button"
onClick={onCancelEdit}
className="rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label className={labelClass} htmlFor="acq-found">
Datum
</label>
<input
id="acq-found"
type="date"
value={form.found_at}
onChange={(event) => set('found_at', event.target.value)}
className={fieldClass}
/>
</div>
{/* Vorbelegt mit dem angemeldeten Benutzer; fuer jemand anderen wird er
in der Liste gewechselt. */}
<OptionSelect
id="acq-sender"
label={ACQUISITION_FIELD_LABELS.sender}
value={form.sender}
options={options.sender}
onChange={(value) => set('sender', value)}
onOpenSettings={openSettings}
alwaysOffer={ownName}
/>
{/* Anbieter bleibt Freitext: neue Vermittler tauchen zu oft auf, als dass
das Eintragen an einer gepflegten Liste haengen sollte. Die
Vorschlagsliste erspart das Ausschreiben bekannter Namen. */}
<div>
<label className={labelClass} htmlFor="acq-provider">
Anbieter
</label>
<input
id="acq-provider"
list="acq-provider-vorschlaege"
value={form.provider}
onChange={(event) => set('provider', event.target.value)}
placeholder="z. B. Hays"
className={fieldClass}
/>
<datalist id="acq-provider-vorschlaege">
{providerSuggestions.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</div>
<OptionSelect
id="acq-channel"
label={ACQUISITION_FIELD_LABELS.channel}
value={form.channel}
options={options.channel}
onChange={(value) => set('channel', value)}
onOpenSettings={openSettings}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_2fr]">
<div>
<label className={labelClass} htmlFor="acq-title">
Projekttitel *
</label>
<input
id="acq-title"
ref={titleRef}
required
value={form.title}
onChange={(event) => set('title', event.target.value)}
placeholder="z. B. SAP FI Rollout Süddeutschland"
className={fieldClass}
/>
</div>
{/* 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}
options={options.consultant}
onChange={(value) => set('consultant', value)}
onOpenSettings={openSettings}
/>
<div>
<label className={labelClass} htmlFor="acq-link">
Link
</label>
{/* Absichtlich kein type="url": das Backend ergaenzt ein fehlendes
https:// selbst, die Browserpruefung wuerde "hays.de/projekt/1"
aber ohne Meldung abweisen und das Formular blockieren. */}
<input
id="acq-link"
inputMode="url"
value={form.link}
onChange={(event) => set('link', event.target.value)}
placeholder="hays.de/projekt/1 - macht den Titel in der Liste klickbar"
className={fieldClass}
/>
</div>
</div>
{/* Alles, was in der Liste keine Spalte hat, aber in die Opportunity wandert. */}
<div>
<button
type="button"
onClick={() => setShowMore((open) => !open)}
aria-expanded={showMore}
className="flex items-center gap-2 text-xs font-extrabold uppercase tracking-wider text-ink-500 transition hover:text-brand-700"
>
<svg
className={`h-4 w-4 transition-transform ${showMore ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Projektdaten für die Opportunity
</button>
{showMore && (
<div className="mt-4 space-y-4 rounded-xl border border-ink-200 bg-ink-50/50 p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className={labelClass} htmlFor="acq-company">
Endkunde
</label>
<input
id="acq-company"
value={form.company}
onChange={(event) => set('company', event.target.value)}
placeholder="Firma hinter dem Projekt"
className={fieldClass}
/>
</div>
<div>
<label className={labelClass} htmlFor="acq-start">
Projektstart
</label>
<input
id="acq-start"
type="date"
value={form.start_date}
onChange={(event) => set('start_date', event.target.value)}
className={fieldClass}
/>
</div>
<div>
<label className={labelClass} htmlFor="acq-end">
Projektende
</label>
<input
id="acq-end"
type="date"
value={form.end_date}
onChange={(event) => set('end_date', event.target.value)}
className={fieldClass}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className={labelClass} htmlFor="acq-rate">
Tagessatz (EUR)
</label>
<input
id="acq-rate"
inputMode="decimal"
value={form.daily_rate}
onChange={(event) => set('daily_rate', event.target.value)}
placeholder="1150"
className={fieldClass}
/>
</div>
<div>
<label className={labelClass} htmlFor="acq-duration">
Laufzeit (Personentage)
</label>
<input
id="acq-duration"
inputMode="decimal"
value={form.duration_days}
onChange={(event) => set('duration_days', event.target.value)}
placeholder="40"
className={fieldClass}
/>
</div>
<div>
<label className={labelClass} htmlFor="acq-fixed">
Projektpreis (EUR)
</label>
<input
id="acq-fixed"
inputMode="decimal"
value={form.fixed_price}
onChange={(event) => set('fixed_price', event.target.value)}
placeholder="Festpreis statt Tagessatz × Laufzeit"
className={fieldClass}
/>
</div>
</div>
{volume !== null && (
<p className="text-xs font-bold text-ink-500">
Volumen: <span className="text-ink-900">{formatEuro(volume)}</span>
{form.fixed_price.trim() !== '' ? ' (Festpreis)' : ' (Tagessatz × Laufzeit)'}
</p>
)}
<DictateTextarea
id="acq-description"
label="Projektbeschreibung"
value={form.description}
onChange={(value) => set('description', value)}
rows={4}
placeholder="Aufgabe, Umfang, Rahmenbedingungen - wandert in die Opportunity."
/>
<div>
<label className={labelClass} htmlFor="acq-next">
Nächster Schritt
</label>
<input
id="acq-next"
value={form.next_step}
onChange={(event) => set('next_step', event.target.value)}
placeholder="z. B. Profil bis Donnerstag schicken"
className={fieldClass}
/>
</div>
<DictateTextarea
id="acq-notes"
label="Interne Notizen"
value={form.notes}
onChange={(value) => set('notes', value)}
rows={3}
placeholder="Was sonst noch wichtig ist."
/>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={isSaving}
className="rounded-xl bg-brand-500 px-6 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
{isSaving ? 'Speichere...' : editing ? 'Speichern' : 'Eintragen'}
</button>
{!editing && (
<span className="text-xs text-ink-400">
Nur der Projekttitel ist Pflicht - alles andere lässt sich später ergänzen.
</span>
)}
</div>
</form>
</section>
);
};