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
+7
View File
@@ -4,6 +4,7 @@ import { Header } from './components/Header';
import { LoginScreen } from './components/LoginScreen';
import { Leads } from './components/leads/Leads';
import { MailPoolLeads } from './components/leads/MailPoolLeads';
import { Acquisitions } from './components/acquisition/Acquisitions';
import { Opportunities } from './components/opportunities/Opportunities';
import { Calendar } from './components/calendar/Calendar';
import { parseSqlDateTime } from './components/calendar/calendarUtils';
@@ -138,6 +139,8 @@ const App: React.FC = () => {
// Untertitel der Kopfzeile. Die Scanner-Reiter bringen ihre Kennzahlen selbst
// mit, deshalb steht hier nur, worum es auf der Seite geht.
const STATIC_SUBTITLES: Partial<Record<ViewState, string>> = {
Akquise: 'Gefundene Projekte - eintragen, prüfen, zur Opportunity machen',
Archiv: 'Akquise-Einträge älter als 14 Tage - nach 90 Tagen werden sie gelöscht',
Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen',
Papierkorb: 'Im CRM gelöschte Mails - im Postfach crm@perinco.de bleiben sie erhalten',
Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen',
@@ -185,6 +188,10 @@ const App: React.FC = () => {
/>
<div className="scrollbar-slim flex-1 overflow-y-auto p-4 md:p-6">
{currentView === 'Akquise' && <Acquisitions reload={() => loadData(true)} />}
{currentView === 'Archiv' && (
<Acquisitions view="archiv" reload={() => loadData(true)} />
)}
{currentView === 'Opportunities' && (
<Opportunities
opportunities={opportunities}
+21 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import { ACQUISITION_VIEWS, CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import { UserAvatar } from './ui/UserAvatar';
import { useAuth } from '../lib/auth';
@@ -51,6 +51,25 @@ const CONTACTS_GROUP: MenuGroup = {
],
};
const ACQUISITION_ICON =
'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01';
const ARCHIVE_ICON =
'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4';
/**
* Akquise. Der Eingang zeigt die Funde der letzten 14 Tage, darunter klappt das
* Archiv mit allem Aelteren auf. Beides ist dieselbe Liste - geteilt wird nach
* dem Funddatum, nicht durch Verschieben.
*/
const ACQUISITION_GROUP: MenuGroup = {
item: { name: 'Akquise', icon: ACQUISITION_ICON },
views: ACQUISITION_VIEWS,
children: [
{ name: 'Akquise', label: 'Eingang', icon: ACQUISITION_ICON },
{ name: 'Archiv', icon: ARCHIVE_ICON },
],
};
const TRASH_ICON =
'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16';
@@ -99,6 +118,7 @@ const SCANNER_GROUP: MenuGroup = {
// Kategorien in Anzeigereihenfolge, einzelne Punkte und Gruppen gemischt.
// Einstellungen bleibt separat am unteren Rand.
const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [
ACQUISITION_GROUP,
{
name: 'Opportunities',
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
@@ -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>
);
};
@@ -0,0 +1,245 @@
import React from 'react';
import { formatEuro, type Acquisition } from '../../types';
/*
* Die Liste der gefundenen Projekte. Genau die Spalten, die beim Durchgehen
* zaehlen - alles Weitere steht im Formular und in der Opportunity.
*
* Der Titel ist ein Link, sobald einer hinterlegt ist. Ohne Link bleibt er
* einfacher Text: ein Link, der nirgends hinfuehrt, ist schlimmer als keiner.
*/
export type AcquisitionSortKey = 'found_at' | 'sender' | 'provider' | 'channel' | 'title' | 'consultant';
export interface AcquisitionSortConfig {
key: AcquisitionSortKey;
direction: 'asc' | 'desc';
}
interface AcquisitionTableProps {
entries: Acquisition[];
sortConfig: AcquisitionSortConfig;
onSort: (key: AcquisitionSortKey) => void;
editingId: number | null;
busyId: number | null;
onEdit: (entry: Acquisition) => void;
onDelete: (entry: Acquisition) => void;
onConvert: (entry: Acquisition) => void;
}
const COLUMNS: { key: AcquisitionSortKey | null; label: string; className?: string }[] = [
{ key: 'found_at', label: 'Datum' },
{ key: 'sender', label: 'Von' },
{ key: 'provider', label: 'Anbieter' },
{ key: 'channel', label: 'Kanal' },
{ key: 'title', label: 'Projekttitel' },
{ key: 'consultant', label: 'Berater' },
{ key: null, label: 'Opportunity' },
{ key: null, label: '', className: 'text-right' },
];
/** Farbe je Kanal, damit sich die Liste beim Durchsehen sortiert anfuehlt. */
const CHANNEL_STYLES: Record<string, string> = {
Mail: 'bg-sky-50 text-sky-700 border-sky-200',
Telefon: 'bg-amber-50 text-amber-700 border-amber-200',
Portal: 'bg-violet-50 text-violet-700 border-violet-200',
LinkedIn: 'bg-blue-50 text-blue-700 border-blue-200',
XING: 'bg-teal-50 text-teal-700 border-teal-200',
Empfehlung: 'bg-brand-50 text-brand-700 border-brand-200',
Sonstiges: 'bg-ink-100 text-ink-600 border-ink-200',
};
/** Kurzform des Funddatums plus Alter in Tagen als Titelhinweis. */
function formatFound(value: string): { label: string; title: string } {
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
if (Number.isNaN(date.getTime())) return { label: '—', title: '' };
const days = Math.round((Date.now() - date.getTime()) / 86_400_000);
const age = days <= 0 ? 'heute' : days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`;
return {
label: date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }),
title: `Gefunden ${age}`,
};
}
/**
* Nur absolute http- und https-Adressen werden zum Link. Das Backend legt
* ohnehin nur solche ab; die Pruefung hier faengt Altbestand und Eintraege ab,
* die auf anderem Weg in die Tabelle gekommen sind. "hays.de/projekt/1" wuerde
* der Browser sonst als Pfad innerhalb des CRM auflegen und ins Leere fuehren.
*/
function safeLink(value: string | null): string | null {
if (!value) return null;
return /^https?:\/\//i.test(value) ? value : null;
}
/** Kurze Zusammenfassung der Angaben, die keine eigene Spalte haben. */
function extras(entry: Acquisition): string[] {
const parts: string[] = [];
if (entry.company) parts.push(entry.company);
if (entry.fixed_price !== null) parts.push(`${formatEuro(entry.fixed_price)} Festpreis`);
else if (entry.daily_rate !== null) parts.push(`${formatEuro(entry.daily_rate)}/Tag`);
if (entry.duration_days !== null) parts.push(`${entry.duration_days} PT`);
return parts;
}
const SortIcon: React.FC<{ active: boolean; direction: 'asc' | 'desc' }> = ({ active, direction }) => (
<svg
className={`h-3 w-3 shrink-0 transition ${active ? 'text-brand-600' : 'text-ink-300'} ${
active && direction === 'asc' ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
</svg>
);
export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
entries,
sortConfig,
onSort,
editingId,
busyId,
onEdit,
onDelete,
onConvert,
}) => (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[1000px] text-left">
<thead className="border-b border-ink-200 bg-ink-50">
<tr>
{COLUMNS.map((column) => (
<th
key={column.label || 'aktionen'}
className={`px-4 py-3 text-[10px] font-extrabold uppercase tracking-wider text-ink-500 ${
column.className ?? ''
}`}
>
{column.key ? (
<button
onClick={() => onSort(column.key!)}
className="flex items-center gap-1.5 transition hover:text-brand-700"
>
{column.label}
<SortIcon active={sortConfig.key === column.key} direction={sortConfig.direction} />
</button>
) : (
column.label
)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{entries.map((entry) => {
const found = formatFound(entry.found_at);
const link = safeLink(entry.link);
const extraParts = extras(entry);
const isBusy = busyId === entry.id;
return (
<tr
key={entry.id}
className={`transition ${editingId === entry.id ? 'bg-brand-50' : 'hover:bg-ink-50'}`}
>
<td className="whitespace-nowrap px-4 py-3 text-sm tabular-nums text-ink-700" title={found.title}>
{found.label}
</td>
<td className="px-4 py-3 text-sm text-ink-700">{entry.sender || '—'}</td>
<td className="px-4 py-3 text-sm font-bold text-ink-800">{entry.provider || '—'}</td>
<td className="px-4 py-3">
{entry.channel ? (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${
CHANNEL_STYLES[entry.channel] ?? CHANNEL_STYLES.Sonstiges
}`}
>
{entry.channel}
</span>
) : (
<span className="text-sm text-ink-400">—</span>
)}
</td>
<td className="max-w-sm px-4 py-3">
{link ? (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
title={`${entry.title} - öffnet ${link}`}
className="inline-flex items-baseline gap-1 text-sm font-bold text-brand-700 underline decoration-brand-300 underline-offset-2 transition hover:text-brand-800 hover:decoration-brand-600"
>
<span className="truncate">{entry.title}</span>
<span aria-hidden>↗</span>
</a>
) : (
<span className="text-sm font-bold text-ink-900">{entry.title}</span>
)}
{extraParts.length > 0 && (
<p className="mt-0.5 truncate text-[11px] text-ink-400">{extraParts.join(' · ')}</p>
)}
</td>
<td className="px-4 py-3 text-sm text-ink-700">{entry.consultant || '—'}</td>
<td className="px-4 py-3">
<button
onClick={() => onConvert(entry)}
disabled={isBusy}
title="Als neue Opportunity in der Phase „Neu“ anlegen. Der Eintrag verschwindet danach aus dieser Liste."
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-50"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 4v16m8-8H4" />
</svg>
{isBusy ? 'Lege an...' : 'Opportunity'}
</button>
</td>
<td className="whitespace-nowrap px-4 py-3 text-right">
<div className="inline-flex gap-1">
<button
onClick={() => onEdit(entry)}
title="Bearbeiten"
aria-label={`${entry.title} bearbeiten`}
className="rounded-lg p-2 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={() => onDelete(entry)}
title="Löschen"
aria-label={`${entry.title} löschen`}
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
+259
View File
@@ -0,0 +1,259 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AcquisitionForm } from './AcquisitionForm';
import {
AcquisitionTable,
type AcquisitionSortConfig,
type AcquisitionSortKey,
} from './AcquisitionTable';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
import { useNavigation } from '../../lib/navigation';
import { acquisitionsApi } from '../../lib/api';
import type { Acquisition, AcquisitionList } from '../../types';
/*
* Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber.
*
* Derselbe Aufbau traegt das Archiv (`view = 'archiv'`): dieselbe Liste, nur
* ohne Eingabe - im Archiv wird nichts Neues eingetragen, es wird abgearbeitet
* oder es verfaellt.
*
* Eingang und Archiv trennt allein das Funddatum; das entscheidet das Backend.
* Nichts hier verschiebt Eintraege, es gibt keinen Zustand, der driften kann.
*/
interface AcquisitionsProps {
/** 'archiv' zeigt die Eintraege, die aelter als die Frist sind. */
view?: 'eingang' | 'archiv';
/** Opportunities der App neu laden - nach einer Uebernahme. */
reload: () => void;
}
/** Vergleichswert je Sortierschluessel; leere Felder sortieren ans Ende. */
function sortValue(entry: Acquisition, key: AcquisitionSortKey): string | null {
switch (key) {
case 'found_at':
return entry.found_at;
case 'title':
return entry.title.toLowerCase();
default:
return entry[key]?.toLowerCase() ?? null;
}
}
export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', reload }) => {
const { addToast } = useToast();
const { goTo } = useNavigation();
const isArchive = view === 'archiv';
const [data, setData] = useState<AcquisitionList | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [editing, setEditing] = useState<Acquisition | null>(null);
const [toDelete, setToDelete] = useState<Acquisition | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({
key: 'found_at',
direction: 'desc',
});
const load = useCallback(
async (silent = false) => {
if (!silent) setIsLoading(true);
try {
setData(await acquisitionsApi.list(view));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Akquise nicht abrufbar.', 'error');
} finally {
setIsLoading(false);
}
},
[view, addToast]
);
useEffect(() => {
load();
}, [load]);
// Beim Wechsel zwischen Eingang und Archiv nichts Angefangenes mitnehmen.
useEffect(() => {
setEditing(null);
setSearch('');
}, [view]);
const entries = useMemo(() => {
const all = data?.entries ?? [];
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = all.filter((entry) => {
if (terms.length === 0) return true;
const haystack = [
entry.title,
entry.sender,
entry.provider,
entry.channel,
entry.consultant,
entry.company,
entry.description,
entry.notes,
entry.tags.join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return terms.every((term) => haystack.includes(term));
});
const direction = sortConfig.direction === 'asc' ? 1 : -1;
return [...matches].sort((a, b) => {
const left = sortValue(a, sortConfig.key);
const right = sortValue(b, sortConfig.key);
if (left === right) return (a.id - b.id) * direction;
if (left === null) return 1;
if (right === null) return -1;
return left.localeCompare(right, 'de') * direction;
});
}, [data, search, sortConfig]);
const handleSort = (key: AcquisitionSortKey) => {
setSortConfig((prev) =>
prev.key === key
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
: // Datum beginnt absteigend - das Neueste zuerst ist der Normalfall.
{ key, direction: key === 'found_at' ? 'desc' : 'asc' }
);
};
const convert = async (entry: Acquisition) => {
setBusyId(entry.id);
try {
await acquisitionsApi.toOpportunity(entry.id);
addToast(`Opportunity „${entry.title}" angelegt - Phase „Neu".`, 'success');
if (editing?.id === entry.id) setEditing(null);
await load(true);
// Die Opportunity-Liste der App kennt den neuen Eintrag noch nicht.
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Übernahme fehlgeschlagen.', 'error');
await load(true);
} finally {
setBusyId(null);
}
};
const confirmDelete = async () => {
if (!toDelete) return;
try {
await acquisitionsApi.remove(toDelete.id);
addToast('Eintrag gelöscht.', 'success');
if (editing?.id === toDelete.id) setEditing(null);
await load(true);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setToDelete(null);
}
};
const archiveAfter = data?.archive_after_days ?? 14;
const deleteAfter = data?.delete_after_days ?? 90;
return (
<div className="space-y-4">
{isArchive ? (
<div className="rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4">
<p className="text-sm font-bold text-ink-700">
Älter als {archiveAfter} Tage - hierher rutscht alles von selbst.
</p>
<p className="mt-1 text-xs text-ink-500">
Nach {deleteAfter} Tagen wird ein Eintrag automatisch gelöscht. Bis dahin lässt er sich
weiter bearbeiten und jederzeit in eine Opportunity übernehmen. Neue Projekte werden
unter <em>Akquise</em> eingetragen.
</p>
</div>
) : (
<AcquisitionForm
editing={editing}
suggestions={data?.suggestions ?? { providers: [], consultants: [], senders: [] }}
onSaved={() => load(true)}
onCancelEdit={() => setEditing(null)}
/>
)}
{/* Steuerleiste */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:max-w-sm">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Titel, Anbieter, Berater, Notizen suchen"
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
/>
<svg
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<div className="flex items-center gap-3 text-xs font-bold text-ink-400 sm:ml-auto">
<span>
{entries.length} von {data?.entries.length ?? 0}
{isArchive ? ' im Archiv' : ' im Eingang'}
</span>
{!isArchive && (data?.counts.archiv ?? 0) > 0 && (
<button
onClick={() => goTo('Archiv')}
className="rounded-lg border border-ink-200 px-3 py-1.5 transition hover:border-brand-300 hover:text-brand-700"
>
{data?.counts.archiv} im Archiv
</button>
)}
</div>
</div>
{isLoading && !data ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : entries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-ink-200 bg-surface px-6 py-16 text-center text-sm font-bold text-ink-400">
{search
? 'Kein Eintrag passt zur Suche.'
: isArchive
? `Das Archiv ist leer - es gibt keine Projekte, die älter als ${archiveAfter} Tage sind.`
: 'Noch keine Projekte eingetragen. Oben ausfüllen und auf „Eintragen" klicken.'}
</div>
) : (
<AcquisitionTable
entries={entries}
sortConfig={sortConfig}
onSort={handleSort}
editingId={editing?.id ?? null}
busyId={busyId}
onEdit={setEditing}
onDelete={setToDelete}
onConvert={convert}
/>
)}
{toDelete && (
<ConfirmDialog
title="Eintrag löschen?"
message={`„${toDelete.title}" wird aus der Akquise entfernt. Wer das Projekt behalten will, übernimmt es stattdessen als Opportunity.`}
onConfirm={confirmDelete}
onCancel={() => setToDelete(null)}
/>
)}
</div>
);
};
+48
View File
@@ -1,4 +1,6 @@
import type {
Acquisition,
AcquisitionList,
AppUser,
CalendarEvent,
Lead,
@@ -197,6 +199,52 @@ export const opportunitiesApi = {
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
};
// ---------- Akquise ----------
/**
* Zahlen gehen als Text raus, damit ein leeres Feld auch leer bleibt und nicht
* als 0 in der Datenbank landet - dasselbe Muster wie bei den Opportunities.
*/
export type AcquisitionInput = Partial<
Omit<
Acquisition,
| 'id'
| 'created_at'
| 'updated_at'
| 'converted_at'
| 'opportunity_id'
| 'opportunity_title'
| 'opportunity_stage'
| 'created_by'
| 'created_by_name'
| 'daily_rate'
| 'duration_days'
| 'fixed_price'
>
> & {
daily_rate?: string | number | null;
duration_days?: string | number | null;
fixed_price?: string | number | null;
};
export type AcquisitionView = 'eingang' | 'archiv' | 'alle';
export const acquisitionsApi = {
list: (view: AcquisitionView = 'eingang') =>
request<AcquisitionList>(`/acquisitions?view=${view}`),
create: (input: AcquisitionInput) =>
request<Acquisition>('/acquisitions', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: AcquisitionInput) =>
request<Acquisition>(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }),
/** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
toOpportunity: (id: number) =>
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {
method: 'POST',
}),
};
// ---------- Interviews (Termine) ----------
export interface EventInput {
+77
View File
@@ -1,4 +1,6 @@
export type ViewState =
| 'Akquise'
| 'Archiv'
| 'Opportunities'
| 'Interviews'
| 'Kontakte'
@@ -45,6 +47,12 @@ export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails'];
*/
export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
/**
* Akquise. "Akquise" ist der Eingang der letzten 14 Tage, "Archiv" alles
* Aeltere. Beides ist dieselbe Liste, nur nach dem Funddatum geteilt.
*/
export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv'];
export type Role = 'admin' | 'user';
export interface AppUser {
@@ -240,6 +248,75 @@ export interface Opportunity {
export type OpportunityViewMode = 'grid' | 'list' | 'pipeline';
// ---------- Akquise ----------
/**
* Wie das Projekt zu uns kam. Feste Auswahl, damit sich die Liste nach dem Kanal
* gruppieren laesst - als Freitext waeren "Mail", "E-Mail" und "mail" drei Kanaele.
*/
export const ACQUISITION_CHANNELS = [
'Mail',
'Telefon',
'Portal',
'LinkedIn',
'XING',
'Empfehlung',
'Sonstiges',
] as const;
export type AcquisitionChannel = (typeof ACQUISITION_CHANNELS)[number];
/**
* Ein gefundenes Projekt, bevor daraus eine Opportunity wird.
*
* Die ersten Felder stehen in der Liste, die uebrigen nur im Formular - sie
* wandern bei der Uebernahme in die Opportunity.
*/
export interface Acquisition {
id: number;
/** Wann gefunden - entscheidet ueber Eingang, Archiv und Loeschung. */
found_at: string;
/** "Von": wer es geschickt hat. */
sender: string | null;
/** "Anbieter": Vermittler oder Portal. */
provider: string | null;
channel: AcquisitionChannel | null;
title: string;
/** "Berater", fuer den es passt. */
consultant: string | null;
/** Ausschreibung; ist er gesetzt, wird der Titel in der Liste klickbar. */
link: string | null;
company: string | null;
description: string | null;
daily_rate: number | null;
duration_days: number | null;
fixed_price: number | null;
start_date: string | null;
end_date: string | null;
next_step: string | null;
notes: string | null;
tags: string[];
/** Gesetzt = uebernommen; der Eintrag verschwindet dann aus beiden Listen. */
converted_at: string | null;
opportunity_id: number | null;
opportunity_title?: string | null;
opportunity_stage?: OpportunityStage | null;
created_by: number | null;
created_by_name?: string | null;
created_at?: string;
updated_at?: string;
}
/** Zaehler und Fristen liefert das Backend mit - sie stehen dort, nicht hier. */
export interface AcquisitionList {
archive_after_days: number;
delete_after_days: number;
counts: { eingang: number; archiv: number };
/** Bisher benutzte Werte aus dem gesamten Bestand, fuer die Eingabehilfen. */
suggestions: { providers: string[]; consultants: string[]; senders: string[] };
entries: Acquisition[];
}
/**
* Projektvolumen. Ein gesetzter Projektpreis gilt als Festpreis und schlaegt die
* Rechnung aus Tagessatz x Personentagen; sonst braucht es beide Werte.