feat(akquise): gefundene Projekte als Eingang vor den Opportunities

Neuer Menuepunkt ueber den Opportunities, mit dem Unterpunkt "Archiv". Die
Liste traegt genau die Spalten, die beim Durchgehen zaehlen: Datum, Von,
Anbieter, Kanal, Projekttitel, Berater, ein Knopf fuer die Uebernahme und die
Symbole fuer Bearbeiten und Loeschen.

Eingabe steht ueber der Liste und ist bewusst kein Dialog - Projekte kommen in
Serie herein. Nach dem Eintragen bleiben Datum, Anbieter, Kanal und Berater
stehen, damit drei Projekte aus einer Mail nicht dreimal dieselben Angaben
kosten. Von, Anbieter und Berater schlagen frueher benutzte Werte vor. Was in
der Liste keine Spalte hat, aber in der Opportunity vorkommt (Endkunde,
Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum, naechster Schritt,
Notizen), klappt darunter auf.

Eingang, Archiv und Loeschung ergeben sich aus dem Funddatum - 14 bzw. 90 Tage -
und aus converted_at. Es gibt keine Zustandsspalte, die ein Job pflegen muesste
und die von der Wirklichkeit abweichen kann. Der Loeschlauf raeumt beim Abruf
der Liste, hoechstens einmal pro Stunde; die Abfragen filtern Faelliges ohnehin
heraus.

Die Uebernahme legt Opportunity und Merker in einer Transaktion an. Anbieter,
Von, Kanal, Berater und Link haben in der Opportunity kein Feld und wandern als
Herkunftsvermerk in die Notizen.

Der Titel wird nur dann zum Link, wenn eine http(s)-Adresse hinterlegt ist:
"hays.de/projekt/1" ergaenzt das Backend zu https://, und die Anzeige laesst
nichts anderes durch als http und https.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 11:56:58 +02:00
co-authored by Claude Opus 5
parent 8741a9ae0c
commit b8a78f609c
16 changed files with 1833 additions and 7 deletions
@@ -0,0 +1,508 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { DictateTextarea } from '../ui/DictationField';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import { acquisitionsApi, type AcquisitionInput } from '../../lib/api';
import {
ACQUISITION_CHANNELS,
formatEuro,
type Acquisition,
type AcquisitionChannel,
} 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;
suggestions: { providers: string[]; consultants: string[]; senders: 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('.', ',');
function toFormState(entry: Acquisition | null): FormState {
return {
found_at: entry?.found_at?.slice(0, 10) ?? today(),
sender: entry?.sender ?? '',
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 ?? '',
};
}
/** Freitextfeld mit Vorschlagsliste aus den bisherigen Eintraegen. */
const SuggestInput: React.FC<{
id: string;
label: string;
value: string;
options: string[];
placeholder?: string;
onChange: (value: string) => void;
}> = ({ id, label, value, options, placeholder, onChange }) => (
<div>
<label className={labelClass} htmlFor={id}>
{label}
</label>
<input
id={id}
list={`${id}-vorschlaege`}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={inputClass}
/>
<datalist id={`${id}-vorschlaege`}>
{options.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</div>
);
export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
editing,
suggestions,
onSaved,
onCancelEdit,
}) => {
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(() => toFormState(editing));
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));
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();
}
}, [editing]);
// 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 as AcquisitionChannel) || 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={inputClass}
/>
</div>
<SuggestInput
id="acq-sender"
label="Von"
value={form.sender}
options={suggestions.senders}
placeholder="Wer hat es geschickt?"
onChange={(value) => set('sender', value)}
/>
<SuggestInput
id="acq-provider"
label="Anbieter"
value={form.provider}
options={suggestions.providers}
placeholder="z. B. Hays"
onChange={(value) => set('provider', value)}
/>
<div>
<label className={labelClass} htmlFor="acq-channel">
Kanal
</label>
<select
id="acq-channel"
value={form.channel}
onChange={(event) => set('channel', event.target.value)}
className={inputClass}
>
<option value="">—</option>
{ACQUISITION_CHANNELS.map((channel) => (
<option key={channel} value={channel}>
{channel}
</option>
))}
</select>
</div>
</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={inputClass}
/>
</div>
<SuggestInput
id="acq-consultant"
label="Berater"
value={form.consultant}
options={suggestions.consultants}
placeholder="für wen?"
onChange={(value) => set('consultant', value)}
/>
<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={inputClass}
/>
</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={inputClass}
/>
</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={inputClass}
/>
</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={inputClass}
/>
</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={inputClass}
/>
</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={inputClass}
/>
</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={inputClass}
/>
</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={inputClass}
/>
</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>
);
};