feat(akquise): "Von" mit dem angemeldeten Benutzer vorbelegen

In der Regel traegt jeder seine eigenen Funde ein - das Feld stand trotzdem bei
jedem Projekt auf leer. Vorbelegt ist jetzt der angemeldete Benutzer; wer fuer
jemand anderen eintraegt, waehlt ihn aus der Liste.

Der eigene Name steht dabei immer zur Auswahl (OptionSelect.alwaysOffer), auch
wenn ihn niemand in den Einstellungen gepflegt hat: sonst muesste sich jeder
erst selbst in die Liste eintragen, bevor er ein Projekt erfassen kann. Steht er
schon in der Liste, gilt deren Schreibweise - sonst stuenden "Markus" und
"Markus Meier" als zwei Namen in derselben Spalte.

Beim Bearbeiten bleibt der gespeicherte Name stehen, sonst wuerde ein Eintrag
beim Oeffnen den Finder wechseln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 13:53:53 +02:00
co-authored by Claude Opus 5
parent 23087a12fb
commit 2717462c3a
5 changed files with 61 additions and 12 deletions
+9 -2
View File
@@ -88,7 +88,7 @@ weg.
| Feld | wofür |
| --- | --- |
| **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung |
| **Von** | Auswahl: wer es geschickt hat |
| **Von** | Auswahl: wer den Fund hereingebracht hat. Vorbelegt mit dem angemeldeten Benutzer |
| **Anbieter** | Freitext: Vermittler oder Portal, z. B. Hays. Vorschläge aus den bisherigen Einträgen |
| **Kanal** | Auswahl: auf welchem Weg es kam |
| **Projekttitel** | das einzige Pflichtfeld |
@@ -99,6 +99,12 @@ weg.
*Einstellungen → Akquise* und lassen sich dort von jedem Benutzer pflegen. Ist
eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
**Von** ist mit dem **angemeldeten Benutzer** vorbelegt - in der Regel trägt
jeder seine eigenen Funde ein. Trägt Markus etwas für Artur ein, wählt er Artur
aus der Liste. Der eigene Name steht dabei immer zur Auswahl, auch wenn ihn
niemand in der Liste gepflegt hat; beim Bearbeiten eines Eintrags bleibt der
gespeicherte Name stehen.
**Anbieter** ist bewusst keine Liste, sondern ein Freitextfeld: neue Vermittler
tauchen zu oft auf, als dass das Eintragen daran hängen sollte. Das Feld schlägt
vor, was schon einmal benutzt wurde - der zuletzt verwendete Anbieter zuerst.
@@ -119,7 +125,8 @@ wird geleert - für das nächste Projekt aus derselben Quelle.
| Spalte | Bedeutung |
| --- | --- |
| **Datum** | Funddatum; der Mauszeiger verrät das Alter in Tagen |
| **Von**, **Anbieter**, **Kanal** | woher es kam |
| **Von** | wer den Fund hereingebracht hat |
| **Anbieter**, **Kanal** | woher es kam |
| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
| **Berater** | für wen |
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Neu* |
+1 -1
View File
@@ -268,7 +268,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen |
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden |
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` |
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
+34 -5
View File
@@ -3,6 +3,7 @@ import { DictateTextarea } from '../ui/DictationField';
import { 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 {
@@ -58,10 +59,16 @@ const today = () => new Date().toISOString().slice(0, 10);
const numberToInput = (value: number | null) =>
value === null ? '' : String(value).replace('.', ',');
function toFormState(entry: Acquisition | null): FormState {
/**
* `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 ?? '',
sender: entry?.sender ?? ownName,
provider: entry?.provider ?? '',
channel: entry?.channel ?? '',
title: entry?.title ?? '',
@@ -87,9 +94,25 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
onCancelEdit,
}) => {
const { addToast } = useToast();
const { user } = useAuth();
const { goTo } = useNavigation();
const openSettings = () => goTo('Einstellungen', { tab: 'akquise' });
const [form, setForm] = useState<FormState>(() => toFormState(editing));
/*
* 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);
@@ -104,7 +127,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
* Liste, das Formular oben.
*/
useEffect(() => {
setForm(toFormState(editing));
setForm(toFormState(editing, ownName));
if (editing) {
const hasExtras = Boolean(
editing.company ||
@@ -121,7 +144,10 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
rootRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
titleRef.current?.focus();
}
}, [editing]);
// `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(() => {
@@ -247,6 +273,8 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
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}
@@ -254,6 +282,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
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
+16 -3
View File
@@ -22,10 +22,16 @@ interface OptionSelectProps {
onChange: (value: string) => void;
/** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */
onOpenSettings: () => void;
/**
* Wert, der immer zur Auswahl steht, auch wenn er nicht in der Liste gepflegt
* ist - gedacht fuer den eigenen Namen im Feld "Von". Ohne das muesste sich
* jeder erst selbst in die Liste eintragen, bevor er ein Projekt erfassen kann.
*/
alwaysOffer?: string;
}
/**
* Auswahlfeld fuer "Von", "Anbieter", "Kanal" und "Berater".
* Auswahlfeld fuer "Von", "Kanal" und "Berater" ("Anbieter" ist Freitext).
*
* Steht im Eintrag ein Wert, den die Liste nicht (mehr) kennt, wird er zusaetzlich
* angeboten und als solcher gekennzeichnet. Ohne das wuerde das Bearbeiten eines
@@ -38,8 +44,14 @@ export const OptionSelect: React.FC<OptionSelectProps> = ({
options,
onChange,
onOpenSettings,
alwaysOffer,
}) => {
const isUnlisted = value !== '' && !options.some((option) => option.value === value);
const inList = (candidate: string) => options.some((option) => option.value === candidate);
// Nur anhaengen, wenn der Wert nicht schon gepflegt ist - sonst stuende er
// zweimal im Auswahlfeld.
const ownEntry = alwaysOffer && !inList(alwaysOffer) ? alwaysOffer : null;
const isUnlisted = value !== '' && !inList(value) && value !== ownEntry;
return (
<div>
@@ -53,6 +65,7 @@ export const OptionSelect: React.FC<OptionSelectProps> = ({
className={fieldClass}
>
<option value="">—</option>
{ownEntry && <option value={ownEntry}>{ownEntry}</option>}
{options.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
@@ -61,7 +74,7 @@ export const OptionSelect: React.FC<OptionSelectProps> = ({
{isUnlisted && <option value={value}>{value} (nicht in der Liste)</option>}
</select>
{options.length === 0 && (
{options.length === 0 && !ownEntry && (
<p className="mt-1.5 text-[11px] text-ink-400">
Liste ist leer -{' '}
<button type="button" onClick={onOpenSettings} className="font-bold underline">
+1 -1
View File
@@ -271,7 +271,7 @@ export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> =
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
sender: 'Personen, von denen Projekte kommen',
sender: 'Wer den Fund hereingebracht hat; vorbelegt ist der angemeldete Benutzer',
channel: 'Auf welchem Weg das Projekt kam',
consultant: 'Eigene Berater, für die Projekte gesucht werden',
};