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 = ({ 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(() => toFormState(editing, ownName)); const [showMore, setShowMore] = useState(false); const [isSaving, setIsSaving] = useState(false); const titleRef = useRef(null); const rootRef = useRef(null); const set = (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 (

{editing ? 'Eintrag bearbeiten' : 'Projekt eintragen'}

{editing ? `Angelegt von ${editing.created_by_name ?? 'unbekannt'}` : 'Datum, Anbieter und Kanal bleiben nach dem Eintragen stehen - für mehrere Projekte aus derselben Quelle.'}

{editing && ( )}
set('found_at', event.target.value)} className={fieldClass} />
{/* Vorbelegt mit dem angemeldeten Benutzer; fuer jemand anderen wird er in der Liste gewechselt. */} 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. */}
set('provider', event.target.value)} placeholder="z. B. Hays" className={fieldClass} /> {providerSuggestions.map((option) => (
set('channel', value)} onOpenSettings={openSettings} />
set('title', event.target.value)} placeholder="z. B. SAP FI Rollout Süddeutschland" className={fieldClass} />
{/* Mehrfachauswahl: ein Projekt passt oft zu mehreren Beratern, und beim Uebernehmen entsteht dann je Berater eine Opportunity. */} set('consultant', value)} onOpenSettings={openSettings} />
{/* 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. */} set('link', event.target.value)} placeholder="hays.de/projekt/1 - macht den Titel in der Liste klickbar" className={fieldClass} />
{/* Alles, was in der Liste keine Spalte hat, aber in die Opportunity wandert. */}
{showMore && (
set('company', event.target.value)} placeholder="Firma hinter dem Projekt" className={fieldClass} />
set('start_date', event.target.value)} className={fieldClass} />
set('end_date', event.target.value)} className={fieldClass} />
set('daily_rate', event.target.value)} placeholder="1150" className={fieldClass} />
set('duration_days', event.target.value)} placeholder="40" className={fieldClass} />
set('fixed_price', event.target.value)} placeholder="Festpreis statt Tagessatz × Laufzeit" className={fieldClass} />
{volume !== null && (

Volumen: {formatEuro(volume)} {form.fixed_price.trim() !== '' ? ' (Festpreis)' : ' (Tagessatz × Laufzeit)'}

)} set('description', value)} rows={4} placeholder="Aufgabe, Umfang, Rahmenbedingungen - wandert in die Opportunity." />
set('next_step', event.target.value)} placeholder="z. B. Profil bis Donnerstag schicken" className={fieldClass} />
set('notes', value)} rows={3} placeholder="Was sonst noch wichtig ist." />
)}
{!editing && ( Nur der Projekttitel ist Pflicht - alles andere lässt sich später ergänzen. )}
); };