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:
co-authored by
Claude Opus 5
parent
8741a9ae0c
commit
b8a78f609c
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user