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>
522 lines
19 KiB
TypeScript
522 lines
19 KiB
TypeScript
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>
|
||
);
|
||
};
|